@payload-auth-link/migrate 0.3.1 → 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 +49 -6
  4. package/dist/cli.js.map +1 -1
  5. package/dist/codemods.d.ts +17 -8
  6. package/dist/codemods.d.ts.map +1 -1
  7. package/dist/codemods.js +439 -20
  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 +6 -0
  40. package/dist/importer/run-import.d.ts.map +1 -1
  41. package/dist/importer/run-import.js +72 -20
  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 +52 -6
  57. package/src/codemods.ts +537 -35
  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 +102 -19
  67. package/src/importer/source.ts +214 -30
  68. package/src/importer/types.ts +86 -0
  69. package/src/index.ts +25 -2
@@ -1,4 +1,9 @@
1
1
  import { Client } from 'pg'
2
+ import {
3
+ resolveSourceTables,
4
+ sourceModelNames,
5
+ type ResolvedSourceTables,
6
+ } from '../importer/types.js'
2
7
  import type { DatabaseFinding } from './types.js'
3
8
 
4
9
  /**
@@ -6,26 +11,18 @@ import type { DatabaseFinding } from './types.js'
6
11
  * payload-auth layout's auth tables, unverified users (who will not
7
12
  * JIT-link), and orphans. Only an honest SELECT — the doctor never
8
13
  * writes.
14
+ *
15
+ * Map-aware (issue #68): the ten source models are counted through the
16
+ * `--source-map` (defaulting to payload-auth's layout), and a mapped
17
+ * table that is absent comes back in `missingTables` for the caller to
18
+ * block on — a missing table is never silently reported as 0 rows.
19
+ * Satellite tables beyond the map (two-factor, passkey, sso, …) stay
20
+ * informational: apps drop plugins, and only the mapped models gate.
9
21
  */
10
22
 
