@payload-auth-link/migrate 0.4.2 → 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,4 +1,5 @@
1
1
  import type { Payload } from 'payload'
2
+ import { capturedKeys, captureBefore } from './host-writes.js'
2
3
  import {
3
4
  readFlatRows,
4
5
  openSource,
@@ -17,6 +18,7 @@ import {
17
18
  type ImportPlan,
18
19
  type ImportResult,
19
20
  type PlanRow,
21
+ type Unmappable,
20
22
  } from './types.js'
21
23
 
22
24
  /**
@@ -25,16 +27,26 @@ import {
25
27
  *
26
28
  * - Password hashes, OAuth tokens, API-key hashes, and jwks carry over
27
29
  * 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.
30
+ * - Row timestamps (`createdAt`/`updatedAt`) carry from the source rows
31
+ * for every model whose source table has them (issue #88).
32
+ * - Better Auth plugin user state carries (issue #88): admin-plugin
33
+ * `banned`/`banReason`/`banExpires`/`role` and the anonymous plugin's
34
+ * `isAnonymous`, whenever the source has the column AND the target
35
+ * `ba-users` collection declares the field. A banned or anonymous
36
+ * source user whose state the target cannot hold is UNMAPPABLE — the
37
+ * run aborts before writing; a banned user never imports unbanned.
38
+ * - `linkable` + `linkableEmail` are granted from VERIFIED emails only
39
+ * (grant-only: an existing proof is never revoked); unverified users
40
+ * are reported by the plan, never linked.
30
41
  * - Sessions default to `carry` so nobody is logged out: the carried
31
42
  * rows keep their tokens, and with the same `advanced.cookiePrefix`
32
43
  * the existing cookies keep working.
33
44
  * - Idempotent and resumable: every target row carries a deterministic
34
45
  * `baId` derived from the source id (`acct-<source id>`), so a
35
46
  * 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.
47
+ * - Every created row and every host field write lands in the
48
+ * manifest — the FIRST captured prior value per field wins, sealed —
49
+ * so `rollback` is exact and the source tables stay untouched.
38
50
  */
39
51
 
40
52
  export type ApplyOptions = {
@@ -76,18 +88,96 @@ function numberOrNull(value: unknown): number | undefined {
76
88
  return Number.isFinite(numeric) ? numeric : undefined
77
89
  }
78
90
 
91
+ /** The source row's timestamps, when its layout has them (issue #88). */
92
+ function stamps(row: SourceRow): { createdAt?: string; updatedAt?: string } {
93
+ return {
94
+ ...(row.created_at instanceof Date ? { createdAt: row.created_at.toISOString() } : {}),
95
+ ...(row.updated_at instanceof Date ? { updatedAt: row.updated_at.toISOString() } : {}),
96
+ }
97
+ }
98
+
99
+ const timestampColumns = ['created_at', 'updated_at'] as const
100
+
79
101
  /**
80
102
  * Rows for one mapped model. A `null` mapping records the model as
81
103
  * not installed (issue #68) — no read, no rows; anything else goes
82
104
  * through the strict `readFlatRows` (a missing mapped table throws;
83
- * the probe aborts the run first).
105
+ * the probe aborts the run first). Row timestamps are read when the
106
+ * layout has them.
84
107
  */
85
108
  async function readMapped(
86
109
  client: Awaited<ReturnType<typeof openSource>>,
87
110
  table: string | null,
88
111
  columns: readonly string[]
89
112
  ): Promise<SourceRow[]> {
90
- return table === null ? [] : readFlatRows(client, table, columns)
113
+ return table === null ? [] : readFlatRows(client, table, columns, timestampColumns)
114
+ }
115
+
116
+ /** Field names a target collection declares (top level, flattened). */
117
+ function targetFieldNames(payload: Payload, slug: string): ReadonlySet<string> {
118
+ const collection = payload.collections[slug]
119
+ const names = new Set<string>()
120
+ for (const field of collection?.config.flattenedFields ?? []) {
121
+ names.add(field.name)
122
+ }
123
+ return names
124
+ }
125
+
126
+ /**
127
+ * Plugin user state the target must be able to hold (issue #88),
128
+ * checked BEFORE any write: a banned user whose target has no `banned`
129
+ * field would import unbanned; an anonymous user without `isAnonymous`
130
+ * would import as a full account.
131
+ */
132
+ export function pluginStateUnmappable(
133
+ users: readonly SourceUser[],
134
+ targetFields: ReadonlySet<string>,
135
+ targetSlug: string
136
+ ): Unmappable[] {
137
+ const found: Unmappable[] = []
138
+ for (const user of users) {
139
+ if (user.banned === true && !targetFields.has('banned')) {
140
+ found.push({
141
+ model: 'user',
142
+ key: user.id,
143
+ 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`,
144
+ })
145
+ }
146
+ if (user.isAnonymous === true && !targetFields.has('isAnonymous')) {
147
+ found.push({
148
+ model: 'user',
149
+ key: user.id,
150
+ 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`,
151
+ })
152
+ }
153
+ }
154
+ return found
155
+ }
156
+
157
+ /** Plugin user state carried onto the ba-users row (issue #88). */
158
+ function pluginUserState(
159
+ user: SourceUser,
160
+ targetFields: ReadonlySet<string>
161
+ ): Record<string, unknown> {
162
+ const state: Record<string, unknown> = {}
163
+ if (user.banned !== null && targetFields.has('banned')) {
164
+ state.banned = user.banned
165
+ }
166
+ if (user.banReason !== null && targetFields.has('banReason')) {
167
+ state.banReason = user.banReason
168
+ }
169
+ if (user.banExpires !== null && targetFields.has('banExpires')) {
170
+ state.banExpires = user.banExpires.toISOString()
171
+ }
172
+ if (user.isAnonymous !== null && targetFields.has('isAnonymous')) {
173
+ state.isAnonymous = user.isAnonymous
174
+ }
175
+ // Better Auth's admin plugin stores roles as one comma-separated
176
+ // string; payload-auth kept them in the users_role child table.
177
+ if (user.roles.length > 0 && targetFields.has('role')) {
178
+ state.role = user.roles.join(',')
179
+ }
180
+ return state
91
181
  }
92
182
 
93
183
  /** Find an existing target row by `baId` — the resumable dedupe key. */
@@ -129,24 +219,6 @@ async function createDeduped(
129
219
  return true
130
220
  }
131
221
 
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
222
  /**
151
223
  * The identity rows pre-populated on the host user (issue #69): ONE
152
224
  * `better-auth` identity per imported user, `providerUserId` set to
@@ -192,9 +264,14 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
192
264
  const sourceTables: ResolvedSourceTables = resolveSourceTables(options.sourceTables)
193
265
  const created: CreatedRow[] = [...(options.manifest?.created ?? [])]
194
266
  const fieldWrites: FieldWrite[] = [...(options.manifest?.fieldWrites ?? [])]
195
- const totals: Record<string, number> = { ...(options.manifest?.totals ?? {}) }
267
+ // First capture wins (issue #88): fields captured by an earlier pass
268
+ // keep their pre-import value.
269
+ const captured = capturedKeys(fieldWrites)
270
+ // Per-PASS totals (issue #88): a re-apply reports what this pass
271
+ // planned, never the sum of every pass.
272
+ const totals: Record<string, number> = {}
196
273
  const planRows: PlanRow[] = []
197
- const unmappable: { model: string; key: string; reason: string }[] = []
274
+ const unmappable: Unmappable[] = []
198
275
  let createdCount = 0
199
276
  let skippedCount = 0
200
277
  let fieldWriteCount = 0
@@ -206,6 +283,24 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
206
283
  const client = await openSource(sourceConnectionString)
207
284
  try {
208
285
  const users = await readSourceUsers(client, sourceTables)
286
+ const baUserFields = targetFieldNames(payload, slugs.user)
287
+
288
+ // --- 0. target-capability gate (issue #88) ---------------------------
289
+ // Runs before ANY write: plugin state the target cannot hold aborts
290
+ // the run instead of importing a banned user unbanned.
291
+ const blocked = pluginStateUnmappable(users, baUserFields, slugs.user)
292
+ if (blocked.length > 0) {
293
+ log(
294
+ `ABORT: ${blocked.length} user(s) carry state the target cannot hold; nothing was written`
295
+ )
296
+ return {
297
+ created: 0,
298
+ skipped: 0,
299
+ fieldWrites: 0,
300
+ plan: { rows: [], unmappable: blocked, sessions, warnings: [] },
301
+ }
302
+ }
303
+
209
304
  const seenUsers = new Set<string>()
210
305
 
211
306
  // --- 1. ba-users + host field writes ---------------------------------
@@ -227,6 +322,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
227
322
  name: user.name ?? '',
228
323
  emailVerified: user.emailVerified,
229
324
  ...(user.image === null ? {} : { image: user.image }),
325
+ ...pluginUserState(user, baUserFields),
230
326
  createdAt: user.createdAt.toISOString(),
231
327
  updatedAt: user.updatedAt.toISOString(),
232
328
  },
@@ -242,30 +338,32 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
242
338
  }
243
339
  // Host-side writes on the EXISTING Payload user (the source
244
340
  // layout's user id IS the host id): identities, roles, and the
245
- // verified-email linking proof. Captured `before` values make
246
- // rollback exact.
341
+ // verified-email linking proof. The prior values (and the
342
+ // doc's updatedAt) are captured first — once — so rollback
343
+ // restores the pre-import document exactly.
247
344
  const hostId = Number(baId)
248
345
  if (Number.isSafeInteger(hostId)) {
346
+ const { present, newlyCaptured } = await captureBefore({
347
+ payload,
348
+ collection: 'users',
349
+ id: hostId,
350
+ fields: ['identities', 'roles', 'linkable', 'linkableEmail'],
351
+ captured,
352
+ into: fieldWrites,
353
+ })
354
+ fieldWriteCount += newlyCaptured
249
355
  const writes: Record<string, unknown> = {
250
356
  identities: identityRows(user, now),
251
- linkable: user.emailVerified,
252
357
  }
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
- }
262
- fieldWriteCount += fields.length
263
- if (user.roles.length > 0) {
358
+ if (user.roles.length > 0 && present.has('roles')) {
264
359
  // Stored role values pass through unchanged (issue #56: the
265
360
  // storage edge owns translation; the map is the app's).
266
361
  writes.roles = user.roles
267
362
  }
268
363
  if (user.emailVerified) {
364
+ // Grant-only: the proof is bound to the address (both
365
+ // fields), and an existing proof is never revoked here.
366
+ writes.linkable = true
269
367
  writes.linkableEmail = user.email
270
368
  }
271
369
  await payload.update({
@@ -286,6 +384,24 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
286
384
  })
287
385
  }
288
386
 
387
+ const tally = (model: string, sourceTable: string, target: string, isNew: boolean): void => {
388
+ if (apply) {
389
+ if (isNew) {
390
+ createdCount += 1
391
+ } else {
392
+ skippedCount += 1
393
+ }
394
+ }
395
+ record({
396
+ model,
397
+ sourceTable,
398
+ targetCollection: target,
399
+ rows: 1,
400
+ skipped: isNew ? 0 : apply ? 1 : 0,
401
+ unmappable: [],
402
+ })
403
+ }
404
+
289
405
  // --- 2. accounts (hashes verbatim) ------------------------------------
290
406
  const accountsTable = sourceTables['accounts']
291
407
  for (const user of users) {
@@ -312,26 +428,18 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
312
428
  ...(account.refreshTokenExpiresAt === null
313
429
  ? {}
314
430
  : { refreshTokenExpiresAt: account.refreshTokenExpiresAt.toISOString() }),
431
+ ...(account.createdAt === null
432
+ ? {}
433
+ : { createdAt: account.createdAt.toISOString() }),
434
+ ...(account.updatedAt === null
435
+ ? {}
436
+ : { updatedAt: account.updatedAt.toISOString() }),
315
437
  },
316
438
  created,
317
439
  `account:${account.id}`
318
440
  )
319
441
  : 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
- })
442
+ tally('account', accountsTable ?? 'accounts', slugs.account, isNew)
335
443
  }
336
444
  }
337
445
 
@@ -345,8 +453,6 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
345
453
  'expires_at',
346
454
  'ip_address',
347
455
  'user_agent',
348
- 'created_at',
349
- 'updated_at',
350
456
  ])
351
457
  for (const row of rows) {
352
458
  totals.session = (totals.session ?? 0) + 1
@@ -369,32 +475,13 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
369
475
  ...(textOrNull(row.user_agent) === undefined
370
476
  ? {}
371
477
  : { 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
- : {}),
478
+ ...stamps(row),
378
479
  },
379
480
  created,
380
481
  `session:${String(row.id)}`
381
482
  )
382
483
  : 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
- })
484
+ tally('session', sessionsTable ?? 'sessions', slugs.session, isNew)
398
485
  }
399
486
  }
400
487
 
@@ -419,26 +506,13 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
419
506
  value: String(row.value),
420
507
  expiresAt:
421
508
  row.expires_at instanceof Date ? row.expires_at.toISOString() : now().toISOString(),
509
+ ...stamps(row),
422
510
  },
423
511
  created,
424
512
  `verification:${String(row.id)}`
425
513
  )
426
514
  : 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
- })
515
+ tally('verification', verificationsTable ?? 'verifications', slugs.verification, isNew)
442
516
  }
443
517
 
444
518
  // --- 5. api keys (hash, prefix, metadata, rate-limit state) --------------
@@ -450,8 +524,8 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
450
524
  'start',
451
525
  'prefix',
452
526
  'key',
453
- // payload-auth stores the satellite's userId as referenceId
454
- // (the #49 field map's source side).
527
+ // payload-auth stores the satellite's owner as reference_id — the
528
+ // same name @better-auth/api-key 1.7 uses (`referenceId`).
455
529
  'reference_id',
456
530
  'refill_interval',
457
531
  'refill_amount',
@@ -481,9 +555,9 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
481
555
  ...(textOrNull(row.start) === undefined ? {} : { start: textOrNull(row.start) }),
482
556
  ...(textOrNull(row.prefix) === undefined ? {} : { prefix: textOrNull(row.prefix) }),
483
557
  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.
558
+ // @better-auth/api-key 1.7 names the owner `referenceId`;
559
+ // `userId` is offered for 1.6-era target schemas (Payload
560
+ // drops it where the collection has no such field).
487
561
  userId: String(row.reference_id),
488
562
  referenceId: String(row.reference_id),
489
563
  ...(numberOrNull(row.refill_interval) === undefined
@@ -523,26 +597,13 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
523
597
  ...(textOrNull(row.metadata) === undefined
524
598
  ? {}
525
599
  : { metadata: textOrNull(row.metadata) }),
600
+ ...stamps(row),
526
601
  },
527
602
  created,
528
603
  `apikey:${String(row.id)}`
529
604
  )
530
605
  : 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
- })
606
+ tally('apikey', apiKeysTable ?? 'api_keys', slugs.apikey, isNew)
546
607
  }
547
608
 
548
609
  // --- 6. jwks --------------------------------------------------------------
@@ -559,26 +620,13 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
559
620
  {
560
621
  publicKey: String(row.public_key),
561
622
  privateKey: String(row.private_key),
623
+ ...stamps(row),
562
624
  },
563
625
  created,
564
626
  `jwks:${String(row.id)}`
565
627
  )
566
628
  : 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
- })
629
+ tally('jwks', jwksTable ?? 'jwks', slugs.jwks, isNew)
582
630
  }
583
631
 
584
632
  // --- 7. organization plugin rows ---------------------------------------
@@ -605,26 +653,13 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
605
653
  ...(textOrNull(row.metadata) === undefined
606
654
  ? {}
607
655
  : { metadata: textOrNull(row.metadata) }),
656
+ ...stamps(row),
608
657
  },
609
658
  created,
610
659
  `organization:${String(row.id)}`
611
660
  )
612
661
  : 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
- })
662
+ tally('organization', organizationsTable ?? 'organizations', slugs.organization, isNew)
628
663
  }
629
664
 
630
665
  const membersTable = sourceTables['members']
@@ -646,26 +681,13 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
646
681
  organizationId: `org-${String(row.organization_id)}`,
647
682
  userId: String(row.user_id),
648
683
  ...(textOrNull(row.role) === undefined ? {} : { role: textOrNull(row.role) }),
684
+ ...stamps(row),
649
685
  },
650
686
  created,
651
687
  `member:${String(row.id)}`
652
688
  )
653
689
  : 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
- })
690
+ tally('member', membersTable ?? 'members', slugs.member, isNew)
669
691
  }
670
692
 
671
693
  const invitationsTable = sourceTables['invitations']
@@ -695,36 +717,24 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
695
717
  ? { expiresAt: row.expires_at.toISOString() }
696
718
  : {}),
697
719
  ...(typeof row.inviter_id === 'number' ? { inviterId: String(row.inviter_id) } : {}),
720
+ ...stamps(row),
698
721
  },
699
722
  created,
700
723
  `invitation:${String(row.id)}`
701
724
  )
702
725
  : 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
- })
726
+ tally('invitation', invitationsTable ?? 'invitations', slugs.invitation, isNew)
718
727
  }
719
728
 
720
729
  log(
721
- `plan: ${planRows.length} row group(s), ${createdCount} created, ${skippedCount} skipped, ${fieldWriteCount} field writes`
730
+ `plan: ${planRows.length} row group(s), ${createdCount} created, ${skippedCount} skipped, ${fieldWriteCount} field value(s) captured`
722
731
  )
723
732
  if (apply) {
724
733
  // The manifest is the run's contract: merge this pass onto any
725
734
  // resumed run's rows and write it back (issue #50).
726
735
  const manifest: ImportManifest = {
727
- version: 1,
736
+ version: 2,
737
+ kind: 'import',
728
738
  createdAt: options.manifest?.createdAt ?? now().toISOString(),
729
739
  sourceDatabase: options.sourceDatabase,
730
740
  options: { sessions },
@@ -732,11 +742,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
732
742
  fieldWrites,
733
743
  finalized: options.manifest?.finalized ?? false,
734
744
  sourceCounts: options.sourceCounts,
735
- totals: {
736
- ...options.sourceCounts,
737
- ...(options.manifest?.totals ?? {}),
738
- ...totals,
739
- },
745
+ totals,
740
746
  }
741
747
  const { writeManifest } = await import('./lifecycle.js')
742
748
  writeManifest(manifest, options.manifestPath)