/** * `clerk migrate runs [run-id]` — what every import, export and undo did. * * Reads the run store and nothing else. `runs` alone lists every run, newest * first. `runs ` shows one: its header, counts, the error breakdown, the * users that did not make it, and the command to run next. */ import path from "node:path"; import { bold, dim, green, red, yellow } from "../../lib/color.ts"; import { throwUsageError } from "../../lib/errors.ts"; import { recordMigrateSource, recordMigrateTarget } from "./telemetry.ts"; import { quoteArg } from "../../lib/json-body.ts"; import { log } from "../../lib/log.ts"; import { tallyError, WITHOUT_IDENTIFIER_WARNING, WITHOUT_PHONE_WARNING } from "./lib/failures.ts"; import { hasRetryableUsers, latestUserLines, listRuns, importsOfFile, undoneInFull, undosOf, readRun, resolveRunsDir, runDir, runState, sha256File, type RunCounts, type RunRecord, type RunState, type UserLine, USERS_FILE, noRunMessage, withLiveCounts, } from "./lib/run-store.ts"; import { carryFlags, importFlags, undoCommand, withFlags } from "./lib/carry-flags.ts"; import { plural } from "./lib/plural.ts"; import { describeTarget, isStandInInstanceId, laterCarry } from "./lib/target.ts"; export type RunsOptions = { json?: boolean; runsDir?: string; }; /** How many users of each list `runs ` prints before pointing at the file. */ const USER_LIST_LIMIT = 20; function formatDate(iso: string): string { const date = new Date(iso); if (Number.isNaN(date.getTime())) return iso; return date.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); } function colorState(state: RunState): string { if (state === "complete") return green(state); if (state === "partial" || state === "interrupted") return yellow(state); if (state === "undone") return dim(state); return state; } // `creating`: a create no answer confirmed. Clerk may hold the user. const COUNT_LABELS: Partial> = { creating: "not confirmed", pending: "left to attach", notSent: "not sent", }; /** The counts that matter for a run's kind, in the order they are read. */ function formatCounts(record: RunRecord): string { const counts = record.counts; const order = record.kind === "export" ? (["exported", "skipped"] as const) : record.kind === "undo" ? (["deleted", "failed"] as const) : (["created", "pending", "creating", "failed", "notSent", "skipped"] as const); const parts = order .filter((status) => (counts[status] ?? 0) > 0 || status === order[0]) .map((status) => `${counts[status] ?? 0} ${COUNT_LABELS[status] ?? status}`); return parts.join(", "); } function fileLabel(record: RunRecord): string { if (!record.file) return ""; const relative = path.relative(process.cwd(), record.file.path); return relative.startsWith("..") ? record.file.path : relative; } function pad(rows: string[][]): string[] { const widths = rows[0]!.map((_, column) => Math.max(...rows.map((row) => Bun.stringWidth(row[column] ?? ""))), ); return rows.map((row) => row .map((cell, column) => column === row.length - 1 ? cell : cell + " ".repeat(widths[column]! - Bun.stringWidth(cell)), ) .join(" ") .trimEnd(), ); } /** Error messages grouped the way the import summary groups them. */ function errorBreakdown(lines: UserLine[]): { error: string; count: number }[] { const counts = new Map(); for (const line of lines) { if ((line.status === "failed" || line.status === "creating") && line.error) { tallyError(counts, line.error); } } return [...counts].map(([error, count]) => ({ error, count })).sort((a, b) => b.count - a.count); } /** * Users imported without their first phone, or an email or phone Clerk * refused, by reason: worded as the import's `result.warnings`. */ function warningBreakdown(lines: UserLine[]): { warning: string; count: number }[] { const counts = new Map(); const add = (warning: string) => counts.set(warning, (counts.get(warning) ?? 0) + 1); for (const line of lines) { if (line.phoneRefused) add(`${WITHOUT_PHONE_WARNING}${line.phoneRefused}`); for (const reason of line.refused ?? []) add(`${WITHOUT_IDENTIFIER_WARNING}${reason}`); } return [...counts] .map(([warning, count]) => ({ warning, count })) .sort((a, b) => b.count - a.count); } /** * The import that continues this run: its file (or export run) and source, * with the flags that reach the same runs folder and instance, ending in * `--yes`. None when the run has no file, or flags this build can't read. */ function continueCommand(record: RunRecord, carry: string[]): string | undefined { // Every import records its flags. None, or the string[] an earlier build // wrote, can't be read back, and a continue without them would create the // users --require-password held back. if (!record.flags || Array.isArray(record.flags)) return undefined; const input = record.fromExport ?? record.file?.path; if (!input) return undefined; const parts = ["clerk migrate import", quoteArg(input)]; // A source you wrote is a file the record knows only by its hash. A name, // not `<…>`: pasted as is, a shell reads `` as a redirect. if (record.sourceHash) parts.push("--source", "SOURCE_FILE"); else if (!record.fromExport && record.source) parts.push("--source", quoteArg(record.source)); return withFlags(parts.join(" "), [...importFlags(record.flags), ...carry, "--yes"]); } /** * Whether importing the record's file again continues this run, rather than * starting another or refusing: no newer import of that file and source (and * version of a source you wrote) may be for the same instance, the file is * unchanged, and no undo of it exists. An unfinished undo makes an import * refuse; a finished one that never marked the run makes it start a new one. */ function continuesThisRun(runsDir: string, record: RunRecord): boolean { const file = record.file; if (!file) return false; const runs = listRuns(runsDir); // An import also takes a run recorded under its key's stand-in ID. Without // the key, a stand-in on either side may be the same instance: offering the // continue then could send the user to a newer run instead. const mayBeSameInstance = (id: string | undefined) => id === record.target.instanceId || isStandInInstanceId(id) || isStandInInstanceId(record.target.instanceId); const newest = importsOfFile(runs, { sha256: file.sha256, source: record.source }).find( (other) => other.sourceHash === record.sourceHash && mayBeSameInstance(other.target.instanceId), ); if (newest?.id !== record.id) return false; if (undosOf(runs, record.id).length > 0) return false; try { return sha256File(file.path) === file.sha256; } catch (error) { log.debug(`migrate: could not read ${file.path} to check it is unchanged: ${String(error)}`); return false; } } /** * What to run after reading about this run. `carry` is the runs folder this * listing read, and the target flags the record says the import used. */ function nextCommands( runsDir: string, record: RunRecord, state: RunState, carry: string[], latest: UserLine[], ): string[] { if (state === "running") return []; if (record.kind === "import" && state !== "undone") { const undo = `Run \`${undoCommand(record.id, carry)}\` to delete the users it created`; const again = continueCommand(record, carry); if (!again || state === "complete" || !continuesThisRun(runsDir, record)) return [undo]; if (state === "partial" && !hasRetryableUsers(record, latest)) return [undo]; // Continuing is what a stopped run is for: the same file, source and // target pick it up where it left off. const why = state === "interrupted" ? "continue it" : "retry the users it didn't finish"; // The record knows a source you wrote only by its hash. const placeholder = record.sourceHash ? ", with SOURCE_FILE replaced by the path to the source you wrote" : ""; return [`Run \`${again}\` to ${why}${placeholder}`, undo]; } if (record.kind === "undo" && state !== "complete" && record.undoes) { return [`Run \`${undoCommand(record.undoes, carry)}\` to retry the users that failed`]; } return []; } function listJson(runsDir: string, records: RunRecord[]) { return { runsDir, runs: records.map((record) => ({ ...record, state: runState(runsDir, record) })), }; } function printList(runsDir: string, records: RunRecord[]): void { log.info(`Runs folder: ${runsDir}`); log.blank(); if (records.length === 0) { log.info("No migration runs yet."); return; } const rows = [ ["RUN", "DATE", "KIND", "STATUS", "TARGET", "FILE", "RESULT"].map((header) => bold(header)), ...records.map((record) => [ record.id, formatDate(record.startedAt), record.kind, colorState(runState(runsDir, record)), describeTarget(record.target), fileLabel(record), formatCounts(record), ]), ]; for (const line of pad(rows)) log.info(line); log.blank(); log.info(dim(plural(records.length, "run"))); } /** * The users an undo of this import deleted. Undo writes its `deleted` lines to * its own run: such a user is gone, whatever the import's last line says. */ function deletedByUndo(runsDir: string, records: RunRecord[], importId: string): Set { return new Set( undosOf(records, importId).flatMap((undo) => [...latestUserLines(runsDir, undo.id).values()] .filter((line) => line.status === "deleted") .map((line) => line.sourceId), ), ); } /** * The run with the counts `runs` shows, which follow its lists: a user an undo * deleted leaves the not-confirmed and left-to-attach counts, and once the run * is undone in full, both are zero. An unconfirmed user an undo deleted was * found by the run's marker, so the import did create it: it counts as created. */ function shownRun(runsDir: string, record: RunRecord, records: RunRecord[]): RunRecord { const live = withLiveCounts(runsDir, record); const deleted = deletedByUndo(runsDir, records, record.id); const inFull = undoneInFull(runsDir, record, records); if (deleted.size === 0 && !inFull) return live; const lines = [...latestUserLines(runsDir, record.id).values()]; const kept = inFull ? [] : lines.filter((line) => !deleted.has(line.sourceId)); const creating = kept.filter((line) => line.status === "creating").length; const pending = kept.filter( (line) => line.status === "created" && (line.pending?.length ?? 0) > 0, ).length; const adopted = lines.filter( (line) => line.status === "creating" && deleted.has(line.sourceId), ).length; const { creating: _creating, pending: _pending, ...counts } = live.counts; return { ...live, counts: { ...counts, created: (counts.created ?? 0) + adopted, ...(creating > 0 ? { creating } : {}), ...(pending > 0 ? { pending } : {}), }, }; } /** One run in full: `--json` prints it, and `printRun` renders it. */ function showJson(runsDir: string, record: RunRecord, carry: string[]) { const lines = [...latestUserLines(runsDir, record.id).values()]; const state = runState(runsDir, record); // The undo's `deleted` lines are its own run's: these users are gone, so // nothing they were imported without is worth a warning. Once the run is // undone in full, no user is left: undo looked up each in-flight one by the // run's marker and deleted what it found. const inFull = undoneInFull(runsDir, record); const deleted = deletedByUndo(runsDir, listRuns(runsDir), record.id); const left = (status: UserLine["status"]) => inFull ? [] : lines.filter((line) => line.status === status && !deleted.has(line.sourceId)); const created = left("created"); const unconfirmed = left("creating"); const failed = lines.filter((line) => line.status === "failed"); return { runsDir, run: { ...record, state }, next: nextCommands(runsDir, record, state, carry, lines), // Only the unconfirmed users still listed: for the rest, "a re-run checks" no longer holds. errors: errorBreakdown([...failed, ...unconfirmed]), warnings: warningBreakdown(created), failed, importedWithout: created.filter( (line) => line.phoneRefused !== undefined || (line.refused?.length ?? 0) > 0, ), leftToAttach: created.filter((line) => (line.pending?.length ?? 0) > 0), skipped: lines.filter((line) => line.status === "skipped"), unconfirmed, }; } function printUsers(title: string, lines: UserLine[], usersFile: string): void { if (lines.length === 0) return; log.blank(); log.info(bold(`${title} (${lines.length})`)); for (const line of lines.slice(0, USER_LIST_LIMIT)) { const why = line.reason ?? line.error ?? ""; log.info(` ${line.sourceId}${why ? dim(` ${why}`) : ""}`); } if (lines.length > USER_LIST_LIMIT) { log.info(dim(` …and ${lines.length - USER_LIST_LIMIT} more in ${usersFile}`)); } } function printRun(runsDir: string, record: RunRecord, carry: string[]): void { const view = showJson(runsDir, record, carry); const usersFile = path.join(runDir(runsDir, record.id), USERS_FILE); log.info(`Runs folder: ${runsDir}`); log.blank(); const header: [string, string | undefined][] = [ ["Run", record.id], ["Kind", record.kind], ["Status", colorState(view.run.state)], ["Started", formatDate(record.startedAt)], ["Finished", record.finishedAt ? formatDate(record.finishedAt) : undefined], ["Target", describeTarget(record.target)], ["Key from", record.target.keySource], ["Source", record.source], ["File", record.file?.path], ["From export", record.fromExport], ["Undoes", record.undoes], ["Undone by", record.undoneBy], ["Result", formatCounts(record)], ]; const shown = header.filter((entry): entry is [string, string] => Boolean(entry[1])); const width = Math.max(...shown.map(([label]) => label.length)) + 2; for (const [label, value] of shown) log.info(`${bold(label.padEnd(width))}${value}`); if (view.errors.length > 0) { log.blank(); log.info(bold("Error breakdown")); for (const { error, count } of view.errors) { log.info(` ${red(String(count))} ${count === 1 ? "user" : "users"}: ${error}`); } } if (view.warnings.length > 0) { log.blank(); log.info(bold("Warnings")); for (const { warning, count } of view.warnings) { log.info(` ${yellow(String(count))} ${count === 1 ? "user" : "users"}: ${warning}`); } } printUsers("Failed", view.failed, usersFile); printUsers("Imported without an email or phone Clerk refused", view.importedWithout, usersFile); printUsers("Identifiers left to attach", view.leftToAttach, usersFile); // A run with an undo is not continued: undoing again looks them up. const checks = undosOf(listRuns(runsDir), record.id).length > 0 ? "the undo" : "a continue"; printUsers( `Not confirmed (Clerk may have created them; ${checks} checks)`, view.unconfirmed, usersFile, ); printUsers("Skipped", view.skipped, usersFile); log.blank(); log.info(dim(`Every outcome: ${usersFile}`)); for (const step of view.next) log.info(` → ${step}`); } export async function runs(id: string | undefined, options: RunsOptions = {}): Promise { const runsDir = await resolveRunsDir(options.runsDir); const all = listRuns(runsDir); if (id === undefined) { const records = all.map((record) => shownRun(runsDir, record, all)); if (options.json) log.data(JSON.stringify(listJson(runsDir, records), null, 2)); else printList(runsDir, records); return; } const stored = readRun(runsDir, id); if (!stored) throwUsageError(noRunMessage(runsDir, id, carryFlags({ runsDir: options.runsDir }))); const record = shownRun(runsDir, stored, all); recordMigrateSource(record.source, { custom: Boolean(record.sourceHash) }); recordMigrateTarget(record.target); // The record says where the import's key came from, and so which flags // reach that instance again. const carry = laterCarry(options.runsDir, record.target); if (options.json) log.data(JSON.stringify(showJson(runsDir, record, carry), null, 2)); else printRun(runsDir, record, carry); }