@payload-auth-link/migrate 0.6.0 → 0.7.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 (66) hide show
  1. package/README.md +20 -1
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +85 -30
  4. package/dist/cli.js.map +1 -1
  5. package/dist/doctor/plugins.d.ts.map +1 -1
  6. package/dist/doctor/plugins.js +6 -2
  7. package/dist/doctor/plugins.js.map +1 -1
  8. package/dist/doctor/run-doctor.d.ts.map +1 -1
  9. package/dist/doctor/run-doctor.js +27 -2
  10. package/dist/doctor/run-doctor.js.map +1 -1
  11. package/dist/doctor/users.d.ts +13 -1
  12. package/dist/doctor/users.d.ts.map +1 -1
  13. package/dist/doctor/users.js +17 -7
  14. package/dist/doctor/users.js.map +1 -1
  15. package/dist/exporters.d.ts +43 -5
  16. package/dist/exporters.d.ts.map +1 -1
  17. package/dist/exporters.js +88 -5
  18. package/dist/exporters.js.map +1 -1
  19. package/dist/importer/apply.d.ts.map +1 -1
  20. package/dist/importer/apply.js +79 -23
  21. package/dist/importer/apply.js.map +1 -1
  22. package/dist/importer/guards.d.ts +49 -0
  23. package/dist/importer/guards.d.ts.map +1 -0
  24. package/dist/importer/guards.js +175 -0
  25. package/dist/importer/guards.js.map +1 -0
  26. package/dist/importer/host-writes.d.ts +6 -2
  27. package/dist/importer/host-writes.d.ts.map +1 -1
  28. package/dist/importer/host-writes.js +9 -4
  29. package/dist/importer/host-writes.js.map +1 -1
  30. package/dist/importer/lifecycle.d.ts +59 -14
  31. package/dist/importer/lifecycle.d.ts.map +1 -1
  32. package/dist/importer/lifecycle.js +123 -20
  33. package/dist/importer/lifecycle.js.map +1 -1
  34. package/dist/importer/manifest-mac.d.ts +15 -3
  35. package/dist/importer/manifest-mac.d.ts.map +1 -1
  36. package/dist/importer/manifest-mac.js +47 -27
  37. package/dist/importer/manifest-mac.js.map +1 -1
  38. package/dist/importer/prelink.d.ts.map +1 -1
  39. package/dist/importer/prelink.js +11 -1
  40. package/dist/importer/prelink.js.map +1 -1
  41. package/dist/importer/run-import.d.ts +22 -5
  42. package/dist/importer/run-import.d.ts.map +1 -1
  43. package/dist/importer/run-import.js +27 -6
  44. package/dist/importer/run-import.js.map +1 -1
  45. package/dist/importer/types.d.ts +7 -4
  46. package/dist/importer/types.d.ts.map +1 -1
  47. package/dist/importer/types.js.map +1 -1
  48. package/dist/index.d.ts +3 -3
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +1 -1
  51. package/dist/index.js.map +1 -1
  52. package/package.json +6 -6
  53. package/src/cli.ts +84 -30
  54. package/src/doctor/plugins.ts +8 -3
  55. package/src/doctor/run-doctor.ts +30 -3
  56. package/src/doctor/users.ts +25 -7
  57. package/src/exporters.ts +113 -12
  58. package/src/importer/apply.ts +92 -22
  59. package/src/importer/guards.ts +223 -0
  60. package/src/importer/host-writes.ts +15 -4
  61. package/src/importer/lifecycle.ts +185 -29
  62. package/src/importer/manifest-mac.ts +65 -30
  63. package/src/importer/prelink.ts +13 -1
  64. package/src/importer/run-import.ts +52 -11
  65. package/src/importer/types.ts +7 -4
  66. package/src/index.ts +10 -1
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,SAAS,EACT,gBAAgB,EAChB,qBAAqB,EACrB,mBAAmB,EACnB,WAAW,GACZ,MAAM,wBAAwB,CAAA;AAE/B,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAA;AACxF,OAAO,EAAE,eAAe,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAA;AAC5E,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAC1F,OAAO,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAChG,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAA;AACzD,OAAO,EACL,kBAAkB,EAClB,SAAS,EACT,WAAW,EACX,WAAW,EACX,eAAe,GAGhB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAE,YAAY,EAA2C,MAAM,uBAAuB,CAAA;AAC7F,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AACpE,OAAO,EACL,WAAW,EACX,WAAW,EACX,UAAU,EACV,iBAAiB,EACjB,YAAY,EACZ,eAAe,EACf,gBAAgB,GAOjB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EACL,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,mBAAmB,EACnB,0BAA0B,GAG3B,MAAM,yBAAyB,CAAA;AAChC,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAClB,aAAa,EACb,mBAAmB,EACnB,gBAAgB,GAcjB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EAAE,aAAa,EAAE,iBAAiB,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAA;AAC/E,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAA"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EACL,SAAS,EACT,gBAAgB,EAChB,qBAAqB,EACrB,mBAAmB,EACnB,WAAW,GACZ,MAAM,wBAAwB,CAAA;AAE/B,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,mBAAmB,CAAA;AACxF,OAAO,EAAE,eAAe,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAA;AAC5E,OAAO,EAAE,UAAU,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AAC1F,OAAO,EAAE,sBAAsB,EAAE,mBAAmB,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAA;AAChG,OAAO,EAAE,kBAAkB,EAAE,MAAM,sBAAsB,CAAA;AACzD,OAAO,EACL,kBAAkB,EAClB,SAAS,EACT,WAAW,EACX,WAAW,EACX,eAAe,GAIhB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAE,YAAY,EAA2C,MAAM,uBAAuB,CAAA;AAC7F,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAA;AACpE,OAAO,EACL,WAAW,EACX,WAAW,EACX,UAAU,EACV,iBAAiB,EACjB,YAAY,EACZ,eAAe,EACf,gBAAgB,GAOjB,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EACL,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,cAAc,EACd,gBAAgB,EAChB,mBAAmB,EACnB,0BAA0B,GAK3B,MAAM,yBAAyB,CAAA;AAChC,OAAO,EACL,mBAAmB,EACnB,kBAAkB,EAClB,kBAAkB,EAClB,aAAa,EACb,mBAAmB,EACnB,gBAAgB,GAcjB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,aAAa,EACb,iBAAiB,EACjB,YAAY,EACZ,kBAAkB,EAClB,iBAAiB,GAClB,MAAM,gBAAgB,CAAA;AACvB,OAAO,EAAE,OAAO,EAAE,MAAM,UAAU,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@payload-auth-link/migrate",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "payload-auth-link migration kit: read-only doctor/preflight, the payload-auth importer, and exporters.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -40,7 +40,7 @@
40
40
  "clean": "rm -rf dist *.tsbuildinfo"
