@payload-auth-link/migrate 0.4.3 → 0.5.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/LICENSE +21 -0
  2. package/README.md +7 -1
  3. package/dist/cli.d.ts.map +1 -1
  4. package/dist/cli.js +103 -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 +23 -5
  18. package/dist/importer/apply.d.ts.map +1 -1
  19. package/dist/importer/apply.js +154 -178
  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 +24 -0
  26. package/dist/importer/lifecycle.d.ts.map +1 -1
  27. package/dist/importer/lifecycle.js +104 -22
  28. package/dist/importer/lifecycle.js.map +1 -1
  29. package/dist/importer/prelink.d.ts +55 -0
  30. package/dist/importer/prelink.d.ts.map +1 -0
  31. package/dist/importer/prelink.js +152 -0
  32. package/dist/importer/prelink.js.map +1 -0
  33. package/dist/importer/run-import.d.ts +13 -0
  34. package/dist/importer/run-import.d.ts.map +1 -1
  35. package/dist/importer/run-import.js +147 -14
  36. package/dist/importer/run-import.js.map +1 -1
  37. package/dist/importer/source.d.ts +35 -7
  38. package/dist/importer/source.d.ts.map +1 -1
  39. package/dist/importer/source.js +37 -6
  40. package/dist/importer/source.js.map +1 -1
  41. package/dist/importer/types.d.ts +26 -4
  42. package/dist/importer/types.d.ts.map +1 -1
  43. package/dist/importer/types.js.map +1 -1
  44. package/dist/index.d.ts +5 -4
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +3 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/private-file.d.ts +8 -0
  49. package/dist/private-file.d.ts.map +1 -0
  50. package/dist/private-file.js +19 -0
  51. package/dist/private-file.js.map +1 -0
  52. package/package.json +6 -6
  53. package/src/cli.ts +111 -9
  54. package/src/doctor/database.ts +36 -4
  55. package/src/doctor/run-doctor.ts +69 -0
  56. package/src/doctor/types.ts +12 -0
  57. package/src/doctor/users.ts +23 -4
  58. package/src/importer/apply.ts +186 -180
  59. package/src/importer/host-writes.ts +231 -0
  60. package/src/importer/lifecycle.ts +126 -24
  61. package/src/importer/prelink.ts +228 -0
  62. package/src/importer/run-import.ts +196 -29
  63. package/src/importer/source.ts +72 -14
  64. package/src/importer/types.ts +27 -4
  65. package/src/index.ts +13 -1
  66. package/src/private-file.ts +18 -0
