@payload-auth-link/migrate 0.3.1 → 0.4.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.
- package/README.md +6 -3
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +49 -6
- package/dist/cli.js.map +1 -1
- package/dist/codemods.d.ts +17 -8
- package/dist/codemods.d.ts.map +1 -1
- package/dist/codemods.js +439 -20
- package/dist/codemods.js.map +1 -1
- package/dist/doctor/database.d.ts +5 -1
- package/dist/doctor/database.d.ts.map +1 -1
- package/dist/doctor/database.js +98 -34
- package/dist/doctor/database.js.map +1 -1
- package/dist/doctor/plugins.d.ts +15 -0
- package/dist/doctor/plugins.d.ts.map +1 -1
- package/dist/doctor/plugins.js +43 -0
- package/dist/doctor/plugins.js.map +1 -1
- package/dist/doctor/run-doctor.d.ts +11 -0
- package/dist/doctor/run-doctor.d.ts.map +1 -1
- package/dist/doctor/run-doctor.js +66 -20
- package/dist/doctor/run-doctor.js.map +1 -1
- package/dist/doctor/source-scan.d.ts +5 -0
- package/dist/doctor/source-scan.d.ts.map +1 -1
- package/dist/doctor/source-scan.js +9 -0
- package/dist/doctor/source-scan.js.map +1 -1
- package/dist/doctor/types.d.ts +12 -0
- package/dist/doctor/types.d.ts.map +1 -1
- package/dist/exporters.d.ts +7 -0
- package/dist/exporters.d.ts.map +1 -1
- package/dist/exporters.js +36 -1
- package/dist/exporters.js.map +1 -1
- package/dist/importer/apply.d.ts +5 -0
- package/dist/importer/apply.d.ts.map +1 -1
- package/dist/importer/apply.js +61 -33
- package/dist/importer/apply.js.map +1 -1
- package/dist/importer/lifecycle.d.ts +15 -6
- package/dist/importer/lifecycle.d.ts.map +1 -1
- package/dist/importer/lifecycle.js +131 -39
- package/dist/importer/lifecycle.js.map +1 -1
- package/dist/importer/run-import.d.ts +6 -0
- package/dist/importer/run-import.d.ts.map +1 -1
- package/dist/importer/run-import.js +72 -20
- package/dist/importer/run-import.js.map +1 -1
- package/dist/importer/source.d.ts +47 -4
- package/dist/importer/source.d.ts.map +1 -1
- package/dist/importer/source.js +159 -27
- package/dist/importer/source.js.map +1 -1
- package/dist/importer/types.d.ts +33 -0
- package/dist/importer/types.d.ts.map +1 -1
- package/dist/importer/types.js +48 -0
- package/dist/importer/types.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/package.json +8 -11
- package/src/cli.ts +52 -6
- package/src/codemods.ts +537 -35
- package/src/doctor/database.ts +129 -46
- package/src/doctor/plugins.ts +51 -0
- package/src/doctor/run-doctor.ts +90 -25
- package/src/doctor/source-scan.ts +10 -0
- package/src/doctor/types.ts +9 -0
- package/src/exporters.ts +46 -1
- package/src/importer/apply.ts +78 -32
- package/src/importer/lifecycle.ts +163 -43
- package/src/importer/run-import.ts +102 -19
- package/src/importer/source.ts +214 -30
- package/src/importer/types.ts +86 -0
- package/src/index.ts +25 -2
package/src/importer/apply.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { Payload } from 'payload'
|
|
2
|
-
import {
|
|
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
|
-
/**
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
* every
|
|
49
|
-
*
|
|
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 } =
|
|
93
|
+
const { openSource, probeSourceLayout, readSourceUsers, countTables } =
|
|
94
|
+
await import('./source.js')
|
|
85
95
|
const client = await openSource(sourceConnectionString)
|
|
86
96
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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 (
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
110
|
-
if (
|
|
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
|
|
153
|
+
const expected = sourceCounts[pair.table] ?? 0
|
|
154
|
+
const actual = await targetCounts(pair.collection)
|
|
114
155
|
check(
|
|
115
|
-
`count ${label}`,
|
|
116
|
-
actual
|
|
117
|
-
`expected
|
|
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
|
-
|
|
306
|
+
sourceAccountsCount === 0 || credentialAccounts > 0,
|
|
187
307
|
'no credential account carries a password hash'
|
|
188
308
|
)
|
|
189
309
|
|