@payload-auth-link/migrate 0.4.3 → 0.6.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 (71) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +109 -19
  5. package/dist/cli.js.map +1 -1
  6. package/dist/doctor/database.d.ts.map +1 -1
  7. package/dist/doctor/database.js +31 -4
  8. package/dist/doctor/database.js.map +1 -1
  9. package/dist/doctor/run-doctor.d.ts.map +1 -1
  10. package/dist/doctor/run-doctor.js +67 -0
  11. package/dist/doctor/run-doctor.js.map +1 -1
  12. package/dist/doctor/types.d.ts +12 -0
  13. package/dist/doctor/types.d.ts.map +1 -1
  14. package/dist/doctor/users.d.ts.map +1 -1
  15. package/dist/doctor/users.js +20 -4
  16. package/dist/doctor/users.js.map +1 -1
  17. package/dist/importer/apply.d.ts +25 -5
  18. package/dist/importer/apply.d.ts.map +1 -1
  19. package/dist/importer/apply.js +164 -180
  20. package/dist/importer/apply.js.map +1 -1
  21. package/dist/importer/host-writes.d.ts +47 -0
  22. package/dist/importer/host-writes.d.ts.map +1 -0
  23. package/dist/importer/host-writes.js +198 -0
  24. package/dist/importer/host-writes.js.map +1 -0
  25. package/dist/importer/lifecycle.d.ts +26 -0
  26. package/dist/importer/lifecycle.d.ts.map +1 -1
  27. package/dist/importer/lifecycle.js +107 -22
  28. package/dist/importer/lifecycle.js.map +1 -1
  29. package/dist/importer/manifest-mac.d.ts +15 -0
  30. package/dist/importer/manifest-mac.d.ts.map +1 -0
  31. package/dist/importer/manifest-mac.js +73 -0
  32. package/dist/importer/manifest-mac.js.map +1 -0
  33. package/dist/importer/prelink.d.ts +57 -0
  34. package/dist/importer/prelink.d.ts.map +1 -0
  35. package/dist/importer/prelink.js +159 -0
  36. package/dist/importer/prelink.js.map +1 -0
  37. package/dist/importer/run-import.d.ts +15 -0
  38. package/dist/importer/run-import.d.ts.map +1 -1
  39. package/dist/importer/run-import.js +155 -14
  40. package/dist/importer/run-import.js.map +1 -1
  41. package/dist/importer/source.d.ts +35 -7
  42. package/dist/importer/source.d.ts.map +1 -1
  43. package/dist/importer/source.js +37 -6
  44. package/dist/importer/source.js.map +1 -1
  45. package/dist/importer/types.d.ts +34 -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 +5 -4
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +3 -2
  51. package/dist/index.js.map +1 -1
  52. package/dist/private-file.d.ts +8 -0
  53. package/dist/private-file.d.ts.map +1 -0
  54. package/dist/private-file.js +19 -0
  55. package/dist/private-file.js.map +1 -0
  56. package/package.json +10 -10
  57. package/src/cli.ts +117 -9
  58. package/src/doctor/database.ts +36 -4
  59. package/src/doctor/run-doctor.ts +69 -0
  60. package/src/doctor/types.ts +12 -0
  61. package/src/doctor/users.ts +23 -4
  62. package/src/importer/apply.ts +198 -182
  63. package/src/importer/host-writes.ts +231 -0
  64. package/src/importer/lifecycle.ts +131 -24
  65. package/src/importer/manifest-mac.ts +90 -0
  66. package/src/importer/prelink.ts +237 -0
  67. package/src/importer/run-import.ts +207 -29
  68. package/src/importer/source.ts +72 -14
  69. package/src/importer/types.ts +35 -4
  70. package/src/index.ts +13 -1
  71. package/src/private-file.ts +18 -0