@@ -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,
@@ -122,10 +124,17 @@ function assertTargetCollections(config: unknown, slugs: TargetSlugMap): void {
122
124
  * Boot the loaded config with a watchdog (#75): a boot that never
123
125
  * settles must fail loudly after a bounded wait, not park the CLI
124
126
  * forever.
127
+ *
128
+ * A FRESH, uncached instance (issue #88): `getPayload`'s module cache
129
+ * would hand a later command in the same process the instance an
130
+ * earlier command already destroyed. The caller owns the instance and
131
+ * destroys it (pool, crons) when the command ends.
125
132
  */
126
133
  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'] })
134
+ const { BasePayload } = await import('payload')
135
+ const boot = new BasePayload().init({
136
+ config: config as Parameters<InstanceType<typeof BasePayload>['init']>[0]['config'],
137
+ })
129
138
  let timer: NodeJS.Timeout | undefined
130
139
  const watchdog = new Promise<never>((_, reject) => {
131
140
  timer = setTimeout(() => {
@@ -163,16 +172,93 @@ function slugsOf(options: ImportCommandOptions): TargetSlugMap {
163
172
  * be a placeholder the loader never reads.
164
173
  */
165
174
  async function bootTargetPayload(
166
- options: ImportCommandOptions
175
+ options: ImportCommandOptions,
176
+ requireTargets = true
167
177
  ): Promise<import('payload').Payload> {
168
178
  if (options.loadPayload !== undefined) {
169
179
  return options.loadPayload(options.configPath)
170
180
  }
171
181
  const { config } = await loadConfigModule(options.configPath)
172
- assertTargetCollections(config, slugsOf(options))
182
+ if (requireTargets) {
183
+ assertTargetCollections(config, slugsOf(options))
184
+ }
173
185
  return bootPayload(config)
174
186
  }
175
187
 
188
+ /**
189
+ * Run `work` against the target Payload and ALWAYS release it
190
+ * (issue #88): an instance this command booted is destroyed —
191
+ * database pool closed, crons stopped — whether `work` succeeds or
192
+ * throws, so the CLI exits after a successful run instead of idling
193
+ * on open handles. An injected `loadPayload` instance belongs to the
194
+ * caller and is left running.
195
+ */
196
+ async function withTargetPayload<T>(
197
+ options: ImportCommandOptions,
198
+ work: (payload: import('payload').Payload) => Promise<T>,
199
+ requireTargets = true
200
+ ): Promise<T> {
201
+ const payload = await bootTargetPayload(options, requireTargets)
202
+ try {
203
+ return await work(payload)
204
+ } finally {
205
+ if (options.loadPayload === undefined) {
206
+ await releasePayload(payload)
207
+ }
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Destroy a booted Payload AND close its connection pool (issue #88).
213
+ * `payload.destroy()` stops crons and resets the adapter, but the SQL
214
+ * adapters' `destroy` leaves the driver pool open — and
215
+ * `@payloadcms/db-postgres` checks one client out of the pool at
216
+ * connect time and never releases it, so even `pool.end()` would wait
217
+ * forever. Those are exactly the handles that kept a finished CLI
218
+ * alive. The pool is reached structurally: every checked-out client
219
+ * is released-and-destroyed, then `pool.end()` (bounded); SQLite/libsql
220
+ * closes its `client`.
221
+ */
222
+ async function releasePayload(payload: import('payload').Payload): Promise<void> {
223
+ const db: unknown = payload.db
224
+ await payload.destroy()
225
+ if (!isRecord(db)) {
226
+ return
227
+ }
228
+ const pool = db['pool']
229
+ if (isRecord(pool) && typeof pool['end'] === 'function') {
230
+ const clients: unknown = pool['_clients']
231
+ if (Array.isArray(clients)) {
232
+ for (const client of (clients as unknown[]).slice()) {
233
+ if (isRecord(client) && typeof client['release'] === 'function') {
234
+ try {
235
+ // `true` destroys the connection instead of returning it.
236
+ ;(client['release'] as (destroy: boolean) => void).call(client, true)
237
+ } catch {
238
+ // Already released (an idle client) — `end()` closes it.
239
+ }
240
+ }
241
+ }
242
+ }
243
+ let timer: NodeJS.Timeout | undefined
244
+ await Promise.race([
245
+ (pool['end'] as () => Promise<void>).call(pool),
246
+ new Promise<void>((resolve) => {
247
+ timer = setTimeout(resolve, 5_000)
248
+ timer.unref()
249
+ }),
250
+ ])
251
+ if (timer !== undefined) {
252
+ clearTimeout(timer)
253
+ }
254
+ return
255
+ }
256
+ const client = db['client']
257
+ if (isRecord(client) && typeof client['close'] === 'function') {
258
+ ;(client['close'] as () => void).call(client)
259
+ }
260
+ }
261
+
176
262
  /**
177
263
  * Pre-flight only (no boot): refuse configs that declare no target
178
264
  * collections. Runs before EVERYTHING — including the source-database
@@ -306,20 +392,21 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
306
392
  const manifestPath = options.manifestPath ?? defaultManifestPath
307
393
  const existingManifest = readManifest(manifestPath)
308
394
 
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
- })
395
+ const result = await withTargetPayload(options, (payload) =>
396
+ applyImport({
397
+ payload,
398
+ sourceConnectionString: options.databaseUrl,
399
+ apply: options.apply,
400
+ sessions: options.sessions,
401
+ manifest: existingManifest,
402
+ manifestPath,
403
+ sourceCounts,
404
+ sourceDatabase: options.databaseUrl.replace(/\/\/[^@]*@/, '//***@'),
405
+ slugs,
406
+ sourceTables,
407
+ log,
408
+ })
409
+ )
323
410
  const plan: ImportPlan = {
324
411
  ...result.plan,
325
412
  warnings: [
@@ -340,6 +427,13 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
340
427
  for (const row of plan.unmappable) {
341
428
  log(` X ${row.model} #${row.key}: ${row.reason}`)
342
429
  }
430
+ if (options.apply && plan.unmappable.length === 0) {
431
+ log(
432
+ `applied: manifest at ${manifestPath} (mode 0600; ids and sealed values only). Next: verify, then finalize after your soak window.`
433
+ )
434
+ } else if (!options.apply) {
435
+ log('dry run: nothing was written. Re-run with --apply to copy.')
436
+ }
343
437
  }
344
438
  return {
345
439
  exitCode: result.plan.unmappable.length > 0 ? 1 : 0,
@@ -375,14 +469,15 @@ export async function runVerify(options: ImportCommandOptions): Promise<VerifyRe
375
469
  return result
376
470
  }
377
471
 
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
- })
472
+ const result = await withTargetPayload(options, (payload) =>
473
+ verifyImport({
474
+ payload,
475
+ sourceConnectionString: options.databaseUrl,
476
+ slugs: slugsOf(options),
477
+ sourceTables: sourceTablesOf(options),
478
+ sessions: options.sessions,
479
+ })
480
+ )
386
481
  if (!options.json) {
387
482
  for (const check of result.checks) {
388
483
  log(` ${check.passed ? 'ok ' : 'FAIL'} ${check.name}`)
@@ -390,6 +485,7 @@ export async function runVerify(options: ImportCommandOptions): Promise<VerifyRe
390
485
  for (const issue of result.issues) {
391
486
  log(` [${issue.code}] ${issue.detail}`)
392
487
  }
488
+ log(result.ok ? 'verify: ok' : `verify: ${result.issues.length} mismatch(es)`)
393
489
  }
394
490
  return result
395
491
  }
@@ -407,9 +503,14 @@ export async function runRollback(
407
503
  if (manifest.finalized) {
408
504
  throw new Error('the migration is finalized; rollback is no longer possible')
409
505
  }
410
- const payload = await bootTargetPayload(options)
411
- const result = await rollbackImport({ payload, manifest, log })
412
- return result
506
+ // A prelink manifest touches only host fields; its rollback needs no
507
+ // ba-* target collections in the config.
508
+ const requireTargets = manifest.kind !== 'adopt-prelink'
509
+ return withTargetPayload(
510
+ options,
511
+ (payload) => rollbackImport({ payload, manifest, log, manifestPath }),
512
+ requireTargets
513
+ )
413
514
  }
414
515
 
415
516
  /** The `finalize` command — manifest-only; no Payload instance is loaded. */
@@ -428,3 +529,69 @@ export function runFinalize(
428
529
  )
429
530
  return Promise.resolve(finalized)
430
531
  }
532
+
533
+ /** Options for `adopt-prelink` (issue #88). */
534
+ export type PrelinkCommandOptions = Pick<
535
+ ImportCommandOptions,
536
+ 'configPath' | 'apply' | 'manifestPath' | 'json' | 'log' | 'loadPayload'
537
+ > & {
538
+ readonly usersSlug?: string | undefined
539
+ readonly verifiedField?: string | undefined
540
+ }
541
+
542
+ /**
543
+ * The `adopt-prelink` command (issues #49, #88): pre-link every adopted
544
+ * user (one better-auth identity; the linking proof for verified
545
+ * emails). Dry run by default; `--apply` writes and records the
546
+ * manifest that `rollback --manifest` undoes exactly.
547
+ */
548
+ export async function runAdoptPrelink(options: PrelinkCommandOptions): Promise<PrelinkResult> {
549
+ const log = log_(options)
550
+ const manifestPath = options.manifestPath ?? defaultPrelinkManifestPath
551
+ const existing = readManifest(manifestPath)
552
+ if (existing !== null && existing.kind !== 'adopt-prelink') {
553
+ throw new Error(
554
+ `the manifest at ${manifestPath} belongs to an import run, not adopt-prelink — pass a different --manifest`
555
+ )
556
+ }
557
+ if (existing?.finalized === true) {
558
+ throw new Error(`the manifest at ${manifestPath} is finalized; nothing to pre-link`)
559
+ }
560
+ const importOptions: ImportCommandOptions = {
561
+ configPath: options.configPath,
562
+ databaseUrl: '',
563
+ apply: options.apply,
564
+ sessions: 'carry',
565
+ ...(options.loadPayload === undefined ? {} : { loadPayload: options.loadPayload }),
566
+ }
567
+ const result = await withTargetPayload(
568
+ importOptions,
569
+ (payload) =>
570
+ prelinkUsers({
571
+ payload,
572
+ apply: options.apply,
573
+ manifest: existing,
574
+ manifestPath,
575
+ usersSlug: options.usersSlug,
576
+ verifiedField: options.verifiedField,
577
+ log,
578
+ }),
579
+ false
580
+ )
581
+ if (options.json !== true) {
582
+ log(
583
+ ` users ${result.users}: ${result.linked} to link, ${result.alreadyLinked} already linked, ${result.proofGranted} verified-email proof grant(s), ${result.unverified} unverified (identity only)`
584
+ )
585
+ for (const row of result.unmappable) {
586
+ log(` X ${row.model} #${row.key}: ${row.reason}`)
587
+ }
588
+ if (result.unmappable.length === 0) {
589
+ log(
590
+ options.apply
591
+ ? `applied: manifest at ${manifestPath} (mode 0600). Undo with: rollback --config <config> --manifest ${manifestPath}`
592
+ : 'dry run: nothing was written. Re-run with --apply to pre-link.'
593
+ )
594
+ }
595
+ }
596
+ return result
597
+ }
@@ -31,10 +31,22 @@ export type SourceUser = {
31
31
  /** Stored role values from the `users_role` child table. */
32
32
  readonly roles: readonly string[]
33
33
  readonly accounts: readonly SourceAccount[]
34
+ /**
35
+ * Better Auth admin-plugin ban state (issue #88), `null` when the
36
+ * source layout has no such column (the plugin was never installed).
37
+ */
38
+ readonly banned: boolean | null
39
+ readonly banReason: string | null
40
+ readonly banExpires: Date | null
41
+ /** Better Auth anonymous-plugin marker; `null` when no column exists. */
42
+ readonly isAnonymous: boolean | null
34
43
  }
35
44
 
36
45
  export type SourceAccount = {
37
46
  readonly id: string
47
+ /** Source row timestamps, carried onto the target row (issue #88). */
48
+ readonly createdAt: Date | null
49
+ readonly updatedAt: Date | null
38
50
  readonly accountId: string
39
51
  readonly providerId: string
40
52
  readonly password: string | null
@@ -76,14 +88,24 @@ const requiredColumns: Readonly<Record<SourceModel, readonly string[]>> = {
76
88
  users_role: ['parent_id', 'value'],
77
89
  }
78
90
 
79
- export async function openSource(connectionString: string): Promise<Client> {
91
+ /**
92
+ * The slice of a `pg` client the read side uses. Public signatures take
93
+ * this structural type (a `pg.Client` satisfies it) so the published
94
+ * `.d.ts` never references `pg` — consumers need no `@types/pg`.
95
+ */
96
+ export interface SourceClient {
97
+ query(text: string, values?: unknown[]): Promise<{ rows: unknown[] }>
98
+ end(): Promise<void>
99
+ }
100
+
101
+ export async function openSource(connectionString: string): Promise<SourceClient> {
80
102
  const client = new Client({ connectionString })
81
103
  await client.connect()
82
104
  return client
83
105
  }
84
106
 
85
107
  /** The columns one table actually has (customized layouts drop columns). */
86
- async function columnsOf(client: Client, table: string): Promise<Set<string>> {
108
+ async function columnsOf(client: SourceClient, table: string): Promise<Set<string>> {
87
109
  const result = await client.query(
88
110
  `SELECT column_name FROM information_schema.columns
89
111
  WHERE table_schema = 'public' AND table_name = $1`,
@@ -92,7 +114,7 @@ async function columnsOf(client: Client, table: string): Promise<Set<string>> {
92
114
  return new Set(result.rows.map((row) => String((row as { column_name: unknown }).column_name)))
93
115
  }
94
116
 
95
- async function tableExists(client: Client, table: string): Promise<boolean> {
117
+ async function tableExists(client: SourceClient, table: string): Promise<boolean> {
96
118
  const result = await client.query(
97
119
  `SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1`,
98
120
  [table]
@@ -108,7 +130,7 @@ async function tableExists(client: Client, table: string): Promise<boolean> {
108
130
  * read, planned, or written past a failed probe.
109
131
  */
110
132
  export async function probeSourceLayout(
111
- client: Client,
133
+ client: SourceClient,
112
134
  tables: ResolvedSourceTables
113
135
  ): Promise<readonly SourceLayoutIssue[]> {
114
136
  const issues: SourceLayoutIssue[] = []
@@ -146,7 +168,7 @@ export async function probeSourceLayout(
146
168
  * throw here means the probe was skipped.
147
169
  */
148
170
  export async function countTables(
149
- client: Client,
171
+ client: SourceClient,
150
172
  tables: readonly string[]
151
173
  ): Promise<Record<string, number>> {
152
174
  const counts: Record<string, number> = {}
@@ -170,7 +192,7 @@ function text(value: unknown): string | null {
170
192
 
171
193
  /** Every source user, with role rows and accounts attached. */
172
194
  export async function readSourceUsers(
173
- client: Client,
195
+ client: SourceClient,
174
196
  tables: ResolvedSourceTables = defaultSourceTables
175
197
  ): Promise<SourceUser[]> {
176
198
  const usersTable = tables['users']
@@ -179,8 +201,14 @@ export async function readSourceUsers(
179
201
  if (usersTable === null) {
180
202
  return []
181
203
  }
204
+ // Plugin columns (admin bans, anonymous marker) exist only when the
205
+ // payload-auth app installed those plugins — read them when present.
206
+ const userColumns = await columnsOf(client, usersTable)
207
+ const optionalUserColumns = ['banned', 'ban_reason', 'ban_expires', 'is_anonymous'].filter(
208
+ (column) => userColumns.has(column)
209
+ )
182
210
  const users = await client.query(
183
- `SELECT id, email, name, email_verified, image, created_at, updated_at FROM ${usersTable} ORDER BY id`
211
+ `SELECT ${['id', 'email', 'name', 'email_verified', 'image', 'created_at', 'updated_at', ...optionalUserColumns].join(', ')} FROM ${usersTable} ORDER BY id`
184
212
  )
185
213
  const rolesTable = tables['users_role']
186
214
  const roleRows =
@@ -193,19 +221,37 @@ export async function readSourceUsers(
193
221
  list.push(row.value)
194
222
  rolesByUser.set(String(row.parent_id), list)
195
223
  }
224
+ const accountColumns =
225
+ accountsTable === null ? new Set<string>() : await columnsOf(client, accountsTable)
226
+ const accountTimestamps = ['created_at', 'updated_at'].filter((column) =>
227
+ accountColumns.has(column)
228
+ )
196
229
  const accounts =
197
230
  accountsTable === null
198
231
  ? { rows: [] as unknown[] }
199
232
  : await client.query(
200
- `SELECT id, account_id, provider_id, password, access_token, refresh_token, id_token,
201
- scope, access_token_expires_at, refresh_token_expires_at, user_id
202
- FROM ${accountsTable} ORDER BY id`
233
+ `SELECT ${[
234
+ 'id',
235
+ 'account_id',
236
+ 'provider_id',
237
+ 'password',
238
+ 'access_token',
239
+ 'refresh_token',
240
+ 'id_token',
241
+ 'scope',
242
+ 'access_token_expires_at',
243
+ 'refresh_token_expires_at',
244
+ 'user_id',
245
+ ...accountTimestamps,
246
+ ].join(', ')} FROM ${accountsTable} ORDER BY id`
203
247
  )
204
248
  const accountsByUser = new Map<string, SourceAccount[]>()
205
249
  for (const row of accounts.rows as Record<string, unknown>[]) {
206
250
  const userId = String(row.user_id)
207
251
  const account: SourceAccount = {
208
252
  id: String(row.id),
253
+ createdAt: iso(row.created_at),
254
+ updatedAt: iso(row.updated_at),
209
255
  accountId: String(row.account_id),
210
256
  providerId: String(row.provider_id),
211
257
  password: text(row.password),
@@ -233,6 +279,10 @@ export async function readSourceUsers(
233
279
  updatedAt: iso(row.updated_at) ?? new Date(),
234
280
  roles: rolesByUser.get(id) ?? [],
235
281
  accounts: accountsByUser.get(id) ?? [],
282
+ banned: userColumns.has('banned') ? row.banned === true : null,
283
+ banReason: userColumns.has('ban_reason') ? text(row.ban_reason) : null,
284
+ banExpires: userColumns.has('ban_expires') ? iso(row.ban_expires) : null,
285
+ isAnonymous: userColumns.has('is_anonymous') ? row.is_anonymous === true : null,
236
286
  })
237
287
  }
238
288
  return out
@@ -244,14 +294,22 @@ export async function readSourceUsers(
244
294
  * asking for it, and an unmapped missing table aborts at the probe.
245
295
  */
246
296
  export async function readFlatRows(
247
- client: Client,
297
+ client: SourceClient,
248
298
  table: string,
249
- columns: readonly string[]
299
+ columns: readonly string[],
300
+ /**
301
+ * Columns read only when the layout has them (issue #88: row
302
+ * timestamps) — an absent optional column is simply not selected,
303
+ * and the row carries no key for it.
304
+ */
305
+ optionalColumns: readonly string[] = []
250
306
  ): Promise<SourceRow[]> {
251
307
  if (!(await tableExists(client, table))) {
252
308
  throw new Error(`source table '${table}' is missing — run probeSourceLayout first`)
253
309
  }
254
- const result = await client.query(`SELECT ${columns.join(', ')} FROM ${table} ORDER BY id`)
310
+ const existing = optionalColumns.length > 0 ? await columnsOf(client, table) : new Set<string>()
311
+ const selected = [...columns, ...optionalColumns.filter((column) => existing.has(column))]
312
+ const result = await client.query(`SELECT ${selected.join(', ')} FROM ${table} ORDER BY id`)
255
313
  return result.rows as SourceRow[]
256
314
  }
257
315
 
@@ -274,7 +332,7 @@ export type OrphanScan = {
274
332
  * of passing silently.
275
333
  */
276
334
  export async function findOrphans(
277
- client: Client,
335
+ client: SourceClient,
278
336
  tables: ResolvedSourceTables = defaultSourceTables
279
337
  ): Promise<OrphanScan> {
280
338
  const orphans: Unmappable[] = []
@@ -2,6 +2,12 @@
2
2
  * Importer manifest (issue #50): every row created and every field
3
3
  * changed, so `rollback` is exact. The manifest is the run's contract —
4
4
  * `verify`, `rollback`, and `finalize` all read it.
5
+ *
6
+ * Privacy (issue #88): a version-2 manifest holds NO email address or
7
+ * other field value in the clear. Created rows are named by ids only,
8
+ * and every captured prior value is sealed (AES-256-GCM, keyed from the
9
+ * target app's Payload `secret`), so only the same app can restore it.
10
+ * The file is written with mode 0600.
5
11
  */
6
12
  export type CreatedRow = {
7
13
  /** Target collection slug. */
@@ -16,12 +22,27 @@ export type FieldWrite = {
16
22
  readonly collection: string
17
23
  readonly id: string | number
18
24
  readonly field: string
19
- /** The prior value, JSON-serialized, restored by rollback. */
20
- readonly before: string | null
25
+ /**
26
+ * The prior value, JSON-serialized — version-1 manifests only.
27
+ * Version 2 never stores values in the clear; see `sealedBefore`.
28
+ */
29
+ readonly before?: string | null | undefined
30
+ /**
31
+ * The prior value, JSON-serialized and sealed with the target app's
32
+ * Payload secret (version 2, issue #88), restored by rollback. Only
33
+ * the FIRST capture per document field is kept, so a rollback after
34
+ * any number of `--apply` passes restores the pre-import state.
35
+ */
36
+ readonly sealedBefore?: string | undefined
21
37
  }
22
38
 
39
+ /** Which command produced the manifest (issue #88). */
40
+ export type ManifestKind = 'import' | 'adopt-prelink'
41
+
23
42
  export type ImportManifest = {
24
- readonly version: 1
43
+ readonly version: 1 | 2
44
+ /** The producing command; absent on version-1 manifests (imports). */
45
+ readonly kind?: ManifestKind | undefined
25
46
  readonly createdAt: string
26
47
  readonly sourceDatabase: string
27
48
  readonly options: {
@@ -32,8 +53,10 @@ export type ImportManifest = {
32
53
  readonly finalized: boolean
33
54
  /** Counts per source table at plan time (the rollback baseline). */
34
55
  readonly sourceCounts: Readonly<Record<string, number>>
35
- /** Cumulative plan totals (rows created + skipped across resumes). */
56
+ /** Rows the LATEST pass planned per model — never summed across passes (#88). */
36
57
  readonly totals: Readonly<Record<string, number>>
58
+ /** When the last rollback reset this manifest (issue #88). */
59
+ readonly rolledBackAt?: string | undefined
37
60
  }
38
61
 
39
62
  /** Per-model plan row for the dry run. */
package/src/index.ts CHANGED
@@ -16,7 +16,16 @@ export { classifyPlugins, pluginsFromSourceTree } from './doctor/plugins.js'
16
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
- export { runFromPayloadAuth, runVerify, runRollback, runFinalize } from './importer/run-import.js'
19
+ export {
20
+ runFromPayloadAuth,
21
+ runVerify,
22
+ runRollback,
23
+ runFinalize,
24
+ runAdoptPrelink,
25
+ type ImportCommandOptions,
26
+ type PrelinkCommandOptions,
27
+ } from './importer/run-import.js'
28
+ export { prelinkUsers, type PrelinkOptions, type PrelinkResult } from './importer/prelink.js'
20
29
  export { applyImport, summarizePlanRows } from './importer/apply.js'
21
30
  export {
22
31
  countTables,
@@ -27,6 +36,7 @@ export {
27
36
  readSourceUsers,
28
37
  unverifiedEmails,
29
38
  type OrphanScan,
39
+ type SourceClient,
30
40
  type SourceLayoutIssue,
31
41
  type SourceAccount,
32
42
  type SourceRow,
@@ -39,6 +49,7 @@ export {
39
49
  rollbackImport,
40
50
  finalizeManifest,
41
51
  defaultManifestPath,
52
+ defaultPrelinkManifestPath,
42
53
  type VerifyIssue,
43
54
  type VerifyResult,
44
55
  } from './importer/lifecycle.js'
@@ -57,6 +68,7 @@ export {
57
68
  type TargetSlugMap,
58
69
  type FieldWrite,
59
70
  type ImportManifest,
71
+ type ManifestKind,
60
72
  type ImportPlan,
61
73
  type ImportResult,
62
74
  type PlanRow,
@@ -0,0 +1,18 @@
1
+ import { closeSync, fchmodSync, ftruncateSync, openSync, writeSync } from 'node:fs'
2
+
3
+ /**
4
+ * Write `content` to `path` readable by the owner only (0600). The
5
+ * mode is tightened on the open descriptor BEFORE any content lands, so
6
+ * a pre-existing world-readable file (an older kit's manifest, a
7
+ * reused `--out` path) never holds the new content with its old mode.
8
+ */
9
+ export function writePrivateFile(path: string, content: string): void {
10
+ const fd = openSync(path, 'a', 0o600)
11
+ try {
12
+ fchmodSync(fd, 0o600)
13
+ ftruncateSync(fd, 0)
14
+ writeSync(fd, content, 0)
15
+ } finally {
16
+ closeSync(fd)
17
+ }
18
+ }