41
41
  },
42
42
  "dependencies": {
43
- "@payload-auth-link/core": "0.8.0",
43
+ "@payload-auth-link/core": "0.9.0",
44
44
  "pg": "^8.23.1",
45
45
  "typescript": "^6.0.3"
46
46
  },
@@ -53,9 +53,9 @@
53
53
  },
54
54
  "devDependencies": {
55
55
  "@better-auth/api-key": "1.7.7",
56
- "@payload-auth-link/core": "0.8.0",
57
- "@payload-auth-link/plugin": "0.12.0",
58
- "@payload-auth-link/provider-better-auth": "0.9.0",
56
+ "@payload-auth-link/core": "0.9.0",
57
+ "@payload-auth-link/plugin": "0.13.0",
58
+ "@payload-auth-link/provider-better-auth": "0.10.0",
59
59
  "@payloadcms/db-postgres": "3.91.0",
60
60
  "@types/node": "^22",
61
61
  "@types/pg": "^8.23.1",
@@ -63,7 +63,7 @@
63
63
  "payload": "3.91.0"
64
64
  },
65
65
  "peerDependencies": {
66
- "@payload-auth-link/next": "^0.5.0",
66
+ "@payload-auth-link/next": "^0.6.0",
67
67
  "payload": "^3.83.0 || 4.0.0-beta.0"
68
68
  },
