Initial import of LogDarioSeq baseline
Establishes version control for the personal Logseq DB fork before starting the server-client architecture migration and domain features described in docs/agent-guide and the approved implementation plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
180
deps/db-sync/worker/scripts/delete_graphs_for_user.js
vendored
Normal file
180
deps/db-sync/worker/scripts/delete_graphs_for_user.js
vendored
Normal file
@@ -0,0 +1,180 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline/promises");
|
||||
const { stdin, stdout } = require("node:process");
|
||||
const { parseArgs } = require("node:util");
|
||||
const {
|
||||
buildAdminGraphDeleteUrl,
|
||||
buildUserGraphsSql,
|
||||
buildWranglerArgs,
|
||||
defaultConfigPath,
|
||||
fail,
|
||||
formatUserGraphsResult,
|
||||
parseWranglerResults,
|
||||
printUserGraphsTable,
|
||||
runWranglerQuery,
|
||||
} = require("./graph_user_lib");
|
||||
|
||||
function escapeSqlValue(value) {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function ensureMutationSuccess(output, context) {
|
||||
if (!Array.isArray(output) || output.length === 0) {
|
||||
fail(`Unexpected empty response from wrangler while ${context}.`);
|
||||
}
|
||||
|
||||
output.forEach((statement, index) => {
|
||||
if (!statement.success) {
|
||||
fail(`Wrangler mutation failed while ${context} (statement ${index + 1}).`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function deleteGraphAesKeys(options, graphId) {
|
||||
const sql = `delete from graph_aes_keys where graph_id = '${escapeSqlValue(graphId)}'`;
|
||||
const wranglerArgs = buildWranglerArgs({
|
||||
database: options.database,
|
||||
config: options.config,
|
||||
env: options.env,
|
||||
sql,
|
||||
});
|
||||
ensureMutationSuccess(runWranglerQuery(wranglerArgs), `deleting graph_aes_keys for ${graphId}`);
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Delete db-sync graphs owned by a user from a remote D1 environment.
|
||||
|
||||
Usage:
|
||||
node worker/scripts/delete_graphs_for_user.js --username <username> [--env prod]
|
||||
node worker/scripts/delete_graphs_for_user.js --user-id <user-id> [--env prod]
|
||||
|
||||
Options:
|
||||
--username <username> Look up the target user by username.
|
||||
--user-id <user-id> Look up the target user by user id.
|
||||
--env <env> Wrangler environment to use. Defaults to "prod".
|
||||
--database <name> D1 binding or database name. Defaults to "DB".
|
||||
--config <path> Wrangler config path. Defaults to worker/wrangler.toml.
|
||||
--base-url <url> Worker base URL. Defaults to DB_SYNC_BASE_URL.
|
||||
--admin-token <token> Admin delete token. Defaults to DB_SYNC_ADMIN_TOKEN.
|
||||
--help Show this message.
|
||||
`);
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
username: { type: "string" },
|
||||
"user-id": { type: "string" },
|
||||
env: { type: "string", default: "prod" },
|
||||
database: { type: "string", default: "DB" },
|
||||
config: { type: "string", default: defaultConfigPath },
|
||||
"base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL },
|
||||
"admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN },
|
||||
help: { type: "boolean", default: false },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"]));
|
||||
if (lookupCount !== 1) {
|
||||
fail("Pass exactly one of --username or --user-id.");
|
||||
}
|
||||
|
||||
return {
|
||||
lookupField: values.username ? "username" : "id",
|
||||
lookupLabel: values.username ? "username" : "user-id",
|
||||
lookupValue: values.username ?? values["user-id"],
|
||||
env: values.env,
|
||||
database: values.database,
|
||||
config: path.resolve(values.config),
|
||||
baseUrl: values["base-url"],
|
||||
adminToken: values["admin-token"],
|
||||
};
|
||||
}
|
||||
|
||||
async function confirmDeletion(result) {
|
||||
const rl = readline.createInterface({ input: stdin, output: stdout });
|
||||
try {
|
||||
const answer = await rl.question(
|
||||
`Type DELETE to remove ${result.graphs.length} owned graph(s) for ${result.user.user_id}: `,
|
||||
);
|
||||
return answer.trim() === "DELETE";
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseCliArgs(process.argv.slice(2));
|
||||
const lookupSql = buildUserGraphsSql({ ...options, ownedOnly: true });
|
||||
const lookupArgs = buildWranglerArgs({
|
||||
database: options.database,
|
||||
config: options.config,
|
||||
env: options.env,
|
||||
sql: lookupSql,
|
||||
});
|
||||
const lookupRows = parseWranglerResults(runWranglerQuery(lookupArgs));
|
||||
const result = formatUserGraphsResult(lookupRows);
|
||||
|
||||
if (!result) {
|
||||
fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`);
|
||||
}
|
||||
|
||||
printUserGraphsTable(result, "Owned graphs to delete");
|
||||
if (result.graphs.length === 0) {
|
||||
console.log("No owned graphs found. Nothing to delete.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!options.baseUrl) {
|
||||
fail("Missing worker base URL. Pass --base-url or set DB_SYNC_BASE_URL.");
|
||||
}
|
||||
|
||||
if (!options.adminToken) {
|
||||
fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN.");
|
||||
}
|
||||
|
||||
const confirmed = await confirmDeletion(result);
|
||||
if (!confirmed) {
|
||||
console.log("Aborted.");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const graph of result.graphs) {
|
||||
const response = await fetch(buildAdminGraphDeleteUrl(options.baseUrl, graph.graph_id), {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"x-db-sync-admin-token": options.adminToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
fail(`Delete failed for ${graph.graph_id}: ${response.status} ${body}`);
|
||||
}
|
||||
|
||||
deleteGraphAesKeys(options, graph.graph_id);
|
||||
}
|
||||
|
||||
console.log(`Deleted ${result.graphs.length} owned graph(s).`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
confirmDeletion,
|
||||
parseCliArgs,
|
||||
};
|
||||
254
deps/db-sync/worker/scripts/delete_user_totally.js
vendored
Normal file
254
deps/db-sync/worker/scripts/delete_user_totally.js
vendored
Normal file
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const path = require("node:path");
|
||||
const readline = require("node:readline/promises");
|
||||
const { stdin, stdout } = require("node:process");
|
||||
const { parseArgs } = require("node:util");
|
||||
const {
|
||||
buildAdminGraphDeleteUrl,
|
||||
buildUserGraphsSql,
|
||||
buildWranglerArgs,
|
||||
defaultConfigPath,
|
||||
fail,
|
||||
formatUserGraphsResult,
|
||||
parseWranglerResults,
|
||||
printUserGraphsTable,
|
||||
runWranglerQuery,
|
||||
} = require("./graph_user_lib");
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Delete a db-sync user and all related data from a remote D1 environment.
|
||||
|
||||
Usage:
|
||||
node worker/scripts/delete_user_totally.js --username <username> [--env prod]
|
||||
node worker/scripts/delete_user_totally.js --user-id <user-id> [--env prod]
|
||||
|
||||
Options:
|
||||
--username <username> Look up the target user by username.
|
||||
--user-id <user-id> Look up the target user by user id.
|
||||
--env <env> Wrangler environment to use. Defaults to "prod".
|
||||
--database <name> D1 binding or database name. Defaults to "DB".
|
||||
--config <path> Wrangler config path. Defaults to worker/wrangler.toml.
|
||||
--base-url <url> Worker base URL. Defaults to DB_SYNC_BASE_URL.
|
||||
--admin-token <token> Admin delete token. Defaults to DB_SYNC_ADMIN_TOKEN.
|
||||
--help Show this message.
|
||||
`);
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
username: { type: "string" },
|
||||
"user-id": { type: "string" },
|
||||
env: { type: "string", default: "prod" },
|
||||
database: { type: "string", default: "DB" },
|
||||
config: { type: "string", default: defaultConfigPath },
|
||||
"base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL },
|
||||
"admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN },
|
||||
help: { type: "boolean", default: false },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"]));
|
||||
if (lookupCount !== 1) {
|
||||
fail("Pass exactly one of --username or --user-id.");
|
||||
}
|
||||
|
||||
return {
|
||||
lookupField: values.username ? "username" : "id",
|
||||
lookupLabel: values.username ? "username" : "user-id",
|
||||
lookupValue: values.username ?? values["user-id"],
|
||||
env: values.env,
|
||||
database: values.database,
|
||||
config: path.resolve(values.config),
|
||||
baseUrl: values["base-url"],
|
||||
adminToken: values["admin-token"],
|
||||
};
|
||||
}
|
||||
|
||||
function escapeSqlValue(value) {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function runSelectQuery(options, sql) {
|
||||
const wranglerArgs = buildWranglerArgs({
|
||||
database: options.database,
|
||||
config: options.config,
|
||||
env: options.env,
|
||||
sql,
|
||||
});
|
||||
|
||||
return parseWranglerResults(runWranglerQuery(wranglerArgs));
|
||||
}
|
||||
|
||||
function runMutationQuery(options, sql) {
|
||||
const wranglerArgs = buildWranglerArgs({
|
||||
database: options.database,
|
||||
config: options.config,
|
||||
env: options.env,
|
||||
sql,
|
||||
});
|
||||
|
||||
const output = runWranglerQuery(wranglerArgs);
|
||||
if (!Array.isArray(output) || output.length === 0) {
|
||||
throw new Error("Unexpected empty response from wrangler.");
|
||||
}
|
||||
|
||||
output.forEach((statement, index) => {
|
||||
if (!statement.success) {
|
||||
throw new Error(`Wrangler reported an unsuccessful mutation (statement ${index + 1}).`);
|
||||
}
|
||||
});
|
||||
|
||||
return output.reduce((sum, statement) => sum + Number(statement?.meta?.changes ?? 0), 0);
|
||||
}
|
||||
|
||||
function sqlCountToNumber(value) {
|
||||
const numericValue = Number(value);
|
||||
return Number.isFinite(numericValue) ? numericValue : 0;
|
||||
}
|
||||
|
||||
function isDeleteConfirmationAccepted(answer, userId) {
|
||||
const normalizedAnswer = answer.trim();
|
||||
return normalizedAnswer === "DELETE" || normalizedAnswer === `DELETE USER ${userId}`;
|
||||
}
|
||||
|
||||
async function confirmDeletion({ user, ownedGraphsCount, memberGraphsCount }) {
|
||||
const rl = readline.createInterface({ input: stdin, output: stdout });
|
||||
try {
|
||||
const answer = await rl.question(
|
||||
`Type DELETE to permanently delete this user (${user.user_id}; ${ownedGraphsCount} owned graph(s), ${memberGraphsCount} membership(s)): `,
|
||||
);
|
||||
return isDeleteConfirmationAccepted(answer, user.user_id);
|
||||
} finally {
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteOwnedGraphs(options, ownedGraphs) {
|
||||
for (const graph of ownedGraphs) {
|
||||
const response = await fetch(buildAdminGraphDeleteUrl(options.baseUrl, graph.graph_id), {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"x-db-sync-admin-token": options.adminToken,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = await response.text();
|
||||
fail(`Delete failed for owned graph ${graph.graph_id}: ${response.status} ${payload}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseCliArgs(process.argv.slice(2));
|
||||
const graphRows = runSelectQuery(options, buildUserGraphsSql({ ...options, ownedOnly: false }));
|
||||
const result = formatUserGraphsResult(graphRows);
|
||||
|
||||
if (!result) {
|
||||
fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`);
|
||||
}
|
||||
|
||||
const ownedGraphs = result.graphs.filter((graph) => graph.access_role === "owner");
|
||||
const memberGraphs = result.graphs.filter((graph) => graph.access_role !== "owner");
|
||||
|
||||
printUserGraphsTable(result, "Graphs linked to user");
|
||||
console.log(`Owned graphs: ${ownedGraphs.length}`);
|
||||
console.log(`Member graphs: ${memberGraphs.length}`);
|
||||
|
||||
if (ownedGraphs.length > 0 && !options.baseUrl) {
|
||||
fail("Missing worker base URL. Pass --base-url or set DB_SYNC_BASE_URL.");
|
||||
}
|
||||
|
||||
if (ownedGraphs.length > 0 && !options.adminToken) {
|
||||
fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN.");
|
||||
}
|
||||
|
||||
const confirmed = await confirmDeletion({
|
||||
user: result.user,
|
||||
ownedGraphsCount: ownedGraphs.length,
|
||||
memberGraphsCount: memberGraphs.length,
|
||||
});
|
||||
|
||||
if (!confirmed) {
|
||||
console.log("Aborted.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ownedGraphs.length > 0) {
|
||||
await deleteOwnedGraphs(options, ownedGraphs);
|
||||
}
|
||||
|
||||
const escapedUserId = escapeSqlValue(result.user.user_id);
|
||||
const remainingOwnedGraphRows = runSelectQuery(
|
||||
options,
|
||||
`select count(1) as owned_graph_count from graphs where user_id = '${escapedUserId}'`,
|
||||
);
|
||||
const remainingOwnedGraphCount = sqlCountToNumber(remainingOwnedGraphRows[0]?.owned_graph_count);
|
||||
if (remainingOwnedGraphCount > 0) {
|
||||
fail(
|
||||
`Owned graph cleanup incomplete: ${remainingOwnedGraphCount} graph(s) still owned by ${result.user.user_id}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const deletedGraphAesKeys = runMutationQuery(
|
||||
options,
|
||||
`delete from graph_aes_keys where user_id = '${escapedUserId}'`,
|
||||
);
|
||||
const deletedGraphMembers = runMutationQuery(
|
||||
options,
|
||||
`delete from graph_members where user_id = '${escapedUserId}'`,
|
||||
);
|
||||
const clearedInvitedBy = runMutationQuery(
|
||||
options,
|
||||
`update graph_members set invited_by = null where invited_by = '${escapedUserId}'`,
|
||||
);
|
||||
const deletedUserRsaKeys = runMutationQuery(
|
||||
options,
|
||||
`delete from user_rsa_keys where user_id = '${escapedUserId}'`,
|
||||
);
|
||||
const deletedUsers = runMutationQuery(options, `delete from users where id = '${escapedUserId}'`);
|
||||
|
||||
if (deletedUsers !== 1) {
|
||||
fail(`Expected to delete exactly one user row, but deleted ${deletedUsers}.`);
|
||||
}
|
||||
|
||||
const userRowsAfterDelete = runSelectQuery(
|
||||
options,
|
||||
`select id from users where id = '${escapedUserId}' limit 1`,
|
||||
);
|
||||
if (userRowsAfterDelete.length > 0) {
|
||||
fail(`User ${result.user.user_id} still exists after deletion.`);
|
||||
}
|
||||
|
||||
console.table([
|
||||
{ step: "owned graphs deleted", rows: ownedGraphs.length },
|
||||
{ step: "graph_aes_keys deleted", rows: deletedGraphAesKeys },
|
||||
{ step: "graph_members deleted", rows: deletedGraphMembers },
|
||||
{ step: "graph_members invited_by cleared", rows: clearedInvitedBy },
|
||||
{ step: "user_rsa_keys deleted", rows: deletedUserRsaKeys },
|
||||
{ step: "users deleted", rows: deletedUsers },
|
||||
]);
|
||||
console.log(`Deleted user ${result.user.user_id} successfully.`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
confirmDeletion,
|
||||
isDeleteConfirmationAccepted,
|
||||
parseCliArgs,
|
||||
};
|
||||
59
deps/db-sync/worker/scripts/delete_user_totally.test.js
vendored
Normal file
59
deps/db-sync/worker/scripts/delete_user_totally.test.js
vendored
Normal file
@@ -0,0 +1,59 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
|
||||
const { isDeleteConfirmationAccepted, parseCliArgs } = require("./delete_user_totally");
|
||||
const { defaultConfigPath } = require("./graph_user_lib");
|
||||
|
||||
function runCli(args) {
|
||||
return spawnSync(process.execPath, [path.join(__dirname, "delete_user_totally.js"), ...args], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
test("parseCliArgs accepts --username", () => {
|
||||
const parsed = parseCliArgs(["--username", "alice"]);
|
||||
|
||||
assert.equal(parsed.lookupField, "username");
|
||||
assert.equal(parsed.lookupLabel, "username");
|
||||
assert.equal(parsed.lookupValue, "alice");
|
||||
assert.equal(parsed.env, "prod");
|
||||
assert.equal(parsed.database, "DB");
|
||||
assert.equal(parsed.config, path.resolve(defaultConfigPath));
|
||||
});
|
||||
|
||||
test("parseCliArgs accepts --user-id", () => {
|
||||
const parsed = parseCliArgs(["--user-id", "user-123"]);
|
||||
|
||||
assert.equal(parsed.lookupField, "id");
|
||||
assert.equal(parsed.lookupLabel, "user-id");
|
||||
assert.equal(parsed.lookupValue, "user-123");
|
||||
});
|
||||
|
||||
test("CLI --help exits successfully", () => {
|
||||
const result = runCli(["--help"]);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /Delete a db-sync user and all related data/);
|
||||
});
|
||||
|
||||
test("CLI rejects passing both --username and --user-id", () => {
|
||||
const result = runCli(["--username", "alice", "--user-id", "user-123"]);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Pass exactly one of --username or --user-id\./);
|
||||
});
|
||||
|
||||
test("confirmation accepts DELETE", () => {
|
||||
assert.equal(isDeleteConfirmationAccepted("DELETE", "user-123"), true);
|
||||
});
|
||||
|
||||
test("confirmation accepts legacy DELETE USER <id>", () => {
|
||||
assert.equal(isDeleteConfirmationAccepted("DELETE USER user-123", "user-123"), true);
|
||||
});
|
||||
|
||||
test("confirmation rejects unrelated input", () => {
|
||||
assert.equal(isDeleteConfirmationAccepted("DELETE USER other-user", "user-123"), false);
|
||||
assert.equal(isDeleteConfirmationAccepted("yes", "user-123"), false);
|
||||
});
|
||||
246
deps/db-sync/worker/scripts/download_graph_db.js
vendored
Normal file
246
deps/db-sync/worker/scripts/download_graph_db.js
vendored
Normal file
@@ -0,0 +1,246 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const Database = require("better-sqlite3");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const zlib = require("node:zlib");
|
||||
const { parseArgs } = require("node:util");
|
||||
const transit = require("transit-js");
|
||||
const { fail } = require("./graph_user_lib");
|
||||
|
||||
const defaultBaseUrl = "https://api.logseq.com";
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Download a graph snapshot and store it as a local sqlite debug DB.
|
||||
|
||||
Usage:
|
||||
node worker/scripts/download_graph_db.js --graph-id <graph-id> --admin-token <token>
|
||||
node worker/scripts/download_graph_db.js --graph-id <graph-id> --output ./tmp/my-graph.sqlite
|
||||
|
||||
Options:
|
||||
--graph-id <id> Target graph id. Required.
|
||||
--admin-token <token> Admin token. Defaults to DB_SYNC_ADMIN_TOKEN.
|
||||
--base-url <url> Worker base URL. Defaults to DB_SYNC_BASE_URL or https://api.logseq.com.
|
||||
--output <path> SQLite output path. Defaults to ./tmp/graph-<graph-id>.snapshot.sqlite.
|
||||
--help Show this message.
|
||||
|
||||
Notes:
|
||||
The output sqlite matches local graph DB schema and contains only:
|
||||
kvs(addr, content, addresses).
|
||||
`);
|
||||
}
|
||||
|
||||
function sanitizeGraphIdForFilename(graphId) {
|
||||
return graphId.replaceAll(/[^a-zA-Z0-9.-]/g, "_");
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
"graph-id": { type: "string" },
|
||||
"admin-token": { type: "string", default: process.env.DB_SYNC_ADMIN_TOKEN },
|
||||
"base-url": { type: "string", default: process.env.DB_SYNC_BASE_URL || defaultBaseUrl },
|
||||
output: { type: "string" },
|
||||
help: { type: "boolean", default: false },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!values["graph-id"]) {
|
||||
fail("Missing required --graph-id.");
|
||||
}
|
||||
|
||||
if (!values["admin-token"]) {
|
||||
fail("Missing admin token. Pass --admin-token or set DB_SYNC_ADMIN_TOKEN.");
|
||||
}
|
||||
|
||||
const output = values.output
|
||||
? path.resolve(values.output)
|
||||
: path.resolve("tmp", `graph-${sanitizeGraphIdForFilename(values["graph-id"])}.snapshot.sqlite`);
|
||||
|
||||
return {
|
||||
graphId: values["graph-id"],
|
||||
adminToken: values["admin-token"],
|
||||
baseUrl: values["base-url"],
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
function authHeaders(adminToken) {
|
||||
return {
|
||||
"x-db-sync-admin-token": adminToken,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(baseUrl) {
|
||||
return baseUrl.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
async function fetchJson(url, adminToken) {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: authHeaders(adminToken),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Request failed (${response.status}) for ${url}: ${body}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchSnapshotDescriptor(options) {
|
||||
const baseUrl = normalizeBaseUrl(options.baseUrl);
|
||||
const url = `${baseUrl}/sync/${encodeURIComponent(options.graphId)}/snapshot/download`;
|
||||
return fetchJson(url, options.adminToken);
|
||||
}
|
||||
|
||||
async function fetchSnapshotBytes(url, adminToken) {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: authHeaders(adminToken),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Snapshot download failed (${response.status}) for ${url}: ${body}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
const contentEncoding = response.headers.get("content-encoding");
|
||||
|
||||
return {
|
||||
buffer,
|
||||
contentEncoding,
|
||||
};
|
||||
}
|
||||
|
||||
function hasGzipMagic(buffer) {
|
||||
return buffer.length >= 2 && buffer[0] === 0x1f && buffer[1] === 0x8b;
|
||||
}
|
||||
|
||||
function maybeDecompressBuffer(buffer, contentEncoding) {
|
||||
if (contentEncoding === "gzip" && hasGzipMagic(buffer)) {
|
||||
return zlib.gunzipSync(buffer);
|
||||
}
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function parseFramedRows(buffer) {
|
||||
const rows = [];
|
||||
const reader = transit.reader("json");
|
||||
let offset = 0;
|
||||
|
||||
while (offset < buffer.length) {
|
||||
if (buffer.length - offset < 4) {
|
||||
throw new Error("Invalid snapshot payload: incomplete frame header");
|
||||
}
|
||||
|
||||
const frameLength = buffer.readUInt32BE(offset);
|
||||
offset += 4;
|
||||
|
||||
if (buffer.length - offset < frameLength) {
|
||||
throw new Error("Invalid snapshot payload: incomplete frame payload");
|
||||
}
|
||||
|
||||
const payload = buffer.subarray(offset, offset + frameLength);
|
||||
offset += frameLength;
|
||||
|
||||
const batch = reader.read(payload.toString("utf8"));
|
||||
if (!Array.isArray(batch)) {
|
||||
throw new Error("Invalid snapshot payload: decoded frame is not an array");
|
||||
}
|
||||
|
||||
for (const row of batch) {
|
||||
if (!Array.isArray(row) || row.length < 2) {
|
||||
throw new Error("Invalid snapshot payload: row must be [addr, content, addresses?]");
|
||||
}
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function writeSnapshotSqlite({
|
||||
outputPath,
|
||||
rows,
|
||||
}) {
|
||||
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
||||
if (fs.existsSync(outputPath)) {
|
||||
fs.rmSync(outputPath);
|
||||
}
|
||||
|
||||
const db = new Database(outputPath);
|
||||
try {
|
||||
db.exec(`
|
||||
create table if not exists kvs (
|
||||
addr INTEGER primary key,
|
||||
content TEXT,
|
||||
addresses JSON
|
||||
);
|
||||
`);
|
||||
|
||||
const upsertKvs = db.prepare(
|
||||
"insert into kvs (addr, content, addresses) values (?, ?, ?) on conflict(addr) do update set content = excluded.content, addresses = excluded.addresses",
|
||||
);
|
||||
|
||||
const writeAll = db.transaction(() => {
|
||||
for (const row of rows) {
|
||||
const [addr, content, addresses] = row;
|
||||
upsertKvs.run(addr, content, addresses ?? null);
|
||||
}
|
||||
});
|
||||
|
||||
writeAll();
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseCliArgs(process.argv.slice(2));
|
||||
const descriptor = await fetchSnapshotDescriptor(options);
|
||||
if (!descriptor || !descriptor.url) {
|
||||
fail("Snapshot download response missing URL.");
|
||||
}
|
||||
|
||||
const snapshot = await fetchSnapshotBytes(descriptor.url, options.adminToken);
|
||||
const effectiveEncoding = descriptor["content-encoding"] || snapshot.contentEncoding || "";
|
||||
const decompressed = maybeDecompressBuffer(snapshot.buffer, effectiveEncoding);
|
||||
const rows = parseFramedRows(decompressed);
|
||||
|
||||
writeSnapshotSqlite({
|
||||
outputPath: options.output,
|
||||
rows,
|
||||
});
|
||||
|
||||
console.log(`Saved graph snapshot sqlite to ${options.output}`);
|
||||
console.log(`Graph: ${options.graphId}`);
|
||||
console.log(`Rows: ${rows.length}`);
|
||||
if (descriptor.key) {
|
||||
console.log(`Snapshot key: ${descriptor.key}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((error) => {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseCliArgs,
|
||||
parseFramedRows,
|
||||
sanitizeGraphIdForFilename,
|
||||
writeSnapshotSqlite,
|
||||
};
|
||||
117
deps/db-sync/worker/scripts/download_graph_db.test.js
vendored
Normal file
117
deps/db-sync/worker/scripts/download_graph_db.test.js
vendored
Normal file
@@ -0,0 +1,117 @@
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const transit = require("transit-js");
|
||||
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const {
|
||||
parseFramedRows,
|
||||
parseCliArgs,
|
||||
sanitizeGraphIdForFilename,
|
||||
writeSnapshotSqlite,
|
||||
} = require("./download_graph_db");
|
||||
|
||||
function runCli(args, env = {}) {
|
||||
return spawnSync(process.execPath, [path.join(__dirname, "download_graph_db.js"), ...args], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, ...env },
|
||||
});
|
||||
}
|
||||
|
||||
test("parseCliArgs accepts required args and defaults output", () => {
|
||||
const parsed = parseCliArgs([
|
||||
"--graph-id",
|
||||
"graph-1",
|
||||
"--admin-token",
|
||||
"admin-token-value",
|
||||
]);
|
||||
|
||||
assert.equal(parsed.graphId, "graph-1");
|
||||
assert.equal(parsed.baseUrl, "https://api.logseq.com");
|
||||
assert.equal(parsed.adminToken, "admin-token-value");
|
||||
assert.equal(parsed.output, path.resolve("tmp", "graph-graph-1.snapshot.sqlite"));
|
||||
});
|
||||
|
||||
test("sanitizeGraphIdForFilename replaces unsafe chars", () => {
|
||||
assert.equal(sanitizeGraphIdForFilename("us-east-1:abc/def"), "us-east-1_abc_def");
|
||||
});
|
||||
|
||||
test("CLI --help exits successfully", () => {
|
||||
const result = runCli(["--help"]);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /Download a graph snapshot and store it as a local sqlite debug DB/);
|
||||
});
|
||||
|
||||
test("CLI rejects missing --graph-id", () => {
|
||||
const result = runCli(["--admin-token", "admin-token-value"]);
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Missing required --graph-id\./);
|
||||
});
|
||||
|
||||
test("CLI rejects missing admin-token when env is absent", () => {
|
||||
const result = runCli(["--graph-id", "graph-1"], { DB_SYNC_ADMIN_TOKEN: "" });
|
||||
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /Missing admin token/);
|
||||
});
|
||||
|
||||
test("parseFramedRows decodes framed transit batches", () => {
|
||||
const writer = transit.writer("json");
|
||||
const rowsA = [
|
||||
[1, "line-1", null],
|
||||
[2, "line-2", null],
|
||||
];
|
||||
const rowsB = [
|
||||
[1000606, "line-1000606", "{\"1\":[2,3]}"],
|
||||
];
|
||||
|
||||
const encodeFrame = (rows) => {
|
||||
const payload = Buffer.from(writer.write(rows), "utf8");
|
||||
const frame = Buffer.allocUnsafe(4 + payload.length);
|
||||
frame.writeUInt32BE(payload.length, 0);
|
||||
payload.copy(frame, 4);
|
||||
return frame;
|
||||
};
|
||||
|
||||
const framed = Buffer.concat([encodeFrame(rowsA), encodeFrame(rowsB)]);
|
||||
const parsed = parseFramedRows(framed);
|
||||
|
||||
assert.deepEqual(parsed, [...rowsA, ...rowsB]);
|
||||
});
|
||||
|
||||
test("writeSnapshotSqlite writes kvs-only sqlite like local dbs", () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "db-sync-download-test-"));
|
||||
const dbPath = path.join(tmpDir, "graph-debug.sqlite");
|
||||
const rows = [
|
||||
[1, "line-1", null],
|
||||
[2, "line-2", null],
|
||||
[1000606, "line-1000606", "{\"1\":[2,3]}"],
|
||||
];
|
||||
|
||||
writeSnapshotSqlite({
|
||||
outputPath: dbPath,
|
||||
rows,
|
||||
});
|
||||
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
const tableNames = db
|
||||
.prepare("select name from sqlite_master where type = 'table' order by name")
|
||||
.all()
|
||||
.map((row) => row.name);
|
||||
const rowCount = db.prepare("select count(1) as count from kvs").get().count;
|
||||
const row1000606 = db.prepare("select addr, content, addresses from kvs where addr = 1000606").get();
|
||||
|
||||
assert.deepEqual(tableNames, ["kvs"]);
|
||||
assert.equal(rowCount, 3);
|
||||
assert.equal(row1000606.addr, 1000606);
|
||||
assert.equal(row1000606.content, "line-1000606");
|
||||
assert.equal(row1000606.addresses, "{\"1\":[2,3]}");
|
||||
|
||||
db.close();
|
||||
});
|
||||
7
deps/db-sync/worker/scripts/embed_api_docs.mjs
vendored
Normal file
7
deps/db-sync/worker/scripts/embed_api_docs.mjs
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const html = readFileSync(new URL("../dist/api-docs.html", import.meta.url), "utf8");
|
||||
writeFileSync(
|
||||
new URL("../dist/api-docs.generated.mjs", import.meta.url),
|
||||
`export default ${JSON.stringify(html)};\n`,
|
||||
);
|
||||
196
deps/db-sync/worker/scripts/graph_user_lib.js
vendored
Normal file
196
deps/db-sync/worker/scripts/graph_user_lib.js
vendored
Normal file
@@ -0,0 +1,196 @@
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
|
||||
const repoRoot = path.resolve(__dirname, "..", "..");
|
||||
const defaultConfigPath = path.join(repoRoot, "worker", "wrangler.toml");
|
||||
|
||||
function fail(message) {
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function escapeSqlValue(value) {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function sqlBooleanToBool(value) {
|
||||
if (value === null || value === undefined) return null;
|
||||
return Number(value) === 1;
|
||||
}
|
||||
|
||||
function sqlTimestampToIso(value) {
|
||||
if (value === null || value === undefined || value === "") return null;
|
||||
const timestamp = Number(value);
|
||||
return Number.isFinite(timestamp) ? new Date(timestamp).toISOString() : null;
|
||||
}
|
||||
|
||||
function buildUserGraphsSql({ lookupField, lookupValue, ownedOnly = false }) {
|
||||
const escapedValue = escapeSqlValue(lookupValue);
|
||||
const memberUnion = ownedOnly
|
||||
? ""
|
||||
: `
|
||||
union all
|
||||
select g.graph_id,
|
||||
g.graph_name,
|
||||
g.user_id as owner_user_id,
|
||||
m.role as access_role,
|
||||
m.invited_by,
|
||||
g.schema_version,
|
||||
g.graph_e2ee,
|
||||
g.graph_ready_for_use,
|
||||
g.created_at,
|
||||
g.updated_at
|
||||
from graph_members m
|
||||
join graphs g on g.graph_id = m.graph_id
|
||||
join target_user u on m.user_id = u.id
|
||||
where g.user_id <> u.id`;
|
||||
|
||||
return `with target_user as (
|
||||
select id, email, username
|
||||
from users
|
||||
where ${lookupField} = '${escapedValue}'
|
||||
limit 1
|
||||
),
|
||||
matching_graphs as (
|
||||
select g.graph_id,
|
||||
g.graph_name,
|
||||
g.user_id as owner_user_id,
|
||||
'owner' as access_role,
|
||||
null as invited_by,
|
||||
g.schema_version,
|
||||
g.graph_e2ee,
|
||||
g.graph_ready_for_use,
|
||||
g.created_at,
|
||||
g.updated_at
|
||||
from graphs g
|
||||
join target_user u on g.user_id = u.id${memberUnion}
|
||||
)
|
||||
select u.id as user_id,
|
||||
u.email as user_email,
|
||||
u.username as user_username,
|
||||
g.graph_id,
|
||||
g.graph_name,
|
||||
g.access_role,
|
||||
g.invited_by,
|
||||
g.owner_user_id,
|
||||
owner.email as owner_email,
|
||||
owner.username as owner_username,
|
||||
g.schema_version,
|
||||
g.graph_e2ee,
|
||||
g.graph_ready_for_use,
|
||||
g.created_at,
|
||||
g.updated_at
|
||||
from target_user u
|
||||
left join matching_graphs g on 1 = 1
|
||||
left join users owner on owner.id = g.owner_user_id
|
||||
order by g.updated_at desc;`;
|
||||
}
|
||||
|
||||
function buildWranglerArgs({ database, config, env, sql, local = false }) {
|
||||
const useLocal = local || env === "local";
|
||||
const args = [
|
||||
"dlx",
|
||||
"wrangler",
|
||||
"d1",
|
||||
"execute",
|
||||
database,
|
||||
"--config",
|
||||
config,
|
||||
];
|
||||
|
||||
if (useLocal) {
|
||||
args.push("--local");
|
||||
} else {
|
||||
args.push("--env", env, "--remote");
|
||||
}
|
||||
|
||||
args.push("--json", "--command", sql);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
function runWranglerQuery(args) {
|
||||
const output = execFileSync("pnpm", args, {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
|
||||
return JSON.parse(output);
|
||||
}
|
||||
|
||||
function parseWranglerResults(output) {
|
||||
if (!Array.isArray(output) || output.length === 0) {
|
||||
throw new Error("Unexpected empty response from wrangler.");
|
||||
}
|
||||
|
||||
const [statement] = output;
|
||||
if (!statement.success) {
|
||||
throw new Error("Wrangler reported an unsuccessful D1 query.");
|
||||
}
|
||||
|
||||
return Array.isArray(statement.results) ? statement.results : [];
|
||||
}
|
||||
|
||||
function formatUserGraphsResult(rows) {
|
||||
if (rows.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [firstRow] = rows;
|
||||
const graphs = rows
|
||||
.filter((row) => row.graph_id)
|
||||
.map((row) => ({
|
||||
graph_id: row.graph_id,
|
||||
graph_name: row.graph_name,
|
||||
access_role: row.access_role,
|
||||
invited_by: row.invited_by ?? null,
|
||||
owner_user_id: row.owner_user_id,
|
||||
owner_username: row.owner_username ?? null,
|
||||
owner_email: row.owner_email ?? null,
|
||||
schema_version: row.schema_version ?? null,
|
||||
graph_e2ee: sqlBooleanToBool(row.graph_e2ee),
|
||||
graph_ready_for_use: sqlBooleanToBool(row.graph_ready_for_use),
|
||||
created_at: sqlTimestampToIso(row.created_at),
|
||||
updated_at: sqlTimestampToIso(row.updated_at),
|
||||
}));
|
||||
|
||||
return {
|
||||
user: {
|
||||
user_id: firstRow.user_id,
|
||||
username: firstRow.user_username ?? null,
|
||||
email: firstRow.user_email ?? null,
|
||||
},
|
||||
graphs,
|
||||
};
|
||||
}
|
||||
|
||||
function printUserGraphsTable(result, countLabel = "Graphs") {
|
||||
console.log(
|
||||
`User: ${result.user.user_id}` +
|
||||
(result.user.username ? ` (${result.user.username})` : "") +
|
||||
(result.user.email ? ` <${result.user.email}>` : ""),
|
||||
);
|
||||
console.log(`${countLabel}: ${result.graphs.length}`);
|
||||
if (result.graphs.length > 0) {
|
||||
console.table(result.graphs);
|
||||
}
|
||||
}
|
||||
|
||||
function buildAdminGraphDeleteUrl(baseUrl, graphId) {
|
||||
const normalizedBaseUrl = baseUrl.replace(/\/+$/, "");
|
||||
return `${normalizedBaseUrl}/admin/graphs/${encodeURIComponent(graphId)}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildAdminGraphDeleteUrl,
|
||||
buildUserGraphsSql,
|
||||
buildWranglerArgs,
|
||||
defaultConfigPath,
|
||||
fail,
|
||||
formatUserGraphsResult,
|
||||
parseWranglerResults,
|
||||
printUserGraphsTable,
|
||||
repoRoot,
|
||||
runWranglerQuery,
|
||||
};
|
||||
104
deps/db-sync/worker/scripts/show_graphs_for_user.js
vendored
Normal file
104
deps/db-sync/worker/scripts/show_graphs_for_user.js
vendored
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const path = require("node:path");
|
||||
const { parseArgs } = require("node:util");
|
||||
const {
|
||||
buildUserGraphsSql,
|
||||
buildWranglerArgs,
|
||||
defaultConfigPath,
|
||||
fail,
|
||||
formatUserGraphsResult,
|
||||
parseWranglerResults,
|
||||
printUserGraphsTable,
|
||||
runWranglerQuery,
|
||||
} = require("./graph_user_lib");
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Show db-sync graphs for a user from a remote D1 environment.
|
||||
|
||||
Usage:
|
||||
node worker/scripts/show_graphs_for_user.js --username <username> [--env prod] [--json]
|
||||
node worker/scripts/show_graphs_for_user.js --user-id <user-id> [--env prod] [--json]
|
||||
|
||||
Options:
|
||||
--username <username> Look up the target user by username.
|
||||
--user-id <user-id> Look up the target user by user id.
|
||||
--env <env> Wrangler environment to use. Defaults to "prod".
|
||||
--database <name> D1 binding or database name. Defaults to "DB".
|
||||
--config <path> Wrangler config path. Defaults to worker/wrangler.toml.
|
||||
--json Print JSON instead of a table.
|
||||
--help Show this message.
|
||||
`);
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
username: { type: "string" },
|
||||
"user-id": { type: "string" },
|
||||
env: { type: "string", default: "prod" },
|
||||
database: { type: "string", default: "DB" },
|
||||
config: { type: "string", default: defaultConfigPath },
|
||||
json: { type: "boolean", default: false },
|
||||
help: { type: "boolean", default: false },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const lookupCount = Number(Boolean(values.username)) + Number(Boolean(values["user-id"]));
|
||||
if (lookupCount !== 1) {
|
||||
fail("Pass exactly one of --username or --user-id.");
|
||||
}
|
||||
|
||||
return {
|
||||
lookupField: values.username ? "username" : "id",
|
||||
lookupLabel: values.username ? "username" : "user-id",
|
||||
lookupValue: values.username ?? values["user-id"],
|
||||
env: values.env,
|
||||
database: values.database,
|
||||
config: path.resolve(values.config),
|
||||
json: values.json,
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseCliArgs(process.argv.slice(2));
|
||||
const sql = buildUserGraphsSql(options);
|
||||
const wranglerArgs = buildWranglerArgs({
|
||||
database: options.database,
|
||||
config: options.config,
|
||||
env: options.env,
|
||||
sql,
|
||||
});
|
||||
const rows = parseWranglerResults(runWranglerQuery(wranglerArgs));
|
||||
const result = formatUserGraphsResult(rows);
|
||||
|
||||
if (!result) {
|
||||
fail(`No user found for ${options.lookupLabel}=${options.lookupValue}.`);
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
} else {
|
||||
printUserGraphsTable(result);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseCliArgs,
|
||||
};
|
||||
188
deps/db-sync/worker/scripts/show_usage_stats.js
vendored
Normal file
188
deps/db-sync/worker/scripts/show_usage_stats.js
vendored
Normal file
@@ -0,0 +1,188 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const path = require("node:path");
|
||||
const { parseArgs } = require("node:util");
|
||||
const {
|
||||
buildWranglerArgs,
|
||||
defaultConfigPath,
|
||||
fail,
|
||||
parseWranglerResults,
|
||||
runWranglerQuery,
|
||||
} = require("./graph_user_lib");
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Show db-sync usage stats from a D1 environment.
|
||||
|
||||
Usage:
|
||||
node worker/scripts/show_usage_stats.js [--env prod|local] [--days 7] [--json]
|
||||
|
||||
Options:
|
||||
--env <env> Wrangler environment to use. Defaults to "prod"; use "local" to query the local D1 database.
|
||||
--days <n> Active window size in days. Defaults to 1.
|
||||
--database <name> D1 binding or database name. Defaults to "DB".
|
||||
--config <path> Wrangler config path. Defaults to worker/wrangler.toml.
|
||||
--json Print JSON instead of a table.
|
||||
--help Show this message.
|
||||
|
||||
Output:
|
||||
days
|
||||
active_since_utc
|
||||
active_users_last_n_days
|
||||
active_graphs_last_n_days
|
||||
total_users
|
||||
total_graphs
|
||||
users_created_today
|
||||
graphs_created_today
|
||||
today_utc
|
||||
`);
|
||||
}
|
||||
|
||||
function parseDays(value) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
fail("--days must be an integer >= 1.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseCliArgs(argv) {
|
||||
const { values } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
env: { type: "string", default: "prod" },
|
||||
days: { type: "string", default: "1" },
|
||||
database: { type: "string", default: "DB" },
|
||||
config: { type: "string", default: defaultConfigPath },
|
||||
json: { type: "boolean", default: false },
|
||||
help: { type: "boolean", default: false },
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false,
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
return {
|
||||
env: values.env,
|
||||
days: parseDays(values.days),
|
||||
database: values.database,
|
||||
config: path.resolve(values.config),
|
||||
json: values.json,
|
||||
};
|
||||
}
|
||||
|
||||
function buildUsageStatsSql(days) {
|
||||
const sinceDays = days - 1;
|
||||
return `with bounds as (
|
||||
select
|
||||
cast(strftime('%s', 'now', 'start of day') as integer) * 1000 as today_start_ms,
|
||||
cast(strftime('%s', 'now', 'start of day', '+1 day') as integer) * 1000 as tomorrow_start_ms,
|
||||
date('now') as today_utc,
|
||||
date('now', '-${sinceDays} days') as active_since_utc
|
||||
)
|
||||
select
|
||||
${days} as days,
|
||||
(select active_since_utc from bounds) as active_since_utc,
|
||||
(select count(distinct entity_id)
|
||||
from daily_active_entities a
|
||||
join bounds b on 1 = 1
|
||||
where a.entity_type = 'user'
|
||||
and a.day_utc >= b.active_since_utc
|
||||
and a.day_utc <= b.today_utc) as active_users_last_n_days,
|
||||
(select count(distinct entity_id)
|
||||
from daily_active_entities a
|
||||
join bounds b on 1 = 1
|
||||
where a.entity_type = 'graph'
|
||||
and a.day_utc >= b.active_since_utc
|
||||
and a.day_utc <= b.today_utc) as active_graphs_last_n_days,
|
||||
(select count(1) from users) as total_users,
|
||||
(select count(1) from graphs) as total_graphs,
|
||||
(select count(1)
|
||||
from users u
|
||||
join bounds b on 1 = 1
|
||||
where u.created_at is not null
|
||||
and u.created_at >= b.today_start_ms
|
||||
and u.created_at < b.tomorrow_start_ms) as users_created_today,
|
||||
(select count(1)
|
||||
from graphs g
|
||||
join bounds b on 1 = 1
|
||||
where g.created_at is not null
|
||||
and g.created_at >= b.today_start_ms
|
||||
and g.created_at < b.tomorrow_start_ms) as graphs_created_today,
|
||||
(select today_utc from bounds) as today_utc;`;
|
||||
}
|
||||
|
||||
function sqlCountToNumber(value) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) ? numeric : 0;
|
||||
}
|
||||
|
||||
function formatUsageStats(rows) {
|
||||
if (!rows.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [row] = rows;
|
||||
return {
|
||||
days: sqlCountToNumber(row.days),
|
||||
active_since_utc: typeof row.active_since_utc === "string" ? row.active_since_utc : null,
|
||||
active_users_last_n_days: sqlCountToNumber(row.active_users_last_n_days),
|
||||
active_graphs_last_n_days: sqlCountToNumber(row.active_graphs_last_n_days),
|
||||
total_users: sqlCountToNumber(row.total_users),
|
||||
total_graphs: sqlCountToNumber(row.total_graphs),
|
||||
users_created_today: sqlCountToNumber(row.users_created_today),
|
||||
graphs_created_today: sqlCountToNumber(row.graphs_created_today),
|
||||
today_utc: typeof row.today_utc === "string" ? row.today_utc : null,
|
||||
};
|
||||
}
|
||||
|
||||
function printUsageStatsTable(stats) {
|
||||
console.table([stats]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseCliArgs(process.argv.slice(2));
|
||||
const sql = buildUsageStatsSql(options.days);
|
||||
const wranglerArgs = buildWranglerArgs({
|
||||
database: options.database,
|
||||
config: options.config,
|
||||
env: options.env,
|
||||
sql,
|
||||
});
|
||||
const rows = parseWranglerResults(runWranglerQuery(wranglerArgs));
|
||||
const stats = formatUsageStats(rows);
|
||||
|
||||
if (!stats) {
|
||||
fail("No stats returned from D1.");
|
||||
}
|
||||
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(stats, null, 2));
|
||||
} else {
|
||||
printUsageStatsTable(stats);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
try {
|
||||
main();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (/no such table: daily_active_entities/i.test(message)) {
|
||||
fail("Missing daily_active_entities. Apply worker migration 0006_add_daily_active_entities.sql.");
|
||||
}
|
||||
if (/no such column: created_at/i.test(message)) {
|
||||
fail("Missing users.created_at. Apply worker migration 0005_add_user_created_at.sql.");
|
||||
}
|
||||
fail(message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildUsageStatsSql,
|
||||
formatUsageStats,
|
||||
parseCliArgs,
|
||||
};
|
||||
32
deps/db-sync/worker/scripts/ws_test.js
vendored
Normal file
32
deps/db-sync/worker/scripts/ws_test.js
vendored
Normal file
@@ -0,0 +1,32 @@
|
||||
const baseUrl = process.env.BASE_URL || "http://127.0.0.1:8787";
|
||||
const graphId = process.env.GRAPH_ID || "dev-graph";
|
||||
|
||||
const wsUrl = baseUrl.replace(/^http/, "ws") + `/sync/${graphId}`;
|
||||
|
||||
const WebSocketCtor = globalThis.WebSocket;
|
||||
if (!WebSocketCtor) {
|
||||
console.error("WebSocket not available. Use node>=20 or provide a WebSocket polyfill.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ws = new WebSocketCtor(wsUrl);
|
||||
let pending = 0;
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
ws.send(JSON.stringify({ type: "hello", client: "ws-test" }));
|
||||
ws.send(JSON.stringify({ type: "ping" }));
|
||||
ws.send(JSON.stringify({ type: "pull", since: 0 }));
|
||||
pending = 3;
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
console.log(String(event.data));
|
||||
pending -= 1;
|
||||
if (pending <= 0) {
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("error", (event) => {
|
||||
console.error("ws error", event);
|
||||
});
|
||||
Reference in New Issue
Block a user