| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254 |
- #!/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,
- };
|