@@ -0,0 +1,237 @@
1
+ import type { Payload } from 'payload'
2
+ import { capturedKeys, captureBefore } from './host-writes.js'
3
+ import { authenticateManifest, signManifest } from './manifest-mac.js'
4
+ import type { FieldWrite, ImportManifest, Unmappable } from './types.js'
5
+
6
+ /**
7
+ * The adopt-mode pre-link (issues #49, #88): `migrate adopt-prelink`.
8
+ *
9
+ * In adopt mode the users collection IS Better Auth's user table
10
+ * (`schemaMap.user`, `idStrategy: 'payload'`), so Better Auth's user id
11
+ * for a document is the document id. What adopt mode does NOT do by
12
+ * itself is link the plugin side: without an identity row, the first
13
+ * provider sign-in reaches JIT linking, which links an existing
14
+ * document only on the verified-email proof (`linkable === true` with
15
+ * `linkableEmail` equal to the document email).
16
+ *
17
+ * This command mirrors exactly what the importer writes on the host
18
+ * users (issue #69):
19
+ *
20
+ * - ONE `better-auth` identity per user, `providerUserId` = the
21
+ * document id (other providers' identity rows are kept);
22
+ * - `linkable: true` + `linkableEmail: <email>` for users whose
23
+ * verified-email field (`emailVerified` on payload-auth layouts) is
24
+ * `true` — grant-only, never revoked.
25
+ *
26
+ * Dry run by default. `--apply` records every prior value in a manifest
27
+ * (first capture wins, sealed, mode 0600) so `rollback --manifest`
28
+ * restores the documents exactly, `updatedAt` included.
29
+ */
30
+
31
+ export type PrelinkOptions = {
32
+ readonly payload: Payload
33
+ readonly apply: boolean
34
+ readonly manifest: ImportManifest | null
35
+ readonly manifestPath: string
36
+ /** Users collection slug. Defaults to `users`. */
37
+ readonly usersSlug?: string | undefined
38
+ /** The users' verified-email checkbox. Defaults to `emailVerified`. */
39
+ readonly verifiedField?: string | undefined
40
+ /** Email field. Defaults to `email`. */
41
+ readonly emailField?: string | undefined
42
+ readonly now?: () => Date
43
+ readonly log?: (message: string) => void
44
+ /** Resume from a pre-0.6.0 manifest that has no MAC (`--allow-unsigned-manifest`). */
45
+ readonly allowUnsignedManifest?: boolean | undefined
46
+ }
47
+
48
+ export type PrelinkResult = {
49
+ readonly users: number
50
+ /** Documents that received (or would receive) the better-auth identity. */
51
+ readonly linked: number
52
+ /** Documents already carrying the exact identity. */
53
+ readonly alreadyLinked: number
54
+ /** Verified documents granted (or to be granted) the linking proof. */
55
+ readonly proofGranted: number
56
+ /** Users without a verified email: identity only, no linking proof. */
57
+ readonly unverified: number
58
+ readonly unmappable: readonly Unmappable[]
59
+ readonly fieldWrites: number
60
+ }
61
+
62
+ type IdentityRow = {
63
+ readonly id?: unknown
64
+ readonly providerId?: unknown
65
+ readonly providerUserId?: unknown
66
+ readonly email?: unknown
67
+ readonly linkedAt?: unknown
68
+ }
69
+
70
+ function identitiesOf(doc: Record<string, unknown>): IdentityRow[] {
71
+ const raw = doc['identities']
72
+ return Array.isArray(raw)
73
+ ? raw.filter((row): row is IdentityRow => typeof row === 'object' && row !== null)
74
+ : []
75
+ }
76
+
77
+ export async function prelinkUsers(options: PrelinkOptions): Promise<PrelinkResult> {
78
+ const { payload, apply, log = () => {}, now = () => new Date() } = options
79
+ const usersSlug = options.usersSlug ?? 'users'
80
+ const verifiedField = options.verifiedField ?? 'emailVerified'
81
+ const emailField = options.emailField ?? 'email'
82
+ if (options.manifest !== null) {
83
+ // Resuming re-signs the old entries: verify them first.
84
+ authenticateManifest(payload.secret, options.manifest, {
85
+ allowUnsigned: options.allowUnsignedManifest,
86
+ })
87
+ }
88
+ const fieldWrites: FieldWrite[] = [...(options.manifest?.fieldWrites ?? [])]
89
+ const captured = capturedKeys(fieldWrites)
90
+
91
+ type Planned = {
92
+ readonly id: string | number
93
+ readonly email: string
94
+ readonly writeIdentity: boolean
95
+ readonly grantProof: boolean
96
+ readonly keep: IdentityRow[]
97
+ }
98
+ const planned: Planned[] = []
99
+ const unmappable: Unmappable[] = []
100
+ let users = 0
101
+ let alreadyLinked = 0
102
+ let unverified = 0
103
+
104
+ // Plan the WHOLE collection before any write: a conflicting identity
105
+ // anywhere aborts the run with nothing written.
106
+ for (let page = 1; ; page += 1) {
107
+ const batch = await payload.find({
108
+ collection: usersSlug,
109
+ depth: 0,
110
+ limit: 200,
111
+ page,
112
+ sort: 'id',
113
+ overrideAccess: true,
114
+ })
115
+ for (const doc of batch.docs as unknown as Record<string, unknown>[]) {
116
+ users += 1
117
+ const id = doc['id']
118
+ const email = doc[emailField]
119
+ if ((typeof id !== 'string' && typeof id !== 'number') || typeof email !== 'string') {
120
+ unmappable.push({
121
+ model: 'user',
122
+ key: String(id),
123
+ reason: `document has no usable id/'${emailField}'`,
124
+ })
125
+ continue
126
+ }
127
+ const providerUserId = String(id)
128
+ const identities = identitiesOf(doc)
129
+ const own = identities.filter((row) => row.providerId === 'better-auth')
130
+ const conflicting = own.filter((row) => String(row.providerUserId) !== providerUserId)
131
+ if (conflicting.length > 0) {
132
+ unmappable.push({
133
+ model: 'user',
134
+ key: providerUserId,
135
+ reason: `user #${providerUserId} already carries a better-auth identity for a different provider user id; adopt mode serves the document id as the Better Auth user id — resolve the link by hand first`,
136
+ })
137
+ continue
138
+ }
139
+ const verified = doc[verifiedField] === true
140
+ if (!verified) {
141
+ unverified += 1
142
+ }
143
+ const writeIdentity = own.length !== 1
144
+ const grantProof = verified && (doc['linkable'] !== true || doc['linkableEmail'] !== email)
145
+ if (!writeIdentity) {
146
+ alreadyLinked += 1
147
+ }
148
+ if (writeIdentity || grantProof) {
149
+ planned.push({
150
+ id,
151
+ email,
152
+ writeIdentity,
153
+ grantProof,
154
+ keep: identities.filter((row) => row.providerId !== 'better-auth'),
155
+ })
156
+ }
157
+ }
158
+ if (!batch.hasNextPage) {
159
+ break
160
+ }
161
+ }
162
+
163
+ const linked = planned.filter((row) => row.writeIdentity).length
164
+ const proofGranted = planned.filter((row) => row.grantProof).length
165
+ if (unmappable.length > 0) {
166
+ log(`ABORT: ${unmappable.length} unmappable user(s); nothing was written`)
167
+ return {
168
+ users,
169
+ linked: 0,
170
+ alreadyLinked,
171
+ proofGranted: 0,
172
+ unverified,
173
+ unmappable,
174
+ fieldWrites: 0,
175
+ }
176
+ }
177
+
178
+ let fieldWriteCount = 0
179
+ if (apply) {
180
+ for (const row of planned) {
181
+ const { newlyCaptured } = await captureBefore({
182
+ payload,
183
+ collection: usersSlug,
184
+ id: row.id,
185
+ fields: ['identities', 'linkable', 'linkableEmail'],
186
+ captured,
187
+ into: fieldWrites,
188
+ })
189
+ fieldWriteCount += newlyCaptured
190
+ const data: Record<string, unknown> = {}
191
+ if (row.writeIdentity) {
192
+ data.identities = [
193
+ ...row.keep.map((identity) => ({
194
+ providerId: identity.providerId,
195
+ providerUserId: identity.providerUserId,
196
+ email: identity.email,
197
+ linkedAt: identity.linkedAt,
198
+ })),
199
+ {
200
+ providerId: 'better-auth',
201
+ providerUserId: String(row.id),
202
+ email: row.email,
203
+ linkedAt: now().toISOString(),
204
+ },
205
+ ]
206
+ }
207
+ if (row.grantProof) {
208
+ data.linkable = true
209
+ data.linkableEmail = row.email
210
+ }
211
+ await payload.update({ collection: usersSlug, id: row.id, data, overrideAccess: true })
212
+ }
213
+ const manifest: ImportManifest = signManifest(payload.secret, {
214
+ version: 3,
215
+ kind: 'adopt-prelink',
216
+ createdAt: options.manifest?.createdAt ?? now().toISOString(),
217
+ sourceDatabase: '(adopt mode: the target app database)',
218
+ options: { sessions: 'carry' },
219
+ created: [],
220
+ fieldWrites,
221
+ finalized: options.manifest?.finalized ?? false,
222
+ sourceCounts: {},
223
+ totals: { user: users, linked, proofGranted },
224
+ })
225
+ const { writeManifest } = await import('./lifecycle.js')
226
+ writeManifest(manifest, options.manifestPath)
227
+ }
228
+ return {
229
+ users,
230
+ linked,
231
+ alreadyLinked,
232
+ proofGranted,
233
+ unverified,
234
+ unmappable,
235
+ fieldWrites: fieldWriteCount,
236
+ }
237
+ }
@@ -1,8 +1,10 @@
1
1
  import { isRecord } from '@payload-auth-link/core'
