Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/migrate-runs-undone-unconfirmed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

`clerk migrate runs <id>` no longer lists or counts a user as "Not confirmed", or keeps its error in the breakdown, once an undo of the run deleted it. When undo finds a cut-off create by the run's marker, it deletes the user, so `runs <id>` and the `clerk migrate runs` listing count that user as created. Once the run is undone in full, `runs <id>` and the listing show no user as not confirmed, in the list or the counts, since undo looked for each one by the marker. After any undo, the warnings, the users left to attach, and the left-to-attach count also leave out the users it deleted. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`.
9 changes: 7 additions & 2 deletions packages/cli-core/src/commands/migrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1055,8 +1055,13 @@ status, target, file and counts. `runs <id>` adds the error breakdown and the
users that failed, were skipped, or are `creating` with no answer to confirm the
create (`unconfirmed` in `--json`). It also warns about users imported without
their phone, or an email or phone Clerk refused (`warnings` and
`importedWithout` in `--json`), unless the run was undone in full, and lists
users with identifiers left to attach and why (`leftToAttach`).
`importedWithout` in `--json`), and lists users with identifiers left to attach
and why (`leftToAttach`). The unconfirmed users, the warnings, and the users
left to attach leave out anyone an undo of the run deleted, and are empty once
the run is undone in full. The not-confirmed and left-to-attach counts in
`runs <id>` and the listing follow them. There, an unconfirmed user an undo
deleted counts as created rather than not confirmed. The error breakdown leaves
out the unconfirmed users that aren't listed.
Then it prints the path to the full record, and what
to run next: for an interrupted or partial import, the import that continues it,
with the flags it ran with, then the undo. The continue is left out when
Expand Down
13 changes: 10 additions & 3 deletions packages/cli-core/src/commands/migrate/lib/run-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -497,11 +497,18 @@ export function undosOf(runs: RunRecord[], importId: string): RunRecord[] {
return runs.filter((record) => record.kind === "undo" && record.undoes === importId);
}

