@payload-auth-link/migrate 0.3.1 → 0.4.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 (69) hide show
  1. package/README.md +6 -3
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +49 -6
  4. package/dist/cli.js.map +1 -1
  5. package/dist/codemods.d.ts +17 -8
  6. package/dist/codemods.d.ts.map +1 -1
  7. package/dist/codemods.js +439 -20
  8. package/dist/codemods.js.map +1 -1
  9. package/dist/doctor/database.d.ts +5 -1
  10. package/dist/doctor/database.d.ts.map +1 -1
  11. package/dist/doctor/database.js +98 -34
  12. package/dist/doctor/database.js.map +1 -1
  13. package/dist/doctor/plugins.d.ts +15 -0
  14. package/dist/doctor/plugins.d.ts.map +1 -1
  15. package/dist/doctor/plugins.js +43 -0
  16. package/dist/doctor/plugins.js.map +1 -1
  17. package/dist/doctor/run-doctor.d.ts +11 -0
  18. package/dist/doctor/run-doctor.d.ts.map +1 -1
  19. package/dist/doctor/run-doctor.js +66 -20
  20. package/dist/doctor/run-doctor.js.map +1 -1
  21. package/dist/doctor/source-scan.d.ts +5 -0
  22. package/dist/doctor/source-scan.d.ts.map +1 -1
  23. package/dist/doctor/source-scan.js +9 -0
  24. package/dist/doctor/source-scan.js.map +1 -1
  25. package/dist/doctor/types.d.ts +12 -0
  26. package/dist/doctor/types.d.ts.map +1 -1
  27. package/dist/exporters.d.ts +7 -0
  28. package/dist/exporters.d.ts.map +1 -1
  29. package/dist/exporters.js +36 -1
  30. package/dist/exporters.js.map +1 -1
  31. package/dist/importer/apply.d.ts +5 -0
  32. package/dist/importer/apply.d.ts.map +1 -1
  33. package/dist/importer/apply.js +61 -33
  34. package/dist/importer/apply.js.map +1 -1
  35. package/dist/importer/lifecycle.d.ts +15 -6
  36. package/dist/importer/lifecycle.d.ts.map +1 -1
  37. package/dist/importer/lifecycle.js +131 -39
  38. package/dist/importer/lifecycle.js.map +1 -1
  39. package/dist/importer/run-import.d.ts +6 -0
  40. package/dist/importer/run-import.d.ts.map +1 -1
  41. package/dist/importer/run-import.js +72 -20
  42. package/dist/importer/run-import.js.map +1 -1
  43. package/dist/importer/source.d.ts +47 -4
  44. package/dist/importer/source.d.ts.map +1 -1
  45. package/dist/importer/source.js +159 -27
  46. package/dist/importer/source.js.map +1 -1
  47. package/dist/importer/types.d.ts +33 -0
  48. package/dist/importer/types.d.ts.map +1 -1
  49. package/dist/importer/types.js +48 -0
  50. package/dist/importer/types.js.map +1 -1
  51. package/dist/index.d.ts +5 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +5 -3
  54. package/dist/index.js.map +1 -1
  55. package/package.json +8 -11
  56. package/src/cli.ts +52 -6
  57. package/src/codemods.ts +537 -35
  58. package/src/doctor/database.ts +129 -46
  59. package/src/doctor/plugins.ts +51 -0
  60. package/src/doctor/run-doctor.ts +90 -25
  61. package/src/doctor/source-scan.ts +10 -0
  62. package/src/doctor/types.ts +9 -0
  63. package/src/exporters.ts +46 -1
  64. package/src/importer/apply.ts +78 -32
  65. package/src/importer/lifecycle.ts +163 -43
  66. package/src/importer/run-import.ts +102 -19
  67. package/src/importer/source.ts +214 -30
  68. package/src/importer/types.ts +86 -0
  69. package/src/index.ts +25 -2
@@ -1,8 +1,16 @@
1
1
  import type { Payload } from 'payload'
