@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
@@ -1,4 +1,6 @@
1
1
  import type { Payload } from 'payload'
2
+ import { capturedKeys, captureBefore } from './host-writes.js'
3
+ import { authenticateManifest, signManifest } from './manifest-mac.js'
2
4
  import {
3
5
  readFlatRows,
4
6
  openSource,
@@ -17,6 +19,7 @@ import {
17
19
  type ImportPlan,
18
20
  type ImportResult,
19
21
  type PlanRow,
22
+ type Unmappable,
20
23
  } from './types.js'
21
24
 
22
25
  /**
@@ -25,16 +28,26 @@ import {
25
28
  *
26
29
  * - Password hashes, OAuth tokens, API-key hashes, and jwks carry over
27
30
  * VERBATIM (payload-auth used Better Auth's own hashing).
28
- * - `linkable` + `linkableEmail` are granted from VERIFIED emails only;
29
- * unverified users are reported by the plan, never linked.
31
+ * - Row timestamps (`createdAt`/`updatedAt`) carry from the source rows
32
+ * for every model whose source table has them (issue #88).
33
+ * - Better Auth plugin user state carries (issue #88): admin-plugin
34
+ * `banned`/`banReason`/`banExpires`/`role` and the anonymous plugin's
35
+ * `isAnonymous`, whenever the source has the column AND the target
36
+ * `ba-users` collection declares the field. A banned or anonymous
37
+ * source user whose state the target cannot hold is UNMAPPABLE — the
38
+ * run aborts before writing; a banned user never imports unbanned.
39
+ * - `linkable` + `linkableEmail` are granted from VERIFIED emails only
40
+ * (grant-only: an existing proof is never revoked); unverified users
41
+ * are reported by the plan, never linked.
30
42
  * - Sessions default to `carry` so nobody is logged out: the carried
31
43
  * rows keep their tokens, and with the same `advanced.cookiePrefix`
32
44
  * the existing cookies keep working.
33
45
  * - Idempotent and resumable: every target row carries a deterministic
34
46
  * `baId` derived from the source id (`acct-<source id>`), so a
35
47
  * re-run after a crash finds its own earlier rows and skips them.
36
- * - Every created row and every field write lands in the manifest, so
37
- * `rollback` is exact and the source tables stay untouched.
48
+ * - Every created row and every host field write lands in the
49
+ * manifest — the FIRST captured prior value per field wins, sealed —
50
+ * so `rollback` is exact and the source tables stay untouched.
38
51
  */
39
52
 
40
53
  export type ApplyOptions = {
@@ -57,6 +70,8 @@ export type ApplyOptions = {
57
70
  /** Injectable clock for deterministic `linkedAt` stamps. */
58
71
  readonly now?: () => Date
59
72
  readonly log?: (message: string) => void
73
+ /** Resume from a pre-0.6.0 manifest that has no MAC (`--allow-unsigned-manifest`). */
74
+ readonly allowUnsignedManifest?: boolean | undefined
60
75
  }
61
76
 
62
77
  const slugsOf = (overrides?: Readonly<Record<string, string>>): TargetSlugMap => ({
@@ -76,18 +91,96 @@ function numberOrNull(value: unknown): number | undefined {
76
91
  return Number.isFinite(numeric) ? numeric : undefined
77
92
  }
78
93
 
94
+ /** The source row's timestamps, when its layout has them (issue #88). */
95
+ function stamps(row: SourceRow): { createdAt?: string; updatedAt?: string } {
96
+ return {
97
+ ...(row.created_at instanceof Date ? { createdAt: row.created_at.toISOString() } : {}),
98
+ ...(row.updated_at instanceof Date ? { updatedAt: row.updated_at.toISOString() } : {}),
99
+ }
100
+ }
101
+
102
+ const timestampColumns = ['created_at', 'updated_at'] as const
103
+
79
104
  /**
80
105
  * Rows for one mapped model. A `null` mapping records the model as
81
106
  * not installed (issue #68) — no read, no rows; anything else goes
82
107
  * through the strict `readFlatRows` (a missing mapped table throws;
83
- * the probe aborts the run first).
108
+ * the probe aborts the run first). Row timestamps are read when the
109
+ * layout has them.
84
110
  */
85
111
  async function readMapped(
86
112
  client: Awaited<ReturnType<typeof openSource>>,
87
113
  table: string | null,
88
114
  columns: readonly string[]
89
115
  ): Promise<SourceRow[]> {
90
- return table === null ? [] : readFlatRows(client, table, columns)
116
+ return table === null ? [] : readFlatRows(client, table, columns, timestampColumns)
117
+ }
118
+
119
+ /** Field names a target collection declares (top level, flattened). */
120
+ function targetFieldNames(payload: Payload, slug: string): ReadonlySet<string> {
121
+ const collection = payload.collections[slug]
122
+ const names = new Set<string>()
123
+ for (const field of collection?.config.flattenedFields ?? []) {
124
+ names.add(field.name)
125
+ }
126
+ return names
127
+ }
128
+
129
+ /**
130
+ * Plugin user state the target must be able to hold (issue #88),
131
+ * checked BEFORE any write: a banned user whose target has no `banned`
132
+ * field would import unbanned; an anonymous user without `isAnonymous`
133
+ * would import as a full account.
134
+ */
135
+ export function pluginStateUnmappable(
136
+ users: readonly SourceUser[],
137
+ targetFields: ReadonlySet<string>,
138
+ targetSlug: string
139
+ ): Unmappable[] {
140
+ const found: Unmappable[] = []
141
+ for (const user of users) {
142
+ if (user.banned === true && !targetFields.has('banned')) {
143
+ found.push({
144
+ model: 'user',
145
+ key: user.id,
146
+ reason: `user #${user.id} is banned on the source, but '${targetSlug}' declares no 'banned' field — add Better Auth's admin() plugin to the engine (autoDeclare derives banned/banReason/banExpires) so the ban carries`,
147
+ })
148
+ }
149
+ if (user.isAnonymous === true && !targetFields.has('isAnonymous')) {
150
+ found.push({
151
+ model: 'user',
152
+ key: user.id,
153
+ reason: `user #${user.id} is anonymous on the source, but '${targetSlug}' declares no 'isAnonymous' field — add Better Auth's anonymous() plugin to the engine, or remove anonymous users before importing`,
154
+ })
155
+ }
156
+ }
157
+ return found
158
+ }
159
+
160
+ /** Plugin user state carried onto the ba-users row (issue #88). */
161
+ function pluginUserState(
162
+ user: SourceUser,
163
+ targetFields: ReadonlySet<string>
164
+ ): Record<string, unknown> {
165
+ const state: Record<string, unknown> = {}
166
+ if (user.banned !== null && targetFields.has('banned')) {
167
+ state.banned = user.banned
168
+ }
169
+ if (user.banReason !== null && targetFields.has('banReason')) {
170
+ state.banReason = user.banReason
171
+ }
172
+ if (user.banExpires !== null && targetFields.has('banExpires')) {
173
+ state.banExpires = user.banExpires.toISOString()
174
+ }
175
+ if (user.isAnonymous !== null && targetFields.has('isAnonymous')) {
176
+ state.isAnonymous = user.isAnonymous
177
+ }
178
+ // Better Auth's admin plugin stores roles as one comma-separated
179
+ // string; payload-auth kept them in the users_role child table.
180
+ if (user.roles.length > 0 && targetFields.has('role')) {
181
+ state.role = user.roles.join(',')
182
+ }
183
+ return state
91
184
  }
92
185
 
93
186
  /** Find an existing target row by `baId` — the resumable dedupe key. */
@@ -129,24 +222,6 @@ async function createDeduped(
129
222
  return true
130
223
  }
131
224
 
132
- /** Capture a host field's current value for the rollback manifest. */
133
- async function captureBefore(
134
- payload: Payload,
135
- hostId: number,
136
- field: string
137
- ): Promise<string | null> {
138
- const doc: unknown = await payload.findByID({
139
- collection: 'users',
140
- id: hostId,
141
- depth: 0,
142
- overrideAccess: true,
143
- })
144
- if (typeof doc !== 'object' || doc === null || !(field in doc)) {
145
- return null
146
- }
147
- return JSON.stringify((doc as Record<string, unknown>)[field] ?? null)
148
- }
149
-
150
225
  /**
151
226
  * The identity rows pre-populated on the host user (issue #69): ONE
152
227
  * `better-auth` identity per imported user, `providerUserId` set to
@@ -190,11 +265,23 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
190
265
  } = options
191
266
  const slugs = slugsOf(options.slugs)
192
267
  const sourceTables: ResolvedSourceTables = resolveSourceTables(options.sourceTables)
268
+ if (options.manifest !== null) {
269
+ // Resuming merges the old entries and re-signs them: never launder
270
+ // an edited manifest into a signed one.
271
+ authenticateManifest(payload.secret, options.manifest, {
272
+ allowUnsigned: options.allowUnsignedManifest,
273
+ })
274
+ }
193
275
  const created: CreatedRow[] = [...(options.manifest?.created ?? [])]
194
276
  const fieldWrites: FieldWrite[] = [...(options.manifest?.fieldWrites ?? [])]
195
- const totals: Record<string, number> = { ...(options.manifest?.totals ?? {}) }
277
+ // First capture wins (issue #88): fields captured by an earlier pass
278
+ // keep their pre-import value.
279
+ const captured = capturedKeys(fieldWrites)
280
+ // Per-PASS totals (issue #88): a re-apply reports what this pass
281
+ // planned, never the sum of every pass.
282
+ const totals: Record<string, number> = {}
196
283
  const planRows: PlanRow[] = []
197
- const unmappable: { model: string; key: string; reason: string }[] = []
284
+ const unmappable: Unmappable[] = []
198
285
  let createdCount = 0
199
286
  let skippedCount = 0
200
287
  let fieldWriteCount = 0
@@ -206,6 +293,24 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
206
293
  const client = await openSource(sourceConnectionString)
207
294
  try {
208
295
  const users = await readSourceUsers(client, sourceTables)
296
+ const baUserFields = targetFieldNames(payload, slugs.user)
297
+
298
+ // --- 0. target-capability gate (issue #88) ---------------------------
299
+ // Runs before ANY write: plugin state the target cannot hold aborts
300
+ // the run instead of importing a banned user unbanned.
301
+ const blocked = pluginStateUnmappable(users, baUserFields, slugs.user)
302
+ if (blocked.length > 0) {
303
+ log(
304
+ `ABORT: ${blocked.length} user(s) carry state the target cannot hold; nothing was written`
305
+ )
306
+ return {
307
+ created: 0,
308
+ skipped: 0,
309
+ fieldWrites: 0,
310
+ plan: { rows: [], unmappable: blocked, sessions, warnings: [] },
311
+ }
312
+ }
313
+
209
314
  const seenUsers = new Set<string>()
210
315
 
211
316
  // --- 1. ba-users + host field writes ---------------------------------
@@ -227,6 +332,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
227
332
  name: user.name ?? '',
228
333
  emailVerified: user.emailVerified,
229
334
  ...(user.image === null ? {} : { image: user.image }),
335
+ ...pluginUserState(user, baUserFields),
230
336
  createdAt: user.createdAt.toISOString(),
231
337
  updatedAt: user.updatedAt.toISOString(),
232
338
  },
@@ -242,30 +348,32 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
242
348
  }
243
349
  // Host-side writes on the EXISTING Payload user (the source
244
350
  // layout's user id IS the host id): identities, roles, and the
245
- // verified-email linking proof. Captured `before` values make
246
- // rollback exact.
351
+ // verified-email linking proof. The prior values (and the
352
+ // doc's updatedAt) are captured first — once — so rollback
353
+ // restores the pre-import document exactly.
247
354
  const hostId = Number(baId)
248
355
  if (Number.isSafeInteger(hostId)) {
356
+ const { present, newlyCaptured } = await captureBefore({
357
+ payload,
358
+ collection: 'users',
359
+ id: hostId,
360
+ fields: ['identities', 'roles', 'linkable', 'linkableEmail'],
361
+ captured,
362
+ into: fieldWrites,
363
+ })
364
+ fieldWriteCount += newlyCaptured
249
365
  const writes: Record<string, unknown> = {
250
366
  identities: identityRows(user, now),
251
- linkable: user.emailVerified,
252
- }
253
- const fields: readonly string[] = ['identities', 'roles', 'linkable', 'linkableEmail']
254
- for (const field of fields) {
255
- fieldWrites.push({
256
- collection: 'users',
257
- id: hostId,
258
- field,
259
- before: await captureBefore(payload, hostId, field),
260
- })
261
367
  }
262
- fieldWriteCount += fields.length
263
- if (user.roles.length > 0) {
368
+ if (user.roles.length > 0 && present.has('roles')) {
264
369
  // Stored role values pass through unchanged (issue #56: the
265
370
  // storage edge owns translation; the map is the app's).
266
371
  writes.roles = user.roles
267
372
  }
268
373
  if (user.emailVerified) {
374
+ // Grant-only: the proof is bound to the address (both
375
+ // fields), and an existing proof is never revoked here.
376
+ writes.linkable = true
269
377
  writes.linkableEmail = user.email
270
378
  }
271
379
  await payload.update({
@@ -286,6 +394,24 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
286
394
  })
287
395
  }
288
396
 
397
+ const tally = (model: string, sourceTable: string, target: string, isNew: boolean): void => {
398
+ if (apply) {
399
+ if (isNew) {
400
+ createdCount += 1
401
+ } else {
402
+ skippedCount += 1
403
+ }
404
+ }
405
+ record({
406
+ model,
407
+ sourceTable,
408
+ targetCollection: target,
409
+ rows: 1,
410
+ skipped: isNew ? 0 : apply ? 1 : 0,
411
+ unmappable: [],
412
+ })
413
+ }
414
+
289
415
  // --- 2. accounts (hashes verbatim) ------------------------------------
290
416
  const accountsTable = sourceTables['accounts']
291
417
  for (const user of users) {
@@ -312,26 +438,18 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
312
438
  ...(account.refreshTokenExpiresAt === null
313
439
  ? {}
314
440
  : { refreshTokenExpiresAt: account.refreshTokenExpiresAt.toISOString() }),
441
+ ...(account.createdAt === null
442
+ ? {}
443
+ : { createdAt: account.createdAt.toISOString() }),
444
+ ...(account.updatedAt === null
445
+ ? {}
446
+ : { updatedAt: account.updatedAt.toISOString() }),
315
447
  },
316
448
  created,
317
449
  `account:${account.id}`
318
450
  )
319
451
  : false
320
- if (apply) {
321
- if (isNew) {
322
- createdCount += 1
323
- } else {
324
- skippedCount += 1
325
- }
326
- }
327
- record({
328
- model: 'account',
329
- sourceTable: accountsTable ?? 'accounts',
330
- targetCollection: slugs.account,
331
- rows: 1,
332
- skipped: isNew ? 0 : apply ? 1 : 0,
333
- unmappable: [],
334
- })
452
+ tally('account', accountsTable ?? 'accounts', slugs.account, isNew)
335
453
  }
336
454
  }
337
455
 
@@ -345,8 +463,6 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
345
463
  'expires_at',
346
464
  'ip_address',
347
465
  'user_agent',
348
- 'created_at',
349
- 'updated_at',
350
466
  ])
351
467
  for (const row of rows) {
352
468
  totals.session = (totals.session ?? 0) + 1
@@ -369,32 +485,13 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
369
485
  ...(textOrNull(row.user_agent) === undefined
370
486
  ? {}
371
487
  : { userAgent: textOrNull(row.user_agent) }),
372
- ...(row.created_at instanceof Date
373
- ? { createdAt: row.created_at.toISOString() }
374
- : {}),
375
- ...(row.updated_at instanceof Date
376
- ? { updatedAt: row.updated_at.toISOString() }
377
- : {}),
488
+ ...stamps(row),
378
489
  },
379
490
  created,
380
491
  `session:${String(row.id)}`
381
492
  )
382
493
  : false
383
- if (apply) {
384
- if (isNew) {
385
- createdCount += 1
386
- } else {
387
- skippedCount += 1
388
- }
389
- }
390
- record({
391
- model: 'session',
392
- sourceTable: sessionsTable ?? 'sessions',
393
- targetCollection: slugs.session,
394
- rows: 1,
395
- skipped: isNew ? 0 : apply ? 1 : 0,
396
- unmappable: [],
397
- })
494
+ tally('session', sessionsTable ?? 'sessions', slugs.session, isNew)
398
495
  }
399
496
  }
400
497
 
@@ -419,26 +516,13 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
419
516
  value: String(row.value),
420
517
  expiresAt:
421
518
  row.expires_at instanceof Date ? row.expires_at.toISOString() : now().toISOString(),
519
+ ...stamps(row),
422
520
  },
423
521
  created,
424
522
  `verification:${String(row.id)}`
425
523
  )
426
524
  : false
427
- if (apply) {
428
- if (isNew) {
429
- createdCount += 1
430
- } else {
431
- skippedCount += 1
432
- }
433
- }
434
- record({
435
- model: 'verification',
436
- sourceTable: verificationsTable ?? 'verifications',
437
- targetCollection: slugs.verification,
438
- rows: 1,
439
- skipped: isNew ? 0 : apply ? 1 : 0,
440
- unmappable: [],
441
- })
525
+ tally('verification', verificationsTable ?? 'verifications', slugs.verification, isNew)
442
526
  }
443
527
 
444
528
  // --- 5. api keys (hash, prefix, metadata, rate-limit state) --------------
@@ -450,8 +534,8 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
450
534
  'start',
451
535
  'prefix',
452
536
  'key',
453
- // payload-auth stores the satellite's userId as referenceId
454
- // (the #49 field map's source side).
537
+ // payload-auth stores the satellite's owner as reference_id — the
538
+ // same name @better-auth/api-key 1.7 uses (`referenceId`).
455
539
  'reference_id',
456
540
  'refill_interval',
457
541
  'refill_amount',
@@ -481,9 +565,9 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
481
565
  ...(textOrNull(row.start) === undefined ? {} : { start: textOrNull(row.start) }),
482
566
  ...(textOrNull(row.prefix) === undefined ? {} : { prefix: textOrNull(row.prefix) }),
483
567
  key: String(row.key),
484
- // The satellite's user reference arrives as referenceId in
485
- // payload-auth layouts; BA 1.7's own schema names it the
486
- // same, so the target field is offered both names.
568
+ // @better-auth/api-key 1.7 names the owner `referenceId`;
569
+ // `userId` is offered for 1.6-era target schemas (Payload
570
+ // drops it where the collection has no such field).
487
571
  userId: String(row.reference_id),
488
572
  referenceId: String(row.reference_id),
489
573
  ...(numberOrNull(row.refill_interval) === undefined
@@ -523,26 +607,13 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
523
607
  ...(textOrNull(row.metadata) === undefined
524
608
  ? {}
525
609
  : { metadata: textOrNull(row.metadata) }),
610
+ ...stamps(row),
526
611
  },
527
612
  created,
528
613
  `apikey:${String(row.id)}`
529
614
  )
530
615
  : false
531
- if (apply) {
532
- if (isNew) {
533
- createdCount += 1
534
- } else {
535
- skippedCount += 1
536
- }
537
- }
538
- record({
539
- model: 'apikey',
540
- sourceTable: apiKeysTable ?? 'api_keys',
541
- targetCollection: slugs.apikey,
542
- rows: 1,
543
- skipped: isNew ? 0 : apply ? 1 : 0,
544
- unmappable: [],
545
- })
616
+ tally('apikey', apiKeysTable ?? 'api_keys', slugs.apikey, isNew)
546
617
  }
547
618
 
548
619
  // --- 6. jwks --------------------------------------------------------------
@@ -559,26 +630,13 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
559
630
  {
560
631
  publicKey: String(row.public_key),
561
632
  privateKey: String(row.private_key),
633
+ ...stamps(row),
562
634
  },
563
635
  created,
564
636
  `jwks:${String(row.id)}`
565
637
  )
566
638
  : false
567
- if (apply) {
568
- if (isNew) {
569
- createdCount += 1
570
- } else {
571
- skippedCount += 1
572
- }
573
- }
574
- record({
575
- model: 'jwks',
576
- sourceTable: jwksTable ?? 'jwks',
577
- targetCollection: slugs.jwks,
578
- rows: 1,
579
- skipped: isNew ? 0 : apply ? 1 : 0,
580
- unmappable: [],
581
- })
639
+ tally('jwks', jwksTable ?? 'jwks', slugs.jwks, isNew)
582
640
  }
583
641
 
584
642
  // --- 7. organization plugin rows ---------------------------------------
@@ -605,26 +663,13 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
605
663
  ...(textOrNull(row.metadata) === undefined
606
664
  ? {}
607
665
  : { metadata: textOrNull(row.metadata) }),
666
+ ...stamps(row),
608
667
  },
609
668
  created,
610
669
  `organization:${String(row.id)}`
611
670
  )
612
671
  : false
613
- if (apply) {
614
- if (isNew) {
615
- createdCount += 1
616
- } else {
617
- skippedCount += 1
618
- }
619
- }
620
- record({
621
- model: 'organization',
622
- sourceTable: organizationsTable ?? 'organizations',
623
- targetCollection: slugs.organization,
624
- rows: 1,
625
- skipped: isNew ? 0 : apply ? 1 : 0,
626
- unmappable: [],
627
- })
672
+ tally('organization', organizationsTable ?? 'organizations', slugs.organization, isNew)
628
673
  }
629
674
 
630
675
  const membersTable = sourceTables['members']
@@ -646,26 +691,13 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
646
691
  organizationId: `org-${String(row.organization_id)}`,
647
692
  userId: String(row.user_id),
648
693
  ...(textOrNull(row.role) === undefined ? {} : { role: textOrNull(row.role) }),
694
+ ...stamps(row),
649
695
  },
650
696
  created,
651
697
  `member:${String(row.id)}`
652
698
  )
653
699
  : false
654
- if (apply) {
655
- if (isNew) {
656
- createdCount += 1
657
- } else {
658
- skippedCount += 1
659
- }
660
- }
661
- record({
662
- model: 'member',
663
- sourceTable: membersTable ?? 'members',
664
- targetCollection: slugs.member,
665
- rows: 1,
666
- skipped: isNew ? 0 : apply ? 1 : 0,
667
- unmappable: [],
668
- })
700
+ tally('member', membersTable ?? 'members', slugs.member, isNew)
669
701
  }
670
702
 
671
703
  const invitationsTable = sourceTables['invitations']
@@ -695,36 +727,24 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
695
727
  ? { expiresAt: row.expires_at.toISOString() }
696
728
  : {}),
697
729
  ...(typeof row.inviter_id === 'number' ? { inviterId: String(row.inviter_id) } : {}),
730
+ ...stamps(row),
698
731
  },
699
732
  created,
700
733
  `invitation:${String(row.id)}`
701
734
  )
702
735
  : false
703
- if (apply) {
704
- if (isNew) {
705
- createdCount += 1
706
- } else {
707
- skippedCount += 1
708
- }
709
- }
710
- record({
711
- model: 'invitation',
712
- sourceTable: invitationsTable ?? 'invitations',
713
- targetCollection: slugs.invitation,
714
- rows: 1,
715
- skipped: isNew ? 0 : apply ? 1 : 0,
716
- unmappable: [],
717
- })
736
+ tally('invitation', invitationsTable ?? 'invitations', slugs.invitation, isNew)
718
737
  }
719
738
 
720
739
  log(
721
- `plan: ${planRows.length} row group(s), ${createdCount} created, ${skippedCount} skipped, ${fieldWriteCount} field writes`
740
+ `plan: ${planRows.length} row group(s), ${createdCount} created, ${skippedCount} skipped, ${fieldWriteCount} field value(s) captured`
722
741
  )
723
742
  if (apply) {
724
743
  // The manifest is the run's contract: merge this pass onto any
725
744
  // resumed run's rows and write it back (issue #50).
726
- const manifest: ImportManifest = {
727
- version: 1,
745
+ const manifest: ImportManifest = signManifest(payload.secret, {
746
+ version: 3,
747
+ kind: 'import',
728
748
  createdAt: options.manifest?.createdAt ?? now().toISOString(),
729
749
  sourceDatabase: options.sourceDatabase,
730
750
  options: { sessions },
@@ -732,12 +752,8 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
732
752
  fieldWrites,
733
753
  finalized: options.manifest?.finalized ?? false,
734
754
  sourceCounts: options.sourceCounts,
735
- totals: {
736
- ...options.sourceCounts,
737
- ...(options.manifest?.totals ?? {}),
738
- ...totals,
739
- },
740
- }
755
+ totals,
756
+ })
741
757
  const { writeManifest } = await import('./lifecycle.js')
742
758
  writeManifest(manifest, options.manifestPath)
743
759
  }