Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
63 commits
Select commit Hold shift + click to select a range
73553e3
feat(migrate): add runs and undo, and continue a stopped import
royanger Oct 5, 2026
6d37212
test(migrate): undo the e2e import and check the users are gone
royanger Oct 5, 2026
0183073
docs(changeset): Add clerk migrate runs and undo, and continue a stop…
royanger Oct 5, 2026
9dfe6c3
fix(migrate): lock the import run while undo deletes its users
royanger Oct 6, 2026
a7ffea0
fix(migrate): refuse an undo when another undo finished during its pr…
royanger Oct 6, 2026
0a9ea71
docs(migrate): say undo skips consent when no users remain
royanger Oct 6, 2026
3e949a9
fix(migrate): check a continued run again once its lock is held
royanger Oct 8, 2026
926b9a5
fix(migrate): count an adopted user against dev headroom once
royanger Oct 8, 2026
6263cb8
fix(migrate): refuse to start over when Clerk can't name a resumed ru…
royanger Oct 8, 2026
7b25fd5
test(migrate): check undo's users are gone by Clerk's not-found answer
royanger Oct 9, 2026
92c8891
fix(migrate): adopt an in-flight user only if Clerk created it during…
royanger Oct 9, 2026
84d5103
fix(migrate): pause every undo delete on a 429, not just the one that…
royanger Oct 9, 2026
19a6a2c
fix(migrate): check for an unfinished undo again once a continue hold…
royanger Oct 9, 2026
f20d670
fix(migrate): still check other users against an adopted user's ident…
royanger Oct 9, 2026
1762e5e
fix(migrate): adopt an in-flight user only if it carries the run's ma…
royanger Oct 9, 2026
1a80a39
fix(migrate): never reject an adopted user, nor count it against the …
royanger Oct 9, 2026
76e0d8e
docs(migrate): say a cut-off create is redone only when nothing matches
royanger Oct 9, 2026
3d39832
refactor(migrate): drop the adopted-user guard the checks made dead w…
royanger Oct 9, 2026
595e4a5
fix(migrate): say a plain re-run sends the users a quota stop left, n…
royanger Oct 9, 2026
258fdd4
fix(migrate): record a created user with its pending attaches in one …
royanger Oct 9, 2026
8f53c88
fix(migrate): still reject a second record with an adopted user's sou…
royanger Oct 9, 2026
83a0a1a
docs(migrate): say the run's marker replaces one the source already c…
royanger Oct 10, 2026
fc70e18
fix(migrate): reject an earlier record that claims an identifier an a…
royanger Oct 10, 2026
798b486
fix(migrate): let adopted users claim their identifiers before the fi…
royanger Oct 13, 2026
e643e32
fix(migrate): have a dry run say the run it would start or continue
royanger Oct 13, 2026
5f99502
fix(migrate): have undo --yes refuse once users have signed in, unles…
royanger Oct 13, 2026
98ca4d7
docs(changeset): say every imported user carries the run marker in pr…
royanger Oct 13, 2026
41912cf
fix(migrate): refuse an undo whose preview another undo overtook
royanger Oct 13, 2026
3d5ae69
fix(migrate): keep the runs folder and target in every suggested migr…
royanger Oct 15, 2026
927ac90
fix(migrate): report a continued run with nothing left as finished in…
royanger Oct 15, 2026
1583996
fix(migrate): print undo's import as undone, recheck sign-ins before …
royanger Oct 15, 2026
44a4aa3
fix(migrate): recheck undo's sign-ins by user, not count, and fail a …
royanger Oct 15, 2026
1572605
fix(migrate): print undo's preview under --json when another process …
royanger Oct 15, 2026
9614c1e
fix(migrate): print undo's preview once for anything that stops it af…
royanger Oct 15, 2026
9e63b93
fix(migrate): raise a stalled lookup as a CliError, log the run store…
royanger Oct 15, 2026
97eb4d3
refactor(migrate): share the unfinished-undo lookup, the undo command…
royanger Oct 15, 2026
11aff60
fix(migrate): count attach-only users in the import's progress, and l…
royanger Oct 15, 2026
325d2ab
fix(migrate): list the same runs folder in run hints, and suggest the…
royanger Oct 15, 2026
b3726b0
refactor(migrate): one JSON printer for undo, one scan in findResume,…
royanger Oct 15, 2026
632ac20
docs(migrate): list --include-signed-in in the synopsis, keep the con…
royanger Oct 15, 2026
bb0adf4
fix(migrate): suggest a run's continue only when importing its file a…
royanger Oct 15, 2026
98be418
refactor(migrate): one runs-command builder, findResume through openU…
royanger Oct 15, 2026
dba20fc
fix(migrate): share findResume's file filter with runs <id>, and leav…
royanger Oct 15, 2026
731e04c
fix(migrate): carry the environment for a stand-in --app run, and sha…
royanger Oct 15, 2026
d3f4e24
fix(migrate): record the instance --app or a linked profile names whe…
royanger Oct 15, 2026
6316d77
fix(migrate): carry --instance for a linked-profile run, and keep the…
royanger Oct 15, 2026
363a18f
fix(migrate): carry every key source's target in runs <id>, from one …
royanger Oct 15, 2026
6593740
fix(migrate): name the key for a CLERK_SECRET_KEY run, and build ever…
royanger Oct 15, 2026
77aa3d7
fix(migrate): carry the key a run's latest attempt used, including a …
royanger Oct 15, 2026
095e529
fix(migrate): carry the key for a stand-in run continued with --app, …
royanger Oct 16, 2026
45ba726
fix(migrate): show unconfirmed creates and identifiers left to attach…
royanger Oct 16, 2026
c962ad5
fix(migrate): offer the continue for any partial import, and count a …
royanger Oct 16, 2026
95cad7f
fix(migrate): offer no continue when only --require-password skips ar…
royanger Oct 16, 2026
35570f3
fix(migrate): point --require-password's skipped users at the re-run …
royanger Oct 16, 2026
7f16165
fix(migrate): offer the continue when --new-run refuses a partial run…
royanger Oct 16, 2026
46a2b10
fix(migrate): offer the refusal's continue only when findResume would…
royanger Oct 16, 2026
299cb03
fix(migrate): count an unfinished run as having users to retry
royanger Oct 16, 2026
5292ffe
fix(migrate): answer the open #515 review comments
royanger Oct 16, 2026
0897892
fix(migrate): leave out runs <id>'s continue when the run's flags can…
royanger Oct 16, 2026
cb57e05
docs(migrate): say an unfinished run counts as having users to retry
royanger Oct 16, 2026
5e05934
fix(migrate): count attach-only users as created, show unsent users i…
royanger Oct 16, 2026
4eb8299
fix(migrate): stop suggesting a continue for skips the same file brin…
royanger Oct 16, 2026
74e7b96
fix(migrate): don't offer --allow-partial when a refusal rejects ever…
royanger Oct 16, 2026
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
7 changes: 7 additions & 0 deletions .changeset/migrate-runs-undo.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"clerk": minor
---