2
- import { readFlatRows, openSource, readSourceUsers, type SourceUser } from './source.js'
2
+ import {
3
+ readFlatRows,
4
+ openSource,
5
+ readSourceUsers,
6
+ type SourceRow,
7
+ type SourceUser,
8
+ } from './source.js'
3
9
  import {
4
10
  defaultTargetSlugs,
11
+ resolveSourceTables,
5
12
  type CreatedRow,
13
+ type ResolvedSourceTables,
6
14
  type TargetSlugMap,
7
15
  type FieldWrite,
8
16
  type ImportManifest,
@@ -41,6 +49,11 @@ export type ApplyOptions = {
41
49
  readonly sourceDatabase: string
42
50
  /** Target collection slugs; defaults must match the engine wiring. */
43
51
  readonly slugs?: Readonly<Record<string, string>> | undefined
52
+ /**
53
+ * Source-side model → table map (issue #68). Defaults to
54
+ * payload-auth's layout; a `null` model is treated as not installed.
55
+ */
56
+ readonly sourceTables?: Readonly<Record<string, string | null>> | undefined
44
57
  /** Injectable clock for deterministic `linkedAt` stamps. */
45
58
  readonly now?: () => Date
46
59
  readonly log?: (message: string) => void
@@ -63,6 +76,20 @@ function numberOrNull(value: unknown): number | undefined {
63
76
  return Number.isFinite(numeric) ? numeric : undefined
64
77
  }
65
78
 
79
+ /**
80
+ * Rows for one mapped model. A `null` mapping records the model as
81
+ * not installed (issue #68) — no read, no rows; anything else goes
82
+ * through the strict `readFlatRows` (a missing mapped table throws;
83
+ * the probe aborts the run first).
84
+ */
85
+ async function readMapped(
86
+ client: Awaited<ReturnType<typeof openSource>>,
87
+ table: string | null,
88
+ columns: readonly string[]
89
+ ): Promise<SourceRow[]> {
90
+ return table === null ? [] : readFlatRows(client, table, columns)
91
+ }
92
+
66
93
  /** Find an existing target row by `baId` — the resumable dedupe key. */
67
94
  async function findByBaId(payload: Payload, collection: string, baId: string): Promise<boolean> {
68
95
  const found = await payload.find({
@@ -120,26 +147,36 @@ async function captureBefore(
120
147
  return JSON.stringify((doc as Record<string, unknown>)[field] ?? null)
121
148
  }
122
149
 
123
- /** Identity rows pre-populated on the host user (verified emails only). */
150
+ /**
151
+ * The identity rows pre-populated on the host user (issue #69): ONE
152
+ * `better-auth` identity per imported user, `providerUserId` set to
153
+ * the ba-users row this import created for them. The engine-created
154
+ * models key their rows by `baId` and serve Better Auth's own
155
+ * `user.id` from that column, so the value here — the deterministic
156
+ * `baId` derived from the source user id — is exactly what
157
+ * `findByProviderIdentity` queries back
158
+ * (`{ providerId: 'better-auth', providerUserId: <BA user id> }`).
159
+ *
160
+ * Every imported user gets the identity — verified or not,
161
+ * credential-only included: the import itself proves the
162
+ * BA-user↔document binding, so identity resolution never depends on
163
+ * the (verified-only) `linkable` proof, which keeps its current
164
+ * granting below. An unverified user who verifies their email later
165
+ * resolves to their ORIGINAL document on the next sign-in instead of
166
+ * being provisioned a fresh one.
167
+ */
124
168
  function identityRows(
125
169
  user: SourceUser,
126
170
  now: () => Date
127
171
  ): { 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,
172
+ return [
173
+ {
174
+ providerId: 'better-auth',
175
+ providerUserId: String(user.id),
138
176
  email: user.email,
139
177
  linkedAt: now().toISOString(),
140
- })
141
- }
142
- return rows
178
+ },
179
+ ]
143
180
  }
144
181
 
145
182
  export async function applyImport(options: ApplyOptions): Promise<ImportResult> {
@@ -152,6 +189,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
152
189
  now = () => new Date(),
153
190
  } = options
154
191
  const slugs = slugsOf(options.slugs)
192
+ const sourceTables: ResolvedSourceTables = resolveSourceTables(options.sourceTables)
155
193
  const created: CreatedRow[] = [...(options.manifest?.created ?? [])]
156
194
  const fieldWrites: FieldWrite[] = [...(options.manifest?.fieldWrites ?? [])]
157
195
  const totals: Record<string, number> = { ...(options.manifest?.totals ?? {}) }
@@ -167,7 +205,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
167
205
 
168
206
  const client = await openSource(sourceConnectionString)
169
207
  try {
170
- const users = await readSourceUsers(client)
208
+ const users = await readSourceUsers(client, sourceTables)
171
209
  const seenUsers = new Set<string>()
172
210
 
173
211
  // --- 1. ba-users + host field writes ---------------------------------
@@ -240,7 +278,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
240
278
  }
241
279
  record({
242
280
  model: 'user',
243
- sourceTable: 'users',
281
+ sourceTable: sourceTables['users'] ?? 'users',
244
282
  targetCollection: slugs.user,
245
283
  rows: 1,
246
284
  skipped: isNew ? 0 : apply ? 1 : 0,
@@ -249,6 +287,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
249
287
  }
250
288
 
251
289
  // --- 2. accounts (hashes verbatim) ------------------------------------
290
+ const accountsTable = sourceTables['accounts']
252
291
  for (const user of users) {
253
292
  for (const account of user.accounts) {
254
293
  totals.account = (totals.account ?? 0) + 1
@@ -287,7 +326,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
287
326
  }
288
327
  record({
289
328
  model: 'account',
290
- sourceTable: 'accounts',
329
+ sourceTable: accountsTable ?? 'accounts',
291
330
  targetCollection: slugs.account,
292
331
  rows: 1,
293
332
  skipped: isNew ? 0 : apply ? 1 : 0,
@@ -297,8 +336,9 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
297
336
  }
298
337
 
299
338
  // --- 3. sessions (carry keeps every token) -----------------------------
339
+ const sessionsTable = sourceTables['sessions']
300
340
  if (sessions === 'carry') {
301
- const rows = await readFlatRows(client, 'sessions', [
341
+ const rows = await readMapped(client, sessionsTable, [
302
342
  'id',
303
343
  'user_id',
304
344
  'token',
@@ -349,7 +389,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
349
389
  }
350
390
  record({
351
391
  model: 'session',
352
- sourceTable: 'sessions',
392
+ sourceTable: sessionsTable ?? 'sessions',
353
393
  targetCollection: slugs.session,
354
394
  rows: 1,
355
395
  skipped: isNew ? 0 : apply ? 1 : 0,
@@ -359,7 +399,8 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
359
399
  }
360
400
 
361
401
  // --- 4. verifications ---------------------------------------------------
362
- const verifications = await readFlatRows(client, 'verifications', [
402
+ const verificationsTable = sourceTables['verifications']
403
+ const verifications = await readMapped(client, verificationsTable, [
363
404
  'id',
364
405
  'identifier',
365
406
  'value',
@@ -392,7 +433,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
392
433
  }
393
434
  record({
394
435
  model: 'verification',
395
- sourceTable: 'verifications',
436
+ sourceTable: verificationsTable ?? 'verifications',
396
437
  targetCollection: slugs.verification,
397
438
  rows: 1,
398
439
  skipped: isNew ? 0 : apply ? 1 : 0,
@@ -401,7 +442,8 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
401
442
  }
402
443
 
403
444
  // --- 5. api keys (hash, prefix, metadata, rate-limit state) --------------
404
- const apiKeys = await readFlatRows(client, 'api_keys', [
445
+ const apiKeysTable = sourceTables['api_keys']
446
+ const apiKeys = await readMapped(client, apiKeysTable, [
405
447
  'id',
406
448
  'config_id',
407
449
  'name',
@@ -495,7 +537,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
495
537
  }
496
538
  record({
497
539
  model: 'apikey',
498
- sourceTable: 'api_keys',
540
+ sourceTable: apiKeysTable ?? 'api_keys',
499
541
  targetCollection: slugs.apikey,
500
542
  rows: 1,
501
543
  skipped: isNew ? 0 : apply ? 1 : 0,
@@ -504,7 +546,8 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
504
546
  }
505
547
 
506
548
  // --- 6. jwks --------------------------------------------------------------
507
- const jwks = await readFlatRows(client, 'jwks', ['id', 'public_key', 'private_key'])
549
+ const jwksTable = sourceTables['jwks']
550
+ const jwks = await readMapped(client, jwksTable, ['id', 'public_key', 'private_key'])
508
551
  for (const row of jwks) {
509
552
  totals.jwks = (totals.jwks ?? 0) + 1
510
553
  const baId = `jwks-${String(row.id)}`
@@ -530,7 +573,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
530
573
  }
531
574
  record({
532
575
  model: 'jwks',
533
- sourceTable: 'jwks',
576
+ sourceTable: jwksTable ?? 'jwks',
534
577
  targetCollection: slugs.jwks,
535
578
  rows: 1,
536
579
  skipped: isNew ? 0 : apply ? 1 : 0,
@@ -539,7 +582,8 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
539
582
  }
540
583
 
541
584
  // --- 7. organization plugin rows ---------------------------------------
542
- const organizations = await readFlatRows(client, 'organizations', [
585
+ const organizationsTable = sourceTables['organizations']
586
+ const organizations = await readMapped(client, organizationsTable, [
543
587
  'id',
544
588
  'name',
545
589
  'slug',
@@ -575,7 +619,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
575
619
  }
576
620
  record({
577
621
  model: 'organization',
578
- sourceTable: 'organizations',
622
+ sourceTable: organizationsTable ?? 'organizations',
579
623
  targetCollection: slugs.organization,
580
624
  rows: 1,
581
625
  skipped: isNew ? 0 : apply ? 1 : 0,
@@ -583,7 +627,8 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
583
627
  })
584
628
  }
585
629
 
586
- const members = await readFlatRows(client, 'members', [
630
+ const membersTable = sourceTables['members']
631
+ const members = await readMapped(client, membersTable, [
587
632
  'id',
588
633
  'organization_id',
589
634
  'user_id',
@@ -615,7 +660,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
615
660
  }
616
661
  record({
617
662
  model: 'member',
618
- sourceTable: 'members',
663
+ sourceTable: membersTable ?? 'members',
619
664
  targetCollection: slugs.member,
620
665
  rows: 1,
621
666
  skipped: isNew ? 0 : apply ? 1 : 0,
@@ -623,7 +668,8 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
623
668
  })
624
669
  }
625
670
 
626
- const invitations = await readFlatRows(client, 'invitations', [
671
+ const invitationsTable = sourceTables['invitations']
672
+ const invitations = await readMapped(client, invitationsTable, [
627
673
  'id',
628
674
  'organization_id',
629
675
  'email',
@@ -663,7 +709,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
663
709
  }
664
710
  record({
665
711
  model: 'invitation',
666
- sourceTable: 'invitations',
712
+ sourceTable: invitationsTable ?? 'invitations',
667
713
  targetCollection: slugs.invitation,
668
714
  rows: 1,
669
715
  skipped: isNew ? 0 : apply ? 1 : 0,
@@ -1,6 +1,12 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
2
  import type { Payload } from 'payload'
3
- import type { ImportManifest } from './types.js'
3
+ import {
4
+ defaultTargetSlugs,
5
+ resolveSourceTables,
6
+ sourceModelNames,
7
+ type ResolvedSourceTables,
8
+ type ImportManifest,
9
+ } from './types.js'
4
10
 
5
11
  /**
6
12
  * Manifest lifecycle (issue #50): `verify` checks both sides, `rollback`
@@ -42,30 +48,33 @@ export type VerifyResult = {
42
48
  }
43
49
 
44
50
  /**
45
- * Verify (issue #50): per-model counts match, every source user has
46
- * exactly one target row, every credential account resolves to its
47
- * user, every API key carries its hash with a resolvable owner, and
48
- * every member/invitation references an existing org and user. Exits
49
- * non-zero on any mismatch (the caller maps `ok` to the exit code).
51
+ * Verify (issues #50, #68, #69): fail closed on the source layout
52
+ * (the probe runs first), per-model counts match EXACTLY (zero never
53
+ * skips; every mapped model is either counted or explicitly
54
+ * null-mapped), every source user has exactly one target row, every
55
+ * imported user resolves through exactly one `better-auth` identity
56
+ * pointing at their ba-users id, every credential account resolves to
57
+ * its user, every API key carries its hash with a resolvable owner,
58
+ * and every member/invitation references an existing org and user.
59
+ * Exits non-zero on any mismatch (the caller maps `ok` to the exit
60
+ * code).
50
61
  */
51
62
  export async function verifyImport(options: {
52
63
  readonly payload: Payload
53
64
  readonly sourceConnectionString: string
54
65
  readonly slugs?: Readonly<Record<string, string>> | undefined
66
+ /** Model → source table map (issue #68); defaults to the default layout. */
67
+ readonly sourceTables?: Readonly<Record<string, string | null>> | undefined
68
+ /** Whether the import carried sessions; `drop` skips the session count. */
69
+ readonly sessions?: 'carry' | 'drop' | undefined
55
70
  }): Promise<VerifyResult> {
56
71
  const { payload, sourceConnectionString } = options
57
72
  const slugs: Record<string, string> = {
58
- ...{
59
- user: 'ba-users',
60
- account: 'ba-accounts',
61
- session: 'ba-sessions',
62
- apikey: 'ba-apikey',
63
- organization: 'ba-organization',
64
- member: 'ba-member',
65
- invitation: 'ba-invitation',
66
- },
73
+ ...defaultTargetSlugs,
67
74
  ...(options.slugs ?? {}),
68
75
  }
76
+ const sourceTables: ResolvedSourceTables = resolveSourceTables(options.sourceTables)
77
+ const sessions = options.sessions ?? 'carry'
69
78
  const issues: VerifyIssue[] = []
70
79
  const checks: { name: string; passed: boolean }[] = []
71
80
  const check = (name: string, passed: boolean, detail?: string): void => {
@@ -81,42 +90,80 @@ export async function verifyImport(options: {
81
90
  }
82
91
 
83
92
  // Source side, read directly.
84
- const { openSource, readSourceUsers, countTables } = await import('./source.js')
93
+ const { openSource, probeSourceLayout, readSourceUsers, countTables } =
94
+ await import('./source.js')
85
95
  const client = await openSource(sourceConnectionString)
86
96
  try {
87
- const sourceCounts = await countTables(client, [
88
- 'users',
89
- 'accounts',
90
- 'sessions',
91
- 'verifications',
92
- 'api_keys',
93
- 'jwks',
94
- 'organizations',
95
- 'members',
96
- 'invitations',
97
- ])
98
- const users = await readSourceUsers(client)
97
+ // Fail closed (issue #68): a missing mapped table or column aborts
98
+ // the check with an actionable report instead of reading zeros.
99
+ const probe = await probeSourceLayout(client, sourceTables)
100
+ if (probe.length > 0) {
101
+ for (const issue of probe) {
102
+ issues.push({ code: 'source-table-missing', detail: issue.reason })
103
+ }
104
+ return { ok: false, issues, checks }
105
+ }
106
+ const sourceCounts = await countTables(
107
+ client,
108
+ sourceModelNames.map((model) => sourceTables[model]).filter((t): t is string => t !== null)
109
+ )
110
+ const users = await readSourceUsers(client, sourceTables)
99
111
 
100
- // 1. per-model counts match (sessions only when carried).
101
- const countPairs: readonly [string, string, number][] = [
102
- ['users', String(slugs['user']), sourceCounts['users'] ?? 0],
103
- ['accounts', String(slugs['account']), sourceCounts['accounts'] ?? 0],
104
- ['api keys', String(slugs['apikey']), sourceCounts['api_keys'] ?? 0],
105
- ['organizations', String(slugs['organization']), sourceCounts['organizations'] ?? 0],
106
- ['members', String(slugs['member']), sourceCounts['members'] ?? 0],
107
- ['invitations', String(slugs['invitation']), sourceCounts['invitations'] ?? 0],
112
+ // 1. per-model counts match EXACTLY (issue #68) — zero never skips.
113
+ // A null-mapped model is recorded as skipped-by-config, visibly.
114
+ const countPairs: readonly {
115
+ readonly label: string
116
+ readonly table: string | null
117
+ readonly collection: string
118
+ }[] = [
119
+ { label: 'users', table: sourceTables['users'], collection: String(slugs['user']) },
120
+ { label: 'accounts', table: sourceTables['accounts'], collection: String(slugs['account']) },
121
+ {
122
+ label: 'sessions',
123
+ table: sessions === 'carry' ? sourceTables['sessions'] : null,
124
+ collection: String(slugs['session']),
125
+ },
126
+ {
127
+ label: 'verifications',
128
+ table: sourceTables['verifications'],
129
+ collection: String(slugs['verification']),
130
+ },
131
+ { label: 'api keys', table: sourceTables['api_keys'], collection: String(slugs['apikey']) },
132
+ { label: 'jwks', table: sourceTables['jwks'], collection: String(slugs['jwks']) },
133
+ {
134
+ label: 'organizations',
135
+ table: sourceTables['organizations'],
136
+ collection: String(slugs['organization']),
137
+ },
138
+ { label: 'members', table: sourceTables['members'], collection: String(slugs['member']) },
139
+ {
140
+ label: 'invitations',
141
+ table: sourceTables['invitations'],
142
+ collection: String(slugs['invitation']),
143
+ },
108
144
  ]
109
- for (const [label, collection, expected] of countPairs) {
110
- if (expected === 0) {
145
+ for (const pair of countPairs) {
146
+ if (pair.table === null) {
147
+ checks.push({
148
+ name: `count ${pair.label} (skipped: null-mapped — not installed on the source)`,
149
+ passed: true,
150
+ })
111
151
  continue
112
152
  }
113
- const actual = await targetCounts(collection)
153
+ const expected = sourceCounts[pair.table] ?? 0
154
+ const actual = await targetCounts(pair.collection)
114
155
  check(
115
- `count ${label}`,
116
- actual >= expected,
117
- `expected >= ${expected} ${label} in ${collection}, found ${actual}`
156
+ `count ${pair.label}`,
157
+ actual === expected,
158
+ `expected exactly ${expected} ${pair.label} in ${pair.collection}, found ${actual}`
118
159
  )
119
160
  }
161
+ if (sessions === 'drop') {
162
+ checks.push({
163
+ name: 'count sessions (skipped: the import dropped them)',
164
+ passed: true,
165
+ })
166
+ }
120
167
 
121
168
  // 2. every source user has exactly one target row and its host doc.
122
169
  let userMatches = 0
@@ -143,6 +190,77 @@ export async function verifyImport(options: {
143
190
  )
144
191
  void userMatches
145
192
 
193
+ // 2b. every imported user resolves through EXACTLY ONE better-auth
194
+ // identity pointing at their ba-users id (issue #69) — that is the
195
+ // lookup findByProviderIdentity issues on the next provider
196
+ // sign-in. The host docs also carry the stored roles (issue #68's
197
+ // users_role accounting) whenever the host declares the field.
198
+ let identityIssues = 0
199
+ let roleMismatchDocs = 0
200
+ let hostDocsWithRoles = 0
201
+ for (const user of users) {
202
+ const hostId = Number(user.id)
203
+ if (!Number.isSafeInteger(hostId)) {
204
+ identityIssues += 1
205
+ continue
206
+ }
207
+ let doc: unknown
208
+ try {
209
+ doc = await payload.findByID({
210
+ collection: 'users',
211
+ id: hostId,
212
+ depth: 0,
213
+ overrideAccess: true,
214
+ })
215
+ } catch {
216
+ identityIssues += 1
217
+ continue
218
+ }
219
+ if (typeof doc !== 'object' || doc === null) {
220
+ identityIssues += 1
221
+ continue
222
+ }
223
+ const record = doc as Record<string, unknown>
224
+ const identities = Array.isArray(record['identities'])
225
+ ? (record['identities'] as readonly unknown[])
226
+ : []
227
+ const resolving = identities.filter(
228
+ (entry) =>
229
+ typeof entry === 'object' &&
230
+ entry !== null &&
231
+ (entry as Record<string, unknown>)['providerId'] === 'better-auth' &&
232
+ String((entry as Record<string, unknown>)['providerUserId']) === String(user.id)
233
+ )
234
+ if (identities.length !== 1 || resolving.length !== 1) {
235
+ identityIssues += 1
236
+ }
237
+ const roles = record['roles']
238
+ if (Array.isArray(roles)) {
239
+ hostDocsWithRoles += 1
240
+ const stored = new Set(roles.map((role) => String(role)))
241
+ if (!user.roles.every((role) => stored.has(role))) {
242
+ roleMismatchDocs += 1
243
+ }
244
+ }
245
+ }
246
+ check(
247
+ 'every imported user resolves through one better-auth identity',
248
+ identityIssues === 0,
249
+ `${identityIssues} user(s) without exactly one better-auth identity resolving to their ba-users id`
250
+ )
251
+ if (hostDocsWithRoles > 0) {
252
+ check(
253
+ 'source roles carried onto the host docs',
254
+ roleMismatchDocs === 0,
255
+ `${roleMismatchDocs} host doc(s) missing stored role values`
256
+ )
257
+ } else if (sourceTables['users_role'] !== null && users.some((user) => user.roles.length > 0)) {
258
+ checks.push({
259
+ name: 'source roles carried onto the host docs (skipped: the host users carry no roles field)',
260
+ passed: true,
261
+ })
262
+ }
263
+
146
264
  // 3. every credential account resolves to its user.
147
265
  const accounts = await payload.find({
148
266
  collection: String(slugs['account']),
@@ -181,9 +299,11 @@ export async function verifyImport(options: {
181
299
  orphanAccounts === 0,
182
300
  `${orphanAccounts} account(s) with a missing user or empty credential hash`
183
301
  )
302
+ const sourceAccountsCount =
303
+ sourceTables['accounts'] === null ? 0 : (sourceCounts[sourceTables['accounts']] ?? 0)
184
304
  check(
185
305
  'credential accounts carry hashes',
186
- sourceCounts.accounts === 0 || credentialAccounts > 0,
306
+ sourceAccountsCount === 0 || credentialAccounts > 0,
187
307
  'no credential account carries a password hash'
188
308
  )
189
309