From 49887532feb807fc37476de0ee40b2e4287e6126 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 13:06:16 -0400 Subject: [PATCH 01/16] feat(migrate): add export authjs and Auth.js imports `clerk migrate export authjs` reads an Auth.js database over --db-url (or AUTHJS_DB_URL, or a prompt): Postgres, MySQL, libsql/Turso or SQLite. Auth.js has no single schema, so it tries the `User`, `user` and `users` tables in turn and reports which one it read, and reads the verified column as `emailVerified`, or `email_verified` on a legacy NextAuth table. A schema missing a column the export reads gets advice naming the columns and a SELECT to export with instead. `clerk migrate import` reads Auth.js exports: `email_verified` is a nullable timestamp, `name` is split into first and last name, and users arrive without passwords, since Auth.js core is passwordless. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 72 ++++--- .../src/commands/migrate/export/authjs.ts | 183 ++++++++++++++++++ .../migrate/export/db-exports.test.ts | 108 ++++++++++- .../src/commands/migrate/export/db-options.ts | 4 +- .../src/commands/migrate/export/index.ts | 15 +- .../commands/migrate/export/registry.test.ts | 1 + .../src/commands/migrate/export/registry.ts | 8 + .../src/commands/migrate/index.test.ts | 13 +- .../src/commands/migrate/lib/db.test.ts | 15 +- .../cli-core/src/commands/migrate/lib/db.ts | 14 +- .../commands/migrate/lib/input-retry.test.ts | 6 +- .../cli-core/src/commands/migrate/run.test.ts | 3 +- .../src/commands/migrate/sources/authjs.ts | 49 +++++ .../src/commands/migrate/sources/registry.ts | 2 + .../commands/migrate/sources/sources.test.ts | 43 +++- .../src/commands/migrate/wizard.test.ts | 1 + .../src/test/integration/completion.test.ts | 10 +- 17 files changed, 488 insertions(+), 59 deletions(-) create mode 100644 packages/cli-core/src/commands/migrate/export/authjs.ts create mode 100644 packages/cli-core/src/commands/migrate/sources/authjs.ts diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index d12731cd6..4c125fe7d 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -217,6 +217,7 @@ clerk migrate export clerk --output users.json clerk migrate export auth0 --domain my-tenant.us.auth0.com \ --client-id … # asks for the secret, masked clerk migrate export supabase --db-url "postgres://postgres:...@db.xxx.supabase.co:5432/postgres" +clerk migrate export authjs --db-url "mysql://user:...@127.0.0.1:3306/authjs" clerk migrate export betterauth --db-url "./db.sqlite" clerk migrate export firebase --service-account ./service-account.json clerk migrate export workos --api-key sk_… @@ -244,6 +245,7 @@ told not to. | `clerk` | Clerk Backend API | `--source clerk` | | `auth0` | Auth0 Management API | `--source auth0` | | `supabase` | Supabase Postgres (`auth.users`) | `--source supabase` | +| `authjs` | Auth.js database | `--source authjs` | | `betterauth` | Better Auth database | `--source betterauth` | | `firebase` | Firebase Identity Toolkit | `--source firebase` | | `workos` | WorkOS User Management API | `--source workos` | @@ -276,20 +278,20 @@ skipped, coverage, next }` — and never prompts, so a missing credential exits naming the flag to pass. `next`, and the commands printed after a human run, keep `--runs-dir`: the import looks the run ID up there. -| Flag | Platforms | Description | -| -------------------------- | ------------------------ | ----------------------------------------------------------------- | -| `-o, --output ` | all | Write the export here instead of the run folder | -| `-y, --yes` | all | Do not prompt: fail on a bad credential | -| `--json` | all | Print the result as JSON; never prompts | -| `--runs-dir ` | all | Where runs are kept (see [the run store](#the-run-store)) | -| `--db-url ` | `supabase`, `betterauth` | Connection string (Postgres only for `supabase`) | -| `--service-account ` | `firebase` | Path to a service account key JSON file | -| `--domain ` | `auth0` | Tenant domain, e.g. `my-tenant.us.auth0.com`, not a custom domain | -| `--client-id ` | `auth0` | Machine-to-machine application client ID | -| `--client-secret ` | `auth0` | Machine-to-machine application client secret | -| `--api-key ` | `workos` | WorkOS secret API key, the one starting `sk_` | -| `--with-identities` | `workos` | Also record each user's OAuth providers | -| `--no-with-identities` | `workos` | Skip the OAuth provider fan-out without being asked | +| Flag | Platforms | Description | +| -------------------------- | ---------------------------------- | ----------------------------------------------------------------- | +| `-o, --output ` | all | Write the export here instead of the run folder | +| `-y, --yes` | all | Do not prompt: fail on a bad credential | +| `--json` | all | Print the result as JSON; never prompts | +| `--runs-dir ` | all | Where runs are kept (see [the run store](#the-run-store)) | +| `--db-url ` | `supabase`, `authjs`, `betterauth` | Connection string (Postgres only for `supabase`) | +| `--service-account ` | `firebase` | Path to a service account key JSON file | +| `--domain ` | `auth0` | Tenant domain, e.g. `my-tenant.us.auth0.com`, not a custom domain | +| `--client-id ` | `auth0` | Machine-to-machine application client ID | +| `--client-secret ` | `auth0` | Machine-to-machine application client secret | +| `--api-key ` | `workos` | WorkOS secret API key, the one starting `sk_` | +| `--with-identities` | `workos` | Also record each user's OAuth providers | +| `--no-with-identities` | `workos` | Skip the OAuth provider fan-out without being asked | `export clerk` also takes the targeting flags — it reads from a Clerk instance, so it resolves a key the same way `clerk migrate import` does, with one extra @@ -380,12 +382,13 @@ coverage also counts users who _have_ a password, so the size of the gap is visible up front — `workos` prints that row at zero unconditionally, because zero is the only value it can take. -#### Database-backed exports (`supabase`, `betterauth`) +#### Database-backed exports (`supabase`, `authjs`, `betterauth`) -These two read the database directly, over **`--db-url`**: +These three read the database directly, over **`--db-url`**: ```sh clerk migrate export supabase --db-url "postgres://postgres:...@db.xxx.supabase.co:5432/postgres" +clerk migrate export authjs --db-url "mysql://user:...@127.0.0.1:3306/authjs" clerk migrate export betterauth --db-url "./db.sqlite" clerk migrate export betterauth --db-url "libsql://app-org.turso.io?authToken=..." # or set TURSO_AUTH_TOKEN ``` @@ -395,8 +398,8 @@ connecting. Postgres and MySQL go through `Bun.sql`; SQLite through `bun:sqlite` `libsql://` (Turso) over the server's HTTP pipeline endpoint, since `bun:sqlite` only opens local files and `@libsql/client` ships native optional dependencies. Nothing native ships in the binary — that is the whole reason the `engines.bun` -floor exists. Resolution is `--db-url`, then `SUPABASE_DB_URL` / -`BETTERAUTH_DB_URL`, then a masked prompt, since a connection string carries the password inline. A password +floor exists. Resolution is `--db-url`, then `SUPABASE_DB_URL` / `AUTHJS_DB_URL` +/ `BETTERAUTH_DB_URL`, then a masked prompt, since a connection string carries the password inline. A password pasted unencoded (`#`, `@`, `/` and the like) is percent-encoded for you. **Connection strings are redacted everywhere.** Errors show @@ -420,6 +423,13 @@ user to reset their password; this one carries the bcrypt digests across. It also keeps `raw_app_meta_data`, which is what the import's [checks](#checks) read for each user's providers. +**`authjs` tries `User`, then `user`, then `users`.** Auth.js has no single +schema — Prisma capitalizes the table, Drizzle does not, and Postgres treats +the difference as significant once quoted. The run reports which one it found. +The verified column is read as `emailVerified`, or `email_verified` on a legacy +NextAuth table. Auth.js core stores no passwords, so its users arrive without +credentials. + **`betterauth` reads its schema before it queries.** It finds the tables (`user` and `account`, or `users` and `accounts` under `usePlural: true`), how the columns are spelled (camelCase, or the snake_case Better Auth's Drizzle @@ -1058,14 +1068,15 @@ A source maps one platform's export onto Clerk's user schema, and says what it brings across. Adding a platform is one file in `sources/` plus one line in `sources/registry.ts`; `--source`'s tab-completion reads from that array. -| Key | Reads | Passwords | MFA | Metadata | -| ------------ | ---------------------------- | --------- | ------- | -------- | -| `clerk` | Clerk Dashboard export | partial | partial | partial | -| `auth0` | Auth0 Management API | partial | no | yes | -| `betterauth` | Better Auth export | yes | no | no | -| `firebase` | `firebase auth:export` | yes | no | no | -| `supabase` | Supabase `auth.users` export | yes | no | partial | -| `workos` | WorkOS User Management API | no | no | yes | +| Key | Reads | Passwords | MFA | Metadata | +| ------------ | ----------------------------- | --------- | ------- | -------- | +| `clerk` | Clerk Dashboard export | partial | partial | partial | +| `auth0` | Auth0 Management API | partial | no | yes | +| `authjs` | Auth.js / NextAuth user table | no | no | no | +| `betterauth` | Better Auth export | yes | no | no | +| `firebase` | `firebase auth:export` | yes | no | no | +| `supabase` | Supabase `auth.users` export | yes | no | partial | +| `workos` | WorkOS User Management API | no | no | yes | An unknown `--source` exits 2 with the list of valid keys. @@ -1131,7 +1142,7 @@ unconfirmed address there would silently promote it. - **Boolean** (`auth0`, `betterauth`, `firebase`, `workos`): `true`/`false`. A CSV export stringifies these, so `"false"` is read as false, not as a non-empty string. `TRUE`, `FALSE`, `t` and `f` read too, as a spreadsheet or psql writes them. -- **Timestamp** (`supabase`): a nullable confirmation time. Any real value +- **Timestamp** (`authjs`, `supabase`): a nullable confirmation time. Any real value means verified; `""`, `null` and `\N` do not. A Clerk export keeps an unverified primary email or phone unverified. @@ -1280,9 +1291,10 @@ The two Identity Toolkit paths are on `identitytoolkit.googleapis.com`, or on the export skips that request, and it may not carry the hash parameters: pass them to the import. The two WorkOS paths are on `api.workos.com`. -The database exports (`supabase`, `betterauth`) connect over `--db-url`. -Better Auth also accepts a `libsql://` URL, which goes over HTTP: it posts to -the server's pipeline endpoint. Supabase takes Postgres only. +The three database exports (`supabase`, `authjs`, `betterauth`) connect over +`--db-url`. Auth.js and Better Auth also accept a `libsql://` URL, which goes +over HTTP: it posts to the server's pipeline endpoint. Supabase takes Postgres +only. ## Notes diff --git a/packages/cli-core/src/commands/migrate/export/authjs.ts b/packages/cli-core/src/commands/migrate/export/authjs.ts new file mode 100644 index 000000000..34c2c5706 --- /dev/null +++ b/packages/cli-core/src/commands/migrate/export/authjs.ts @@ -0,0 +1,183 @@ +/** + * `clerk migrate export authjs` — read users out of an Auth.js database. + * + * Ported from the standalone migration-tool's `src/export/authjs.ts`, on the + * `Bun.sql`/`bun:sqlite` client. + * + * Auth.js has no export tool and no single schema: the adapter decides the + * table name, and Prisma's `User` differs from Drizzle's `user` only in + * casing — which Postgres and SQLite treat as significant once quoted. The + * export tries the documented casing first and falls back rather than making + * the user find out from a driver error. + */ + +import { withGutter, withSpinner } from "../../../lib/spinner.ts"; +import { log } from "../../../lib/log.ts"; +import type { UserLine } from "../lib/run-store.ts"; +import { printTarget } from "../lib/target.ts"; +import { withDbClient, type DbClient } from "../lib/db.ts"; +import { finishExport, startExportRun } from "./shared.ts"; +import { + promptDbUrl, + resolveDbUrl, + type DbExportOptions, + type ResolveConfig, +} from "./db-options.ts"; +import { withInputRetry } from "../lib/input-retry.ts"; + +/** Table names to try, in order. Prisma capitalizes; Drizzle does not. */ +const TABLE_CANDIDATES = ["User", "user", "users"] as const; + +type AuthJsRow = Record & { + id?: unknown; + name?: string | null; + email?: string | null; + email_verified?: unknown; +}; + +/** + * The verified-email column, in the order tried: the current adapters' name, + * then the legacy NextAuth `users` table's. + */ +const VERIFIED_COLUMNS = ["emailVerified", "email_verified"] as const; + +/** + * Every column is qualified with the table alias: SQLite reads an unqualified + * double-quoted name that matches no column as a string literal, so a missing + * `"emailVerified"` would come back as the text "emailVerified" on every row, + * which reads as verified. + */ +export function buildAuthJsQuery( + client: DbClient, + table: string, + verifiedColumn: (typeof VERIFIED_COLUMNS)[number] = "emailVerified", +): string { + const q = (identifier: string) => client.quote(identifier); + return ( + `SELECT u.${q("id")}, u.${q("name")}, u.${q("email")}, u.${q(verifiedColumn)} AS ${q("email_verified")} ` + + `FROM ${q(table)} u ORDER BY u.${q("id")} ASC` + ); +} + +const messageOf = (error: unknown) => (error instanceof Error ? error.message : String(error)); + +/** True for an error that means "no such column". Postgres says "does not exist" for both. */ +function isMissingColumn(error: unknown): boolean { + return /no such column|column .* does not exist|unknown column/i.test(messageOf(error)); +} + +/** True for an error that means "wrong table name", not "broken connection". */ +function isMissingTable(error: unknown): boolean { + return /does not exist|no such table|doesn't exist|unknown table/i.test(messageOf(error)); +} + +/** + * Reads the user table, trying each casing until one answers. + * + * @returns The rows and the table they came from, so the run can say which. + */ +export async function fetchAuthJsUsers( + client: DbClient, +): Promise<{ rows: AuthJsRow[]; table: string }> { + let lastError: unknown; + + for (const table of TABLE_CANDIDATES) { + let columnError: unknown; + for (const column of VERIFIED_COLUMNS) { + try { + const rows = await client.query(buildAuthJsQuery(client, table, column)); + return { rows, table }; + } catch (error) { + // The table is there: try the other name for the verified column, and + // report the first failure if neither reads. + if (isMissingColumn(error)) { + columnError ??= error; + continue; + } + if (!isMissingTable(error)) throw error; + lastError = error; + break; + } + } + if (columnError) throw columnError; + } + + throw lastError instanceof Error + ? new Error( + `No Auth.js user table found. Tried ${TABLE_CANDIDATES.join(", ")}. ${lastError.message}`, + ) + : new Error(`No Auth.js user table found. Tried ${TABLE_CANDIDATES.join(", ")}.`); +} + +export function buildAuthJsExport(rows: AuthJsRow[], record: (line: UserLine) => void = () => {}) { + const users: Record[] = []; + const counts = { email: 0, emailVerified: 0, name: 0 }; + + for (const row of rows) { + const userId = String(row.id ?? ""); + const user: Record = { id: userId }; + + if (row.name) { + user.name = row.name; + counts.name++; + } + if (row.email) { + user.email = row.email; + counts.email++; + } + // A nullable timestamp, not a boolean: the transformer reads presence. + if (row.email_verified) { + user.email_verified = + row.email_verified instanceof Date ? row.email_verified.toISOString() : row.email_verified; + counts.emailVerified++; + } + + users.push(user); + record({ sourceId: userId, status: "exported" }); + } + + return { + users, + coverage: [ + { label: "have an email address", count: counts.email }, + { label: "have a verified email", count: counts.emailVerified }, + { label: "have a name", count: counts.name }, + ], + }; +} + +const AUTHJS_DB = { + platform: "authjs", + envVar: "AUTHJS_DB_URL", + prompt: "Auth.js database connection string", + hint: "Postgres, MySQL, libsql://… or a SQLite file — whichever your Auth.js adapter uses.", +} as const satisfies ResolveConfig; + +export async function exportAuthJs(options: DbExportOptions): Promise { + const dbUrl = await resolveDbUrl(options, AUTHJS_DB); + + await withGutter("Exporting users from Auth.js", async () => { + if (!options.json) printTarget({ platform: "authjs" }); + const { + value: { rows, table }, + } = await withInputRetry( + dbUrl, + async () => promptDbUrl(AUTHJS_DB), + async (connectionString) => + withSpinner("Reading the user table...", async () => + withDbClient(connectionString, "authjs", fetchAuthJsUsers), + ), + ); + log.info(`Read ${rows.length} row${rows.length === 1 ? "" : "s"} from ${table}.`); + + const run = await startExportRun(options, { platform: "authjs" }); + const { users, coverage } = buildAuthJsExport(rows, run.append); + finishExport({ run, options, users, coverage }); + + if (users.length > 0) { + log.warn( + "Auth.js core stores no passwords — its users sign in with OAuth or email links, so they arrive without credentials.", + ); + } + }); +} diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index effcc242d..3568fbddf 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -1,7 +1,8 @@ /** * The database-backed exports, driven against a real SQLite database: * resolving the connection string, the Supabase export's row mapping, the - * Better Auth export's schema detection, and connection failures. + * Auth.js export's table detection, the Better Auth export's schema + * detection, and connection failures. * * SQLite because it is the one engine that needs no container, and it * exercises the same client, the same query building and the same plugin @@ -17,6 +18,7 @@ import path from "node:path"; import type { UserLine } from "../lib/run-store.ts"; import { useCaptureLog } from "../../../test/lib/stubs.ts"; import { createDbClient, type DbClient } from "../lib/db.ts"; +import { buildAuthJsExport, buildAuthJsQuery, exportAuthJs, fetchAuthJsUsers } from "./authjs.ts"; import { buildBetterAuthExport, buildBetterAuthQuery, @@ -147,17 +149,17 @@ describe("normalizeConnectionString", () => { }); describe("resolveDbUrl", () => { - const config = { platform: "supabase" as const, envVar: "SUPABASE_DB_URL", prompt: "url" }; + const config = { platform: "authjs" as const, envVar: "AUTHJS_DB_URL", prompt: "url" }; test("prefers the flag", async () => { const url = await resolveDbUrl({ dbUrl: "postgres://u:p@h/db" }, config, { - SUPABASE_DB_URL: "mysql://u:p@h/db", + AUTHJS_DB_URL: "mysql://u:p@h/db", }); expect(url).toBe("postgres://u:p@h/db"); }); test("falls back to the environment variable", async () => { - expect(await resolveDbUrl({}, config, { SUPABASE_DB_URL: "mysql://u:p@h/db" })).toBe( + expect(await resolveDbUrl({}, config, { AUTHJS_DB_URL: "mysql://u:p@h/db" })).toBe( "mysql://u:p@h/db", ); }); @@ -173,10 +175,10 @@ describe("resolveDbUrl", () => { test("warns and moves on when the environment variable is unusable", async () => { // Tests run non-TTY, so it then hits the agent-mode branch. - await expect(resolveDbUrl({}, config, { SUPABASE_DB_URL: "garbage" })).rejects.toThrow( + await expect(resolveDbUrl({}, config, { AUTHJS_DB_URL: "garbage" })).rejects.toThrow( /cannot prompt here/, ); - expect(captured.err).toContain("SUPABASE_DB_URL is not a valid connection string"); + expect(captured.err).toContain("AUTHJS_DB_URL is not a valid connection string"); }); test("says the variable is set but unusable, rather than asking for it", async () => { @@ -186,7 +188,99 @@ describe("resolveDbUrl", () => { }); test("names both the flag and the variable when it cannot prompt", async () => { - await expect(resolveDbUrl({}, config, {})).rejects.toThrow(/--db-url.*SUPABASE_DB_URL/s); + await expect(resolveDbUrl({}, config, {})).rejects.toThrow(/--db-url.*AUTHJS_DB_URL/s); + }); +}); + +describe("authjs export", () => { + const authJsDb = (table: string) => + makeDb((db) => { + db.run( + `CREATE TABLE "${table}" (id TEXT PRIMARY KEY, name TEXT, email TEXT, "emailVerified" TEXT)`, + ); + db.run(`INSERT INTO "${table}" VALUES (?,?,?,?)`, [ + "aj1", + "Jane Doe", + "jane@x.dev", + "2024-01-15", + ]); + db.run(`INSERT INTO "${table}" VALUES (?,?,?,?)`, ["aj2", "John Smith", "john@x.dev", null]); + }); + + test("quotes identifiers for the dialect", async () => { + await withClient(authJsDb("User"), async (client) => { + expect(buildAuthJsQuery(client, "User")).toContain('"User" u'); + expect(buildAuthJsQuery(client, "User")).toContain('u."emailVerified" AS "email_verified"'); + }); + }); + + // Prisma capitalizes the table, Drizzle does not, and Auth.js has no single + // schema — so the export tries rather than making the user guess. + test.each([["User"], ["user"], ["users"]])("finds the %s table", async (table) => { + const { rows } = await withClient(authJsDb(table), fetchAuthJsUsers); + expect(rows).toHaveLength(2); + }); + + // SQLite reads an unqualified "emailVerified" that matches no column as the + // string "emailVerified", which would mark every email verified. + test("reads a legacy NextAuth table's email_verified, and keeps null unverified", async () => { + const file = makeDb((db) => { + db.run( + `CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, email TEXT, email_verified TEXT)`, + ); + db.run(`INSERT INTO users VALUES (?,?,?,?)`, ["n1", "Nv", "nv@x.dev", null]); + db.run(`INSERT INTO users VALUES (?,?,?,?)`, ["n2", "V", "v@x.dev", "2024-01-15"]); + }); + + const { rows, table } = await withClient(file, fetchAuthJsUsers); + + expect(table).toBe("users"); + expect(rows.map((row) => row.email_verified)).toEqual([null, "2024-01-15"]); + }); + + test("a missing column is an error, not a literal", async () => { + const file = makeDb((db) => { + db.run(`CREATE TABLE "User" (id TEXT PRIMARY KEY, email TEXT, "emailVerified" TEXT)`); + db.run(`INSERT INTO "User" VALUES (?,?,?)`, ["a", "a@x.dev", null]); + }); + + await expect(withClient(file, fetchAuthJsUsers)).rejects.toThrow(/no such column/); + }); + + test("fails clearly when no candidate table exists", async () => { + const file = makeDb((db) => db.run(`CREATE TABLE unrelated (id TEXT)`)); + await expect(withClient(file, fetchAuthJsUsers)).rejects.toThrow( + /No Auth.js user table found. Tried User, user, users/, + ); + }); + + test("treats email_verified as a nullable timestamp, not a boolean", () => { + const { users } = buildAuthJsExport([ + { id: "a", email: "a@x.dev", email_verified: "2024-01-15" }, + { id: "b", email: "b@x.dev", email_verified: null }, + ]); + expect(users[0]?.email_verified).toBe("2024-01-15"); + expect("email_verified" in (users[1] ?? {})).toBe(false); + }); + + test("counts coverage", () => { + const { coverage } = buildAuthJsExport([ + { id: "a", email: "a@x.dev", name: "A", email_verified: "2024-01-01" }, + { id: "b" }, + ]); + const byLabel = Object.fromEntries(coverage.map((c) => [c.label, c.count])); + expect(byLabel["have an email address"]).toBe(1); + expect(byLabel["have a verified email"]).toBe(1); + }); + + test("exports end to end and says which table it read", async () => { + await exportAuthJs({ dbUrl: authJsDb("User"), output: "authjs.json" }); + + const written = JSON.parse(fs.readFileSync(path.join(workDir, "authjs.json"), "utf-8")); + expect(written).toMatchObject({ clerkMigrate: 1, source: "authjs" }); + expect(written.users).toHaveLength(2); + expect(captured.err).toContain("Read 2 rows from"); + expect(captured.err).toContain("stores no passwords"); }); }); diff --git a/packages/cli-core/src/commands/migrate/export/db-options.ts b/packages/cli-core/src/commands/migrate/export/db-options.ts index 8abb2ff1c..7853171a7 100644 --- a/packages/cli-core/src/commands/migrate/export/db-options.ts +++ b/packages/cli-core/src/commands/migrate/export/db-options.ts @@ -1,8 +1,8 @@ /** * Resolving a `--db-url` for the database-backed exports. * - * Shared by the database-backed exports: each takes one connection string, - * from a flag, an environment variable, or a prompt. + * Shared by supabase, authjs and betterauth: all three take one connection + * string, from a flag, an environment variable, or a prompt. */ import { CliError, throwUsageError } from "../../../lib/errors.ts"; diff --git a/packages/cli-core/src/commands/migrate/export/index.ts b/packages/cli-core/src/commands/migrate/export/index.ts index 47813e5e3..69678bef4 100644 --- a/packages/cli-core/src/commands/migrate/export/index.ts +++ b/packages/cli-core/src/commands/migrate/export/index.ts @@ -3,6 +3,7 @@ import { throwUsageError } from "../../../lib/errors.ts"; import { select } from "../../../lib/listage.ts"; import { isAgent, isHuman } from "../../../mode.ts"; import { exportAuth0 } from "./auth0.ts"; +import { exportAuthJs } from "./authjs.ts"; import { exportBetterAuth } from "./betterauth.ts"; import { exportClerk } from "./clerk.ts"; import { exportFirebase } from "./firebase.ts"; @@ -54,12 +55,13 @@ const handlers = { clerk: exportClerk, auth0: exportAuth0, supabase: exportSupabase, + authjs: exportAuthJs, betterauth: exportBetterAuth, firebase: exportFirebase, workos: exportWorkOs, }; -/** The platforms that read a database, which share `--db-url`. */ +/** The three platforms that read a database, which share `--db-url`. */ const DB_PLATFORMS = [ { key: "supabase", @@ -68,6 +70,13 @@ const DB_PLATFORMS = [ dbUrl: "Postgres connection string", example: "postgres://postgres:password@db.xxx.supabase.co:5432/postgres", }, + { + key: "authjs", + summary: "Export users from an Auth.js database", + envVar: "AUTHJS_DB_URL", + dbUrl: "Postgres, MySQL, libsql/Turso or SQLite connection string", + example: "mysql://user:password@127.0.0.1:3306/authjs", + }, { key: "betterauth", summary: "Export users from a Better Auth database", @@ -211,8 +220,8 @@ export function registerMigrateExport(migrateCommand: Command<[], Record[0]), ); - // Each takes exactly one connection string, so they are registered from a - // table rather than near-identical blocks. + // All three take exactly one connection string, so they are registered from + // a table rather than three near-identical blocks. for (const platform of DB_PLATFORMS) { exportCommand .command(platform.key) diff --git a/packages/cli-core/src/commands/migrate/export/registry.test.ts b/packages/cli-core/src/commands/migrate/export/registry.test.ts index e53f07085..fbdb8ec7b 100644 --- a/packages/cli-core/src/commands/migrate/export/registry.test.ts +++ b/packages/cli-core/src/commands/migrate/export/registry.test.ts @@ -8,6 +8,7 @@ describe("export registry", () => { "clerk", "auth0", "supabase", + "authjs", "firebase", "betterauth", "workos", diff --git a/packages/cli-core/src/commands/migrate/export/registry.ts b/packages/cli-core/src/commands/migrate/export/registry.ts index bfef51ac2..385953b20 100644 --- a/packages/cli-core/src/commands/migrate/export/registry.ts +++ b/packages/cli-core/src/commands/migrate/export/registry.ts @@ -11,6 +11,7 @@ */ import { exportAuth0 } from "./auth0.ts"; +import { exportAuthJs } from "./authjs.ts"; import { exportBetterAuth } from "./betterauth.ts"; import { exportClerk } from "./clerk.ts"; import { exportFirebase } from "./firebase.ts"; @@ -48,6 +49,13 @@ export const exportPlatforms: ExportRegistryEntry[] = [ sourceKey: "supabase", run: async (options) => exportSupabase(options), }, + { + key: "authjs", + label: "Auth.js (NextAuth)", + description: "An Auth.js database — Postgres, MySQL or SQLite", + sourceKey: "authjs", + run: async (options) => exportAuthJs(options), + }, { key: "firebase", label: "Firebase", diff --git a/packages/cli-core/src/commands/migrate/index.test.ts b/packages/cli-core/src/commands/migrate/index.test.ts index bd794973a..449e4853c 100644 --- a/packages/cli-core/src/commands/migrate/index.test.ts +++ b/packages/cli-core/src/commands/migrate/index.test.ts @@ -112,11 +112,14 @@ describe("registerMigrate", () => { ]); }); - test.each(["supabase", "betterauth"])("migrate export %s takes exactly its flags", (platform) => { - expect( - findCommand(["migrate", "export", platform])?.options.map((option) => option.long), - ).toEqual(["--db-url", "--output", "--yes", "--runs-dir", "--json"]); - }); + test.each(["supabase", "authjs", "betterauth"])( + "migrate export %s takes exactly its flags", + (platform) => { + expect( + findCommand(["migrate", "export", platform])?.options.map((option) => option.long), + ).toEqual(["--db-url", "--output", "--yes", "--runs-dir", "--json"]); + }, + ); test("migrate export firebase takes exactly its flags", () => { expect( diff --git a/packages/cli-core/src/commands/migrate/lib/db.test.ts b/packages/cli-core/src/commands/migrate/lib/db.test.ts index a4c34bba0..b635e3f4f 100644 --- a/packages/cli-core/src/commands/migrate/lib/db.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/db.test.ts @@ -303,13 +303,20 @@ describe("withDbClient", () => { // A schema the export cannot read is a refusal (exit 2), and the advice is // about the columns, not the connection. - test("names a missing column, and exits 2", async () => { - const error = (await withDbClient(dbPath, "supabase", (client) => - client.query(`SELECT no_such_column FROM sqlite_master`), + test("names the columns for a missing one, and exits 2", async () => { + const db = new Database(dbPath); + db.run( + `CREATE TABLE IF NOT EXISTS "User" (id TEXT, name TEXT, email TEXT, email_verified TEXT)`, + ); + db.close(); + + const error = (await withDbClient(dbPath, "authjs", (client) => + client.query(`SELECT no_such_column FROM "User"`), ).catch((caught: unknown) => caught)) as CliError; expect(error.exitCode).toBe(EXIT_CODE.USAGE); - expect(error.message).toContain("missing a column the export reads"); + expect(error.message).toContain("reads `id`, `name`, `email` and `emailVerified`"); + expect(error.message).toContain("SELECT id, full_name AS name, email, email_verified"); expect(error.message).not.toContain("Check the connection string"); }); diff --git a/packages/cli-core/src/commands/migrate/lib/db.ts b/packages/cli-core/src/commands/migrate/lib/db.ts index ef3a46e62..e54306fb7 100644 --- a/packages/cli-core/src/commands/migrate/lib/db.ts +++ b/packages/cli-core/src/commands/migrate/lib/db.ts @@ -323,7 +323,7 @@ export async function createDbClient( } } -export type DbPlatform = "supabase" | "betterauth"; +export type DbPlatform = "supabase" | "betterauth" | "authjs"; /** * Turns a driver error into something a user can act on. @@ -372,7 +372,17 @@ export function describeDbError(error: unknown, platform?: DbPlatform): string { // Before the table check: Postgres words a missing column "does not exist" too. if (/no such column|column .* does not exist|unknown column/i.test(message)) { - return "The user table is missing a column the export reads. Check the schema matches the platform's default."; + const needs: Partial> = { + authjs: + "The Auth.js export reads `id`, `name`, `email` and `emailVerified` (or `email_verified`). " + + 'For a schema that renames another column (Prisma `@map("full_name")` on `name`, for one), ' + + "export the users with your own query that aliases it back, " + + "`SELECT id, full_name AS name, email, email_verified FROM …`, and import that file with the authjs source.", + }; + return ( + needs[platform as DbPlatform] ?? + "The user table is missing a column the export reads. Check the schema matches the platform's default." + ); } // MySQL says "doesn't exist" and "Unknown table" where Postgres says "does not exist". diff --git a/packages/cli-core/src/commands/migrate/lib/input-retry.test.ts b/packages/cli-core/src/commands/migrate/lib/input-retry.test.ts index 4f3f597e3..9c088901b 100644 --- a/packages/cli-core/src/commands/migrate/lib/input-retry.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/input-retry.test.ts @@ -42,9 +42,9 @@ const { setAssumeYes } = await import("./assume-yes.ts"); const captured = useCaptureLog(); const CONFIG = { - platform: "supabase", - envVar: "SUPABASE_DB_URL", - prompt: "Supabase Postgres connection string", + platform: "authjs", + envVar: "AUTHJS_DB_URL", + prompt: "Auth.js database connection string", } as const; const FIRST = "libsql://typo.turso.io?authToken=t"; diff --git a/packages/cli-core/src/commands/migrate/run.test.ts b/packages/cli-core/src/commands/migrate/run.test.ts index 76890f483..383f693a7 100644 --- a/packages/cli-core/src/commands/migrate/run.test.ts +++ b/packages/cli-core/src/commands/migrate/run.test.ts @@ -2023,6 +2023,7 @@ describe("run", () => { ], "auth0|1", ], + ["authjs", [{ id: "aj1", email: "a@x.dev", email_verified: "2024-01-01T00:00:00Z" }], "aj1"], [ "betterauth", [{ user_id: "ba1", email: "a@x.dev", email_verified: true, password_hash: BCRYPT }], @@ -2177,7 +2178,7 @@ describe("run", () => { )) as CliError; expect(error.exitCode).toBe(EXIT_CODE.USAGE); expect(error.message).toContain( - 'Unknown source "nope". Valid sources: clerk, auth0, betterauth, firebase, supabase, workos.', + 'Unknown source "nope". Valid sources: clerk, auth0, authjs, betterauth, firebase, supabase, workos.', ); expect(requests).toHaveLength(0); }); diff --git a/packages/cli-core/src/commands/migrate/sources/authjs.ts b/packages/cli-core/src/commands/migrate/sources/authjs.ts new file mode 100644 index 000000000..abf7ed97f --- /dev/null +++ b/packages/cli-core/src/commands/migrate/sources/authjs.ts @@ -0,0 +1,49 @@ +import type { SourceEntry } from "../types.ts"; +import { routeByVerification, splitName } from "./shared.ts"; + +/** + * Auth.js (formerly NextAuth) → Clerk transformer. + * + * Auth.js has no export tool and no fixed user table, so this assumes the + * common shape: `SELECT id, name, email, email_verified, created_at FROM users`. + * A different schema means editing the mapping below or supplying a custom + * transformer file. + * + * `email_verified` is a nullable timestamp rather than a boolean — any value + * means verified. + * + * No password default: Auth.js's core is passwordless (OAuth and email links), + * so users arrive without a digest and are imported with + * `skip_password_requirement`. + */ +const authjsSource = { + key: "authjs", + label: "Auth.js (NextAuth)", + description: + "Assumes an export of `SELECT id, name, email, email_verified, created_at FROM users`. `name` is split into firstName and lastName.", + carries: { + passwords: { + level: "no", + note: "Auth.js is passwordless (OAuth and email links), so users arrive without one.", + }, + mfa: { level: "no", note: "Auth.js has no MFA of its own." }, + metadata: { + level: "no", + note: "Only `id`, `name`, `email`, `email_verified` and `created_at` are read.", + }, + }, + transformer: { + id: "userId", + email: "email", + email_verified: "emailVerified", + name: "name", + created_at: "createdAt", + updated_at: "updatedAt", + }, + postTransform: (user) => { + routeByVerification(user, "email", "emailVerified", "timestamp"); + splitName(user); + }, +} satisfies SourceEntry; + +export default authjsSource; diff --git a/packages/cli-core/src/commands/migrate/sources/registry.ts b/packages/cli-core/src/commands/migrate/sources/registry.ts index 0b37827a7..25d376e71 100644 --- a/packages/cli-core/src/commands/migrate/sources/registry.ts +++ b/packages/cli-core/src/commands/migrate/sources/registry.ts @@ -10,6 +10,7 @@ import { throwUsageError } from "../../../lib/errors.ts"; import type { SourceEntry } from "../types.ts"; import auth0Source from "./auth0.ts"; +import authjsSource from "./authjs.ts"; import betterAuthSource from "./betterauth.ts"; import clerkSource from "./clerk.ts"; import firebaseSource from "./firebase.ts"; @@ -19,6 +20,7 @@ import workosSource from "./workos.ts"; export const sources: SourceEntry[] = [ clerkSource, auth0Source, + authjsSource, betterAuthSource, firebaseSource, supabaseSource, diff --git a/packages/cli-core/src/commands/migrate/sources/sources.test.ts b/packages/cli-core/src/commands/migrate/sources/sources.test.ts index 186b62f43..d4cdab06c 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -45,10 +45,11 @@ const one = (key: string, record: Record, context = {}) => | undefined; describe("registry", () => { - test("registers the built-in platforms", () => { + test("registers all seven platforms", () => { expect(sourceKeys()).toEqual([ "clerk", "auth0", + "authjs", "betterauth", "firebase", "supabase", @@ -262,6 +263,44 @@ describe("workos", () => { }); }); +describe("authjs", () => { + const base = { id: "cuid1", email: "a@x.dev" }; + + test("treats a confirmation timestamp as verified", () => { + const user = one("authjs", { ...base, email_verified: "2024-01-15T10:30:00.000Z" }); + expect(user?.email).toBe("a@x.dev"); + expect(user?.unverifiedEmailAddresses).toBeUndefined(); + }); + + test.each([[null], [""], [undefined]])("treats email_verified=%p as unverified", (value) => { + const user = one("authjs", { ...base, email_verified: value }); + expect(user?.unverifiedEmailAddresses).toBe("a@x.dev"); + }); + + test.each([ + ["Jane Doe", "Jane", "Doe"], + ["Mary Jane Watson", "Mary", "Jane Watson"], + [" Ada Lovelace ", "Ada", "Lovelace"], + ])("splits %p into %p / %p", (name, firstName, lastName) => { + const user = one("authjs", { ...base, name }); + expect(user?.firstName).toBe(firstName); + expect(user?.lastName).toBe(lastName); + }); + + test("keeps a single-word name as the first name, without inventing a last name", () => { + const user = one("authjs", { ...base, name: "Prince" }); + expect(user?.firstName).toBe("Prince"); + expect(user?.lastName).toBeUndefined(); + expect("name" in (user ?? {})).toBe(false); + }); + + test("imports without a password, since Auth.js core is passwordless", async () => { + const { users } = await load("authjs", [{ ...base, email_verified: "2024-01-01" }]); + expect(users[0]?.password).toBeUndefined(); + expect(users[0]?.passwordHasher).toBeUndefined(); + }); +}); + describe("betterauth", () => { const base = { user_id: "ba1", email: "a@x.dev", email_verified: true }; @@ -830,6 +869,7 @@ describe("fields the CLI's own export adds", () => { describe("invalid records", () => { const INVALID: [string, Record][] = [ ["auth0", { user_id: "a1" }], + ["authjs", { id: "a2" }], ["betterauth", { user_id: "a3" }], ["firebase", { localId: "a4" }], ["supabase", { id: "a5" }], @@ -854,6 +894,7 @@ describe("invalid records", () => { /** The per-platform source field that becomes a Clerk identifier. */ function identifierFor(key: string, email = "ok@x.dev"): Record { if (key === "auth0") return { email, email_verified: true }; + if (key === "authjs") return { email, email_verified: "2024-01-01" }; if (key === "betterauth") return { email, email_verified: true }; if (key === "firebase") return { email, emailVerified: true }; return { email, email_confirmed_at: "2024-01-01 00:00:00+00" }; diff --git a/packages/cli-core/src/commands/migrate/wizard.test.ts b/packages/cli-core/src/commands/migrate/wizard.test.ts index 394742c6c..df3d17bd3 100644 --- a/packages/cli-core/src/commands/migrate/wizard.test.ts +++ b/packages/cli-core/src/commands/migrate/wizard.test.ts @@ -64,6 +64,7 @@ describe("promptForSource", () => { expect(selectCall(0)?.choices.map((choice) => choice.value)).toEqual([ "clerk", "auth0", + "authjs", "betterauth", "firebase", "supabase", diff --git a/packages/cli-core/src/test/integration/completion.test.ts b/packages/cli-core/src/test/integration/completion.test.ts index 66bb697cf..f0b1fc91c 100644 --- a/packages/cli-core/src/test/integration/completion.test.ts +++ b/packages/cli-core/src/test/integration/completion.test.ts @@ -230,7 +230,15 @@ describe("generateCompletions", () => { "--source", "", ]).completions.map((c) => c.name); - expect(names).toEqual(["clerk", "auth0", "betterauth", "firebase", "supabase", "workos"]); + expect(names).toEqual([ + "clerk", + "auth0", + "authjs", + "betterauth", + "firebase", + "supabase", + "workos", + ]); } finally { if (saved === undefined) delete process.env.CLERK_EXPERIMENTAL; else process.env.CLERK_EXPERIMENTAL = saved; From 93a358b4e4e4e4d45b64a006d33136a40a2836e8 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Tue, 6 Oct 2026 13:06:17 -0400 Subject: [PATCH 02/16] docs(changeset): Add clerk migrate export authjs and Auth.js imports Co-Authored-By: Claude Opus 5.5 --- .changeset/migrate-authjs.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/migrate-authjs.md diff --git a/.changeset/migrate-authjs.md b/.changeset/migrate-authjs.md new file mode 100644 index 000000000..98f40174e --- /dev/null +++ b/.changeset/migrate-authjs.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk migrate export authjs`, and let `clerk migrate import` read Auth.js exports. The export reads an Auth.js database over `--db-url` (Postgres, MySQL, libsql/Turso or SQLite), finding the user table whether Prisma or Drizzle named it. Auth.js stores no passwords, so imported users sign in with OAuth or an email link, or set a password. `clerk migrate` is still experimental and needs `CLERK_EXPERIMENTAL=migrate`. From f5b3ab49e90bbbd753f0c7573c6d0062c30a2cd3 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 14:48:34 -0400 Subject: [PATCH 03/16] fix(migrate): never re-prompt for an Auth.js connection under --json Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/export/authjs.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli-core/src/commands/migrate/export/authjs.ts b/packages/cli-core/src/commands/migrate/export/authjs.ts index 34c2c5706..6d7cca1ff 100644 --- a/packages/cli-core/src/commands/migrate/export/authjs.ts +++ b/packages/cli-core/src/commands/migrate/export/authjs.ts @@ -167,6 +167,7 @@ export async function exportAuthJs(options: DbExportOptions): Promise { withSpinner("Reading the user table...", async () => withDbClient(connectionString, "authjs", fetchAuthJsUsers), ), + options, ); log.info(`Read ${rows.length} row${rows.length === 1 ? "" : "s"} from ${table}.`); From 9c715b9194b283de29e66c65c699d5e99f4399f7 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 17:12:13 -0400 Subject: [PATCH 04/16] fix(migrate): say Auth.js Credentials-provider passwords are not exported The warning said Auth.js users sign in with OAuth or email links. An app using the Credentials provider keeps its own passwords, which the export does not read, so the operator needs to migrate those separately or have those users reset their password. The warning, the source note and the README now say so. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 5 ++++- packages/cli-core/src/commands/migrate/export/authjs.ts | 4 +++- .../src/commands/migrate/export/db-exports.test.ts | 1 + packages/cli-core/src/commands/migrate/sources/authjs.ts | 8 ++++---- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 4c125fe7d..4cb961a22 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -428,7 +428,10 @@ schema — Prisma capitalizes the table, Drizzle does not, and Postgres treats the difference as significant once quoted. The run reports which one it found. The verified column is read as `emailVerified`, or `email_verified` on a legacy NextAuth table. Auth.js core stores no passwords, so its users arrive without -credentials. +credentials. That is all an OAuth or email-link app has. An app that also uses +the Credentials provider keeps passwords in its own tables, which the export +does not read: migrate those separately, or have those users reset their +password. **`betterauth` reads its schema before it queries.** It finds the tables (`user` and `account`, or `users` and `accounts` under `usePlural: true`), how diff --git a/packages/cli-core/src/commands/migrate/export/authjs.ts b/packages/cli-core/src/commands/migrate/export/authjs.ts index 6d7cca1ff..85558ffef 100644 --- a/packages/cli-core/src/commands/migrate/export/authjs.ts +++ b/packages/cli-core/src/commands/migrate/export/authjs.ts @@ -177,7 +177,9 @@ export async function exportAuthJs(options: DbExportOptions): Promise { if (users.length > 0) { log.warn( - "Auth.js core stores no passwords — its users sign in with OAuth or email links, so they arrive without credentials.", + "Auth.js core stores no passwords, so users arrive without one. That covers OAuth and email-link sign-in. " + + "If the app also uses the Credentials provider, its passwords live in the app's own tables and were not exported: " + + "migrate them separately, or have those users reset their password.", ); } }); diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index 3568fbddf..40ccf114f 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -281,6 +281,7 @@ describe("authjs export", () => { expect(written.users).toHaveLength(2); expect(captured.err).toContain("Read 2 rows from"); expect(captured.err).toContain("stores no passwords"); + expect(captured.err).toContain("Credentials provider"); }); }); diff --git a/packages/cli-core/src/commands/migrate/sources/authjs.ts b/packages/cli-core/src/commands/migrate/sources/authjs.ts index abf7ed97f..89d814d8b 100644 --- a/packages/cli-core/src/commands/migrate/sources/authjs.ts +++ b/packages/cli-core/src/commands/migrate/sources/authjs.ts @@ -12,9 +12,9 @@ import { routeByVerification, splitName } from "./shared.ts"; * `email_verified` is a nullable timestamp rather than a boolean — any value * means verified. * - * No password default: Auth.js's core is passwordless (OAuth and email links), - * so users arrive without a digest and are imported with - * `skip_password_requirement`. + * No password default: Auth.js core stores no passwords, so users arrive + * without a digest and are imported with `skip_password_requirement`. An app + * using the Credentials provider keeps its own, which this does not read. */ const authjsSource = { key: "authjs", @@ -24,7 +24,7 @@ const authjsSource = { carries: { passwords: { level: "no", - note: "Auth.js is passwordless (OAuth and email links), so users arrive without one.", + note: "Auth.js core stores no passwords. An app using the Credentials provider keeps its own, which this does not read: migrate them separately, or have those users reset their password.", }, mfa: { level: "no", note: "Auth.js has no MFA of its own." }, metadata: { From 97c8957953afb481a85b807fa130b72b9ef6b57b Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 17:32:11 -0400 Subject: [PATCH 05/16] fix(migrate): keep looking for the Auth.js table past one without its columns Postgres keeps a quoted "User" apart from "user", so an app's own User table stopped the search before Auth.js's table was tried. A candidate without the columns is now passed over; the first column error is still the one thrown when nothing reads. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/authjs.ts | 12 ++++++++---- .../commands/migrate/export/db-exports.test.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/authjs.ts b/packages/cli-core/src/commands/migrate/export/authjs.ts index 85558ffef..79c601862 100644 --- a/packages/cli-core/src/commands/migrate/export/authjs.ts +++ b/packages/cli-core/src/commands/migrate/export/authjs.ts @@ -74,22 +74,26 @@ function isMissingTable(error: unknown): boolean { /** * Reads the user table, trying each casing until one answers. * + * A table that exists but lacks the columns is passed over too: Postgres + * keeps a quoted `"User"` apart from `"user"`, and an app's own `User` table + * must not hide Auth.js's. When nothing reads, the first column error is the + * one thrown, since it names a table that was there. + * * @returns The rows and the table they came from, so the run can say which. */ export async function fetchAuthJsUsers( client: DbClient, ): Promise<{ rows: AuthJsRow[]; table: string }> { let lastError: unknown; + let columnError: unknown; for (const table of TABLE_CANDIDATES) { - let columnError: unknown; for (const column of VERIFIED_COLUMNS) { try { const rows = await client.query(buildAuthJsQuery(client, table, column)); return { rows, table }; } catch (error) { - // The table is there: try the other name for the verified column, and - // report the first failure if neither reads. + // The table is there: try the other name for the verified column. if (isMissingColumn(error)) { columnError ??= error; continue; @@ -99,9 +103,9 @@ export async function fetchAuthJsUsers( break; } } - if (columnError) throw columnError; } + if (columnError) throw columnError; throw lastError instanceof Error ? new Error( `No Auth.js user table found. Tried ${TABLE_CANDIDATES.join(", ")}. ${lastError.message}`, diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index 40ccf114f..f88e08a62 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -238,6 +238,23 @@ describe("authjs export", () => { expect(rows.map((row) => row.email_verified)).toEqual([null, "2024-01-15"]); }); + // Postgres keeps a quoted "User" apart from "user"; SQLite does not, so the + // plural table stands in for the later candidate here. + test("passes over a candidate table without the columns for a later one", async () => { + const file = makeDb((db) => { + db.run(`CREATE TABLE "User" (id TEXT PRIMARY KEY, handle TEXT)`); + db.run( + `CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, email TEXT, "emailVerified" TEXT)`, + ); + db.run(`INSERT INTO users VALUES (?,?,?,?)`, ["u1", "U", "u@x.dev", null]); + }); + + const { rows, table } = await withClient(file, fetchAuthJsUsers); + + expect(table).toBe("users"); + expect(rows).toHaveLength(1); + }); + test("a missing column is an error, not a literal", async () => { const file = makeDb((db) => { db.run(`CREATE TABLE "User" (id TEXT PRIMARY KEY, email TEXT, "emailVerified" TEXT)`); From 9cf480294a0166fb9d324140bb08fcac88f63d24 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 7 Oct 2026 18:37:21 -0400 Subject: [PATCH 06/16] test(migrate): cover the generic missing-column advice again The Auth.js hint replaced the only test of the generic advice that the Supabase and Better Auth exports get. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/lib/db.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/cli-core/src/commands/migrate/lib/db.test.ts b/packages/cli-core/src/commands/migrate/lib/db.test.ts index b635e3f4f..6a38ea52a 100644 --- a/packages/cli-core/src/commands/migrate/lib/db.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/db.test.ts @@ -320,6 +320,18 @@ describe("withDbClient", () => { expect(error.message).not.toContain("Check the connection string"); }); + // Supabase and Better Auth get the generic advice; only Auth.js has its own. + test("gives the generic column advice outside Auth.js, and exits 2", async () => { + const error = (await withDbClient(dbPath, "supabase", (client) => + client.query(`SELECT no_such_column FROM sqlite_master`), + ).catch((caught: unknown) => caught)) as CliError; + + expect(error.exitCode).toBe(EXIT_CODE.USAGE); + expect(error.message).toContain("missing a column the export reads"); + expect(error.message).not.toContain("reads `id`, `name`, `email`"); + expect(error.message).not.toContain("Check the connection string"); + }); + test("passes a CliError through unchanged", async () => { await expect( withDbClient(dbPath, undefined, async () => { From c808775fbf771db9c754fff9204837864c03bb9a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Thu, 8 Oct 2026 13:56:19 -0400 Subject: [PATCH 07/16] fix(migrate): give the Auth.js column hint a query that runs on its own example The hint names a Prisma schema but selected email_verified, while Prisma's Auth.js schema has "emailVerified" in a quoted "User" table, so the query it suggested failed the same way. It now aliases that column, quotes the table, and says MySQL quotes with backticks. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/lib/db.test.ts | 5 ++++- packages/cli-core/src/commands/migrate/lib/db.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/lib/db.test.ts b/packages/cli-core/src/commands/migrate/lib/db.test.ts index 6a38ea52a..7453b4db8 100644 --- a/packages/cli-core/src/commands/migrate/lib/db.test.ts +++ b/packages/cli-core/src/commands/migrate/lib/db.test.ts @@ -316,7 +316,10 @@ describe("withDbClient", () => { expect(error.exitCode).toBe(EXIT_CODE.USAGE); expect(error.message).toContain("reads `id`, `name`, `email` and `emailVerified`"); - expect(error.message).toContain("SELECT id, full_name AS name, email, email_verified"); + // Prisma's Auth.js schema: a quoted "User" table and camelCase "emailVerified". + expect(error.message).toContain( + 'SELECT id, full_name AS name, email, "emailVerified" AS email_verified FROM "User"', + ); expect(error.message).not.toContain("Check the connection string"); }); diff --git a/packages/cli-core/src/commands/migrate/lib/db.ts b/packages/cli-core/src/commands/migrate/lib/db.ts index e54306fb7..90e8ec76a 100644 --- a/packages/cli-core/src/commands/migrate/lib/db.ts +++ b/packages/cli-core/src/commands/migrate/lib/db.ts @@ -377,7 +377,8 @@ export function describeDbError(error: unknown, platform?: DbPlatform): string { "The Auth.js export reads `id`, `name`, `email` and `emailVerified` (or `email_verified`). " + 'For a schema that renames another column (Prisma `@map("full_name")` on `name`, for one), ' + "export the users with your own query that aliases it back, " + - "`SELECT id, full_name AS name, email, email_verified FROM …`, and import that file with the authjs source.", + '`SELECT id, full_name AS name, email, "emailVerified" AS email_verified FROM "User"` on Postgres ' + + "(MySQL quotes with backticks), and import that file with the authjs source.", }; return ( needs[platform as DbPlatform] ?? From 5cb47835b23e744a201758f95ce10466068d0c82 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 15:01:20 -0400 Subject: [PATCH 08/16] docs(migrate): list Auth.js among the platforms that export no passwords The Auth.js section says its core stores no passwords, but the list of platforms that export none named only three. Auth.js is the fourth, and its export says so on every run like the others. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 4cb961a22..9558b65de 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -365,7 +365,7 @@ profile in that order. The export run has one line per exported user, so `clerk migrate runs` lists it alongside imports. -#### Three platforms export no passwords +#### Four platforms export no passwords - **Clerk** never returns password digests, TOTP secrets or backup codes over the API — only the `*_enabled` booleans. Migrated users must reset their @@ -376,8 +376,11 @@ alongside imports. - **WorkOS** returns neither password hashes nor TOTP secrets, and has no support-request escape hatch: hashes go in on import and never come back, and `totp.secret` is returned on enrol only. There is nothing to add to the file. +- **Auth.js** core stores no passwords. An app that also uses the Credentials + provider keeps them in its own tables, which the export does not read: + migrate those separately, or have those users reset their password. -All three print a warning whenever they export anyone. `clerk`'s and `workos`'s +All four print a warning whenever they export anyone. `clerk`'s and `workos`'s coverage also counts users who _have_ a password, so the size of the gap is visible up front — `workos` prints that row at zero unconditionally, because zero is the only value it can take. From e4ee5855c78e9831ebff357bb5c24cdb5c1f025a Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 9 Oct 2026 17:18:25 -0400 Subject: [PATCH 09/16] docs(migrate): say an Auth.js Credentials app stores passwords wherever it chose to Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 9558b65de..400793e1b 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -377,8 +377,9 @@ alongside imports. support-request escape hatch: hashes go in on import and never come back, and `totp.secret` is returned on enrol only. There is nothing to add to the file. - **Auth.js** core stores no passwords. An app that also uses the Credentials - provider keeps them in its own tables, which the export does not read: - migrate those separately, or have those users reset their password. + provider stores them wherever it chose to, which the export does not read, + even a password column on the user table: migrate those separately, or have + those users reset their password. All four print a warning whenever they export anyone. `clerk`'s and `workos`'s coverage also counts users who _have_ a password, so the size of the gap is @@ -432,9 +433,9 @@ the difference as significant once quoted. The run reports which one it found. The verified column is read as `emailVerified`, or `email_verified` on a legacy NextAuth table. Auth.js core stores no passwords, so its users arrive without credentials. That is all an OAuth or email-link app has. An app that also uses -the Credentials provider keeps passwords in its own tables, which the export -does not read: migrate those separately, or have those users reset their -password. +the Credentials provider stores passwords wherever it chose to, which the export +does not read, even a password column on the user table: migrate those +separately, or have those users reset their password. **`betterauth` reads its schema before it queries.** It finds the tables (`user` and `account`, or `users` and `accounts` under `usePlural: true`), how From 3ab2ee05f495ab9f6083b9371cce003797b0f5aa Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 14:11:51 -0400 Subject: [PATCH 10/16] fix(migrate): find Auth.js's tables in the Supabase adapter's next_auth schema The lookup tried only unqualified names, so an app on @auth/supabase-adapter got "No Auth.js user table found", or, with its own public.users, silently read that instead. On Postgres, next_auth.users is now tried first, with the schema and table quoted apart. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 5 +++- .../src/commands/migrate/export/authjs.ts | 24 ++++++++++++++----- .../migrate/export/db-exports.test.ts | 23 ++++++++++++++++++ 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 400793e1b..4cac31fe2 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -429,7 +429,10 @@ also keeps `raw_app_meta_data`, which is what the import's **`authjs` tries `User`, then `user`, then `users`.** Auth.js has no single schema — Prisma capitalizes the table, Drizzle does not, and Postgres treats -the difference as significant once quoted. The run reports which one it found. +the difference as significant once quoted. On Postgres it tries +`next_auth.users` first: `@auth/supabase-adapter` keeps its tables in the +`next_auth` schema, off the default `search_path`, and an app's own +`public.users` must not answer for it. The run reports which one it found. The verified column is read as `emailVerified`, or `email_verified` on a legacy NextAuth table. Auth.js core stores no passwords, so its users arrive without credentials. That is all an OAuth or email-link app has. An app that also uses diff --git a/packages/cli-core/src/commands/migrate/export/authjs.ts b/packages/cli-core/src/commands/migrate/export/authjs.ts index 79c601862..48ebba09c 100644 --- a/packages/cli-core/src/commands/migrate/export/authjs.ts +++ b/packages/cli-core/src/commands/migrate/export/authjs.ts @@ -28,6 +28,17 @@ import { withInputRetry } from "../lib/input-retry.ts"; /** Table names to try, in order. Prisma capitalizes; Drizzle does not. */ const TABLE_CANDIDATES = ["User", "user", "users"] as const; +/** + * The tables to try on this database. On Postgres, `@auth/supabase-adapter` + * keeps its own in the `next_auth` schema, off the default `search_path`: + * tried first, since an app's own `public.users` could otherwise answer for it. + */ +function tableCandidates(client: DbClient): string[] { + return client.dbType === "postgres" + ? ["next_auth.users", ...TABLE_CANDIDATES] + : [...TABLE_CANDIDATES]; +} + type AuthJsRow = Record & { id?: unknown; name?: string | null; @@ -53,9 +64,11 @@ export function buildAuthJsQuery( verifiedColumn: (typeof VERIFIED_COLUMNS)[number] = "emailVerified", ): string { const q = (identifier: string) => client.quote(identifier); + // `next_auth.users` is a schema and a table, each quoted on its own. + const from = table.split(".").map(q).join("."); return ( `SELECT u.${q("id")}, u.${q("name")}, u.${q("email")}, u.${q(verifiedColumn)} AS ${q("email_verified")} ` + - `FROM ${q(table)} u ORDER BY u.${q("id")} ASC` + `FROM ${from} u ORDER BY u.${q("id")} ASC` ); } @@ -87,7 +100,8 @@ export async function fetchAuthJsUsers( let lastError: unknown; let columnError: unknown; - for (const table of TABLE_CANDIDATES) { + const candidates = tableCandidates(client); + for (const table of candidates) { for (const column of VERIFIED_COLUMNS) { try { const rows = await client.query(buildAuthJsQuery(client, table, column)); @@ -107,10 +121,8 @@ export async function fetchAuthJsUsers( if (columnError) throw columnError; throw lastError instanceof Error - ? new Error( - `No Auth.js user table found. Tried ${TABLE_CANDIDATES.join(", ")}. ${lastError.message}`, - ) - : new Error(`No Auth.js user table found. Tried ${TABLE_CANDIDATES.join(", ")}.`); + ? new Error(`No Auth.js user table found. Tried ${candidates.join(", ")}. ${lastError.message}`) + : new Error(`No Auth.js user table found. Tried ${candidates.join(", ")}.`); } export function buildAuthJsExport(rows: AuthJsRow[], record: (line: UserLine) => void = () => {}) { diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index f88e08a62..0b8688747 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -214,6 +214,29 @@ describe("authjs export", () => { }); }); + // `@auth/supabase-adapter` keeps its tables in the `next_auth` schema, and an + // app's own `public.users` must not answer for it. + test("on Postgres, reads next_auth.users first, quoting schema and table", async () => { + const sql: string[] = []; + const client = { + dbType: "postgres", + quote: (identifier: string) => `"${identifier}"`, + placeholder: (position: number) => `$${position}`, + close: async () => {}, + query: async (query: string) => { + sql.push(query); + if (query.includes('"next_auth"."users"')) return [{ id: "aj1", email: "a@x.dev" }]; + throw new Error('relation "public.users" does not exist'); + }, + } as unknown as DbClient; + + const { rows, table } = await fetchAuthJsUsers(client); + + expect(table).toBe("next_auth.users"); + expect(rows).toHaveLength(1); + expect(sql[0]).toContain('FROM "next_auth"."users" u'); + }); + // Prisma capitalizes the table, Drizzle does not, and Auth.js has no single // schema — so the export tries rather than making the user guess. test.each([["User"], ["user"], ["users"]])("finds the %s table", async (table) => { From 2977df8e275bddf76957adb026e3df10975476ec Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 14:12:33 -0400 Subject: [PATCH 11/16] fix(migrate): leave a date that holds no real date out of a database export A MySQL zero date can come back from Bun as an Invalid Date, whose toISOString() throws and ends the export. One shared guard, used by the Supabase, Better Auth and Auth.js exports, leaves such a value out: for Auth.js's emailVerified, that reads as unverified. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/authjs.ts | 9 +++++---- .../src/commands/migrate/export/betterauth.ts | 5 +++-- .../migrate/export/db-exports.test.ts | 20 +++++++++++++++++++ .../src/commands/migrate/export/supabase.ts | 5 +++-- .../cli-core/src/commands/migrate/lib/db.ts | 10 ++++++++++ 5 files changed, 41 insertions(+), 8 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/authjs.ts b/packages/cli-core/src/commands/migrate/export/authjs.ts index 48ebba09c..745663251 100644 --- a/packages/cli-core/src/commands/migrate/export/authjs.ts +++ b/packages/cli-core/src/commands/migrate/export/authjs.ts @@ -15,7 +15,7 @@ import { withGutter, withSpinner } from "../../../lib/spinner.ts"; import { log } from "../../../lib/log.ts"; import type { UserLine } from "../lib/run-store.ts"; import { printTarget } from "../lib/target.ts"; -import { withDbClient, type DbClient } from "../lib/db.ts"; +import { exportValue, withDbClient, type DbClient } from "../lib/db.ts"; import { finishExport, startExportRun } from "./shared.ts"; import { promptDbUrl, @@ -142,9 +142,10 @@ export function buildAuthJsExport(rows: AuthJsRow[], record: (line: UserLine) => counts.email++; } // A nullable timestamp, not a boolean: the transformer reads presence. - if (row.email_verified) { - user.email_verified = - row.email_verified instanceof Date ? row.email_verified.toISOString() : row.email_verified; + // A zero date is no verification. + const verified = row.email_verified ? exportValue(row.email_verified) : undefined; + if (verified !== undefined) { + user.email_verified = verified; counts.emailVerified++; } diff --git a/packages/cli-core/src/commands/migrate/export/betterauth.ts b/packages/cli-core/src/commands/migrate/export/betterauth.ts index bc1e31452..1862a8f52 100644 --- a/packages/cli-core/src/commands/migrate/export/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/export/betterauth.ts @@ -20,7 +20,7 @@ import { log } from "../../../lib/log.ts"; import { withGutter, withSpinner } from "../../../lib/spinner.ts"; import type { UserLine } from "../lib/run-store.ts"; import { printTarget } from "../lib/target.ts"; -import { withDbClient, type DbClient } from "../lib/db.ts"; +import { exportValue, withDbClient, type DbClient } from "../lib/db.ts"; import { finishExport, startExportRun } from "./shared.ts"; import { promptDbUrl, @@ -238,7 +238,8 @@ export function buildBetterAuthExport( for (const [key, value] of Object.entries(row)) { if (value === null || value === undefined) continue; if (conflicting && key === "password_hash") continue; - user[FIELD_ALIASES[key] ?? key] = value instanceof Date ? value.toISOString() : value; + const exported = exportValue(value); + if (exported !== undefined) user[FIELD_ALIASES[key] ?? key] = exported; } // The source maps this to `passwordDropped`, which the checks count. if (conflicting) user.password_dropped = true; diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index 0b8688747..9b98e64db 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -653,6 +653,26 @@ describe("supabase export", () => { }); }); +// A MySQL zero date can come back as an Invalid Date, whose toISOString throws. +describe("a date that holds no real date", () => { + const invalid = new Date(Number.NaN); + + test("is no verification in an Auth.js export, and does not throw", () => { + const { users } = buildAuthJsExport([{ id: "aj1", email: "a@x.dev", email_verified: invalid }]); + expect(users[0]).not.toHaveProperty("email_verified"); + }); + + // Supabase skipped the whole row on the throw; Better Auth ended the export. + test.each([ + ["supabase", () => buildSupabaseExport([{ id: "u1", created_at: invalid }]).users], + ["betterauth", () => buildBetterAuthExport([{ id: "u1", createdAt: invalid }]).users], + ])("is left out of a %s export, which keeps the user", (_platform, build) => { + const users = build(); + expect(users).toHaveLength(1); + expect(users[0]).not.toHaveProperty("created_at"); + }); +}); + describe("connection failures", () => { // Supabase is Postgres: anything else would connect and then fail. test.each([["mysql://u:p@127.0.0.1:1/db"], ["./definitely-not-here.sqlite"]])( diff --git a/packages/cli-core/src/commands/migrate/export/supabase.ts b/packages/cli-core/src/commands/migrate/export/supabase.ts index efe8427da..911c3483d 100644 --- a/packages/cli-core/src/commands/migrate/export/supabase.ts +++ b/packages/cli-core/src/commands/migrate/export/supabase.ts @@ -14,7 +14,7 @@ import { log } from "../../../lib/log.ts"; import { withGutter, withSpinner } from "../../../lib/spinner.ts"; import type { UserLine } from "../lib/run-store.ts"; import { printTarget } from "../lib/target.ts"; -import type { DbClient } from "../lib/db.ts"; +import { exportValue, type DbClient } from "../lib/db.ts"; import { finishExport, startExportRun } from "./shared.ts"; import { resolveDbUrl, @@ -88,7 +88,8 @@ function normalizeRow(row: SupabaseRow): Record { if (value === -Infinity) continue; // Postgres returns timestamps as Date objects; the transformer parses // strings, and JSON.stringify would otherwise bury the format difference. - normalized[key] = value instanceof Date ? value.toISOString() : value; + const exported = exportValue(value); + if (exported !== undefined) normalized[key] = exported; } return normalized; diff --git a/packages/cli-core/src/commands/migrate/lib/db.ts b/packages/cli-core/src/commands/migrate/lib/db.ts index 90e8ec76a..ff19fac25 100644 --- a/packages/cli-core/src/commands/migrate/lib/db.ts +++ b/packages/cli-core/src/commands/migrate/lib/db.ts @@ -21,6 +21,16 @@ import { CliError, ERROR_CODE, EXIT_CODE } from "../../../lib/errors.ts"; export type DbType = "postgres" | "mysql" | "sqlite"; +/** + * A driver value as the export file carries it: a `Date` as ISO 8601, and + * one that holds no real date as absent. A MySQL zero date may come back as an + * Invalid Date, whose `toISOString()` throws. + */ +export function exportValue(value: unknown): unknown { + if (!(value instanceof Date)) return value; + return Number.isNaN(value.getTime()) ? undefined : value.toISOString(); +} + export interface DbClient { dbType: DbType; query>(sql: string, params?: unknown[]): Promise; From 912a2dd8e2c04152ec06188f73aebb9d0056a0b2 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 14:13:05 -0400 Subject: [PATCH 12/16] docs(migrate): stop counting the platforms in the export headings "Four platforms export no passwords" and "These three read the database" each went stale when the next platform landed. Co-Authored-By: Claude Opus 5.5 --- packages/cli-core/src/commands/migrate/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index 4cac31fe2..dd29a6765 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -365,7 +365,7 @@ profile in that order. The export run has one line per exported user, so `clerk migrate runs` lists it alongside imports. -#### Four platforms export no passwords +#### Platforms that export no passwords - **Clerk** never returns password digests, TOTP secrets or backup codes over the API — only the `*_enabled` booleans. Migrated users must reset their @@ -381,14 +381,14 @@ alongside imports. even a password column on the user table: migrate those separately, or have those users reset their password. -All four print a warning whenever they export anyone. `clerk`'s and `workos`'s +Each prints a warning whenever it exports anyone. `clerk`'s and `workos`'s coverage also counts users who _have_ a password, so the size of the gap is visible up front — `workos` prints that row at zero unconditionally, because zero is the only value it can take. #### Database-backed exports (`supabase`, `authjs`, `betterauth`) -These three read the database directly, over **`--db-url`**: +These read the database directly, over **`--db-url`**: ```sh clerk migrate export supabase --db-url "postgres://postgres:...@db.xxx.supabase.co:5432/postgres" From 6d838d2f13cba68c2447e22d007207ffa067a563 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 14:13:51 -0400 Subject: [PATCH 13/16] fix(migrate): drop a one-word name only when it is the user's own email Any single word with an @ was dropped as Auth0's email placeholder, so an Auth.js user named "jane@acme" lost their name. Now only a name that is one of the user's own email addresses is dropped, for every source. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/sources/shared.ts | 19 ++++++++++++++++--- .../commands/migrate/sources/sources.test.ts | 14 ++++++++++++++ 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/sources/shared.ts b/packages/cli-core/src/commands/migrate/sources/shared.ts index 64405b279..aeebe3c63 100644 --- a/packages/cli-core/src/commands/migrate/sources/shared.ts +++ b/packages/cli-core/src/commands/migrate/sources/shared.ts @@ -80,12 +80,25 @@ export function routeByVerification( delete user[verifiedField]; } +/** The user's email addresses, wherever the source routed them. */ +function emailsOf(user: Record): Set { + const emails = new Set(); + for (const field of ["email", "emailAddresses", "unverifiedEmailAddresses"]) { + const value = user[field]; + for (const email of Array.isArray(value) ? value : [value]) { + if (typeof email === "string") emails.add(email.trim().toLowerCase()); + } + } + return emails; +} + /** * Splits a single display name into `firstName` and `lastName`: the first word, * then the rest. A one-word name (`Cher`) becomes the first name alone. * - * A one-word value that is an email address is dropped instead: Auth0 fills - * `name` with the email for database users, and that is not anyone's name. + * A one-word value that is one of the user's own email addresses is dropped + * instead: Auth0 fills `name` with the email for database users, and that is + * not anyone's name. Any other name with an `@` in it is kept. */ export function splitName(user: Record, field = "name"): void { const name = user[field]; @@ -95,7 +108,7 @@ export function splitName(user: Record, field = "name"): void { if (parts.length > 1) { user.firstName = parts[0]; user.lastName = parts.slice(1).join(" "); - } else if (parts[0] && !parts[0].includes("@")) { + } else if (parts[0] && !emailsOf(user).has(parts[0].toLowerCase())) { user.firstName = parts[0]; } delete user[field]; diff --git a/packages/cli-core/src/commands/migrate/sources/sources.test.ts b/packages/cli-core/src/commands/migrate/sources/sources.test.ts index d4cdab06c..cfe111ba2 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -199,6 +199,12 @@ describe("auth0", () => { const user = one("auth0", { ...base, name: "a@x.dev" }); expect(user?.firstName).toBeUndefined(); }); + + // Only the user's own address is a placeholder. + test("keeps a one-word name with an @ that is not the user's email", () => { + const user = one("auth0", { ...base, name: "ada@work" }); + expect(user?.firstName).toBe("ada@work"); + }); }); describe("workos", () => { @@ -266,6 +272,14 @@ describe("workos", () => { describe("authjs", () => { const base = { id: "cuid1", email: "a@x.dev" }; + // Auth.js lets `name` be any string; only the user's own email is dropped. + test.each([ + ["jane@acme", "jane@acme"], + ["a@x.dev", undefined], + ])("name %p becomes first name %p", (name, firstName) => { + expect(one("authjs", { ...base, name })?.firstName).toBe(firstName); + }); + test("treats a confirmation timestamp as verified", () => { const user = one("authjs", { ...base, email_verified: "2024-01-15T10:30:00.000Z" }); expect(user?.email).toBe("a@x.dev"); From 45bd4dca746acce0ea32c9c4b62b0001a87f79fe Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Wed, 14 Oct 2026 16:35:17 -0400 Subject: [PATCH 14/16] fix(migrate): count verified emails from the exported values, not the rows A date that holds no real date is left out of the export, but the Supabase and Better Auth coverage counted it from the row, so the report called the user confirmed while the file said unverified. Co-Authored-By: Claude Opus 5.5 --- .../src/commands/migrate/export/betterauth.ts | 3 ++- .../commands/migrate/export/db-exports.test.ts | 16 ++++++++++++++++ .../src/commands/migrate/export/supabase.ts | 6 ++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/betterauth.ts b/packages/cli-core/src/commands/migrate/export/betterauth.ts index 1862a8f52..dc9673cad 100644 --- a/packages/cli-core/src/commands/migrate/export/betterauth.ts +++ b/packages/cli-core/src/commands/migrate/export/betterauth.ts @@ -245,7 +245,8 @@ export function buildBetterAuthExport( if (conflicting) user.password_dropped = true; if (row.email) counts.email++; - if (row.emailVerified) counts.emailVerified++; + // From the exported value: a date that holds none is left out of it. + if (user.email_verified) counts.emailVerified++; if (row.password_hash && !conflicting) counts.password++; if (row.name) counts.name++; if (row.username) counts.username++; diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index 9b98e64db..5261b7d7f 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -662,6 +662,22 @@ describe("a date that holds no real date", () => { expect(users[0]).not.toHaveProperty("email_verified"); }); + // Counted from what is exported, so coverage agrees with the file. + test.each([ + [ + "supabase", + () => buildSupabaseExport([{ id: "u1", email: "a@x.dev", email_confirmed_at: invalid }]), + "have a confirmed email", + ], + [ + "betterauth", + () => buildBetterAuthExport([{ id: "u1", email: "a@x.dev", emailVerified: invalid }]), + "have a verified email", + ], + ])("is not counted as a verified email in a %s export", (_platform, build, label) => { + expect(build().coverage.find((row) => row.label === label)?.count).toBe(0); + }); + // Supabase skipped the whole row on the throw; Better Auth ended the export. test.each([ ["supabase", () => buildSupabaseExport([{ id: "u1", created_at: invalid }]).users], diff --git a/packages/cli-core/src/commands/migrate/export/supabase.ts b/packages/cli-core/src/commands/migrate/export/supabase.ts index 911c3483d..4d43058f8 100644 --- a/packages/cli-core/src/commands/migrate/export/supabase.ts +++ b/packages/cli-core/src/commands/migrate/export/supabase.ts @@ -116,10 +116,12 @@ export function buildSupabaseExport( for (const row of rows) { const userId = String(row.id ?? ""); try { - users.push(normalizeRow(row)); + const user = normalizeRow(row); + users.push(user); if (row.email) counts.email++; - if (row.email_confirmed_at) counts.emailConfirmed++; + // From the exported value: a date that holds none is left out of it. + if (user.email_confirmed_at) counts.emailConfirmed++; if (row.encrypted_password) counts.password++; if (row.phone) counts.phone++; // The source splits a name it finds in the metadata, so count those too. From 98b68fd22d58e813dc352ef5ae4cdc3b3a58f7af Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 16:18:25 -0400 Subject: [PATCH 15/16] fix(migrate): read Auth.js's zero and unparseable timestamps as unverified, and keep a legacy table's created_at - The import treated `email_verified: 0` (or "0") and a value that isn't a date as verified, though the export reads 0 as no verification. Both are now unverified. Digits alone stay a stored integer timestamp. - The export reads `created_at` from a legacy NextAuth table that has one, and falls back when it doesn't. Auth.js's own schemas have none. - The dead `updated_at` mapping is gone: Clerk's create takes no update time. - The README says both. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/README.md | 14 ++++++---- .../src/commands/migrate/export/authjs.ts | 28 +++++++++++++++---- .../migrate/export/db-exports.test.ts | 22 +++++++++++++++ .../src/commands/migrate/sources/authjs.ts | 16 +++++++++-- .../commands/migrate/sources/sources.test.ts | 21 +++++++++++--- 5 files changed, 83 insertions(+), 18 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/README.md b/packages/cli-core/src/commands/migrate/README.md index dd29a6765..255e35d2e 100644 --- a/packages/cli-core/src/commands/migrate/README.md +++ b/packages/cli-core/src/commands/migrate/README.md @@ -434,11 +434,12 @@ the difference as significant once quoted. On Postgres it tries `next_auth` schema, off the default `search_path`, and an app's own `public.users` must not answer for it. The run reports which one it found. The verified column is read as `emailVerified`, or `email_verified` on a legacy -NextAuth table. Auth.js core stores no passwords, so its users arrive without -credentials. That is all an OAuth or email-link app has. An app that also uses -the Credentials provider stores passwords wherever it chose to, which the export -does not read, even a password column on the user table: migrate those -separately, or have those users reset their password. +NextAuth table, whose `created_at` is kept too when it has one. Auth.js's own +schemas record no creation time. Auth.js core stores no passwords, so its users +arrive without credentials. That is all an OAuth or email-link app has. An app +that also uses the Credentials provider stores passwords wherever it chose to, +which the export does not read, even a password column on the user table: +migrate those separately, or have those users reset their password. **`betterauth` reads its schema before it queries.** It finds the tables (`user` and `account`, or `users` and `accounts` under `usePlural: true`), how @@ -1153,7 +1154,8 @@ unconfirmed address there would silently promote it. read as false, not as a non-empty string. `TRUE`, `FALSE`, `t` and `f` read too, as a spreadsheet or psql writes them. - **Timestamp** (`authjs`, `supabase`): a nullable confirmation time. Any real value - means verified; `""`, `null` and `\N` do not. + means verified; `""`, `null` and `\N` do not. For `authjs`, neither does `0` + (the epoch) or a value that isn't a date. A Clerk export keeps an unverified primary email or phone unverified. diff --git a/packages/cli-core/src/commands/migrate/export/authjs.ts b/packages/cli-core/src/commands/migrate/export/authjs.ts index 745663251..6406003b6 100644 --- a/packages/cli-core/src/commands/migrate/export/authjs.ts +++ b/packages/cli-core/src/commands/migrate/export/authjs.ts @@ -44,6 +44,7 @@ type AuthJsRow = Record & { name?: string | null; email?: string | null; email_verified?: unknown; + created_at?: unknown; }; /** @@ -52,6 +53,16 @@ type AuthJsRow = Record & { */ const VERIFIED_COLUMNS = ["emailVerified", "email_verified"] as const; +/** + * What each attempt reads, in order. Only the legacy table has a + * `created_at`, and it may be gone: the second attempt drops it. + */ +const ATTEMPTS = [ + { verifiedColumn: "emailVerified", createdAt: false }, + { verifiedColumn: "email_verified", createdAt: true }, + { verifiedColumn: "email_verified", createdAt: false }, +] as const; + /** * Every column is qualified with the table alias: SQLite reads an unqualified * double-quoted name that matches no column as a string literal, so a missing @@ -62,13 +73,15 @@ export function buildAuthJsQuery( client: DbClient, table: string, verifiedColumn: (typeof VERIFIED_COLUMNS)[number] = "emailVerified", + createdAt = false, ): string { const q = (identifier: string) => client.quote(identifier); // `next_auth.users` is a schema and a table, each quoted on its own. const from = table.split(".").map(q).join("."); return ( - `SELECT u.${q("id")}, u.${q("name")}, u.${q("email")}, u.${q(verifiedColumn)} AS ${q("email_verified")} ` + - `FROM ${from} u ORDER BY u.${q("id")} ASC` + `SELECT u.${q("id")}, u.${q("name")}, u.${q("email")}, u.${q(verifiedColumn)} AS ${q("email_verified")}` + + (createdAt ? `, u.${q("created_at")}` : "") + + ` FROM ${from} u ORDER BY u.${q("id")} ASC` ); } @@ -102,12 +115,14 @@ export async function fetchAuthJsUsers( const candidates = tableCandidates(client); for (const table of candidates) { - for (const column of VERIFIED_COLUMNS) { + for (const { verifiedColumn, createdAt } of ATTEMPTS) { try { - const rows = await client.query(buildAuthJsQuery(client, table, column)); + const rows = await client.query( + buildAuthJsQuery(client, table, verifiedColumn, createdAt), + ); return { rows, table }; } catch (error) { - // The table is there: try the other name for the verified column. + // The table is there: try the next shape of it. if (isMissingColumn(error)) { columnError ??= error; continue; @@ -148,6 +163,9 @@ export function buildAuthJsExport(rows: AuthJsRow[], record: (line: UserLine) => user.email_verified = verified; counts.emailVerified++; } + // Only a legacy NextAuth table has one: Auth.js's own schemas don't. + const created = row.created_at ? exportValue(row.created_at) : undefined; + if (created !== undefined) user.created_at = created; users.push(user); record({ sourceId: userId, status: "exported" }); diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index 5261b7d7f..f3f648d62 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -261,6 +261,28 @@ describe("authjs export", () => { expect(rows.map((row) => row.email_verified)).toEqual([null, "2024-01-15"]); }); + // Auth.js's own schemas have no creation time; a legacy NextAuth table does. + test("keeps a legacy NextAuth table's created_at, and reads one without it", async () => { + const withCreated = makeDb((db) => { + db.run( + `CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, email TEXT, email_verified TEXT, created_at TEXT)`, + ); + db.run(`INSERT INTO users VALUES (?,?,?,?,?)`, ["n1", "N", "n@x.dev", null, "2021-03-04"]); + }); + const { rows } = await withClient(withCreated, fetchAuthJsUsers); + expect(buildAuthJsExport(rows).users[0]).toMatchObject({ created_at: "2021-03-04" }); + + const without = makeDb((db) => { + db.run( + `CREATE TABLE users (id TEXT PRIMARY KEY, name TEXT, email TEXT, email_verified TEXT)`, + ); + db.run(`INSERT INTO users VALUES (?,?,?,?)`, ["n1", "N", "n@x.dev", null]); + }); + const plain = await withClient(without, fetchAuthJsUsers); + expect(plain.rows).toHaveLength(1); + expect("created_at" in (buildAuthJsExport(plain.rows).users[0] ?? {})).toBe(false); + }); + // Postgres keeps a quoted "User" apart from "user"; SQLite does not, so the // plural table stands in for the later candidate here. test("passes over a candidate table without the columns for a later one", async () => { diff --git a/packages/cli-core/src/commands/migrate/sources/authjs.ts b/packages/cli-core/src/commands/migrate/sources/authjs.ts index 89d814d8b..7b5daffc8 100644 --- a/packages/cli-core/src/commands/migrate/sources/authjs.ts +++ b/packages/cli-core/src/commands/migrate/sources/authjs.ts @@ -9,8 +9,9 @@ import { routeByVerification, splitName } from "./shared.ts"; * A different schema means editing the mapping below or supplying a custom * transformer file. * - * `email_verified` is a nullable timestamp rather than a boolean — any value - * means verified. + * `email_verified` is a nullable timestamp rather than a boolean: any real + * date means verified. `0` (the epoch) and a value that isn't a date don't, + * as the export reads them; digits alone are a stored integer timestamp. * * No password default: Auth.js core stores no passwords, so users arrive * without a digest and are imported with `skip_password_requirement`. An app @@ -38,9 +39,18 @@ const authjsSource = { email_verified: "emailVerified", name: "name", created_at: "createdAt", - updated_at: "updatedAt", }, postTransform: (user) => { + const verified = user.emailVerified; + if ( + verified === 0 || + (typeof verified === "string" && + (/^\d+$/.test(verified.trim()) + ? Number(verified) === 0 + : Number.isNaN(Date.parse(verified)))) + ) { + user.emailVerified = null; + } routeByVerification(user, "email", "emailVerified", "timestamp"); splitName(user); }, diff --git a/packages/cli-core/src/commands/migrate/sources/sources.test.ts b/packages/cli-core/src/commands/migrate/sources/sources.test.ts index cfe111ba2..221babe5d 100644 --- a/packages/cli-core/src/commands/migrate/sources/sources.test.ts +++ b/packages/cli-core/src/commands/migrate/sources/sources.test.ts @@ -286,10 +286,23 @@ describe("authjs", () => { expect(user?.unverifiedEmailAddresses).toBeUndefined(); }); - test.each([[null], [""], [undefined]])("treats email_verified=%p as unverified", (value) => { - const user = one("authjs", { ...base, email_verified: value }); - expect(user?.unverifiedEmailAddresses).toBe("a@x.dev"); - }); + test.each([[null], [""], [undefined], [0], ["0"], ["not-a-date"]])( + "treats email_verified=%p as unverified", + (value) => { + const user = one("authjs", { ...base, email_verified: value }); + expect(user?.unverifiedEmailAddresses).toBe("a@x.dev"); + }, + ); + + // A SQLite integer column exports as digits, and Postgres or MySQL as a + // date string: each is a real verification. + test.each([[1700000000000], ["1700000000000"], ["2024-06-29 20:25:06+00"]])( + "treats email_verified=%p as verified", + (value) => { + const user = one("authjs", { ...base, email_verified: value }); + expect(user?.email).toBe("a@x.dev"); + }, + ); test.each([ ["Jane Doe", "Jane", "Doe"], From 38e07e5ee20798363e7d13c7102fe665703dbb37 Mon Sep 17 00:00:00 2001 From: Roy Anger Date: Fri, 16 Oct 2026 19:33:18 -0400 Subject: [PATCH 16/16] fix(migrate): export Auth.js's zero-epoch Date as unverified Postgres and MySQL hand back email_verified as a Date. A Date(0) is truthy, so it exported as 1970-01-01 and imported as verified, against the rule that the epoch is no verification. It now exports as unverified, like a numeric 0. Co-Authored-By: Claude Opus 5.5 --- .../cli-core/src/commands/migrate/export/authjs.ts | 5 +++-- .../src/commands/migrate/export/db-exports.test.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/cli-core/src/commands/migrate/export/authjs.ts b/packages/cli-core/src/commands/migrate/export/authjs.ts index 6406003b6..922b0759d 100644 --- a/packages/cli-core/src/commands/migrate/export/authjs.ts +++ b/packages/cli-core/src/commands/migrate/export/authjs.ts @@ -157,8 +157,9 @@ export function buildAuthJsExport(rows: AuthJsRow[], record: (line: UserLine) => counts.email++; } // A nullable timestamp, not a boolean: the transformer reads presence. - // A zero date is no verification. - const verified = row.email_verified ? exportValue(row.email_verified) : undefined; + // A zero date is no verification, as a number or as a driver's Date. + const zeroDate = row.email_verified instanceof Date && row.email_verified.getTime() === 0; + const verified = row.email_verified && !zeroDate ? exportValue(row.email_verified) : undefined; if (verified !== undefined) { user.email_verified = verified; counts.emailVerified++; diff --git a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts index f3f648d62..2076141b0 100644 --- a/packages/cli-core/src/commands/migrate/export/db-exports.test.ts +++ b/packages/cli-core/src/commands/migrate/export/db-exports.test.ts @@ -325,6 +325,16 @@ describe("authjs export", () => { expect("email_verified" in (users[1] ?? {})).toBe(false); }); + // Postgres and MySQL hand back a Date: the epoch is no verification there either. + test("a zero-epoch Date exports as unverified", () => { + const { users } = buildAuthJsExport([ + { id: "a", email: "a@x.dev", email_verified: new Date(0) }, + { id: "b", email: "b@x.dev", email_verified: new Date("2024-01-15T00:00:00Z") }, + ]); + expect("email_verified" in (users[0] ?? {})).toBe(false); + expect(users[1]?.email_verified).toBe("2024-01-15T00:00:00.000Z"); + }); + test("counts coverage", () => { const { coverage } = buildAuthJsExport([ { id: "a", email: "a@x.dev", name: "A", email_verified: "2024-01-01" },