@payload-auth-link/migrate 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +6 -3
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +149 -101
  4. package/dist/cli.js.map +1 -1
  5. package/dist/codemods.d.ts +19 -8
  6. package/dist/codemods.d.ts.map +1 -1
  7. package/dist/codemods.js +440 -21
  8. package/dist/codemods.js.map +1 -1
  9. package/dist/doctor/database.d.ts +5 -1
  10. package/dist/doctor/database.d.ts.map +1 -1
  11. package/dist/doctor/database.js +98 -34
  12. package/dist/doctor/database.js.map +1 -1
  13. package/dist/doctor/plugins.d.ts +15 -0
  14. package/dist/doctor/plugins.d.ts.map +1 -1
  15. package/dist/doctor/plugins.js +43 -0
  16. package/dist/doctor/plugins.js.map +1 -1
  17. package/dist/doctor/run-doctor.d.ts +11 -0
  18. package/dist/doctor/run-doctor.d.ts.map +1 -1
  19. package/dist/doctor/run-doctor.js +66 -20
  20. package/dist/doctor/run-doctor.js.map +1 -1
  21. package/dist/doctor/source-scan.d.ts +5 -0
  22. package/dist/doctor/source-scan.d.ts.map +1 -1
  23. package/dist/doctor/source-scan.js +9 -0
  24. package/dist/doctor/source-scan.js.map +1 -1
  25. package/dist/doctor/types.d.ts +12 -0
  26. package/dist/doctor/types.d.ts.map +1 -1
  27. package/dist/exporters.d.ts +7 -0
  28. package/dist/exporters.d.ts.map +1 -1
  29. package/dist/exporters.js +36 -1
  30. package/dist/exporters.js.map +1 -1
  31. package/dist/importer/apply.d.ts +5 -0
  32. package/dist/importer/apply.d.ts.map +1 -1
  33. package/dist/importer/apply.js +61 -33
  34. package/dist/importer/apply.js.map +1 -1
  35. package/dist/importer/lifecycle.d.ts +15 -6
  36. package/dist/importer/lifecycle.d.ts.map +1 -1
  37. package/dist/importer/lifecycle.js +131 -39
  38. package/dist/importer/lifecycle.js.map +1 -1
  39. package/dist/importer/run-import.d.ts +8 -2
  40. package/dist/importer/run-import.d.ts.map +1 -1
  41. package/dist/importer/run-import.js +73 -21
  42. package/dist/importer/run-import.js.map +1 -1
  43. package/dist/importer/source.d.ts +47 -4
  44. package/dist/importer/source.d.ts.map +1 -1
  45. package/dist/importer/source.js +159 -27
  46. package/dist/importer/source.js.map +1 -1
  47. package/dist/importer/types.d.ts +33 -0
  48. package/dist/importer/types.d.ts.map +1 -1
  49. package/dist/importer/types.js +48 -0
  50. package/dist/importer/types.js.map +1 -1
  51. package/dist/index.d.ts +5 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -3
  54. package/dist/index.js.map +1 -1
  55. package/package.json +8 -11
  56. package/src/cli.ts +153 -100
  57. package/src/codemods.ts +540 -36
  58. package/src/doctor/database.ts +129 -46
  59. package/src/doctor/plugins.ts +51 -0
  60. package/src/doctor/run-doctor.ts +90 -25
  61. package/src/doctor/source-scan.ts +10 -0
  62. package/src/doctor/types.ts +9 -0
  63. package/src/exporters.ts +46 -1
  64. package/src/importer/apply.ts +78 -32
  65. package/src/importer/lifecycle.ts +163 -43
  66. package/src/importer/run-import.ts +107 -22
  67. package/src/importer/source.ts +214 -30
  68. package/src/importer/types.ts +86 -0
  69. package/src/index.ts +25 -2
@@ -9,12 +9,24 @@ import {
9
9
  verifyImport,
10
10
  type VerifyResult,
11
11
  } from './lifecycle.js'
12
- import { countTables, findOrphans, openSource, unverifiedEmails } from './source.js'
12
+ import {
13
+ countTables,
14
+ findOrphans,
15
+ openSource,
16
+ probeSourceLayout,
17
+ readSourceUsers,
18
+ unverifiedEmails,
19
+ type SourceLayoutIssue,
20
+ } from './source.js'
13
21
  import {
14
22
  defaultTargetSlugs,
23
+ parseSourceMapJson,
24
+ presentTables,
25
+ resolveSourceTables,
15
26
  type ImportManifest,
16
27
  type ImportPlan,
17
28
  type ImportResult,
29
+ type ResolvedSourceTables,
18
30
  type TargetSlugMap,
19
31
  } from './types.js'
