#!/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 [--env prod] node worker/scripts/delete_user_totally.js --user-id [--env prod] Options: --username Look up the target user by username. --user-id Look up the target user by user id. --env Wrangler environment to use. Defaults to "prod". --database D1 binding or database name. Defaults to "DB". --config Wrangler config path. Defaults to worker/wrangler.toml. --base-url Worker base URL. Defaults to DB_SYNC_BASE_URL. --admin-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, };