Add `clerk migrate runs` and `clerk migrate undo`, and let a re-run of `clerk migrate import` continue where a stopped one left off. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`.
Comment thread
wyattjoh marked this conversation as resolved.

Every user an import creates now carries `clerkMigrateRun` (the run's ID) in its `private_metadata`, replacing any value the source had under that key. A re-run reads it to adopt a user whose create was cut off. Nothing removes it afterwards, and your backend can read it.
337 changes: 278 additions & 59 deletions packages/cli-core/src/commands/migrate/README.md

Large diffs are not rendered by default.

171 changes: 159 additions & 12 deletions packages/cli-core/src/commands/migrate/import-users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { useCaptureLog } from "../../test/lib/stubs.ts";
import {
buildCreateUserBody,
importUsers,
normalizeErrorMessage,
readRetryAfter,
splitIdentifiers,
} from "./import-users.ts";
import { normalizeErrorMessage } from "./lib/failures.ts";
import type { ResolvedLimits } from "./lib/instance.ts";
import type { UserLine } from "./lib/run-store.ts";
import type { User } from "./types.ts";
Expand Down Expand Up @@ -210,6 +210,42 @@ describe("importUsers", () => {
headers,
});

// How a continue or an undo tells its own cut-off create from a user
// someone else made with the same external_id.
test("marks each create with its run, keeping the source's private metadata", async () => {
stub(() => ok("user_created"));

await importUsers({
users: [user({ userId: "u1", privateMetadata: { plan: "pro" } })],
secretKey: "sk_test_x",
limits: LIMITS,
record,
runId: "20260101-000000-abcd",
});

expect(requests.find((r) => r.url.endsWith("/v1/users"))?.body).toMatchObject({
private_metadata: { plan: "pro", clerkMigrateRun: "20260101-000000-abcd" },
});
});

// A Clerk export of users an earlier migration imported carries that run's
// marker; this run's must win, or a cut-off create could never be adopted.
test("replaces a marker the source's private metadata already carries", async () => {
stub(() => ok("user_created"));

await importUsers({
users: [user({ userId: "u1", privateMetadata: { clerkMigrateRun: "20250101-000000-0000" } })],
secretKey: "sk_test_x",
limits: LIMITS,
record,
runId: "20260101-000000-abcd",
});

expect(requests.find((r) => r.url.endsWith("/v1/users"))?.body).toMatchObject({
private_metadata: { clerkMigrateRun: "20260101-000000-abcd" },
});
});

test("creates each user and reports them as successful", async () => {
stub(() => ok("user_created"));

Expand Down Expand Up @@ -327,14 +363,18 @@ describe("importUsers", () => {
});

expect(summary).toMatchObject({ successful: 1, failed: 0 });
// On record once created, then with its attach pending, then with what
// the attach added. A refused attach is not retried, so nothing is pending.
expect(lines).toHaveLength(3);
expect(lines[0]).toEqual({ sourceId: "u1", clerkId: "user_created", status: "created" });
expect(lines[1]?.pending).toEqual([{ kind: "email", value: "b@x.dev", verified: true }]);
expect(lines[2]).toMatchObject({ status: "created", clerkId: "user_created" });
expect(lines[2]?.error).toContain("Failed to add additional email b@x.dev");
expect(lines[2]).not.toHaveProperty("pending");
// On record once created with its attach pending, in one line, then with
// what the attach added. A refused attach is not retried, so nothing is pending.
expect(lines).toHaveLength(2);
expect(lines[0]).toEqual({
sourceId: "u1",
clerkId: "user_created",
status: "created",
pending: [{ kind: "email", value: "b@x.dev", verified: true }],
});
expect(lines[1]).toMatchObject({ status: "created", clerkId: "user_created" });
expect(lines[1]?.error).toContain("Failed to add additional email b@x.dev");
expect(lines[1]).not.toHaveProperty("pending");
});

// A Ctrl-C mid-backoff should not wait out the pause, nor send the attach.
Expand Down Expand Up @@ -380,7 +420,7 @@ describe("importUsers", () => {
expect(lines.at(-1)).not.toHaveProperty("pending");
});

test("keeps an attach with no answer pending", async () => {
test("keeps an attach with no answer pending, for a continued run", async () => {
stub((url) =>
url.endsWith("/v1/email_addresses") ? clerkError(503, "unavailable") : ok("user_created"),
);
Expand Down Expand Up @@ -421,6 +461,103 @@ describe("importUsers", () => {
]);
});

test("attachOnly sends just the pending attaches, and clears them", async () => {
stub(() => ok("idn_1"));

await importUsers({
users: [],
attachOnly: [
{
sourceId: "u1",
clerkId: "user_1",
status: "created",
pending: [{ kind: "phone", value: "+15555550100", verified: false }],
},
],
secretKey: "sk_test_x",
limits: LIMITS,
record,
});

expect(requests.map((r) => [new URL(r.url).pathname, r.body])).toEqual([
[
"/v1/phone_numbers",
{ user_id: "user_1", phone_number: "+15555550100", primary: false, verified: false },
],
]);
expect(lines.at(-1)).toEqual({ sourceId: "u1", clerkId: "user_1", status: "created" });
});

// A continue with only attaches left would otherwise read 0/0 throughout.
test("counts an attach-only user towards progress once its attaches are sent", async () => {
stub(() => ok("idn_1"));
const reported: { done: number; ok: number }[] = [];

const summary = await importUsers({
users: [],
attachOnly: [
{
sourceId: "u1",
clerkId: "user_1",
status: "created",
pending: [{ kind: "phone", value: "+15555550100", verified: false }],
},
],
secretKey: "sk_test_x",
limits: LIMITS,
record,
progress: ({ done, ok }) => reported.push({ done, ok }),
});

// Created by the run's earlier attempt: the bar and the summary agree.
expect(reported.at(-1)).toEqual({ done: 1, ok: 1 });
expect(summary.successful).toBe(1);
});

// The pending phone attaches now, but the email Clerk refused stays refused.
test("an attach-only user keeps the error its earlier line recorded", async () => {
stub(() => ok("idn_1"));

await importUsers({
users: [],
attachOnly: [
{
sourceId: "u1",
clerkId: "user_1",
status: "created",
error: "b@x.dev: That email address is taken.",
pending: [{ kind: "phone", value: "+15555550100", verified: false }],
},
],
secretKey: "sk_test_x",
limits: LIMITS,
record,
});

expect(lines.at(-1)).toMatchObject({
sourceId: "u1",
status: "created",
error: "b@x.dev: That email address is taken.",
});
expect(lines.at(-1)?.pending).toBeUndefined();
});

test("an adopted user is not created again; only its extras attach", async () => {
stub(() => ok("idn_1"));

const summary = await importUsers({
users: [user({ email: ["a@x.dev", "b@x.dev"] })],
adopted: new Map([["u1", "user_found"]]),
secretKey: "sk_test_x",
limits: LIMITS,
record,
});

expect(requests.map((r) => new URL(r.url).pathname)).toEqual(["/v1/email_addresses"]);
expect(summary.successful).toBe(1);
expect(lines.at(-1)).toMatchObject({ clerkId: "user_found", status: "created" });
});

// Shapes from clerk_go's apierror: the country error carries its own code
// and no param_name; the E.164 error is a form error on phone_number.
test.each([
Expand Down Expand Up @@ -604,7 +741,9 @@ describe("importUsers", () => {
});

expect(summary.failed).toBe(1);
expect(allLines.map((line) => line.status)).toEqual(["creating"]);
// 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");
expect([...summary.errorBreakdown.keys()][0]).toContain("a re-run checks");
});

Expand All @@ -619,7 +758,15 @@ describe("importUsers", () => {
});

expect(summary).toMatchObject({ successful: 0, failed: 1 });
expect(allLines).toEqual([{ sourceId: "u1", status: "creating" }]);
expect(allLines).toEqual([
{ sourceId: "u1", status: "creating" },
{
sourceId: "u1",
status: "creating",
error: expect.stringContaining("without a user ID"),
code: "unknown",
},
]);
});

// Every create after the first refusal would be refused too, so none is
Expand Down
Loading
Loading