20
32
 
@@ -36,12 +48,18 @@ export type ImportCommandOptions = {
36
48
  readonly sessions: 'carry' | 'drop'
37
49
  readonly manifestPath?: string | undefined
38
50
  readonly slugsJson?: string | undefined
51
+ /**
52
+ * The `--source-map` JSON (issue #68): model → source table name, or
53
+ * `null` for "model not installed". Missing mapped tables abort the
54
+ * run before anything is written without it.
55
+ */
56
+ readonly sourceMapJson?: string | undefined
39
57
  readonly json?: boolean
40
58
  readonly log?: (message: string) => void
41
59
  readonly loadPayload?: (configPath: string) => Promise<import('payload').Payload>
42
60
  }
43
61
 
44
- const log_ = (options: ImportCommandOptions): ((message: string) => void) =>
62
+ const log_ = (options: Pick<ImportCommandOptions, 'log'>): ((message: string) => void) =>
45
63
  options.log ?? (() => {})
46
64
 
47
65
  async function loadPayloadInstance(configPath: string): Promise<import('payload').Payload> {
@@ -67,33 +85,53 @@ function slugsOf(options: ImportCommandOptions): TargetSlugMap {
67
85
  return { ...defaultTargetSlugs, ...(parsed as Record<string, string>) }
68
86
  }
69
87
 
88
+ /** The resolved source layout (`--source-map` over the default layout). */
89
+ function sourceTablesOf(options: ImportCommandOptions): ResolvedSourceTables {
90
+ return resolveSourceTables(
91
+ options.sourceMapJson === undefined ? undefined : parseSourceMapJson(options.sourceMapJson)
92
+ )
93
+ }
94
+
95
+ /**
96
+ * Fail-closed probe (issue #68): runs FIRST, before any plan, read, or
97
+ * Payload boot. Findings come back as unmappable rows — the caller's
98
+ * exit-1 path — so nothing is silently read as empty.
99
+ */
100
+ async function probeOrFail(
101
+ options: ImportCommandOptions,
102
+ log: (message: string) => void
103
+ ): Promise<readonly SourceLayoutIssue[]> {
104
+ const client = await openSource(options.databaseUrl)
105
+ try {
106
+ const issues = await probeSourceLayout(client, sourceTablesOf(options))
107
+ for (const issue of issues) {
108
+ log(` X ${issue.model}: ${issue.reason}`)
109
+ }
110
+ return issues
111
+ } finally {
112
+ await client.end()
113
+ }
114
+ }
115
+
70
116
  /** Plan-only pass: counts, orphans, unverified users, warnings. */
71
- async function buildPlan(options: ImportCommandOptions): Promise<{
117
+ async function buildPlan(
118
+ options: ImportCommandOptions,
119
+ sourceTables: ResolvedSourceTables
120
+ ): Promise<{
72
121
  plan: ImportPlan
73
122
  sourceCounts: Record<string, number>
74
123
  }> {
75
124
  const client = await openSource(options.databaseUrl)
76
125
  try {
77
- const sourceCounts = await countTables(client, [
78
- 'users',
79
- 'accounts',
80
- 'sessions',
81
- 'verifications',
82
- 'api_keys',
83
- 'jwks',
84
- 'organizations',
85
- 'members',
86
- 'invitations',
87
- 'users_role',
88
- ])
89
- const orphans = await findOrphans(client)
126
+ const sourceCounts = await countTables(client, presentTables(sourceTables))
127
+ const { orphans, skipped } = await findOrphans(client, sourceTables)
90
128
  return {
91
129
  sourceCounts,
92
130
  plan: {
93
131
  rows: [],
94
132
  unmappable: orphans,
95
133
  sessions: options.sessions,
96
- warnings: [],
134
+ warnings: skipped.map((reason) => `orphan check ${reason}`),
97
135
  },
98
136
  }
99
137
  } finally {
@@ -110,13 +148,34 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
110
148
  }> {
111
149
  const log = log_(options)
112
150
  const slugs = slugsOf(options)
113
- const { plan: basePlan, sourceCounts } = await buildPlan(options)
151
+ const sourceTables = sourceTablesOf(options)
152
+
153
+ // The probe runs before anything else (issue #68): a missing mapped
154
+ // table or column aborts here — exit 1, nothing planned or written.
155
+ const probeIssues = await probeOrFail(options, log)
156
+ if (probeIssues.length > 0) {
157
+ return {
158
+ exitCode: 1,
159
+ plan: {
160
+ rows: [],
161
+ unmappable: probeIssues.map((issue) => ({
162
+ model: issue.model,
163
+ key: issue.key,
164
+ reason: issue.reason,
165
+ })),
166
+ sessions: options.sessions,
167
+ warnings: [],
168
+ },
169
+ unverified: [],
170
+ }
171
+ }
172
+
173
+ const { plan: basePlan, sourceCounts } = await buildPlan(options, sourceTables)
114
174
 
115
175
  const unverified = await (async () => {
116
176
  const client = await openSource(options.databaseUrl)
117
177
  try {
118
- const { readSourceUsers } = await import('./source.js')
119
- return unverifiedEmails(await readSourceUsers(client))
178
+ return unverifiedEmails(await readSourceUsers(client, sourceTables))
120
179
  } finally {
121
180
  await client.end()
122
181
  }
@@ -152,6 +211,7 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
152
211
  sourceCounts,
153
212
  sourceDatabase: options.databaseUrl.replace(/\/\/[^@]*@/, '//***@'),
154
213
  slugs,
214
+ sourceTables,
155
215
  log,
156
216
  })
157
217
  const plan: ImportPlan = {
@@ -186,11 +246,34 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
186
246
  /** The `verify` command. */
187
247
  export async function runVerify(options: ImportCommandOptions): Promise<VerifyResult> {
188
248
  const log = log_(options)
249
+
250
+ // The probe runs first (issue #68): a missing mapped table fails the
251
+ // check with a blocker — before any Payload boot or read.
252
+ const probeIssues = await probeOrFail(options, log)
253
+ if (probeIssues.length > 0) {
254
+ const result: VerifyResult = {
255
+ ok: false,
256
+ issues: probeIssues.map((issue) => ({
257
+ code: 'source-table-missing',
258
+ detail: issue.reason,
259
+ })),
260
+ checks: [],
261
+ }
262
+ if (!options.json) {
263
+ for (const issue of result.issues) {
264
+ log(` [${issue.code}] ${issue.detail}`)
265
+ }
266
+ }
267
+ return result
268
+ }
269
+
189
270
  const payload = await (options.loadPayload ?? loadPayloadInstance)(options.configPath)
190
271
  const result = await verifyImport({
191
272
  payload,
192
273
  sourceConnectionString: options.databaseUrl,
193
274
  slugs: slugsOf(options),
275
+ sourceTables: sourceTablesOf(options),
276
+ sessions: options.sessions,
194
277
  })
195
278
  if (!options.json) {
196
279
  for (const check of result.checks) {
@@ -221,8 +304,10 @@ export async function runRollback(
221
304
  return result
222
305
  }
223
306
 
224
- /** The `finalize` command. */
225
- export function runFinalize(options: ImportCommandOptions): Promise<ImportManifest> {
307
+ /** The `finalize` command — manifest-only; no Payload instance is loaded. */
308
+ export function runFinalize(
309
+ options: Pick<ImportCommandOptions, 'manifestPath' | 'log'>
310
+ ): Promise<ImportManifest> {
226
311
  const log = log_(options)
227
312
  const manifestPath = options.manifestPath ?? defaultManifestPath
228
313
  const manifest = readManifest(manifestPath)
@@ -1,11 +1,22 @@
1
1
  import { Client } from 'pg'
2
- import type { Unmappable } from './types.js'
2
+ import {
3
+ defaultSourceTables,
4
+ type ResolvedSourceTables,
5
+ type SourceModel,
6
+ type Unmappable,
7
+ } from './types.js'
3
8
 
4
9
  /**
5
10
  * Read side (issue #50): the payload-auth layout, straight from
6
11
  * Postgres. Rows are read with plain SELECTs — the source tables are
7
12
  * never modified until `finalize` (which only marks the manifest; the
8
13
  * drop itself stays the operator's step).
14
+ *
15
+ * Fail closed (issue #68): nothing here tolerates a missing table.
16
+ * `probeSourceLayout` is the gate — the run commands call it first and
17
+ * abort on any finding — so every read below assumes the mapped tables
18
+ * exist and throws if they don't. A customized layout renames tables
19
+ * with `--source-map`, never with silent skips.
9
20
  */
10
21
 
11
22
  /** One payload-auth source user with its role rows and accounts. */
@@ -37,6 +48,34 @@ export type SourceAccount = {
37
48
 
38
49
  export type SourceRow = Record<string, unknown>
39
50
 
51
+ /** One fail-closed finding: a mapped model's table or column is absent. */
52
+ export type SourceLayoutIssue = {
53
+ readonly model: SourceModel
54
+ /** The table (or `table.column`) the probe looked for. */
55
+ readonly key: string
56
+ /** Human reason, naming the table and the `--source-map` escape hatch. */
57
+ readonly reason: string
58
+ }
59
+
60
+ /**
61
+ * The columns each model's reads dereference (issue #68). A layout
62
+ * missing any of these cannot be imported faithfully — the probe
63
+ * blocks on it instead of letting the SELECT fail mid-run (or worse,
64
+ * silently store `undefined`-derived values).
65
+ */
66
+ const requiredColumns: Readonly<Record<SourceModel, readonly string[]>> = {
67
+ users: ['id', 'email', 'email_verified'],
68
+ accounts: ['id', 'account_id', 'provider_id', 'user_id'],
69
+ sessions: ['id', 'user_id', 'token', 'expires_at'],
70
+ verifications: ['id', 'identifier', 'value', 'expires_at'],
71
+ api_keys: ['id', 'key', 'reference_id'],
72
+ jwks: ['id', 'public_key', 'private_key'],
73
+ organizations: ['id', 'name', 'slug'],
74
+ members: ['id', 'organization_id', 'user_id'],
75
+ invitations: ['id', 'organization_id', 'email'],
76
+ users_role: ['parent_id', 'value'],
77
+ }
78
+
40
79
  export async function openSource(connectionString: string): Promise<Client> {
41
80
  const client = new Client({ connectionString })
42
81
  await client.connect()
@@ -61,18 +100,62 @@ async function tableExists(client: Client, table: string): Promise<boolean> {
61
100
  return result.rows.length > 0
62
101
  }
63
102
 
103
+ /**
104
+ * Fail-closed probe (issue #68): every mapped model's table must
105
+ * exist (a `null` mapping records "not installed" and is skipped),
106
+ * and every column the reads dereference must be present. The run
107
+ * commands call this FIRST and abort on any finding — nothing is
108
+ * read, planned, or written past a failed probe.
109
+ */
110
+ export async function probeSourceLayout(
111
+ client: Client,
112
+ tables: ResolvedSourceTables
113
+ ): Promise<readonly SourceLayoutIssue[]> {
114
+ const issues: SourceLayoutIssue[] = []
115
+ for (const model of Object.keys(defaultSourceTables) as SourceModel[]) {
116
+ const table = tables[model]
117
+ if (table === null) {
118
+ // Explicitly not installed: skipped by config, never an error.
119
+ continue
120
+ }
121
+ if (!(await tableExists(client, table))) {
122
+ issues.push({
123
+ model,
124
+ key: table,
125
+ reason: `model '${model}' expects source table '${table}', which does not exist — if your layout renamed it, pass --source-map '{"${model}":"<table>"}', or map it null to record it as not installed`,
126
+ })
127
+ continue
128
+ }
129
+ const columns = await columnsOf(client, table)
130
+ for (const column of requiredColumns[model]) {
131
+ if (!columns.has(column)) {
132
+ issues.push({
133
+ model,
134
+ key: `${table}.${column}`,
135
+ reason: `model '${model}' needs column '${column}' on source table '${table}', which does not exist — --source-map maps tables, not columns, so this layout needs a manual review`,
136
+ })
137
+ }
138
+ }
139
+ }
140
+ return issues
141
+ }
142
+
143
+ /**
144
+ * Row counts for the given (mapped, existing) tables. A missing table
145
+ * throws — the probe owns the structured "missing table" report, so a
146
+ * throw here means the probe was skipped.
147
+ */
64
148
  export async function countTables(
65
149
  client: Client,
66
150
  tables: readonly string[]
67
151
  ): Promise<Record<string, number>> {
68
152
  const counts: Record<string, number> = {}
69
153
  for (const table of tables) {
70
- if (await tableExists(client, table)) {
71
- const counted = await client.query(`SELECT count(*)::int AS n FROM ${table}`)
72
- counts[table] = (counted.rows[0] as { n?: number } | undefined)?.n ?? 0
73
- } else {
74
- counts[table] = 0
154
+ if (!(await tableExists(client, table))) {
155
+ throw new Error(`source table '${table}' is missing — run probeSourceLayout first`)
75
156
  }
157
+ const counted = await client.query(`SELECT count(*)::int AS n FROM ${table}`)
158
+ counts[table] = (counted.rows[0] as { n?: number } | undefined)?.n ?? 0
76
159
  }
77
160
  return counts
78
161
  }
@@ -86,25 +169,38 @@ function text(value: unknown): string | null {
86
169
  }
87
170
 
88
171
  /** Every source user, with role rows and accounts attached. */
89
- export async function readSourceUsers(client: Client): Promise<SourceUser[]> {
90
- const hasRoleTable = await tableExists(client, 'users_role')
172
+ export async function readSourceUsers(
173
+ client: Client,
174
+ tables: ResolvedSourceTables = defaultSourceTables
175
+ ): Promise<SourceUser[]> {
176
+ const usersTable = tables['users']
177
+ const accountsTable = tables['accounts']
178
+ // A null-mapped model is "not installed" by config: no rows, no read.
179
+ if (usersTable === null) {
180
+ return []
181
+ }
91
182
  const users = await client.query(
92
- `SELECT id, email, name, email_verified, image, created_at, updated_at FROM users ORDER BY id`
183
+ `SELECT id, email, name, email_verified, image, created_at, updated_at FROM ${usersTable} ORDER BY id`
93
184
  )
94
- const roleRows = hasRoleTable
95
- ? await client.query(`SELECT parent_id, value FROM users_role ORDER BY parent_id, "order"`)
96
- : { rows: [] as unknown[] }
185
+ const rolesTable = tables['users_role']
186
+ const roleRows =
187
+ rolesTable === null
188
+ ? { rows: [] as unknown[] }
189
+ : await client.query(`SELECT parent_id, value FROM ${rolesTable} ORDER BY parent_id, "order"`)
97
190
  const rolesByUser = new Map<string, string[]>()
98
191
  for (const row of roleRows.rows as { parent_id: number; value: string }[]) {
99
192
  const list = rolesByUser.get(String(row.parent_id)) ?? []
100
193
  list.push(row.value)
101
194
  rolesByUser.set(String(row.parent_id), list)
102
195
  }
103
- const accounts = await client.query(
104
- `SELECT id, account_id, provider_id, password, access_token, refresh_token, id_token,
196
+ const accounts =
197
+ accountsTable === null
198
+ ? { rows: [] as unknown[] }
199
+ : await client.query(
200
+ `SELECT id, account_id, provider_id, password, access_token, refresh_token, id_token,
105
201
  scope, access_token_expires_at, refresh_token_expires_at, user_id
106
- FROM accounts ORDER BY id`
107
- )
202
+ FROM ${accountsTable} ORDER BY id`
203
+ )
108
204
  const accountsByUser = new Map<string, SourceAccount[]>()
109
205
  for (const row of accounts.rows as Record<string, unknown>[]) {
110
206
  const userId = String(row.user_id)
@@ -142,51 +238,100 @@ export async function readSourceUsers(client: Client): Promise<SourceUser[]> {
142
238
  return out
143
239
  }
144
240
 
145
- /** Rows of one flat plugin table (sessions, verifications, apiKeys, jwks, …). */
241
+ /**
242
+ * Rows of one flat plugin table (sessions, verifications, apiKeys, jwks, …).
243
+ * A missing table throws — a `null` mapping keeps the caller from ever
244
+ * asking for it, and an unmapped missing table aborts at the probe.
245
+ */
146
246
  export async function readFlatRows(
147
247
  client: Client,
148
248
  table: string,
149
249
  columns: readonly string[]
150
250
  ): Promise<SourceRow[]> {
151
251
  if (!(await tableExists(client, table))) {
152
- return []
252
+ throw new Error(`source table '${table}' is missing — run probeSourceLayout first`)
153
253
  }
154
254
  const result = await client.query(`SELECT ${columns.join(', ')} FROM ${table} ORDER BY id`)
155
255
  return result.rows as SourceRow[]
156
256
  }
157
257
 
258
+ /** What the orphan sweep found, and which checks could not run (and why). */
259
+ export type OrphanScan = {
260
+ readonly orphans: readonly Unmappable[]
261
+ /** Checks skipped with a reason — reported, never silent (issue #68). */
262
+ readonly skipped: readonly string[]
263
+ }
264
+
158
265
  /**
159
266
  * Members and invitations reference organizations and users by numeric
160
267
  * id; the importer remaps to the target's baIds. Orphaned references
161
268
  * are unmappable — they abort the run rather than skip silently.
269
+ *
270
+ * Every check is table-mapped (issue #68). The api-key check probes
271
+ * its columns: better-auth names the owner reference `reference_id`
272
+ * (1.7 / payload-auth layouts) or `user_id` (1.6-era layouts); when
273
+ * neither exists the check is reported as skipped-with-reason instead
274
+ * of passing silently.
162
275
  */
163
- export async function findOrphans(client: Client): Promise<readonly Unmappable[]> {
276
+ export async function findOrphans(
277
+ client: Client,
278
+ tables: ResolvedSourceTables = defaultSourceTables
279
+ ): Promise<OrphanScan> {
164
280
  const orphans: Unmappable[] = []
165
- const checks: readonly { model: string; table: string; column: string; target: string }[] = [
166
- { model: 'account', table: 'accounts', column: 'user_id', target: 'users' },
167
- { model: 'session', table: 'sessions', column: 'user_id', target: 'users' },
168
- { model: 'member', table: 'members', column: 'organization_id', target: 'organizations' },
169
- { model: 'member', table: 'members', column: 'user_id', target: 'users' },
281
+ const skipped: string[] = []
282
+ const checks: readonly {
283
+ model: string
284
+ table: string | null
285
+ column: string
286
+ target: string | null
287
+ }[] = [
288
+ { model: 'account', table: tables['accounts'], column: 'user_id', target: tables['users'] },
289
+ { model: 'session', table: tables['sessions'], column: 'user_id', target: tables['users'] },
290
+ {
291
+ model: 'member',
292
+ table: tables['members'],
293
+ column: 'organization_id',
294
+ target: tables['organizations'],
295
+ },
296
+ { model: 'member', table: tables['members'], column: 'user_id', target: tables['users'] },
170
297
  {
171
298
  model: 'invitation',
172
- table: 'invitations',
299
+ table: tables['invitations'],
173
300
  column: 'organization_id',
174
- target: 'organizations',
301
+ target: tables['organizations'],
302
+ },
303
+ {
304
+ model: 'invitation',
305
+ table: tables['invitations'],
306
+ column: 'inviter_id',
307
+ target: tables['users'],
175
308
  },
176
- { model: 'invitation', table: 'invitations', column: 'inviter_id', target: 'users' },
177
- { model: 'apikey', table: 'api_keys', column: 'user_id', target: 'users' },
178
309
  ]
179
310
  for (const check of checks) {
311
+ if (check.table === null || check.target === null) {
312
+ // Null-mapped model: not installed by config — an expected skip,
313
+ // recorded so the report says why nothing was checked.
314
+ skipped.push(
315
+ `${check.model}: skipped — ${check.table === null ? 'the model is null-mapped (not installed)' : `its target '${String(check.target)}' is null-mapped (not installed)`}`
316
+ )
317
+ continue
318
+ }
180
319
  const exists = await Promise.all([
181
320
  tableExists(client, check.table),
182
321
  tableExists(client, check.target),
183
322
  ])
184
323
  if (!exists[0] || !exists[1]) {
324
+ skipped.push(
325
+ `${check.model}: skipped — table '${!exists[0] ? check.table : String(check.target)}' is absent`
326
+ )
185
327
  continue
186
328
  }
187
329
  // Customized layouts drop columns; a missing reference column is
188
- // not an orphan farm.
330
+ // reported, not treated as an all-clear.
189
331
  if (!(await columnsOf(client, check.table)).has(check.column)) {
332
+ skipped.push(
333
+ `${check.model}: skipped — column '${check.column}' is absent on '${check.table}'`
334
+ )
190
335
  continue
191
336
  }
192
337
  const result = await client.query(
@@ -202,7 +347,46 @@ export async function findOrphans(client: Client): Promise<readonly Unmappable[]
202
347
  })
203
348
  }
204
349
  }
205
- return orphans
350
+
351
+ // api_keys: the owner column is named per layout — probe it.
352
+ const apiKeysTable = tables['api_keys']
353
+ if (apiKeysTable === null) {
354
+ skipped.push('apikey: skipped — the model is null-mapped (not installed)')
355
+ } else if (!(await tableExists(client, apiKeysTable))) {
356
+ skipped.push(`apikey: skipped — table '${apiKeysTable}' is absent`)
357
+ } else {
358
+ const columns = await columnsOf(client, apiKeysTable)
359
+ const column = columns.has('reference_id')
360
+ ? 'reference_id'
361
+ : columns.has('user_id')
362
+ ? 'user_id'
363
+ : null
364
+ const usersTable = tables['users']
365
+ if (column === null) {
366
+ skipped.push(
367
+ `apikey: skipped — neither 'reference_id' nor 'user_id' exists on '${apiKeysTable}'; the owner check cannot run`
368
+ )
369
+ } else if (usersTable === null) {
370
+ skipped.push('apikey: skipped — its target is null-mapped (not installed)')
371
+ } else {
372
+ // The owner column can be varchar (reference_id) or integer
373
+ // (1.6-era user_id): compare through text on both sides so the
374
+ // check runs on either layout.
375
+ const result = await client.query(
376
+ `SELECT t.id FROM ${apiKeysTable} t
377
+ WHERE t.${column} IS NOT NULL
378
+ AND NOT EXISTS (SELECT 1 FROM ${usersTable} s WHERE s.id::text = t.${column}::text)`
379
+ )
380
+ for (const row of result.rows as { id: unknown }[]) {
381
+ orphans.push({
382
+ model: 'apikey',
383
+ key: String(row.id),
384
+ reason: `${apiKeysTable}.${column} references a missing ${usersTable} row`,
385
+ })
386
+ }
387
+ }
388
+ }
389
+ return { orphans, skipped }
206
390
  }
207
391
 
208
392
  /** Users whose email never verified — reported, never granted `linkable`. */
@@ -67,6 +67,92 @@ export type ImportResult = {
67
67
  readonly plan: ImportPlan
68
68
  }
69
69
 
70
+ /**
71
+ * The models the source side names (issue #68). One entry per
72
+ * payload-auth table the importer/doctor reads — the keys are stable
73
+ * model names; the values are whatever the source database actually
74
+ * called the table.
75
+ */
76
+ export type SourceModel =
77
+ | 'users'
78
+ | 'accounts'
79
+ | 'sessions'
80
+ | 'verifications'
81
+ | 'api_keys'
82
+ | 'jwks'
83
+ | 'organizations'
84
+ | 'members'
85
+ | 'invitations'
86
+ | 'users_role'
87
+
88
+ /**
89
+ * A model → source-table override for custom layouts (`--source-map`):
90
+ * `{"accounts":"user_accounts","sessions":"user_sessions"}`. A `null`
91
+ * value means the model is explicitly NOT installed on the source
92
+ * (recorded as skipped-by-config, never probed as an error).
93
+ */
94
+ export type SourceTableMap = { readonly [M in SourceModel]?: string | null }
95
+
96
+ /** Every model resolved to its source table (`null` = not installed). */
97
+ export type ResolvedSourceTables = Readonly<Record<SourceModel, string | null>>
98
+
99
+ /** payload-auth's default Postgres layout. */
100
+ export const defaultSourceTables: Readonly<Record<SourceModel, string>> = {
101
+ users: 'users',
102
+ accounts: 'accounts',
103
+ sessions: 'sessions',
104
+ verifications: 'verifications',
105
+ api_keys: 'api_keys',
106
+ jwks: 'jwks',
107
+ organizations: 'organizations',
108
+ members: 'members',
109
+ invitations: 'invitations',
110
+ users_role: 'users_role',
111
+ }
112
+
113
+ /** All known source models — the error message names them. */
114
+ export const sourceModelNames: readonly SourceModel[] = Object.keys(
115
+ defaultSourceTables
116
+ ) as SourceModel[]
117
+
118
+ /**
119
+ * Parse the `--source-map` JSON (same boundary style as `--slugs`):
120
+ * keys must be known source models, values a table name or `null`
121
+ * ("model not installed"). Unknown keys throw — a typo would
122
+ * otherwise silently skip the model it meant to map.
123
+ */
124
+ export function parseSourceMapJson(json: string): SourceTableMap {
125
+ const parsed: unknown = JSON.parse(json)
126
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
127
+ throw new Error('--source-map must be a JSON object of model -> table name (or null)')
128
+ }
129
+ const map: Record<string, string | null> = {}
130
+ for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) {
131
+ if (!sourceModelNames.includes(key as SourceModel)) {
132
+ throw new Error(
133
+ `--source-map has an unknown model '${key}'; the known models are: ${sourceModelNames.join(', ')}`
134
+ )
135
+ }
136
+ if (value !== null && (typeof value !== 'string' || value.trim() === '')) {
137
+ throw new Error(`--source-map['${key}'] must be a table name or null`)
138
+ }
139
+ map[key] = value === null ? null : value.trim()
140
+ }
141
+ return map
142
+ }
143
+
144
+ /** Merge `--source-map` overrides onto the default layout. */
145
+ export function resolveSourceTables(map: SourceTableMap | undefined): ResolvedSourceTables {
146
+ return { ...defaultSourceTables, ...(map ?? {}) }
147
+ }
148
+
149
+ /** The tables to read (the mapped ones — `null` entries are skipped). */
150
+ export function presentTables(tables: ResolvedSourceTables): readonly string[] {
151
+ return sourceModelNames
152
+ .map((model) => tables[model])
153
+ .filter((table): table is string => table !== null)
154
+ }
155
+
70
156
  /** The models the importer copies. */
71
157
  export type TargetModel =
72
158
  | 'user'
package/src/index.ts CHANGED
@@ -12,12 +12,26 @@ export {
12
12
  } from './doctor/run-doctor.js'
13
13
  export type { DoctorOptions } from './doctor/run-doctor.js'
14
14
  export { checkVersions, payloadBand, betterAuthBand, nextBand } from './doctor/bands.js'
15
- export { classifyPlugins } from './doctor/plugins.js'
16
- export { scanSource, projectedUserFields } from './doctor/source-scan.js'
15
+ export { classifyPlugins, pluginsFromSourceTree } from './doctor/plugins.js'
16
+ export { scanSource, projectedUserFields, listSourceFiles } from './doctor/source-scan.js'
17
17
  export { analyzeUsersCollection, findUsersCollection, localStrategyOn } from './doctor/users.js'
18
18
  export { introspectDatabase } from './doctor/database.js'
19
19
  export { runFromPayloadAuth, runVerify, runRollback, runFinalize } from './importer/run-import.js'
20
20
  export { applyImport, summarizePlanRows } from './importer/apply.js'
21
+ export {
22
+ countTables,
23
+ findOrphans,
24
+ openSource,
25
+ probeSourceLayout,
26
+ readFlatRows,
27
+ readSourceUsers,
28
+ unverifiedEmails,
29
+ type OrphanScan,
30
+ type SourceLayoutIssue,
31
+ type SourceAccount,
32
+ type SourceRow,
33
+ type SourceUser,
34
+ } from './importer/source.js'
21
35
  export {
22
36
  readManifest,
23
37
  writeManifest,
@@ -29,8 +43,16 @@ export {
29
43
  type VerifyResult,
30
44
  } from './importer/lifecycle.js'
31
45
  export {
46
+ defaultSourceTables,
32
47
  defaultTargetSlugs,
48
+ parseSourceMapJson,
49
+ presentTables,
50
+ resolveSourceTables,
51
+ sourceModelNames,
33
52
  type CreatedRow,
53
+ type ResolvedSourceTables,
54
+ type SourceModel,
55
+ type SourceTableMap,
34
56
  type TargetModel,
35
57
  type TargetSlugMap,
36
58
  type FieldWrite,
@@ -40,6 +62,7 @@ export {
40
62
  type PlanRow,
41
63
  type Unmappable,
42
64
  } from './importer/types.js'
65
+ export { exportToJsonl, parseExportTables, exportTables } from './exporters.js'
43
66
  export { cliMain } from './cli.js'
44
67
  export type {
45
68
  BehaviourChange,