2
2
  import { pathToFileURL } from 'node:url'
3
3
  import { applyImport, summarizePlanRows } from './apply.js'
4
+ import { prelinkUsers, type PrelinkResult } from './prelink.js'
4
5
  import {
5
6
  defaultManifestPath,
7
+ defaultPrelinkManifestPath,
6
8
  finalizeManifest,
7
9
  readManifest,
8
10
  rollbackImport,
@@ -57,6 +59,8 @@ export type ImportCommandOptions = {
57
59
  readonly json?: boolean
58
60
  readonly log?: (message: string) => void
59
61
  readonly loadPayload?: (configPath: string) => Promise<import('payload').Payload>
62
+ /** Accept a pre-0.6.0 manifest without a MAC (`--allow-unsigned-manifest`). */
63
+ readonly allowUnsignedManifest?: boolean | undefined
60
64
  }
61
65
 
62
66
  const log_ = (options: Pick<ImportCommandOptions, 'log'>): ((message: string) => void) =>
@@ -122,10 +126,17 @@ function assertTargetCollections(config: unknown, slugs: TargetSlugMap): void {
122
126
  * Boot the loaded config with a watchdog (#75): a boot that never
123
127
  * settles must fail loudly after a bounded wait, not park the CLI
124
128
  * forever.
129
+ *
130
+ * A FRESH, uncached instance (issue #88): `getPayload`'s module cache
131
+ * would hand a later command in the same process the instance an
132
+ * earlier command already destroyed. The caller owns the instance and
133
+ * destroys it (pool, crons) when the command ends.
125
134
  */
126
135
  async function bootPayload(config: unknown): Promise<import('payload').Payload> {
127
- const { getPayload } = await import('payload')
128
- const boot = getPayload({ config: config as Parameters<typeof getPayload>[0]['config'] })
136
+ const { BasePayload } = await import('payload')
137
+ const boot = new BasePayload().init({
138
+ config: config as Parameters<InstanceType<typeof BasePayload>['init']>[0]['config'],
139
+ })
129
140
  let timer: NodeJS.Timeout | undefined
130
141
  const watchdog = new Promise<never>((_, reject) => {
131
142
  timer = setTimeout(() => {
@@ -163,16 +174,93 @@ function slugsOf(options: ImportCommandOptions): TargetSlugMap {
163
174
  * be a placeholder the loader never reads.
164
175
  */
165
176
  async function bootTargetPayload(
166
- options: ImportCommandOptions
177
+ options: ImportCommandOptions,
178
+ requireTargets = true
167
179
  ): Promise<import('payload').Payload> {
168
180
  if (options.loadPayload !== undefined) {
169
181
  return options.loadPayload(options.configPath)
170
182
  }
171
183
  const { config } = await loadConfigModule(options.configPath)
172
- assertTargetCollections(config, slugsOf(options))
184
+ if (requireTargets) {
185
+ assertTargetCollections(config, slugsOf(options))
186
+ }
173
187
  return bootPayload(config)
174
188
  }
175
189
 
190
+ /**
191
+ * Run `work` against the target Payload and ALWAYS release it
192
+ * (issue #88): an instance this command booted is destroyed —
193
+ * database pool closed, crons stopped — whether `work` succeeds or
194
+ * throws, so the CLI exits after a successful run instead of idling
195
+ * on open handles. An injected `loadPayload` instance belongs to the
196
+ * caller and is left running.
197
+ */
198
+ async function withTargetPayload<T>(
199
+ options: ImportCommandOptions,
200
+ work: (payload: import('payload').Payload) => Promise<T>,
201
+ requireTargets = true
202
+ ): Promise<T> {
203
+ const payload = await bootTargetPayload(options, requireTargets)
204
+ try {
205
+ return await work(payload)
206
+ } finally {
207
+ if (options.loadPayload === undefined) {
208
+ await releasePayload(payload)
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Destroy a booted Payload AND close its connection pool (issue #88).
215
+ * `payload.destroy()` stops crons and resets the adapter, but the SQL
216
+ * adapters' `destroy` leaves the driver pool open — and
217
+ * `@payloadcms/db-postgres` checks one client out of the pool at
218
+ * connect time and never releases it, so even `pool.end()` would wait
219
+ * forever. Those are exactly the handles that kept a finished CLI
220
+ * alive. The pool is reached structurally: every checked-out client
221
+ * is released-and-destroyed, then `pool.end()` (bounded); SQLite/libsql
222
+ * closes its `client`.
223
+ */
224
+ async function releasePayload(payload: import('payload').Payload): Promise<void> {
225
+ const db: unknown = payload.db
226
+ await payload.destroy()
227
+ if (!isRecord(db)) {
228
+ return
229
+ }
230
+ const pool = db['pool']
231
+ if (isRecord(pool) && typeof pool['end'] === 'function') {
232
+ const clients: unknown = pool['_clients']
233
+ if (Array.isArray(clients)) {
234
+ for (const client of (clients as unknown[]).slice()) {
235
+ if (isRecord(client) && typeof client['release'] === 'function') {
236
+ try {
237
+ // `true` destroys the connection instead of returning it.
238
+ ;(client['release'] as (destroy: boolean) => void).call(client, true)
239
+ } catch {
240
+ // Already released (an idle client) — `end()` closes it.
241
+ }
242
+ }
243
+ }
244
+ }
245
+ let timer: NodeJS.Timeout | undefined
246
+ await Promise.race([
247
+ (pool['end'] as () => Promise<void>).call(pool),
248
+ new Promise<void>((resolve) => {
249
+ timer = setTimeout(resolve, 5_000)
250
+ timer.unref()
251
+ }),
252
+ ])
253
+ if (timer !== undefined) {
254
+ clearTimeout(timer)
255
+ }
256
+ return
257
+ }
258
+ const client = db['client']
259
+ if (isRecord(client) && typeof client['close'] === 'function') {
260
+ ;(client['close'] as () => void).call(client)
261
+ }
262
+ }
263
+
176
264
  /**
177
265
  * Pre-flight only (no boot): refuse configs that declare no target
178
266
  * collections. Runs before EVERYTHING — including the source-database
@@ -306,20 +394,22 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
306
394
  const manifestPath = options.manifestPath ?? defaultManifestPath
307
395
  const existingManifest = readManifest(manifestPath)
308
396
 
309
- const payload = await bootTargetPayload(options)
310
- const result = await applyImport({
311
- payload,
312
- sourceConnectionString: options.databaseUrl,
313
- apply: options.apply,
314
- sessions: options.sessions,
315
- manifest: existingManifest,
316
- manifestPath,
317
- sourceCounts,
318
- sourceDatabase: options.databaseUrl.replace(/\/\/[^@]*@/, '//***@'),
319
- slugs,
320
- sourceTables,
321
- log,
322
- })
397
+ const result = await withTargetPayload(options, (payload) =>
398
+ applyImport({
399
+ payload,
400
+ sourceConnectionString: options.databaseUrl,
401
+ apply: options.apply,
402
+ sessions: options.sessions,
403
+ manifest: existingManifest,
404
+ manifestPath,
405
+ sourceCounts,
406
+ sourceDatabase: options.databaseUrl.replace(/\/\/[^@]*@/, '//***@'),
407
+ slugs,
408
+ sourceTables,
409
+ log,
410
+ allowUnsignedManifest: options.allowUnsignedManifest,
411
+ })
412
+ )
323
413
  const plan: ImportPlan = {
324
414
  ...result.plan,
325
415
  warnings: [
@@ -340,6 +430,13 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
340
430
  for (const row of plan.unmappable) {
341
431
  log(` X ${row.model} #${row.key}: ${row.reason}`)
342
432
  }
433
+ if (options.apply && plan.unmappable.length === 0) {
434
+ log(
435
+ `applied: manifest at ${manifestPath} (mode 0600; ids and sealed values only). Next: verify, then finalize after your soak window.`
436
+ )
437
+ } else if (!options.apply) {
438
+ log('dry run: nothing was written. Re-run with --apply to copy.')
439
+ }
343
440
  }
344
441
  return {
345
442
  exitCode: result.plan.unmappable.length > 0 ? 1 : 0,
@@ -375,14 +472,15 @@ export async function runVerify(options: ImportCommandOptions): Promise<VerifyRe
375
472
  return result
376
473
  }
377
474
 
378
- const payload = await bootTargetPayload(options)
379
- const result = await verifyImport({
380
- payload,
381
- sourceConnectionString: options.databaseUrl,
382
- slugs: slugsOf(options),
383
- sourceTables: sourceTablesOf(options),
384
- sessions: options.sessions,
385
- })
475
+ const result = await withTargetPayload(options, (payload) =>
476
+ verifyImport({
477
+ payload,
478
+ sourceConnectionString: options.databaseUrl,
479
+ slugs: slugsOf(options),
480
+ sourceTables: sourceTablesOf(options),
481
+ sessions: options.sessions,
482
+ })
483
+ )
386
484
  if (!options.json) {
387
485
  for (const check of result.checks) {
388
486
  log(` ${check.passed ? 'ok ' : 'FAIL'} ${check.name}`)
@@ -390,6 +488,7 @@ export async function runVerify(options: ImportCommandOptions): Promise<VerifyRe
390
488
  for (const issue of result.issues) {
391
489
  log(` [${issue.code}] ${issue.detail}`)
392
490
  }
491
+ log(result.ok ? 'verify: ok' : `verify: ${result.issues.length} mismatch(es)`)
393
492
  }
394
493
  return result
395
494
  }
@@ -407,9 +506,21 @@ export async function runRollback(
407
506
  if (manifest.finalized) {
408
507
  throw new Error('the migration is finalized; rollback is no longer possible')
409
508
  }
410
- const payload = await bootTargetPayload(options)
411
- const result = await rollbackImport({ payload, manifest, log })
412
- return result
509
+ // A prelink manifest touches only host fields; its rollback needs no
510
+ // ba-* target collections in the config.
511
+ const requireTargets = manifest.kind !== 'adopt-prelink'
512
+ return withTargetPayload(
513
+ options,
514
+ (payload) =>
515
+ rollbackImport({
516
+ payload,
517
+ manifest,
518
+ log,
519
+ manifestPath,
520
+ allowUnsignedManifest: options.allowUnsignedManifest,
521
+ }),
522
+ requireTargets
523
+ )
413
524
  }
414
525
 
415
526
  /** The `finalize` command — manifest-only; no Payload instance is loaded. */
@@ -428,3 +539,70 @@ export function runFinalize(
428
539
  )
429
540
  return Promise.resolve(finalized)
430
541
  }
542
+
543
+ /** Options for `adopt-prelink` (issue #88). */
544
+ export type PrelinkCommandOptions = Pick<
545
+ ImportCommandOptions,
546
+ 'configPath' | 'apply' | 'manifestPath' | 'json' | 'log' | 'loadPayload' | 'allowUnsignedManifest'
547
+ > & {
548
+ readonly usersSlug?: string | undefined
549
+ readonly verifiedField?: string | undefined
550
+ }
551
+
552
+ /**
553
+ * The `adopt-prelink` command (issues #49, #88): pre-link every adopted
554
+ * user (one better-auth identity; the linking proof for verified
555
+ * emails). Dry run by default; `--apply` writes and records the
556
+ * manifest that `rollback --manifest` undoes exactly.
557
+ */
558
+ export async function runAdoptPrelink(options: PrelinkCommandOptions): Promise<PrelinkResult> {
559
+ const log = log_(options)
560
+ const manifestPath = options.manifestPath ?? defaultPrelinkManifestPath
561
+ const existing = readManifest(manifestPath)
562
+ if (existing !== null && existing.kind !== 'adopt-prelink') {
563
+ throw new Error(
564
+ `the manifest at ${manifestPath} belongs to an import run, not adopt-prelink — pass a different --manifest`
565
+ )
566
+ }
567
+ if (existing?.finalized === true) {
568
+ throw new Error(`the manifest at ${manifestPath} is finalized; nothing to pre-link`)
569
+ }
570
+ const importOptions: ImportCommandOptions = {
571
+ configPath: options.configPath,
572
+ databaseUrl: '',
573
+ apply: options.apply,
574
+ sessions: 'carry',
575
+ ...(options.loadPayload === undefined ? {} : { loadPayload: options.loadPayload }),
576
+ }
577
+ const result = await withTargetPayload(
578
+ importOptions,
579
+ (payload) =>
580
+ prelinkUsers({
581
+ payload,
582
+ apply: options.apply,
583
+ manifest: existing,
584
+ manifestPath,
585
+ usersSlug: options.usersSlug,
586
+ verifiedField: options.verifiedField,
587
+ log,
588
+ allowUnsignedManifest: options.allowUnsignedManifest,
589
+ }),
590
+ false
591
+ )
592
+ if (options.json !== true) {
593
+ log(
594
+ ` users ${result.users}: ${result.linked} to link, ${result.alreadyLinked} already linked, ${result.proofGranted} verified-email proof grant(s), ${result.unverified} unverified (identity only)`
595
+ )
596
+ for (const row of result.unmappable) {
597
+ log(` X ${row.model} #${row.key}: ${row.reason}`)
598
+ }
599
+ if (result.unmappable.length === 0) {
600
+ log(
601
+ options.apply
602
+ ? `applied: manifest at ${manifestPath} (mode 0600). Undo with: rollback --config <config> --manifest ${manifestPath}`
603
+ : 'dry run: nothing was written. Re-run with --apply to pre-link.'
604
+ )
605
+ }
606
+ }
607
+ return result
608
+ }