11
- /**
12
- * The payload-auth 1.9.x Postgres layout (snake_case columns; Payload's
13
- * drizzle adapter stores hasMany selects as child tables like
14
- * `users_role`). Tables that don't exist are simply absent from the
15
- * report — apps customize slugs and drop plugins.
16
- */
23
+ /** Satellite/extra tables reported when present, never gating. */
17
24
  const knownTables: readonly string[] = [
18
- 'users',
19
- 'accounts',
20
- 'sessions',
21
- 'verifications',
22
- 'api_keys',
23
- 'organizations',
24
- 'members',
25
- 'invitations',
26
- 'jwks',
27
25
  'admin_invitations',
28
- 'users_role',
29
26
  'two_factors',
30
27
  'passkeys',
31
28
  'oauth_applications',
@@ -60,70 +57,156 @@ async function columnsOf(client: Client, table: string): Promise<Set<string>> {
60
57
  return new Set(result.rows.map((row) => String((row as { column_name: unknown }).column_name)))
61
58
  }
62
59
 
63
- export async function introspectDatabase(connectionString: string): Promise<DatabaseFinding> {
60
+ export type IntrospectOptions = {
61
+ /** Model → source table map (`--source-map`); defaults to the default layout. */
62
+ readonly sourceTables?: Readonly<Record<string, string | null>> | undefined
63
+ }
64
+
65
+ export async function introspectDatabase(
66
+ connectionString: string,
67
+ options: IntrospectOptions = {}
68
+ ): Promise<DatabaseFinding> {
69
+ const tables: ResolvedSourceTables = resolveSourceTables(options.sourceTables)
64
70
  const client = new Client({ connectionString })
65
71
  await client.connect()
66
72
  try {
67
- const tables: { name: string; rows: number }[] = []
73
+ const counted: { name: string; rows: number }[] = []
74
+ const missing: { model: string; table: string }[] = []
75
+
76
+ // The mapped models count, and a missing mapped table is reported
77
+ // for the caller to block on (issue #68).
78
+ for (const model of sourceModelNames) {
79
+ const table = tables[model]
80
+ if (table === null) {
81
+ continue
82
+ }
83
+ if (!(await tableExists(client, table))) {
84
+ missing.push({ model, table })
85
+ continue
86
+ }
87
+ const result = await client.query(`SELECT count(*)::int AS n FROM ${table}`)
88
+ const first = result.rows[0] as { n?: number } | undefined
89
+ counted.push({ name: table, rows: first?.n ?? 0 })
90
+ }
91
+
92
+ // Satellite tables: informational when present.
68
93
  for (const table of knownTables) {
69
94
  if (!(await tableExists(client, table))) {
70
95
  continue
71
96
  }
72
- const counted = await client.query(`SELECT count(*)::int AS n FROM ${table}`)
73
- const first = counted.rows[0] as { n?: number } | undefined
74
- tables.push({ name: table, rows: first?.n ?? 0 })
97
+ const result = await client.query(`SELECT count(*)::int AS n FROM ${table}`)
98
+ const first = result.rows[0] as { n?: number } | undefined
99
+ counted.push({ name: table, rows: first?.n ?? 0 })
75
100
  }
76
101
 
77
102
  let unverifiedUsers: number | null = null
78
- if (await tableExists(client, 'users')) {
79
- const columns = await columnsOf(client, 'users')
103
+ const usersTable = tables['users']
104
+ if (usersTable !== null && (await tableExists(client, usersTable))) {
105
+ const columns = await columnsOf(client, usersTable)
80
106
  if (columns.has('email_verified')) {
81
- const counted = await client.query(
82
- 'SELECT count(*)::int AS n FROM users WHERE email_verified = false'
107
+ const result = await client.query(
108
+ `SELECT count(*)::int AS n FROM ${usersTable} WHERE email_verified = false`
83
109
  )
84
- const first = counted.rows[0] as { n?: number } | undefined
110
+ const first = result.rows[0] as { n?: number } | undefined
85
111
  unverifiedUsers = first?.n ?? null
86
112
  }
87
113
  }
88
114
 
89
115
  const orphans: { kind: string; count: number }[] = []
90
- const orphanChecks: readonly { kind: string; table: string; column: string; target: string }[] =
91
- [
92
- { kind: 'accounts-without-user', table: 'accounts', column: 'user_id', target: 'users' },
93
- {
94
- kind: 'members-without-org',
95
- table: 'members',
96
- column: 'organization_id',
97
- target: 'organizations',
98
- },
99
- { kind: 'members-without-user', table: 'members', column: 'user_id', target: 'users' },
100
- {
101
- kind: 'invitations-without-org',
102
- table: 'invitations',
103
- column: 'organization_id',
104
- target: 'organizations',
105
- },
106
- ]
116
+ const skippedChecks: string[] = []
117
+ const orphanChecks: readonly {
118
+ kind: string
119
+ table: string | null
120
+ column: string
121
+ target: string | null
122
+ }[] = [
123
+ {
124
+ kind: 'accounts-without-user',
125
+ table: tables['accounts'],
126
+ column: 'user_id',
127
+ target: usersTable,
128
+ },
129
+ {
130
+ kind: 'members-without-org',
131
+ table: tables['members'],
132
+ column: 'organization_id',
133
+ target: tables['organizations'],
134
+ },
135
+ {
136
+ kind: 'members-without-user',
137
+ table: tables['members'],
138
+ column: 'user_id',
139
+ target: usersTable,
140
+ },
141
+ {
142
+ kind: 'invitations-without-org',
143
+ table: tables['invitations'],
144
+ column: 'organization_id',
145
+ target: tables['organizations'],
146
+ },
147
+ ]
107
148
  for (const check of orphanChecks) {
149
+ if (check.table === null || check.target === null) {
150
+ skippedChecks.push(`${check.kind}: skipped — a mapped model is null-mapped (not installed)`)
151
+ continue
152
+ }
108
153
  if (
109
154
  (await tableExists(client, check.table)) &&
110
155
  (await tableExists(client, check.target)) &&
111
156
  (await columnsOf(client, check.table)).has(check.column)
112
157
  ) {
113
- const counted = await client.query(
158
+ const result = await client.query(
114
159
  `SELECT count(*)::int AS n FROM ${check.table}
115
160
  WHERE ${check.column} IS NOT NULL
116
161
  AND NOT EXISTS (SELECT 1 FROM ${check.target} t WHERE t.id = ${check.table}.${check.column})`
117
162
  )
118
- const first = counted.rows[0] as { n?: number } | undefined
163
+ const first = result.rows[0] as { n?: number } | undefined
119
164
  const count = first?.n ?? 0
120
165
  if (count > 0) {
121
166
  orphans.push({ kind: check.kind, count })
122
167
  }
168
+ } else {
169
+ skippedChecks.push(`${check.kind}: skipped — a table or column involved is absent`)
170
+ }
171
+ }
172
+
173
+ // api keys: the owner column is named per layout — probe it
174
+ // (issue #68: `reference_id` on current layouts, `user_id` on 1.6).
175
+ const apiKeysTable = tables['api_keys']
176
+ if (apiKeysTable === null) {
177
+ skippedChecks.push('api-keys-without-user: skipped — the model is null-mapped')
178
+ } else if (!(await tableExists(client, apiKeysTable))) {
179
+ if (!missing.some((entry) => entry.model === 'api_keys')) {
180
+ skippedChecks.push(`api-keys-without-user: skipped — table '${apiKeysTable}' is absent`)
181
+ }
182
+ } else {
183
+ const columns = await columnsOf(client, apiKeysTable)
184
+ const column = columns.has('reference_id')
185
+ ? 'reference_id'
186
+ : columns.has('user_id')
187
+ ? 'user_id'
188
+ : null
189
+ if (column === null || usersTable === null) {
190
+ skippedChecks.push(
191
+ `api-keys-without-user: skipped — neither 'reference_id' nor 'user_id' exists on '${apiKeysTable}'`
192
+ )
193
+ } else {
194
+ // Text casts on both sides: the owner column may be varchar
195
+ // (reference_id) or integer (1.6-era user_id).
196
+ const result = await client.query(
197
+ `SELECT count(*)::int AS n FROM ${apiKeysTable}
198
+ WHERE ${column} IS NOT NULL
199
+ AND NOT EXISTS (SELECT 1 FROM ${usersTable} t WHERE t.id::text = ${apiKeysTable}.${column}::text)`
200
+ )
201
+ const first = result.rows[0] as { n?: number } | undefined
202
+ const count = first?.n ?? 0
203
+ if (count > 0) {
204
+ orphans.push({ kind: 'api-keys-without-user', count })
205
+ }
123
206
  }
124
207
  }
125
208
 
126
- return { tables, unverifiedUsers, orphans }
209
+ return { tables: counted, unverifiedUsers, orphans, missingTables: missing, skippedChecks }
127
210
  } finally {
128
211
  await client.end()
129
212
  }
@@ -1,4 +1,7 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { relative } from 'node:path'
1
3
  import type { PluginFinding } from './types.js'
4
+ import { listSourceFiles } from './source-scan.js'
2
5
 
3
6
  /**
4
7
  * Better Auth plugin classification (issue #52): for each plugin the
@@ -193,9 +196,17 @@ const callNameToPlugin: Readonly<Record<string, string>> = {
193
196
  nextCookies: 'next-cookies',
194
197
  tanstackStartCookies: 'tanstack-start-cookies',
195
198
  oidcProvider: 'oidcProvider',
199
+ mcp: 'mcp',
196
200
  passkey: 'passkey',
197
201
  apiKey: 'api-key',
198
202
  sso: 'sso',
203
+ multiSession: 'multi-session',
204
+ bearer: 'bearer',
205
+ oneTimeToken: 'one-time-token',
206
+ siwe: 'siwe',
207
+ expo: 'expo',
208
+ deviceAuthorization: 'device-authorization',
209
+ subscriptions: 'subscriptions',
199
210
  }
200
211
 
201
212
  /**
@@ -214,3 +225,43 @@ export function pluginsFromConfigSource(configSource: string): string[] {
214
225
  }
215
226
  return [...ids].sort()
216
227
  }
228
+
229
+ /** One plugin factory call found in a source file. */
230
+ export type PluginSourceHit = {
231
+ /** The Better Auth plugin id (or payload-auth-era name). */
232
+ readonly id: string
233
+ /** The file it was found in, relative to the scanned root. */
234
+ readonly file: string
235
+ }
236
+
237
+ /**
238
+ * Plugin factory calls across the WHOLE source tree (issue #71), not
239
+ * just the config file: a mid-upgrade app calls `admin(...)` or
240
+ * `oidcProvider(...)` in helpers too, and a schema-adding plugin that
241
+ * hides in a non-config file changes what the migration must carry.
242
+ * Same walk/skip rules as the source scan.
243
+ */
244
+ export function pluginsFromSourceTree(srcDir: string): readonly PluginSourceHit[] {
245
+ const hits: PluginSourceHit[] = []
246
+ const seen = new Set<string>()
247
+ for (const file of listSourceFiles(srcDir)) {
248
+ let content: string
249
+ try {
250
+ content = readFileSync(file, 'utf8')
251
+ } catch {
252
+ continue
253
+ }
254
+ for (const [call, id] of Object.entries(callNameToPlugin)) {
255
+ const pattern = new RegExp(`\\b${call}\\s*\\(`)
256
+ if (pattern.test(content)) {
257
+ const rel = relative(srcDir, file)
258
+ const key = `${id}\n${rel}`
259
+ if (!seen.has(key)) {
260
+ seen.add(key)
261
+ hits.push({ id, file: rel })
262
+ }
263
+ }
264
+ }
265
+ }
266
+ return hits
267
+ }
@@ -4,10 +4,16 @@ import { dirname, resolve } from 'node:path'
4
4
  import { pathToFileURL } from 'node:url'
5
5
  import { checkVersions } from './bands.js'
6
6
  import { introspectDatabase } from './database.js'
7
- import { classifyPlugins, pluginsFromCollectionSlugs, pluginsFromConfigSource } from './plugins.js'
7
+ import {
8
+ classifyPlugins,
9
+ pluginsFromCollectionSlugs,
10
+ pluginsFromConfigSource,
11
+ pluginsFromSourceTree,
12
+ } from './plugins.js'
8
13
  import { scanSource } from './source-scan.js'
9
14
  import { analyzeUsersCollection, findUsersCollection } from './users.js'
10
- import type { BehaviourChange, DoctorIssue, DoctorReport } from './types.js'
15
+ import { parseSourceMapJson, resolveSourceTables } from '../importer/types.js'
16
+ import type { BehaviourChange, DoctorIssue, DoctorReport, PluginFinding } from './types.js'
11
17
 
12
18
  /**
13
19
  * The read-only doctor (issue #52): point it at a payload-auth app and
@@ -16,6 +22,12 @@ import type { BehaviourChange, DoctorIssue, DoctorReport } from './types.js'
16
22
  * Auth plugins, scans the source tree for call sites that must
17
23
  * migrate, and (with a database URL) reports row counts, unverified
18
24
  * users, and orphans. It never writes.
25
+ *
26
+ * Never crashes (issue #71): a config that cannot even be IMPORTED
27
+ * (the mid-upgrade state — better-auth 1.7 installed while the config
28
+ * still calls `oidcProvider`) is a `config-unloadable` blocker, not a
29
+ * crash. The static scans (config text, source tree, database) still
30
+ * run and still catch what the load cannot.
19
31
  */
20
32
 
21
33
  export type DoctorOptions = {
@@ -25,6 +37,11 @@ export type DoctorOptions = {
25
37
  readonly srcDir?: string | undefined
26
38
  /** Postgres connection string. Defaults to `DATABASE_URL`. */
27
39
  readonly databaseUrl?: string | undefined
40
+ /**
41
+ * The `--source-map` JSON (issue #68): model → source table name, or
42
+ * `null` for "model not installed". Shapes the database section.
43
+ */
44
+ readonly sourceMapJson?: string | undefined
28
45
  /** Config loader, injectable for tests. Defaults to dynamic import. */
29
46
  readonly loadModule?: ((path: string) => Promise<unknown>) | undefined
30
47
  }
@@ -141,7 +158,6 @@ async function defaultLoadModule(path: string): Promise<unknown> {
141
158
  export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
142
159
  const configPath = resolve(options.configPath)
143
160
  const loadModule = options.loadModule ?? defaultLoadModule
144
- const config = await loadModule(configPath)
145
161
  const configDir = dirname(configPath)
146
162
  const srcDir = options.srcDir === undefined ? configDir : resolve(options.srcDir)
147
163
 
@@ -162,8 +178,25 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
162
178
  }
163
179
  }
164
180
 
165
- const plugin = findPayloadAuthPlugin(config)
166
- if (plugin === null) {
181
+ // Issue #71: the load can fail on the mid-upgrade state (better-auth
182
+ // 1.7 installed while the config still calls `oidcProvider`). That is
183
+ // a blocker — never a crash — and the static scans below still run.
184
+ let config: unknown = null
185
+ let configLoaded = true
186
+ try {
187
+ config = await loadModule(configPath)
188
+ } catch (error) {
189
+ configLoaded = false
190
+ blockers.push({
191
+ code: 'config-unloadable',
192
+ detail: `the config module could not be loaded: ${
193
+ error instanceof Error ? error.message : String(error)
194
+ } — fix the load error before migrating; the report below still reflects the static scans`,
195
+ })
196
+ }
197
+
198
+ const plugin = configLoaded ? findPayloadAuthPlugin(config) : null
199
+ if (configLoaded && plugin === null) {
167
200
  warnings.push({
168
201
  code: 'payload-auth-not-detected',
169
202
  detail:
@@ -179,22 +212,35 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
179
212
  }
180
213
 
181
214
  // payload-auth keeps the builder's options in its closure, so the
182
- // plugin set comes from what the built config proves: the collections
183
- // payload-auth created and the plugin calls in the config source.
215
+ // plugin set comes from what the sources prove: the collections
216
+ // payload-auth built, the plugin calls in the config source, AND the
217
+ // plugin calls anywhere under --src (issue #71 — schema-adding
218
+ // plugins hide in helper files too).
184
219
  let configSource: string
185
220
  try {
186
221
  configSource = readFileSync(configPath, 'utf8')
187
222
  } catch {
188
223
  configSource = ''
189
224
  }
225
+ const sourceTreeHits = pluginsFromSourceTree(srcDir)
226
+ const filesById = new Map<string, string[]>()
227
+ for (const hit of sourceTreeHits) {
228
+ const list = filesById.get(hit.id) ?? []
229
+ list.push(hit.file)
230
+ filesById.set(hit.id, list)
231
+ }
190
232
  const pluginIds = [
191
233
  ...new Set([
192
- ...betterAuthPluginIds(plugin?.pluginOptions ?? {}),
193
- ...pluginsFromCollectionSlugs(collectionSlugs(config)),
234
+ ...(configLoaded ? betterAuthPluginIds(plugin?.pluginOptions ?? {}) : []),
235
+ ...(configLoaded ? pluginsFromCollectionSlugs(collectionSlugs(config)) : []),
194
236
  ...pluginsFromConfigSource(configSource),
237
+ ...sourceTreeHits.map((hit) => hit.id),
195
238
  ]),
196
239
  ]
197
- const pluginFindings = classifyPlugins(pluginIds)
240
+ const pluginFindings: PluginFinding[] = classifyPlugins(pluginIds).map((finding) => {
241
+ const files = filesById.get(finding.id)
242
+ return files === undefined ? finding : { ...finding, files }
243
+ })
198
244
  for (const finding of pluginFindings) {
199
245
  if (finding.status === 'removed-upstream') {
200
246
  blockers.push({
@@ -206,20 +252,24 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
206
252
  }
207
253
  }
208
254
 
209
- const usersSlug = usersSlugOf(plugin?.pluginOptions ?? {})
210
- const { finding: users, issues: userIssues } = analyzeUsersCollection(
211
- findUsersCollection(config, usersSlug),
212
- 'roles'
213
- )
214
- for (const issue of userIssues) {
215
- if (
216
- issue.code === 'injected-field-collision' ||
217
- issue.code === 'roles-field-not-select' ||
218
- issue.code === 'users-collection-missing'
219
- ) {
220
- blockers.push(issue)
221
- } else {
222
- warnings.push(issue)
255
+ let users: DoctorReport['users'] = null
256
+ if (configLoaded) {
257
+ const usersSlug = usersSlugOf(plugin?.pluginOptions ?? {})
258
+ const { finding, issues: userIssues } = analyzeUsersCollection(
259
+ findUsersCollection(config, usersSlug),
260
+ 'roles'
261
+ )
262
+ users = finding
263
+ for (const issue of userIssues) {
264
+ if (
265
+ issue.code === 'injected-field-collision' ||
266
+ issue.code === 'roles-field-not-select' ||
267
+ issue.code === 'users-collection-missing'
268
+ ) {
269
+ blockers.push(issue)
270
+ } else {
271
+ warnings.push(issue)
272
+ }
223
273
  }
224
274
  }
225
275
 
@@ -237,7 +287,11 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
237
287
  let database: DoctorReport['database'] = null
238
288
  if (databaseUrl !== undefined && databaseUrl !== '') {
239
289
  try {
240
- const found = await introspectDatabase(databaseUrl)
290
+ const sourceTables =
291
+ options.sourceMapJson === undefined
292
+ ? undefined
293
+ : resolveSourceTables(parseSourceMapJson(options.sourceMapJson))
294
+ const found = await introspectDatabase(databaseUrl, { sourceTables })
241
295
  database = found
242
296
  if (found.unverifiedUsers !== null && found.unverifiedUsers > 0) {
243
297
  warnings.push({
@@ -245,12 +299,23 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
245
299
  detail: `${found.unverifiedUsers} user(s) have emailVerified = false: they will not JIT-link on a provider sign-in; they need a verified email (or a role manager's manual link) after migration`,
246
300
  })
247
301
  }
302
+ // Fail closed on the source layout (issue #68): a mapped model
303
+ // whose table is absent blocks until --source-map explains it.
304
+ for (const table of found.missingTables ?? []) {
305
+ blockers.push({
306
+ code: 'source-table-missing',
307
+ detail: `model '${table.model}' expects source table '${table.table}', which does not exist — if your layout renamed it, pass --source-map '{"${table.model}":"<table>"}', or map it null to record it as not installed`,
308
+ })
309
+ }
248
310
  for (const orphan of found.orphans) {
249
311
  blockers.push({
250
312
  code: 'orphan-rows',
251
313
  detail: `${orphan.count} orphaned row(s) of kind '${orphan.kind}': the importer refuses unmappable references rather than silently skipping them`,
252
314
  })
253
315
  }
316
+ for (const skip of found.skippedChecks ?? []) {
317
+ warnings.push({ code: 'orphan-check-skipped', detail: skip })
318
+ }
254
319
  } catch (error) {
255
320
  warnings.push({
256
321
  code: 'database-unreachable',
@@ -74,6 +74,16 @@ function walk(dir: string, into: string[]): void {
74
74
  }
75
75
  }
76
76
 
77
+ /**
78
+ * Every scannable file under `dir` — the walk/skip rules the source
79
+ * scan uses, shared with the plugin factory scan (issue #71).
80
+ */
81
+ export function listSourceFiles(dir: string): readonly string[] {
82
+ const files: string[] = []
83
+ walk(dir, files)
84
+ return files
85
+ }
86
+
77
87
  export function scanSource(srcDir: string): SourceFinding[] {
78
88
  const files: string[] = []
79
89
  walk(srcDir, files)
@@ -29,6 +29,8 @@ export type PluginFinding = {
29
29
  readonly id: string
30
30
  readonly status: 'supported' | 'auto-declarable' | 'unprobed' | 'unsupported' | 'removed-upstream'
31
31
  readonly detail: string
32
+ /** Files under `--src` whose source names this plugin (issue #71). */
33
+ readonly files?: readonly string[]
32
34
  }
33
35
 
34
36
  /** The users collection, as the migration will meet it. */
@@ -71,6 +73,13 @@ export type DatabaseFinding = {
71
73
  /** Users with `emailVerified = false` — they will not JIT-link. */
72
74
  readonly unverifiedUsers: number | null
73
75
  readonly orphans: readonly { readonly kind: string; readonly count: number }[]
76
+ /**
77
+ * Mapped source models whose table is absent (issue #68) — blockers
78
+ * unless `--source-map` records the model as not installed.
79
+ */
80
+ readonly missingTables?: readonly { readonly model: string; readonly table: string }[]
81
+ /** Orphan checks that could not run, with the reason (issue #68). */
82
+ readonly skippedChecks?: readonly string[]
74
83
  }
75
84
 
76
85
  /** Default-behaviour changes in payload-auth-link 0.7.0 that bite. */
package/src/exporters.ts CHANGED
@@ -39,6 +39,46 @@ export type ExportResult = {
39
39
  /** The JSONL document: one JSON object per line, `{"table": …, "row": …}`. */
40
40
  readonly jsonl: string
41
41
  readonly totalRows: number
42
+ /**
43
+ * Requested tables that do not exist on the source (issue #68). The
44
+ * caller decides policy: `--tables` names must abort (exit 1),
45
+ * default-list gaps are reported but tolerated.
46
+ */
47
+ readonly missing: readonly string[]
48
+ }
49
+
50
+ /**
51
+ * The `--tables` flag value (issue #68): a JSON array of table names or
52
+ * a comma-separated list of table names. Anything else is a usage error.
53
+ */
54
+ const tableName = /^[A-Za-z_][A-Za-z0-9_]*$/
55
+
56
+ export function parseExportTables(value: string): readonly string[] {
57
+ const trimmed = value.trim()
58
+ const reject = (): never => {
59
+ throw new Error('--tables must be a JSON array of table names or a comma-separated list')
60
+ }
61
+ if (trimmed === '') {
62
+ return reject()
63
+ }
64
+ if (trimmed.startsWith('[')) {
65
+ const parsed: unknown = JSON.parse(trimmed)
66
+ if (
67
+ !Array.isArray(parsed) ||
68
+ parsed.some((entry) => typeof entry !== 'string' || !tableName.test(entry.trim()))
69
+ ) {
70
+ return reject()
71
+ }
72
+ return (parsed as string[]).map((entry) => entry.trim())
73
+ }
74
+ const names = trimmed
75
+ .split(',')
76
+ .map((entry) => entry.trim())
77
+ .filter((entry) => entry !== '')
78
+ if (names.length === 0 || names.some((entry) => !tableName.test(entry))) {
79
+ return reject()
80
+ }
81
+ return names
42
82
  }
43
83
 
44
84
  /** Export the auth tables to lossless JSONL (issue #58). */
@@ -49,6 +89,7 @@ export async function exportToJsonl(options: ExportOptions): Promise<ExportResul
49
89
  try {
50
90
  const lines: string[] = []
51
91
  const exported: { name: string; rows: number }[] = []
92
+ const missing: string[] = []
52
93
  let totalRows = 0
53
94
  for (const table of tables) {
54
95
  const exists = await client.query(
@@ -56,6 +97,10 @@ export async function exportToJsonl(options: ExportOptions): Promise<ExportResul
56
97
  [table]
57
98
  )
58
99
  if (exists.rows.length === 0) {
100
+ // Nothing is silently omitted (issue #68): every absent table
101
+ // is logged and lands in `missing` for the caller's policy.
102
+ missing.push(table)
103
+ options.log?.(`table '${table}' does not exist on the source — not exported`)
59
104
  continue
60
105
  }
61
106
  // Export EVERY column raw (JSON types preserved by ::jsonb cast
@@ -70,7 +115,7 @@ export async function exportToJsonl(options: ExportOptions): Promise<ExportResul
70
115
  }
71
116
  }
72
117
  options.log?.(`exported ${totalRows} row(s) across ${exported.length} table(s)`)
73
- return { tables: exported, jsonl: `${lines.join('\n')}\n`, totalRows }
118
+ return { tables: exported, jsonl: `${lines.join('\n')}\n`, totalRows, missing }
74
119
  } finally {
75
120
  await client.end()
76
121
  }