69
69
  "peerDependenciesMeta": {
package/src/cli.ts CHANGED
@@ -67,17 +67,29 @@ Commands:
67
67
  [--source-map '{"accounts":"user_accounts","sessions":"user_sessions"}']
68
68
  export --database-url URL [--out file.jsonl]
69
69
  [--tables users,accounts | '["users","accounts"]']
70
- (lossless JSONL of every auth table — the off-ramp, #58)
70
+ [--source-map '{"accounts":"user_accounts","sessions":"user_sessions"}']
71
+ (lossless JSONL of every auth table — the off-ramp, #58:
72
+ each mapped model's table, their child tables such as
73
+ users_role / organizations_tags, and every ba_* and
74
+ oauth_* table present; a mapped table that is missing
75
+ exits 1)
71
76
  codemod --src ./src [--write]
72
77
  from-payload-auth --config ./src/payload.config.ts [--apply] [--sessions=carry|drop]
73
78
  [--manifest ./payload-auth-link-migration.json] [--database-url URL] [--json]
74
79
  [--source-map '{"accounts":"user_accounts","sessions":"user_sessions"}']
75
80
  verify --config ./src/payload.config.ts [--database-url URL] [--json]
76
81
  [--source-map '{"accounts":"user_accounts","sessions":"user_sessions"}']
77
- rollback --config ./src/payload.config.ts [--manifest ...]
78
- [--allow-unsigned-manifest] (a pre-0.6.0 manifest; also
79
- accepted by from-payload-auth / adopt-prelink when resuming)
80
- finalize [--manifest ...]
82
+ rollback --config ./src/payload.config.ts [--manifest ...] [--slugs JSON]
83
+ [--allow-unsigned-manifest] (a manifest without a full
84
+ signature: pre-0.6.0, or 0.6.0's which leaves 'finalized'
85
+ unsigned; also accepted by from-payload-auth /
86
+ adopt-prelink / finalize when resuming)
87
+ [--allow-unsigned-deletes] (with an UNSIGNED manifest,
88
+ also delete its created rows and restore plaintext
89
+ values; without it an unsigned rollback deletes nothing)
90
+ finalize --config ./src/payload.config.ts [--manifest ...]
91
+ (records the finalize in the app's database and
92
+ re-signs the manifest; rollback is refused afterwards)
81
93
  adopt-prelink --config ./src/payload.config.ts [--apply] [--json]
82
94
  [--manifest ./payload-auth-link-prelink.json]
83
95
  [--users-slug users] [--verified-field emailVerified]
@@ -101,15 +113,17 @@ Commands:
101
113
  writes the payload-auth-link collections through the Payload Local
102
114
  API, records every created row and field change in the manifest,
103
115
  and never modifies the source tables. verify checks both sides;
104
- rollback undoes exactly the manifest; finalize marks it read-only
105
- so the legacy tables may be dropped later, in their own migration.
116
+ rollback undoes exactly the manifest (and only ever deletes rows in
117
+ the import's own target collections); finalize records the point of
118
+ no return so the legacy tables may be dropped later, in their own
119
+ migration (adopt mode: those are the live tables — never drop them).
106
120
 
107
121
  The manifest lands in the CURRENT DIRECTORY unless --manifest says
108
122
  otherwise. It holds ids and sealed values only (no emails in the
109
123
  clear; sealed with the app's PAYLOAD_SECRET, so rollback must run
110
124
  against the same app) and is written with mode 0600. It is signed
111
- with the same secret: rollback and a resumed --apply refuse an
112
- edited manifest. Delete it once
125
+ with the same secret: rollback, finalize, and a resumed --apply
126
+ refuse an edited manifest. Delete it once
113
127
  the migration is finalized and your soak window has passed.
114
128
 
115
129
  codemod rewrites the three named payload-auth call-site patterns
@@ -285,20 +299,40 @@ async function main(
285
299
  return 3
286
300
  }
287
301
  }
302
+ // --source-map: the same layout semantics as every other command
303
+ // (issue #110) — renamed tables are exported, null-mapped models
304
+ // are skipped, and a mapped table that is missing exits 1.
305
+ let sourceTables: import('./importer/types.js').ResolvedSourceTables | undefined
306
+ const sourceMapFlag = flags.get('source-map')
307
+ if (typeof sourceMapFlag === 'string') {
308
+ try {
309
+ const { parseSourceMapJson, resolveSourceTables } = await import('./importer/types.js')
310
+ sourceTables = resolveSourceTables(parseSourceMapJson(sourceMapFlag))
311
+ } catch (error) {
312
+ err.write(`${error instanceof Error ? error.message : String(error)}\n`)
313
+ return 3
314
+ }
315
+ }
288
316
  const { exportToJsonl } = await import('./exporters.js')
289
317
  const result = await exportToJsonl({
290
318
  connectionString: databaseUrl,
291
319
  format: 'jsonl',
292
320
  ...(tables === undefined ? {} : { tables }),
293
- log: (message) => out.write(`${message}\n`),
321
+ ...(sourceTables === undefined ? {} : { sourceTables }),
322
+ // Progress goes to stderr: stdout may BE the JSONL document.
323
+ log: (message) => err.write(`${message}\n`),
294
324
  })
295
- // A requested table that doesn't exist is an error, not a gap
296
- // (issue #68): nothing is silently omitted.
297
- if (tables !== undefined && result.missing.length > 0) {
325
+ // A required table that doesn't exist is an error, not a gap
326
+ // (issues #68, #110): nothing is silently omitted.
327
+ if (result.missing.length > 0) {
298
328
  for (const table of result.missing) {
299
329
  err.write(`export: table '${table}' does not exist on the source\n`)
300
330
  }
301
- err.write('nothing silently omitted; fix --tables or drop it\n')
331
+ err.write(
332
+ tables === undefined
333
+ ? "nothing silently omitted; pass --source-map to name your layout's tables (null for a model that is not installed)\n"
334
+ : 'nothing silently omitted; fix --tables or drop it\n'
335
+ )
302
336
  return 1
303
337
  }
304
338
  if (typeof flags.get('out') === 'string') {
@@ -373,27 +407,39 @@ async function main(
373
407
  )
374
408
  return 0
375
409
  }
376
- // finalize [--manifest ...]
377
- // (no --config: manifest-only; no Payload instance is loaded)
410
+ // The remaining commands (from-payload-auth, verify, rollback,
411
+ // finalize, adopt-prelink) all load a Payload instance, so they
412
+ // need the config.
413
+ const configFlag = flags.get('config')
414
+ if (typeof configFlag !== 'string' || configFlag === '') {
415
+ err.write(`${command} requires --config <path to payload.config.ts>\n`)
416
+ return 3
417
+ }
418
+ // finalize --config ... [--manifest ...]: boots the target app to
419
+ // sign the finalized manifest and record the database marker.
378
420
  if (command === 'finalize') {
379
- const finalized = await runFinalize({
380
- log,
381
- ...(typeof flags.get('manifest') === 'string'
382
- ? { manifestPath: flags.get('manifest') as string }
383
- : {}),
384
- })
421
+ let finalized: Awaited<ReturnType<typeof runFinalize>>
422
+ try {
423
+ finalized = await runFinalize({
424
+ configPath: new URL(configFlag, `file://${process.cwd()}/`).pathname,
425
+ log,
426
+ ...(typeof flags.get('manifest') === 'string'
427
+ ? { manifestPath: flags.get('manifest') as string }
428
+ : {}),
429
+ ...(typeof flags.get('slugs') === 'string'
430
+ ? { slugsJson: flags.get('slugs') as string }
431
+ : {}),
432
+ allowUnsignedManifest: flags.get('allow-unsigned-manifest') === true,
433
+ })
434
+ } catch (error) {
435
+ err.write(`finalize failed: ${error instanceof Error ? error.message : String(error)}\n`)
436
+ return 1
437
+ }
385
438
  if (json) {
386
439
  out.write(`${JSON.stringify({ finalized: finalized.finalized })}\n`)
387
440
  }
388
441
  return 0
389
442
  }
390
- // The remaining commands (from-payload-auth, verify, rollback) all
391
- // load a Payload instance, so they need the config.
392
- const configFlag = flags.get('config')
393
- if (typeof configFlag !== 'string' || configFlag === '') {
394
- err.write(`${command} requires --config <path to payload.config.ts>\n`)
395
- return 3
396
- }
397
443
  if (command === 'adopt-prelink') {
398
444
  const usersSlug = flags.get('users-slug')
399
445
  const verifiedField = flags.get('verified-field')
@@ -441,6 +487,7 @@ async function main(
441
487
  json,
442
488
  log,
443
489
  allowUnsignedManifest: flags.get('allow-unsigned-manifest') === true,
490
+ allowUnsignedDeletes: flags.get('allow-unsigned-deletes') === true,
444
491
  }
445
492
  if (command === 'from-payload-auth') {
446
493
  const outcome = await runFromPayloadAuth(importOptions)
@@ -478,7 +525,14 @@ async function main(
478
525
  return result.ok ? 0 : 1
479
526
  }
480
527
  if (command === 'rollback') {
481
- const undone = await runRollback(importOptions)
528
+ let undone: Awaited<ReturnType<typeof runRollback>>
529
+ try {
530
+ undone = await runRollback(importOptions)
531
+ } catch (error) {
532
+ // A refused or failed rollback is an error exit, never a quiet 0.
533
+ err.write(`rollback failed: ${error instanceof Error ? error.message : String(error)}\n`)
534
+ return 1
535
+ }
482
536
  if (json) {
483
537
  out.write(`${JSON.stringify(undone)}\n`)
484
538
  }
@@ -181,10 +181,15 @@ export function pluginsFromCollectionSlugs(slugs: readonly string[]): string[] {
181
181
  return [...ids].sort()
182
182
  }
183
183
 
184
- /** Plugin call names a config source can name, mapped to plugin ids. */
185
- /** True when a source file actually imports Better Auth. */
184
+ /**
185
+ * True when a source file actually imports Better Auth — any subpath
186
+ * depth (`better-auth/plugins/generic-oauth`, issue #110), static or
187
+ * dynamic `import()`, or `require()`.
188
+ */
186
189
  const betterAuthImport =
187
- /(?:from\s*|require\(\s*)['"](?:better-auth(?:\/[\w-]+)?|@better-auth\/[\w-]+)['"]/
190
+ /(?:from\s*|require\(\s*|import\(\s*)['"](?:better-auth(?:\/[\w.-]+)*|@better-auth\/[\w.-]+(?:\/[\w.-]+)*)['"]/
191
+
192
+ /** Plugin call names a config source can name, mapped to plugin ids. */
188
193
 
189
194
  const callNameToPlugin: Readonly<Record<string, string>> = {
190
195
  magicLink: 'magic-link',
@@ -124,6 +124,24 @@ export function betterAuthPluginIds(pluginOptions: Record<string, unknown>): str
124
124
  return ids
125
125
  }
126
126
 
127
+ /**
128
+ * The options of the payload-auth-link plugin in a built (TARGET)
129
+ * config, or `null` when the plugin is not installed. Payload keeps each
130
+ * plugin's `slug` and `options` for cross-plugin discovery.
131
+ */
132
+ function authLinkPluginOptions(config: unknown): Record<string, unknown> | null {
133
+ if (!isRecord(config) || !Array.isArray(config.plugins)) {
134
+ return null
135
+ }
136
+ for (const entry of config.plugins) {
137
+ if (typeof entry === 'function' && (entry as { slug?: unknown }).slug === 'payload-auth-link') {
138
+ const options: unknown = (entry as { options?: unknown }).options
139
+ return isRecord(options) ? options : {}
140
+ }
141
+ }
142
+ return null
143
+ }
144
+
127
145
  /** The users collection slug the payload-auth app configured. */
128
146
  export function usersSlugOf(pluginOptions: Record<string, unknown>): string {
129
147
  const users: unknown = pluginOptions.users
@@ -285,16 +303,25 @@ export async function runDoctor(options: DoctorOptions): Promise<DoctorReport> {
285
303
  let users: DoctorReport['users'] = null
286
304
  if (configLoaded) {
287
305
  const usersSlug = usersSlugOf(plugin?.pluginOptions ?? {})
306
+ const usersCollection = findUsersCollection(config, usersSlug)
307
+ const authLinkOptions = authLinkPluginOptions(config)
308
+ // An adopt-mode TARGET (issue #110): payload-auth-link is installed
309
+ // on the users collection and no engine-owned ba-users collection
310
+ // exists, so Better Auth creates its users in the host collection.
311
+ const adoptTarget = authLinkOptions !== null && !collectionSlugs(config).includes('ba-users')
312
+ const rolesField = authLinkOptions?.['rolesField']
288
313
  const { finding, issues: userIssues } = analyzeUsersCollection(
289
- findUsersCollection(config, usersSlug),
290
- 'roles'
314
+ usersCollection,
315
+ typeof rolesField === 'string' && rolesField !== '' ? rolesField : 'roles',
316
+ { adoptTarget }
291
317
  )
292
318
  users = finding
293
319
  for (const issue of userIssues) {
294
320
  if (
295
321
  issue.code === 'injected-field-collision' ||
296
322
  issue.code === 'roles-field-not-select' ||
297
- issue.code === 'users-collection-missing'
323
+ issue.code === 'users-collection-missing' ||
324
+ issue.code === 'adopt-local-strategy-on'
298
325
  ) {
299
326
  blockers.push(issue)
300
327
  } else {
@@ -70,11 +70,6 @@ export function findUsersCollection(
70
70
  return null
71
71
  }
72
72
 
73
- /**
74
- * Payload's local email+password strategy for a collection. payload-auth
75
- * leaves it on (its own Better Auth strategy rides alongside), which is
76
- * exactly the surface 0.7.0 closes for provider-linked documents.
77
- */
78
73
  /** Names of the custom auth strategies a collection declares. */
79
74
  function strategiesOf(collection: Record<string, unknown>): string[] {
80
75
  const auth: unknown = collection.auth
@@ -87,6 +82,11 @@ function strategiesOf(collection: Record<string, unknown>): string[] {
87
82
  .filter((name): name is string => typeof name === 'string')
88
83
  }
89
84
 
85
+ /**
86
+ * Payload's local email+password strategy for a collection, as the
87
+ * BUILT config has it. payload-auth leaves it on unless the app set
88
+ * `disableDefaultPayloadAuth: true`; either way, this reads the result.
89
+ */
90
90
  export function localStrategyOn(collection: Record<string, unknown>): boolean {
91
91
  const auth: unknown = collection.auth
92
92
  if (auth === undefined || auth === null) {
@@ -109,7 +109,15 @@ const pluginStrategyName = 'auth-link'
109
109
 
110
110
  export function analyzeUsersCollection(
111
111
  collection: Record<string, unknown> | null,
112
- rolesFieldName: string
112
+ rolesFieldName: string,
113
+ options: {
114
+ /**
115
+ * The config is an adopt-mode TARGET: payload-auth-link is installed
116
+ * and Better Auth serves its users from this collection (no ba-users
117
+ * collection). Enables the target-side adopt checks.
118
+ */
119
+ readonly adoptTarget?: boolean | undefined
120
+ } = {}
113
121
  ): { finding: UsersFinding | null; issues: DoctorIssue[] } {
114
122
  if (collection === null) {
115
123
  return {
@@ -193,8 +201,18 @@ export function analyzeUsersCollection(
193
201
  const local = localStrategyOn(collection)
194
202
  if (local) {
195
203
  notes.push(
196
- 'Payload local strategy is ON (payload-auth leaves it active): since 0.7.0, provider-linked documents refuse local sign-in and forgot/reset by default — break-glass operator accounts keep theirs; allowLocalCredentialsForLinkedDocs: true reopens it only for a migration window'
204
+ 'Payload local strategy is ON for this collection: since 0.7.0, provider-linked documents refuse local sign-in and forgot/reset by default — break-glass operator accounts keep theirs; allowLocalCredentialsForLinkedDocs: true reopens it only for a migration window'
197
205
  )
206
+ if (options.adoptTarget === true) {
207
+ // Adopt mode (issue #110): Better Auth creates its users IN this
208
+ // collection, and Payload's local strategy demands a password on
209
+ // every create — sign-up and anonymous sign-in fail for every
210
+ // new user.
211
+ issues.push({
212
+ code: 'adopt-local-strategy-on',
213
+ detail: `adopt mode serves Better Auth's users from the collection '${typeof collection.slug === 'string' ? collection.slug : 'users'}', but Payload's local strategy is ON there: Payload requires a password on every create, so every NEW user fails (email sign-up 422, anonymous sign-in 500 "field is invalid: password"). Set auth: { disableLocalStrategy: true } on the collection (payload-auth's disableDefaultPayloadAuth: true did the same), or use the importer (ba-* collections) instead of adopt`,
214
+ })
215
+ }
198
216
  } else {
199
217
  const strategies = Array.isArray(collection.auth) ? [] : strategiesOf(collection)
200
218
  notes.push(
package/src/exporters.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Client } from 'pg'
2
+ import { presentTables, resolveSourceTables, type ResolvedSourceTables } from './importer/types.js'
2
3
 
3
4
  /**
4
5
  * Off-ramp exporters (issue #58): every payload-auth-link collection's
@@ -14,12 +15,42 @@ export type ExportFormat = 'jsonl'
14
15
  export type ExportOptions = {
15
16
  readonly connectionString: string
16
17
  readonly format: ExportFormat
17
- /** Tables to export; defaults to the full auth-table set. */
18
+ /**
19
+ * Tables to export, exactly (`--tables`). Without it, the export
20
+ * covers EVERY auth table (see {@link resolveAuthTables}).
21
+ */
18
22
  readonly tables?: readonly string[] | undefined
23
+ /**
24
+ * The source layout (`--source-map`, issue #110): model → table, or
25
+ * `null` for "not installed". Defaults to payload-auth's layout. A
26
+ * mapped table that does not exist lands in `missing`.
27
+ */
28
+ readonly sourceTables?: ResolvedSourceTables | undefined
19
29
  readonly log?: (message: string) => void
20
30
  }
21
31
 
22
- /** The auth-table set, in dependency order for re-import. */
32
+ /**
33
+ * Better Auth plugin tables payload-auth creates beyond the importer's
34
+ * models — exported whenever present (they are auth data the off-ramp
35
+ * must not lose).
36
+ */
37
+ export const optionalAuthTables: readonly string[] = [
38
+ 'admin_invitations',
39
+ 'two_factors',
40
+ 'passkeys',
41
+ 'sso_providers',
42
+ 'organization_roles',
43
+ 'teams',
44
+ 'team_members',
45
+ 'device_code',
46
+ 'subscriptions',
47
+ ]
48
+
49
+ /**
50
+ * The legacy default auth-table list (payload-auth's default layout plus
51
+ * the plugin's users child tables). Kept for callers that name it;
52
+ * {@link resolveAuthTables} is what the export uses.
53
+ */
23
54
  export const exportTables: readonly string[] = [
24
55
  'users',
25
56
  'users_role',
@@ -40,9 +71,9 @@ export type ExportResult = {
40
71
  readonly jsonl: string
41
72
  readonly totalRows: number
42
73
  /**
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.
74
+ * Required tables that do not exist on the source (issues #68, #110):
75
+ * a `--tables` name, or a mapped model's table. The CLI exits 1 on
76
+ * any; optional tables (children, ba_*, oauth_*) never land here.
46
77
  */
47
78
  readonly missing: readonly string[]
48
79
  }
@@ -81,32 +112,102 @@ export function parseExportTables(value: string): readonly string[] {
81
112
  return names
82
113
  }
83
114
 
115
+ /**
116
+ * "Every auth table" (issue #110), resolved against what the database
117
+ * actually holds, in dependency order:
118
+ *
119
+ * 1. each mapped model's table (`--source-map` over payload-auth's
120
+ * layout; `null` = not installed) — REQUIRED: a missing one lands in
121
+ * `missing`;
122
+ * 2. every child table of those (`<table>_*`: `users_role`,
123
+ * `users_identities`, `users_sessions`, `organizations_tags`, `_rels`
124
+ * tables, …) and payload-auth's other Better Auth plugin tables
125
+ * ({@link optionalAuthTables}) — when present;
126
+ * 3. every `ba_*` (payload-auth-link engine) and `oauth_*` (OAuth
127
+ * provider) table — when present.
128
+ */
129
+ export function resolveAuthTables(
130
+ existing: ReadonlySet<string>,
131
+ sourceTables: ResolvedSourceTables = resolveSourceTables(undefined)
132
+ ): { readonly tables: readonly string[]; readonly missing: readonly string[] } {
133
+ const tables: string[] = []
134
+ const missing: string[] = []
135
+ const add = (table: string): void => {
136
+ if (!tables.includes(table)) {
137
+ tables.push(table)
138
+ }
139
+ }
140
+ const mapped = presentTables(sourceTables)
141
+ for (const table of mapped) {
142
+ if (existing.has(table)) {
143
+ add(table)
144
+ } else {
145
+ missing.push(table)
146
+ }
147
+ }
148
+ const sorted = [...existing].sort()
149
+ for (const table of [...mapped, ...optionalAuthTables]) {
150
+ if (!mapped.includes(table) && existing.has(table)) {
151
+ add(table)
152
+ }
153
+ for (const child of sorted) {
154
+ if (child.startsWith(`${table}_`)) {
155
+ add(child)
156
+ }
157
+ }
158
+ }
159
+ for (const table of sorted) {
160
+ if (table.startsWith('ba_') || table.startsWith('oauth_')) {
161
+ add(table)
162
+ }
163
+ }
164
+ return { tables, missing }
165
+ }
166
+
84
167
  /** Export the auth tables to lossless JSONL (issue #58). */
85
168
  export async function exportToJsonl(options: ExportOptions): Promise<ExportResult> {
86
- const tables = options.tables ?? exportTables
87
169
  const client = new Client({ connectionString: options.connectionString })
88
170
  await client.connect()
89
171
  try {
172
+ const listed = await client.query(
173
+ `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`
174
+ )
175
+ const existing = new Set(
176
+ listed.rows.map((row) => String((row as { table_name: unknown }).table_name))
177
+ )
90
178
  const lines: string[] = []
91
179
  const exported: { name: string; rows: number }[] = []
92
180
  const missing: string[] = []
181
+ let tables: readonly string[]
182
+ if (options.tables === undefined) {
183
+ const resolved = resolveAuthTables(existing, options.sourceTables)
184
+ tables = resolved.tables
185
+ for (const table of resolved.missing) {
186
+ missing.push(table)
187
+ options.log?.(
188
+ `mapped table '${table}' does not exist on the source — not exported (map its model null with --source-map if it is not installed)`
189
+ )
190
+ }
191
+ } else {
192
+ tables = options.tables
193
+ }
93
194
  let totalRows = 0
94
195
  for (const table of tables) {
95
- const exists = await client.query(
96
- `SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1`,
97
- [table]
98
- )
99
- if (exists.rows.length === 0) {
196
+ if (!existing.has(table)) {
100
197
  // Nothing is silently omitted (issue #68): every absent table
101
198
  // is logged and lands in `missing` for the caller's policy.
102
199
  missing.push(table)
103
200
  options.log?.(`table '${table}' does not exist on the source — not exported`)
104
201
  continue
105
202
  }
203
+ const columns = await client.query(
204
+ `SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'id'`,
205
+ [table]
206
+ )
106
207
  // Export EVERY column raw (JSON types preserved by ::jsonb cast
107
208
  // per row via row_to_json) — lossless by construction.
108
209
  const result = await client.query(
109
- `SELECT row_to_json(t)::jsonb AS row FROM ${table} t ORDER BY t.id`
210
+ `SELECT row_to_json(t)::jsonb AS row FROM "${table}" t${columns.rows.length > 0 ? ' ORDER BY t.id' : ''}`
110
211
  )
111
212
  exported.push({ name: table, rows: result.rows.length })
112
213
  totalRows += result.rows.length