@payload-auth-link/migrate 0.0.0-stage → 0.2.1

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 (72) hide show
  1. package/README.md +49 -2
  2. package/dist/cli.d.ts +7 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +257 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/doctor/bands.d.ts +16 -0
  7. package/dist/doctor/bands.d.ts.map +1 -0
  8. package/dist/doctor/bands.js +49 -0
  9. package/dist/doctor/bands.js.map +1 -0
  10. package/dist/doctor/database.d.ts +3 -0
  11. package/dist/doctor/database.d.ts.map +1 -0
  12. package/dist/doctor/database.js +110 -0
  13. package/dist/doctor/database.js.map +1 -0
  14. package/dist/doctor/plugins.d.ts +12 -0
  15. package/dist/doctor/plugins.d.ts.map +1 -0
  16. package/dist/doctor/plugins.js +202 -0
  17. package/dist/doctor/plugins.js.map +1 -0
  18. package/dist/doctor/run-doctor.d.ts +48 -0
  19. package/dist/doctor/run-doctor.d.ts.map +1 -0
  20. package/dist/doctor/run-doctor.js +236 -0
  21. package/dist/doctor/run-doctor.js.map +1 -0
  22. package/dist/doctor/source-scan.d.ts +10 -0
  23. package/dist/doctor/source-scan.d.ts.map +1 -0
  24. package/dist/doctor/source-scan.js +142 -0
  25. package/dist/doctor/source-scan.js.map +1 -0
  26. package/dist/doctor/types.d.ts +86 -0
  27. package/dist/doctor/types.d.ts.map +1 -0
  28. package/dist/doctor/types.js +7 -0
  29. package/dist/doctor/types.js.map +1 -0
  30. package/dist/doctor/users.d.ts +10 -0
  31. package/dist/doctor/users.d.ts.map +1 -0
  32. package/dist/doctor/users.js +176 -0
  33. package/dist/doctor/users.js.map +1 -0
  34. package/dist/importer/apply.d.ts +39 -0
  35. package/dist/importer/apply.d.ts.map +1 -0
  36. package/dist/importer/apply.js +588 -0
  37. package/dist/importer/apply.js.map +1 -0
  38. package/dist/importer/lifecycle.d.ts +52 -0
  39. package/dist/importer/lifecycle.d.ts.map +1 -0
  40. package/dist/importer/lifecycle.js +265 -0
  41. package/dist/importer/lifecycle.js.map +1 -0
  42. package/dist/importer/run-import.d.ts +40 -0
  43. package/dist/importer/run-import.d.ts.map +1 -0
  44. package/dist/importer/run-import.js +176 -0
  45. package/dist/importer/run-import.js.map +1 -0
  46. package/dist/importer/source.d.ts +49 -0
  47. package/dist/importer/source.d.ts.map +1 -0
  48. package/dist/importer/source.js +147 -0
  49. package/dist/importer/source.js.map +1 -0
  50. package/dist/importer/types.d.ts +69 -0
  51. package/dist/importer/types.d.ts.map +1 -0
  52. package/dist/importer/types.js +13 -0
  53. package/dist/importer/types.js.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +17 -0
  57. package/dist/index.js.map +1 -0
  58. package/package.json +70 -4
  59. package/src/cli.ts +287 -0
  60. package/src/doctor/bands.ts +66 -0
  61. package/src/doctor/database.ts +130 -0
  62. package/src/doctor/plugins.ts +216 -0
  63. package/src/doctor/run-doctor.ts +278 -0
  64. package/src/doctor/source-scan.ts +148 -0
  65. package/src/doctor/types.ts +92 -0
  66. package/src/doctor/users.ts +207 -0
  67. package/src/importer/apply.ts +731 -0
  68. package/src/importer/lifecycle.ts +328 -0
  69. package/src/importer/run-import.ts +237 -0
  70. package/src/importer/source.ts +211 -0
  71. package/src/importer/types.ts +96 -0
  72. package/src/index.ts +53 -0