/** True when a run's users are gone: it is marked undone, or an undo of it finished. */
export function undoneInFull(runsDir: string, record: RunRecord): boolean {
/**
* True when a run's users are gone: it is marked undone, or an undo of it
* finished. `runs` is the caller's `listRuns`, when it has one already.
*/
export function undoneInFull(
runsDir: string,
record: RunRecord,
runs = listRuns(runsDir),
): boolean {
return (
runState(runsDir, record) === "undone" ||
undosOf(listRuns(runsDir), record.id).some((undo) => runState(runsDir, undo) === "complete")
undosOf(runs, record.id).some((undo) => runState(runsDir, undo) === "complete")
);
}

Expand Down
144 changes: 144 additions & 0 deletions packages/cli-core/src/commands/migrate/runs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { quoteArg } from "../../lib/json-body.ts";
import { captureTelemetryPayload, useCaptureLog } from "../../test/lib/stubs.ts";
import {
continueRun,
patchRun,
type RunRecord,
sha256File,
startRun,
Expand Down Expand Up @@ -292,6 +293,149 @@ describe("runs <id>", () => {
expect(err).not.toContain("Imported without");
});

// A partial undo leaves the users it deleted out of the warnings and lists.
test("warns about no user a partial undo deleted", async () => {
fs.writeFileSync(usersFile(), "[]");
const run = startRun(runsDir, {
kind: "import",
target: TARGET,
source: "clerk",
file: { path: usersFile(), sha256: sha256File(usersFile()) },
});
for (const id of ["a", "b"]) {
run.append({
sourceId: id,
status: "created",
clerkId: `user_${id}`,
refused: ["unverified phone: That phone number is taken."],
pending: [{ kind: "phone", value: "+15555550100", verified: false }],
});
}
const record = run.finish();
const undo = startRun(runsDir, { kind: "undo", target: TARGET, undoes: record.id });
undo.append({ sourceId: "a", status: "deleted", clerkId: "user_a" });
undo.release();

await runs(record.id, { runsDir, json: true });
expect(JSON.parse(captured.out)).toMatchObject({
warnings: [{ count: 1 }],
importedWithout: [{ sourceId: "b" }],
leftToAttach: [{ sourceId: "b" }],
});

// The count follows the list.
captured.clear();
await runs(record.id, { runsDir });
expect(Bun.stripANSI(captured.err)).toMatch(/^Result +2 created, 1 left to attach$/m);
});

// Undo finds an in-flight user by the run's marker and deletes it, writing
// the delete to its own run. Until it finishes, one it didn't delete may
// still be in Clerk. Once it has, none with the marker is left.
test("lists no unconfirmed user an undo deleted, and none once it finished", async () => {
fs.writeFileSync(usersFile(), "[]");
const run = startRun(runsDir, {
kind: "import",
target: TARGET,
source: "clerk",
file: { path: usersFile(), sha256: sha256File(usersFile()) },
});
run.append({ sourceId: "a", status: "creating", error: "bad gateway (a re-run checks)" });
run.append({ sourceId: "b", status: "creating", error: "timed out (a re-run checks)" });
run.append({
sourceId: "c",
status: "created",
clerkId: "user_c",
pending: [{ kind: "phone", value: "+15555550100", verified: false }],
});
run.release();
const undo = startRun(runsDir, { kind: "undo", target: TARGET, undoes: run.record.id });
undo.append({ sourceId: "a", clerkId: "user_a", status: "deleted" });
undo.release();

await runs(run.record.id, { runsDir, json: true });
expect(JSON.parse(captured.out)).toMatchObject({
run: { counts: { created: 2, creating: 1, pending: 1 } },
unconfirmed: [{ sourceId: "b" }],
errors: [{ error: "timed out (a re-run checks)", count: 1 }],
});

captured.clear();
await runs(run.record.id, { runsDir });
let err = Bun.stripANSI(captured.err);
expect(err).toMatch(/^Result +2 created, 1 left to attach, 1 not confirmed$/m);
expect(err).not.toContain("bad gateway");
expect(err).toContain("Not confirmed (Clerk may have created them; the undo checks) (1)");
expect(err).not.toMatch(/^ {2}a\b/m);

// The listing counts it the same way.
captured.clear();
await runs(undefined, { runsDir, json: true });
const listed = (JSON.parse(captured.out) as { runs: RunRecord[] }).runs;
expect(listed.find((record) => record.id === run.record.id)?.counts).toMatchObject({
created: 2,
creating: 1,
pending: 1,
});

// The undo finishes: it deletes c, and looked b up by the marker and found nothing.
undo.append({ sourceId: "c", clerkId: "user_c", status: "deleted" });
undo.finish();
patchRun(runsDir, run.record.id, { status: "undone" });
captured.clear();
await runs(run.record.id, { runsDir, json: true });
expect(JSON.parse(captured.out)).toMatchObject({
run: { state: "undone" },
unconfirmed: [],
errors: [],
});

captured.clear();
await runs(run.record.id, { runsDir });
err = Bun.stripANSI(captured.err);
expect(err).toMatch(/^Result +2 created$/m);
expect(err).not.toContain("Not confirmed (");
expect(err).not.toContain("timed out");

captured.clear();
await runs(undefined, { runsDir });
err = Bun.stripANSI(captured.err);
expect(err).not.toContain("not confirmed");
expect(err).not.toContain("left to attach");
});

// A finished run's counts come from run.json, not its lines: the same holds.
test("counts no unconfirmed user once an undo deleted them all", async () => {
fs.writeFileSync(usersFile(), "[]");
const run = startRun(runsDir, {
kind: "import",
target: TARGET,
source: "clerk",
file: { path: usersFile(), sha256: sha256File(usersFile()) },
});
run.append({ sourceId: "a", status: "creating", error: "bad gateway (a re-run checks)" });
run.finish();
const undo = startRun(runsDir, { kind: "undo", target: TARGET, undoes: run.record.id });
undo.append({ sourceId: "a", clerkId: "user_a", status: "deleted" });
undo.finish();
patchRun(runsDir, run.record.id, { status: "undone" });

await runs(run.record.id, { runsDir, json: true });
const view = JSON.parse(captured.out) as {
run: RunRecord;
unconfirmed: unknown[];
errors: unknown[];
};
expect(view.run.counts).toMatchObject({ created: 1 });
expect(view.run.counts).not.toHaveProperty("creating");
expect(view.unconfirmed).toEqual([]);
expect(view.errors).toEqual([]);

captured.clear();
await runs(undefined, { runsDir });
expect(Bun.stripANSI(captured.err)).not.toContain("not confirmed");
});

// A stopped import's unsent users have no lines: only the count says so.
test("shows the users a stopped import never sent, in the list and the run", async () => {
fs.writeFileSync(usersFile(), "[]");
Expand Down
76 changes: 66 additions & 10 deletions packages/cli-core/src/commands/migrate/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,28 +245,81 @@ function printList(runsDir: string, records: RunRecord[]): void {
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<string> {
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.
const created = undoneInFull(runsDir, record)
? []
: lines.filter((line) => line.status === "created");
// nothing they were imported without is worth a warning. Once the run is
// undone in full, no user with the marker 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),
errors: errorBreakdown(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: lines.filter((line) => line.status === "failed"),
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: lines.filter((line) => line.status === "creating"),
unconfirmed,
};
}

Expand Down Expand Up @@ -327,8 +380,10 @@ function printRun(runsDir: string, record: RunRecord, carry: string[]): void {
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; a continue checks)",
`Not confirmed (Clerk may have created them; ${checks} checks)`,
view.unconfirmed,
usersFile,
);
Expand All @@ -342,16 +397,17 @@ function printRun(runsDir: string, record: RunRecord, carry: string[]): void {
export async function runs(id: string | undefined, options: RunsOptions = {}): Promise<void> {
const runsDir = await resolveRunsDir(options.runsDir);

const all = listRuns(runsDir);
if (id === undefined) {
const records = listRuns(runsDir).map((record) => withLiveCounts(runsDir, record));
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 = withLiveCounts(runsDir, stored);
const record = shownRun(runsDir, stored, all);
recordMigrateSource(record.source, { custom: Boolean(record.sourceHash) });
recordMigrateTarget(record.target);

Expand Down
Loading