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-unconfirmed-not-failed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

`clerk migrate import` no longer counts a user whose create got no answer (a 5xx or a network error) as failed. Clerk may hold that user, so the summary lists it under "Not confirmed" and `--json` under `result.unconfirmed`, matching `clerk migrate runs <id>`. The import still exits 1, and a continue checks for the user. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`.
15 changes: 9 additions & 6 deletions packages/cli-core/src/commands/migrate/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ Every command follows these:
4. **Every command prints its target first:** the environment, app and
instance, and where the key came from.
5. **Every subcommand takes `--json`.** Exit codes: `0` all good, `1` some users
failed, `2` a usage error or a refusal, and `130` (death by SIGINT) when
failed or are not confirmed, `2` a usage error or a refusal, and `130` (death by SIGINT) when
Ctrl-C stops an import or an undo partway. The UI goes to stderr and data to
stdout.

Expand Down Expand Up @@ -160,10 +160,13 @@ field.

`creating` is written as a user's `POST /v1/users` goes out. It stays the latest
status when no answer says whether the create landed: an abort, a network error,
or a 5xx. The import counts a network error or a 5xx as failed, and a second
`creating` line records its `error`. A `created` line with `pending` lists the
extra emails and phones not yet attached, and `counts.pending` (`result.pending`
in the import's `--json`) counts those users.
or a 5xx. The import counts a network error or a 5xx as not confirmed, not
failed ("Not confirmed" in the summary, `result.unconfirmed` in `--json`), and
exits 1, since Clerk may or may not hold the user. A second `creating` line
records its `error`, and a continue looks the user up before creating it again.
A `created` line with `pending` lists the extra emails and phones not yet
attached, and `counts.pending` (`result.pending` in the import's `--json`)
counts those users.

A run is `partial` when any user failed, was skipped, is still `creating`, has
identifiers still `pending`, or was never sent (`counts.notSent`), and
Expand Down Expand Up @@ -768,7 +771,7 @@ Failures do not stop the run: each user's outcome is written to the
[run](#the-run-store) and the import continues. A `429` backs off —
honouring `Retry-After` when the response carries it — and retries up to 5
times before the user is recorded as failed. The command exits 1 if any user
failed.
failed or is not confirmed.

`--require-password` records each user it leaves out as `skipped`, with
`code: "no_password"`, so the run ends `partial`. A continue carries the flag
Expand Down
7 changes: 4 additions & 3 deletions packages/cli-core/src/commands/migrate/import-users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,7 +850,7 @@ describe("importUsers", () => {
reserveUnverified: true,
});

expect(summary.failed).toBe(1);
expect(summary).toMatchObject({ failed: 0, unconfirmed: 1 });
expect(allLines.at(-1)).toMatchObject({
status: "creating",
reserved: true,
Expand Down Expand Up @@ -1065,7 +1065,8 @@ describe("importUsers", () => {
record,
});

expect(summary.failed).toBe(1);
// Clerk may hold the user: not a failure until a continue checks.
expect(summary).toMatchObject({ failed: 0, unconfirmed: 1 });
// A second `creating` line, so `runs <id>` can say why.
expect(allLines.map((line) => line.status)).toEqual(["creating", "creating"]);
expect(allLines[1]?.error).toContain("a re-run checks");
Expand All @@ -1082,7 +1083,7 @@ describe("importUsers", () => {
record,
});

expect(summary).toMatchObject({ successful: 0, failed: 1 });
expect(summary).toMatchObject({ successful: 0, failed: 0, unconfirmed: 1 });
expect(allLines).toEqual([
{ sourceId: "u1", status: "creating" },
{
Expand Down
5 changes: 4 additions & 1 deletion packages/cli-core/src/commands/migrate/import-users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -493,6 +493,7 @@ export async function importUsers(options: ImportUsersOptions): Promise<ImportSu
let processed = 0;
let successful = 0;
let failed = 0;
let unconfirmed = 0;
let notSent = 0;
let stopReason: string | undefined;
const droppedPhones = new Map<string, number>();
Expand Down Expand Up @@ -536,7 +537,8 @@ export async function importUsers(options: ImportUsersOptions): Promise<ImportSu
unknown: boolean,
attempt?: UserLine,
) => {
failed++;
if (unknown) unconfirmed++;
else failed++;
processed++;
tallyError(errorBreakdown, message);
// With no answer, the user stays `creating`: Clerk may hold it, and a
Expand Down Expand Up @@ -713,6 +715,7 @@ export async function importUsers(options: ImportUsersOptions): Promise<ImportSu
totalProcessed: total,
successful,
failed,
unconfirmed,
notSent,
...(stopReason ? { stopReason } : {}),
droppedPhones,
Expand Down
28 changes: 28 additions & 0 deletions packages/cli-core/src/commands/migrate/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1334,6 +1334,34 @@ describe("run", () => {
expect(listRuns(runsDir())[0]).toMatchObject({ status: "partial", counts: { failed: 1 } });
});

// Clerk may hold a user whose create got no answer, so it isn't a failure:
// it reads as `runs <id>` reads it, and a continue checks.
test("counts a create with no answer as not confirmed, not failed", async () => {
stubClerk({ unanswered: new Set(["u2"]) });

await run(baseOptions);

const err = Bun.stripANSI(captured.err);
expect(err).toContain("Imported: 1");
expect(err).toContain("Failed: 0");
expect(err).toContain("Not confirmed: 1");
expect(process.exitCode).toBe(1);
// The run is partial, so the next step is the continue that checks.
expect(err).toContain("to retry them, in the same run");
});

test("puts a create with no answer under result.unconfirmed in --json", async () => {
stubClerk({ unanswered: new Set(["u2"]) });

await run({ ...baseOptions, json: true });

expect(JSON.parse(captured.out).result).toMatchObject({
created: 1,
failed: 0,
unconfirmed: 1,
});
});

// Run later, the key may no longer be exported: the steps name it as a
// placeholder, as `runs <id>` does, rather than nothing.
test("after failures with an exported key, the retry and undo name the key", async () => {
Expand Down
7 changes: 6 additions & 1 deletion packages/cli-core/src/commands/migrate/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,8 @@ function formatSummary(
`${green("Imported:")} ${summary.successful}`,
`${red("Failed:")} ${summary.failed}`,
];
// No answer, so Clerk may hold them: a continue checks.
if (summary.unconfirmed > 0) lines.push(`${yellow("Not confirmed:")} ${summary.unconfirmed}`);
if (skipped > 0) lines.push(`${yellow("Skipped:")} ${skipped}`);
// The user quota stopped the run: these go out on a re-run.
if (summary.notSent > 0) lines.push(`${yellow("Not sent:")} ${summary.notSent}`);
Expand Down Expand Up @@ -1539,6 +1541,7 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi
totalProcessed: 0,
successful: 0,
failed: 0,
unconfirmed: 0,
notSent: 0,
droppedPhones: new Map(),
validationFailed: 0,
Expand All @@ -1554,7 +1557,8 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi
}
const record = run.finish({ notSent: summary.notSent });
setTelemetryStage("done");
if (summary.failed > 0) {
// Unconfirmed users aren't done either: Clerk may not hold them.
if (summary.failed > 0 || summary.unconfirmed > 0) {
process.exitCode = 1;
declareSoftExitOutcome("error", "migrate_import_partial");
}
Expand All @@ -1571,6 +1575,7 @@ async function runImport(rawOptions: MigrateRunOptions, lock: ImportLock): Promi
result: {
created: summary.successful,
failed: summary.failed,
unconfirmed: summary.unconfirmed,
notSent: summary.notSent,
pending: record.counts.pending ?? 0,
...(summary.stopReason ? { stopReason: summary.stopReason } : {}),
Expand Down
5 changes: 5 additions & 0 deletions packages/cli-core/src/commands/migrate/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ export type ImportSummary = {
totalProcessed: number;
successful: number;
failed: number;
/**
* Users whose create got no answer, so Clerk may hold them. Their line stays
* `creating`, and a continue looks them up before creating them again.
*/
unconfirmed: number;
/**
* Users never sent because a Ctrl-C or the instance's user quota stopped
* the run first, so they have no line in the run. One whose first create
Expand Down
Loading