@@ -0,0 +1,731 @@
1
+ import type { Payload } from 'payload'
2
+ import { readFlatRows, openSource, readSourceUsers, type SourceUser } from './source.js'
3
+ import {
4
+ defaultTargetSlugs,
5
+ type CreatedRow,
6
+ type TargetSlugMap,
7
+ type FieldWrite,
8
+ type ImportManifest,
9
+ type ImportPlan,
10
+ type ImportResult,
11
+ type PlanRow,
12
+ } from './types.js'
13
+
14
+ /**
15
+ * Apply side (issue #50): copy payload-auth rows into the
16
+ * payload-auth-link collections through the Payload Local API.
17
+ *
18
+ * - Password hashes, OAuth tokens, API-key hashes, and jwks carry over
19
+ * VERBATIM (payload-auth used Better Auth's own hashing).
20
+ * - `linkable` + `linkableEmail` are granted from VERIFIED emails only;
21
+ * unverified users are reported by the plan, never linked.
22
+ * - Sessions default to `carry` so nobody is logged out: the carried
23
+ * rows keep their tokens, and with the same `advanced.cookiePrefix`
24
+ * the existing cookies keep working.
25
+ * - Idempotent and resumable: every target row carries a deterministic
26
+ * `baId` derived from the source id (`acct-<source id>`), so a
27
+ * re-run after a crash finds its own earlier rows and skips them.
28
+ * - Every created row and every field write lands in the manifest, so
29
+ * `rollback` is exact and the source tables stay untouched.
30
+ */
31
+
32
+ export type ApplyOptions = {
33
+ readonly payload: Payload
34
+ readonly sourceConnectionString: string
35
+ readonly apply: boolean
36
+ readonly sessions: 'carry' | 'drop'
37
+ readonly manifest: ImportManifest | null
38
+ /** Written (merged) on `apply`; required when `apply` is true. */
39
+ readonly manifestPath: string
40
+ readonly sourceCounts: Readonly<Record<string, number>>
41
+ readonly sourceDatabase: string
42
+ /** Target collection slugs; defaults must match the engine wiring. */
43
+ readonly slugs?: Readonly<Record<string, string>> | undefined
44
+ /** Injectable clock for deterministic `linkedAt` stamps. */
45
+ readonly now?: () => Date
46
+ readonly log?: (message: string) => void
47
+ }
48
+
49
+ const slugsOf = (overrides?: Readonly<Record<string, string>>): TargetSlugMap => ({
50
+ ...defaultTargetSlugs,
51
+ ...(overrides ?? {}),
52
+ })
53
+
54
+ function textOrNull(value: unknown): string | undefined {
55
+ return typeof value === 'string' && value !== '' ? value : undefined
56
+ }
57
+
58
+ function numberOrNull(value: unknown): number | undefined {
59
+ if (value === null || value === undefined) {
60
+ return undefined
61
+ }
62
+ const numeric = typeof value === 'number' ? value : Number(value)
63
+ return Number.isFinite(numeric) ? numeric : undefined
64
+ }
65
+
66
+ /** Find an existing target row by `baId` — the resumable dedupe key. */
67
+ async function findByBaId(payload: Payload, collection: string, baId: string): Promise<boolean> {
68
+ const found = await payload.find({
69
+ collection,
70
+ where: { baId: { equals: baId } },
71
+ limit: 1,
72
+ depth: 0,
73
+ pagination: false,
74
+ overrideAccess: true,
75
+ })
76
+ return found.docs.length > 0
77
+ }
78
+
79
+ /** Create one target row, deduped by its deterministic baId. */
80
+ async function createDeduped(
81
+ payload: Payload,
82
+ collection: string,
83
+ baId: string,
84
+ data: Record<string, unknown>,
85
+ created: CreatedRow[],
86
+ sourceKey: string
87
+ ): Promise<boolean> {
88
+ if (await findByBaId(payload, collection, baId)) {
89
+ return false
90
+ }
91
+ const doc: unknown = await payload.create({
92
+ collection,
93
+ data: { baId, ...data },
94
+ overrideAccess: true,
95
+ })
96
+ if (typeof doc === 'object' && doc !== null && 'id' in doc) {
97
+ const id: unknown = doc['id']
98
+ if (typeof id === 'string' || typeof id === 'number') {
99
+ created.push({ collection, id, sourceKey })
100
+ }
101
+ }
102
+ return true
103
+ }
104
+
105
+ /** Capture a host field's current value for the rollback manifest. */
106
+ async function captureBefore(
107
+ payload: Payload,
108
+ hostId: number,
109
+ field: string
110
+ ): Promise<string | null> {
111
+ const doc: unknown = await payload.findByID({
112
+ collection: 'users',
113
+ id: hostId,
114
+ depth: 0,
115
+ overrideAccess: true,
116
+ })
117
+ if (typeof doc !== 'object' || doc === null || !(field in doc)) {
118
+ return null
119
+ }
120
+ return JSON.stringify((doc as Record<string, unknown>)[field] ?? null)
121
+ }
122
+
123
+ /** Identity rows pre-populated on the host user (verified emails only). */
124
+ function identityRows(
125
+ user: SourceUser,
126
+ now: () => Date
127
+ ): { providerId: string; providerUserId: string; email: string; linkedAt: string }[] {
128
+ const rows: { providerId: string; providerUserId: string; email: string; linkedAt: string }[] = []
129
+ for (const account of user.accounts) {
130
+ if (account.providerId === 'credential') {
131
+ // A password credential is not an identity: the engine signs the
132
+ // user in through ba-accounts; no link is claimed.
133
+ continue
134
+ }
135
+ rows.push({
136
+ providerId: account.providerId,
137
+ providerUserId: account.accountId,
138
+ email: user.email,
139
+ linkedAt: now().toISOString(),
140
+ })
141
+ }
142
+ return rows
143
+ }
144
+
145
+ export async function applyImport(options: ApplyOptions): Promise<ImportResult> {
146
+ const {
147
+ payload,
148
+ sourceConnectionString,
149
+ apply,
150
+ sessions,
151
+ log = () => {},
152
+ now = () => new Date(),
153
+ } = options
154
+ const slugs = slugsOf(options.slugs)
155
+ const created: CreatedRow[] = [...(options.manifest?.created ?? [])]
156
+ const fieldWrites: FieldWrite[] = [...(options.manifest?.fieldWrites ?? [])]
157
+ const totals: Record<string, number> = { ...(options.manifest?.totals ?? {}) }
158
+ const planRows: PlanRow[] = []
159
+ const unmappable: { model: string; key: string; reason: string }[] = []
160
+ let createdCount = 0
161
+ let skippedCount = 0
162
+ let fieldWriteCount = 0
163
+
164
+ const record = (row: PlanRow): void => {
165
+ planRows.push(row)
166
+ }
167
+
168
+ const client = await openSource(sourceConnectionString)
169
+ try {
170
+ const users = await readSourceUsers(client)
171
+ const seenUsers = new Set<string>()
172
+
173
+ // --- 1. ba-users + host field writes ---------------------------------
174
+ for (const user of users) {
175
+ const baId = String(user.id)
176
+ if (seenUsers.has(baId)) {
177
+ unmappable.push({ model: 'user', key: baId, reason: 'duplicate source id' })
178
+ continue
179
+ }
180
+ seenUsers.add(baId)
181
+ totals.user = (totals.user ?? 0) + 1
182
+ const isNew = apply
183
+ ? await createDeduped(
184
+ payload,
185
+ slugs.user,
186
+ baId,
187
+ {
188
+ email: user.email,
189
+ name: user.name ?? '',
190
+ emailVerified: user.emailVerified,
191
+ ...(user.image === null ? {} : { image: user.image }),
192
+ createdAt: user.createdAt.toISOString(),
193
+ updatedAt: user.updatedAt.toISOString(),
194
+ },
195
+ created,
196
+ `user:${baId}`
197
+ )
198
+ : false
199
+ if (apply) {
200
+ if (isNew) {
201
+ createdCount += 1
202
+ } else {
203
+ skippedCount += 1
204
+ }
205
+ // Host-side writes on the EXISTING Payload user (the source
206
+ // layout's user id IS the host id): identities, roles, and the
207
+ // verified-email linking proof. Captured `before` values make
208
+ // rollback exact.
209
+ const hostId = Number(baId)
210
+ if (Number.isSafeInteger(hostId)) {
211
+ const writes: Record<string, unknown> = {
212
+ identities: identityRows(user, now),
213
+ linkable: user.emailVerified,
214
+ }
215
+ const fields: readonly string[] = ['identities', 'roles', 'linkable', 'linkableEmail']
216
+ for (const field of fields) {
217
+ fieldWrites.push({
218
+ collection: 'users',
219
+ id: hostId,
220
+ field,
221
+ before: await captureBefore(payload, hostId, field),
222
+ })
223
+ }
224
+ fieldWriteCount += fields.length
225
+ if (user.roles.length > 0) {
226
+ // Stored role values pass through unchanged (issue #56: the
227
+ // storage edge owns translation; the map is the app's).
228
+ writes.roles = user.roles
229
+ }
230
+ if (user.emailVerified) {
231
+ writes.linkableEmail = user.email
232
+ }
233
+ await payload.update({
234
+ collection: 'users',
235
+ id: hostId,
236
+ data: writes,
237
+ overrideAccess: true,
238
+ })
239
+ }
240
+ }
241
+ record({
242
+ model: 'user',
243
+ sourceTable: 'users',
244
+ targetCollection: slugs.user,
245
+ rows: 1,
246
+ skipped: isNew ? 0 : apply ? 1 : 0,
247
+ unmappable: [],
248
+ })
249
+ }
250
+
251
+ // --- 2. accounts (hashes verbatim) ------------------------------------
252
+ for (const user of users) {
253
+ for (const account of user.accounts) {
254
+ totals.account = (totals.account ?? 0) + 1
255
+ const baId = `acct-${account.id}`
256
+ const isNew = apply
257
+ ? await createDeduped(
258
+ payload,
259
+ slugs.account,
260
+ baId,
261
+ {
262
+ accountId: account.accountId,
263
+ providerId: account.providerId,
264
+ userId: String(user.id),
265
+ ...(account.password === null ? {} : { password: account.password }),
266
+ ...(account.accessToken === null ? {} : { accessToken: account.accessToken }),
267
+ ...(account.refreshToken === null ? {} : { refreshToken: account.refreshToken }),
268
+ ...(account.idToken === null ? {} : { idToken: account.idToken }),
269
+ ...(account.scope === null ? {} : { scope: account.scope }),
270
+ ...(account.accessTokenExpiresAt === null
271
+ ? {}
272
+ : { accessTokenExpiresAt: account.accessTokenExpiresAt.toISOString() }),
273
+ ...(account.refreshTokenExpiresAt === null
274
+ ? {}
275
+ : { refreshTokenExpiresAt: account.refreshTokenExpiresAt.toISOString() }),
276
+ },
277
+ created,
278
+ `account:${account.id}`
279
+ )
280
+ : false
281
+ if (apply) {
282
+ if (isNew) {
283
+ createdCount += 1
284
+ } else {
285
+ skippedCount += 1
286
+ }
287
+ }
288
+ record({
289
+ model: 'account',
290
+ sourceTable: 'accounts',
291
+ targetCollection: slugs.account,
292
+ rows: 1,
293
+ skipped: isNew ? 0 : apply ? 1 : 0,
294
+ unmappable: [],
295
+ })
296
+ }
297
+ }
298
+
299
+ // --- 3. sessions (carry keeps every token) -----------------------------
300
+ if (sessions === 'carry') {
301
+ const rows = await readFlatRows(client, 'sessions', [
302
+ 'id',
303
+ 'user_id',
304
+ 'token',
305
+ 'expires_at',
306
+ 'ip_address',
307
+ 'user_agent',
308
+ 'created_at',
309
+ 'updated_at',
310
+ ])
311
+ for (const row of rows) {
312
+ totals.session = (totals.session ?? 0) + 1
313
+ const baId = `sess-${String(row.id)}`
314
+ const isNew = apply
315
+ ? await createDeduped(
316
+ payload,
317
+ slugs.session,
318
+ baId,
319
+ {
320
+ userId: String(row.user_id),
321
+ token: String(row.token),
322
+ expiresAt:
323
+ row.expires_at instanceof Date
324
+ ? row.expires_at.toISOString()
325
+ : now().toISOString(),
326
+ ...(textOrNull(row.ip_address) === undefined
327
+ ? {}
328
+ : { ipAddress: textOrNull(row.ip_address) }),
329
+ ...(textOrNull(row.user_agent) === undefined
330
+ ? {}
331
+ : { userAgent: textOrNull(row.user_agent) }),
332
+ ...(row.created_at instanceof Date
333
+ ? { createdAt: row.created_at.toISOString() }
334
+ : {}),
335
+ ...(row.updated_at instanceof Date
336
+ ? { updatedAt: row.updated_at.toISOString() }
337
+ : {}),
338
+ },
339
+ created,
340
+ `session:${String(row.id)}`
341
+ )
342
+ : false
343
+ if (apply) {
344
+ if (isNew) {
345
+ createdCount += 1
346
+ } else {
347
+ skippedCount += 1
348
+ }
349
+ }
350
+ record({
351
+ model: 'session',
352
+ sourceTable: 'sessions',
353
+ targetCollection: slugs.session,
354
+ rows: 1,
355
+ skipped: isNew ? 0 : apply ? 1 : 0,
356
+ unmappable: [],
357
+ })
358
+ }
359
+ }
360
+
361
+ // --- 4. verifications ---------------------------------------------------
362
+ const verifications = await readFlatRows(client, 'verifications', [
363
+ 'id',
364
+ 'identifier',
365
+ 'value',
366
+ 'expires_at',
367
+ ])
368
+ for (const row of verifications) {
369
+ totals.verification = (totals.verification ?? 0) + 1
370
+ const baId = `ver-${String(row.id)}`
371
+ const isNew = apply
372
+ ? await createDeduped(
373
+ payload,
374
+ slugs.verification,
375
+ baId,
376
+ {
377
+ identifier: String(row.identifier),
378
+ value: String(row.value),
379
+ expiresAt:
380
+ row.expires_at instanceof Date ? row.expires_at.toISOString() : now().toISOString(),
381
+ },
382
+ created,
383
+ `verification:${String(row.id)}`
384
+ )
385
+ : false
386
+ if (apply) {
387
+ if (isNew) {
388
+ createdCount += 1
389
+ } else {
390
+ skippedCount += 1
391
+ }
392
+ }
393
+ record({
394
+ model: 'verification',
395
+ sourceTable: 'verifications',
396
+ targetCollection: slugs.verification,
397
+ rows: 1,
398
+ skipped: isNew ? 0 : apply ? 1 : 0,
399
+ unmappable: [],
400
+ })
401
+ }
402
+
403
+ // --- 5. api keys (hash, prefix, metadata, rate-limit state) --------------
404
+ const apiKeys = await readFlatRows(client, 'api_keys', [
405
+ 'id',
406
+ 'config_id',
407
+ 'name',
408
+ 'start',
409
+ 'prefix',
410
+ 'key',
411
+ // payload-auth stores the satellite's userId as referenceId
412
+ // (the #49 field map's source side).
413
+ 'reference_id',
414
+ 'refill_interval',
415
+ 'refill_amount',
416
+ 'last_refill_at',
417
+ 'enabled',
418
+ 'rate_limit_enabled',
419
+ 'rate_limit_time_window',
420
+ 'rate_limit_max',
421
+ 'request_count',
422
+ 'remaining',
423
+ 'last_request',
424
+ 'expires_at',
425
+ 'permissions',
426
+ 'metadata',
427
+ ])
428
+ for (const row of apiKeys) {
429
+ totals.apikey = (totals.apikey ?? 0) + 1
430
+ const baId = `key-${String(row.id)}`
431
+ const isNew = apply
432
+ ? await createDeduped(
433
+ payload,
434
+ slugs.apikey,
435
+ baId,
436
+ {
437
+ configId: textOrNull(row.config_id) ?? '',
438
+ ...(textOrNull(row.name) === undefined ? {} : { name: textOrNull(row.name) }),
439
+ ...(textOrNull(row.start) === undefined ? {} : { start: textOrNull(row.start) }),
440
+ ...(textOrNull(row.prefix) === undefined ? {} : { prefix: textOrNull(row.prefix) }),
441
+ key: String(row.key),
442
+ // The satellite's user reference arrives as referenceId in
443
+ // payload-auth layouts; BA 1.7's own schema names it the
444
+ // same, so the target field is offered both names.
445
+ userId: String(row.reference_id),
446
+ referenceId: String(row.reference_id),
447
+ ...(numberOrNull(row.refill_interval) === undefined
448
+ ? {}
449
+ : { refillInterval: numberOrNull(row.refill_interval) }),
450
+ ...(numberOrNull(row.refill_amount) === undefined
451
+ ? {}
452
+ : { refillAmount: numberOrNull(row.refill_amount) }),
453
+ ...(row.last_refill_at instanceof Date
454
+ ? { lastRefillAt: row.last_refill_at.toISOString() }
455
+ : {}),
456
+ ...(row.enabled === null ? {} : { enabled: row.enabled === true }),
457
+ ...(row.rate_limit_enabled === null
458
+ ? {}
459
+ : { rateLimitEnabled: row.rate_limit_enabled === true }),
460
+ ...(numberOrNull(row.rate_limit_time_window) === undefined
461
+ ? {}
462
+ : { rateLimitTimeWindow: numberOrNull(row.rate_limit_time_window) }),
463
+ ...(numberOrNull(row.rate_limit_max) === undefined
464
+ ? {}
465
+ : { rateLimitMax: numberOrNull(row.rate_limit_max) }),
466
+ ...(numberOrNull(row.request_count) === undefined
467
+ ? {}
468
+ : { requestCount: numberOrNull(row.request_count) }),
469
+ ...(numberOrNull(row.remaining) === undefined
470
+ ? {}
471
+ : { remaining: numberOrNull(row.remaining) }),
472
+ ...(row.last_request instanceof Date
473
+ ? { lastRequest: row.last_request.toISOString() }
474
+ : {}),
475
+ ...(row.expires_at instanceof Date
476
+ ? { expiresAt: row.expires_at.toISOString() }
477
+ : {}),
478
+ ...(textOrNull(row.permissions) === undefined
479
+ ? {}
480
+ : { permissions: textOrNull(row.permissions) }),
481
+ ...(textOrNull(row.metadata) === undefined
482
+ ? {}
483
+ : { metadata: textOrNull(row.metadata) }),
484
+ },
485
+ created,
486
+ `apikey:${String(row.id)}`
487
+ )
488
+ : false
489
+ if (apply) {
490
+ if (isNew) {
491
+ createdCount += 1
492
+ } else {
493
+ skippedCount += 1
494
+ }
495
+ }
496
+ record({
497
+ model: 'apikey',
498
+ sourceTable: 'api_keys',
499
+ targetCollection: slugs.apikey,
500
+ rows: 1,
501
+ skipped: isNew ? 0 : apply ? 1 : 0,
502
+ unmappable: [],
503
+ })
504
+ }
505
+
506
+ // --- 6. jwks --------------------------------------------------------------
507
+ const jwks = await readFlatRows(client, 'jwks', ['id', 'public_key', 'private_key'])
508
+ for (const row of jwks) {
509
+ totals.jwks = (totals.jwks ?? 0) + 1
510
+ const baId = `jwks-${String(row.id)}`
511
+ const isNew = apply
512
+ ? await createDeduped(
513
+ payload,
514
+ slugs.jwks,
515
+ baId,
516
+ {
517
+ publicKey: String(row.public_key),
518
+ privateKey: String(row.private_key),
519
+ },
520
+ created,
521
+ `jwks:${String(row.id)}`
522
+ )
523
+ : false
524
+ if (apply) {
525
+ if (isNew) {
526
+ createdCount += 1
527
+ } else {
528
+ skippedCount += 1
529
+ }
530
+ }
531
+ record({
532
+ model: 'jwks',
533
+ sourceTable: 'jwks',
534
+ targetCollection: slugs.jwks,
535
+ rows: 1,
536
+ skipped: isNew ? 0 : apply ? 1 : 0,
537
+ unmappable: [],
538
+ })
539
+ }
540
+
541
+ // --- 7. organization plugin rows ---------------------------------------
542
+ const organizations = await readFlatRows(client, 'organizations', [
543
+ 'id',
544
+ 'name',
545
+ 'slug',
546
+ 'logo',
547
+ 'metadata',
548
+ ])
549
+ for (const row of organizations) {
550
+ totals.organization = (totals.organization ?? 0) + 1
551
+ const baId = `org-${String(row.id)}`
552
+ const isNew = apply
553
+ ? await createDeduped(
554
+ payload,
555
+ slugs.organization,
556
+ baId,
557
+ {
558
+ name: String(row.name),
559
+ slug: String(row.slug),
560
+ ...(textOrNull(row.logo) === undefined ? {} : { logo: textOrNull(row.logo) }),
561
+ ...(textOrNull(row.metadata) === undefined
562
+ ? {}
563
+ : { metadata: textOrNull(row.metadata) }),
564
+ },
565
+ created,
566
+ `organization:${String(row.id)}`
567
+ )
568
+ : false
569
+ if (apply) {
570
+ if (isNew) {
571
+ createdCount += 1
572
+ } else {
573
+ skippedCount += 1
574
+ }
575
+ }
576
+ record({
577
+ model: 'organization',
578
+ sourceTable: 'organizations',
579
+ targetCollection: slugs.organization,
580
+ rows: 1,
581
+ skipped: isNew ? 0 : apply ? 1 : 0,
582
+ unmappable: [],
583
+ })
584
+ }
585
+
586
+ const members = await readFlatRows(client, 'members', [
587
+ 'id',
588
+ 'organization_id',
589
+ 'user_id',
590
+ 'role',
591
+ ])
592
+ for (const row of members) {
593
+ totals.member = (totals.member ?? 0) + 1
594
+ const baId = `member-${String(row.id)}`
595
+ const isNew = apply
596
+ ? await createDeduped(
597
+ payload,
598
+ slugs.member,
599
+ baId,
600
+ {
601
+ organizationId: `org-${String(row.organization_id)}`,
602
+ userId: String(row.user_id),
603
+ ...(textOrNull(row.role) === undefined ? {} : { role: textOrNull(row.role) }),
604
+ },
605
+ created,
606
+ `member:${String(row.id)}`
607
+ )
608
+ : false
609
+ if (apply) {
610
+ if (isNew) {
611
+ createdCount += 1
612
+ } else {
613
+ skippedCount += 1
614
+ }
615
+ }
616
+ record({
617
+ model: 'member',
618
+ sourceTable: 'members',
619
+ targetCollection: slugs.member,
620
+ rows: 1,
621
+ skipped: isNew ? 0 : apply ? 1 : 0,
622
+ unmappable: [],
623
+ })
624
+ }
625
+
626
+ const invitations = await readFlatRows(client, 'invitations', [
627
+ 'id',
628
+ 'organization_id',
629
+ 'email',
630
+ 'role',
631
+ 'status',
632
+ 'expires_at',
633
+ 'inviter_id',
634
+ ])
635
+ for (const row of invitations) {
636
+ totals.invitation = (totals.invitation ?? 0) + 1
637
+ const baId = `inv-${String(row.id)}`
638
+ const isNew = apply
639
+ ? await createDeduped(
640
+ payload,
641
+ slugs.invitation,
642
+ baId,
643
+ {
644
+ organizationId: `org-${String(row.organization_id)}`,
645
+ email: String(row.email),
646
+ ...(textOrNull(row.role) === undefined ? {} : { role: textOrNull(row.role) }),
647
+ ...(textOrNull(row.status) === undefined ? {} : { status: textOrNull(row.status) }),
648
+ ...(row.expires_at instanceof Date
649
+ ? { expiresAt: row.expires_at.toISOString() }
650
+ : {}),
651
+ ...(typeof row.inviter_id === 'number' ? { inviterId: String(row.inviter_id) } : {}),
652
+ },
653
+ created,
654
+ `invitation:${String(row.id)}`
655
+ )
656
+ : false
657
+ if (apply) {
658
+ if (isNew) {
659
+ createdCount += 1
660
+ } else {
661
+ skippedCount += 1
662
+ }
663
+ }
664
+ record({
665
+ model: 'invitation',
666
+ sourceTable: 'invitations',
667
+ targetCollection: slugs.invitation,
668
+ rows: 1,
669
+ skipped: isNew ? 0 : apply ? 1 : 0,
670
+ unmappable: [],
671
+ })
672
+ }
673
+
674
+ log(
675
+ `plan: ${planRows.length} row group(s), ${createdCount} created, ${skippedCount} skipped, ${fieldWriteCount} field writes`
676
+ )
677
+ if (apply) {
678
+ // The manifest is the run's contract: merge this pass onto any
679
+ // resumed run's rows and write it back (issue #50).
680
+ const manifest: ImportManifest = {
681
+ version: 1,
682
+ createdAt: options.manifest?.createdAt ?? now().toISOString(),
683
+ sourceDatabase: options.sourceDatabase,
684
+ options: { sessions },
685
+ created,
686
+ fieldWrites,
687
+ finalized: options.manifest?.finalized ?? false,
688
+ sourceCounts: options.sourceCounts,
689
+ totals: {
690
+ ...options.sourceCounts,
691
+ ...(options.manifest?.totals ?? {}),
692
+ ...totals,
693
+ },
694
+ }
695
+ const { writeManifest } = await import('./lifecycle.js')
696
+ writeManifest(manifest, options.manifestPath)
697
+ }
698
+ const plan: ImportPlan = {
699
+ rows: planRows,
700
+ unmappable,
701
+ sessions,
702
+ warnings: [],
703
+ }
704
+ return {
705
+ created: createdCount,
706
+ skipped: skippedCount,
707
+ fieldWrites: fieldWriteCount,
708
+ plan,
709
+ }
710
+ } finally {
711
+ await client.end()
712
+ }
713
+ }
714
+
715
+ /** Collapse per-row plan entries into one line per model (dry-run print). */
716
+ export function summarizePlanRows(rows: readonly PlanRow[]): PlanRow[] {
717
+ const byModel = new Map<string, PlanRow>()
718
+ for (const row of rows) {
719
+ const existing = byModel.get(row.model)
720
+ if (existing === undefined) {
721
+ byModel.set(row.model, { ...row })
722
+ continue
723
+ }
724
+ byModel.set(row.model, {
725
+ ...existing,
726
+ rows: existing.rows + row.rows,
727
+ skipped: existing.skipped + row.skipped,
728
+ })
729
+ }
730
+ return [...byModel.values()]
731
+ }