@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.
- package/README.md +49 -2
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +257 -0
- package/dist/cli.js.map +1 -0
- package/dist/doctor/bands.d.ts +16 -0
- package/dist/doctor/bands.d.ts.map +1 -0
- package/dist/doctor/bands.js +49 -0
- package/dist/doctor/bands.js.map +1 -0
- package/dist/doctor/database.d.ts +3 -0
- package/dist/doctor/database.d.ts.map +1 -0
- package/dist/doctor/database.js +110 -0
- package/dist/doctor/database.js.map +1 -0
- package/dist/doctor/plugins.d.ts +12 -0
- package/dist/doctor/plugins.d.ts.map +1 -0
- package/dist/doctor/plugins.js +202 -0
- package/dist/doctor/plugins.js.map +1 -0
- package/dist/doctor/run-doctor.d.ts +48 -0
- package/dist/doctor/run-doctor.d.ts.map +1 -0
- package/dist/doctor/run-doctor.js +236 -0
- package/dist/doctor/run-doctor.js.map +1 -0
- package/dist/doctor/source-scan.d.ts +10 -0
- package/dist/doctor/source-scan.d.ts.map +1 -0
- package/dist/doctor/source-scan.js +142 -0
- package/dist/doctor/source-scan.js.map +1 -0
- package/dist/doctor/types.d.ts +86 -0
- package/dist/doctor/types.d.ts.map +1 -0
- package/dist/doctor/types.js +7 -0
- package/dist/doctor/types.js.map +1 -0
- package/dist/doctor/users.d.ts +10 -0
- package/dist/doctor/users.d.ts.map +1 -0
- package/dist/doctor/users.js +176 -0
- package/dist/doctor/users.js.map +1 -0
- package/dist/importer/apply.d.ts +39 -0
- package/dist/importer/apply.d.ts.map +1 -0
- package/dist/importer/apply.js +588 -0
- package/dist/importer/apply.js.map +1 -0
- package/dist/importer/lifecycle.d.ts +52 -0
- package/dist/importer/lifecycle.d.ts.map +1 -0
- package/dist/importer/lifecycle.js +265 -0
- package/dist/importer/lifecycle.js.map +1 -0
- package/dist/importer/run-import.d.ts +40 -0
- package/dist/importer/run-import.d.ts.map +1 -0
- package/dist/importer/run-import.js +176 -0
- package/dist/importer/run-import.js.map +1 -0
- package/dist/importer/source.d.ts +49 -0
- package/dist/importer/source.d.ts.map +1 -0
- package/dist/importer/source.js +147 -0
- package/dist/importer/source.js.map +1 -0
- package/dist/importer/types.d.ts +69 -0
- package/dist/importer/types.d.ts.map +1 -0
- package/dist/importer/types.js +13 -0
- package/dist/importer/types.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -4
- package/src/cli.ts +287 -0
- package/src/doctor/bands.ts +66 -0
- package/src/doctor/database.ts +130 -0
- package/src/doctor/plugins.ts +216 -0
- package/src/doctor/run-doctor.ts +278 -0
- package/src/doctor/source-scan.ts +148 -0
- package/src/doctor/types.ts +92 -0
- package/src/doctor/users.ts +207 -0
- package/src/importer/apply.ts +731 -0
- package/src/importer/lifecycle.ts +328 -0
- package/src/importer/run-import.ts +237 -0
- package/src/importer/source.ts +211 -0
- package/src/importer/types.ts +96 -0
- package/src/index.ts +53 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import type { Payload } from 'payload'
|
|
3
|
+
import type { ImportManifest } from './types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Manifest lifecycle (issue #50): `verify` checks both sides, `rollback`
|
|
7
|
+
* undoes exactly what the manifest records, and `finalize` marks the
|
|
8
|
+
* manifest so the source tables may be dropped — by the operator, as a
|
|
9
|
+
* separate later step.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export const defaultManifestPath = 'payload-auth-link-migration.json'
|
|
13
|
+
|
|
14
|
+
export function readManifest(path: string): ImportManifest | null {
|
|
15
|
+
if (!existsSync(path)) {
|
|
16
|
+
return null
|
|
17
|
+
}
|
|
18
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'))
|
|
19
|
+
if (
|
|
20
|
+
typeof parsed !== 'object' ||
|
|
21
|
+
parsed === null ||
|
|
22
|
+
(parsed as { version?: unknown }).version !== 1
|
|
23
|
+
) {
|
|
24
|
+
throw new Error(`manifest at ${path} is not a version-1 import manifest`)
|
|
25
|
+
}
|
|
26
|
+
return parsed as ImportManifest
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function writeManifest(manifest: ImportManifest, path: string): void {
|
|
30
|
+
writeFileSync(path, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type VerifyIssue = {
|
|
34
|
+
readonly code: string
|
|
35
|
+
readonly detail: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type VerifyResult = {
|
|
39
|
+
readonly ok: boolean
|
|
40
|
+
readonly issues: readonly VerifyIssue[]
|
|
41
|
+
readonly checks: readonly { readonly name: string; readonly passed: boolean }[]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
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).
|
|
50
|
+
*/
|
|
51
|
+
export async function verifyImport(options: {
|
|
52
|
+
readonly payload: Payload
|
|
53
|
+
readonly sourceConnectionString: string
|
|
54
|
+
readonly slugs?: Readonly<Record<string, string>> | undefined
|
|
55
|
+
}): Promise<VerifyResult> {
|
|
56
|
+
const { payload, sourceConnectionString } = options
|
|
57
|
+
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
|
+
},
|
|
67
|
+
...(options.slugs ?? {}),
|
|
68
|
+
}
|
|
69
|
+
const issues: VerifyIssue[] = []
|
|
70
|
+
const checks: { name: string; passed: boolean }[] = []
|
|
71
|
+
const check = (name: string, passed: boolean, detail?: string): void => {
|
|
72
|
+
checks.push({ name, passed })
|
|
73
|
+
if (!passed && detail !== undefined) {
|
|
74
|
+
issues.push({ code: 'verify-mismatch', detail })
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const targetCounts = async (collection: string): Promise<number> => {
|
|
79
|
+
const counted = await payload.count({ collection, overrideAccess: true })
|
|
80
|
+
return counted.totalDocs
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Source side, read directly.
|
|
84
|
+
const { openSource, readSourceUsers, countTables } = await import('./source.js')
|
|
85
|
+
const client = await openSource(sourceConnectionString)
|
|
86
|
+
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)
|
|
99
|
+
|
|
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],
|
|
108
|
+
]
|
|
109
|
+
for (const [label, collection, expected] of countPairs) {
|
|
110
|
+
if (expected === 0) {
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
const actual = await targetCounts(collection)
|
|
114
|
+
check(
|
|
115
|
+
`count ${label}`,
|
|
116
|
+
actual >= expected,
|
|
117
|
+
`expected >= ${expected} ${label} in ${collection}, found ${actual}`
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// 2. every source user has exactly one target row and its host doc.
|
|
122
|
+
let userMatches = 0
|
|
123
|
+
let userIssues = 0
|
|
124
|
+
for (const user of users) {
|
|
125
|
+
const found = await payload.find({
|
|
126
|
+
collection: String(slugs['user']),
|
|
127
|
+
where: { baId: { equals: user.id } },
|
|
128
|
+
limit: 2,
|
|
129
|
+
depth: 0,
|
|
130
|
+
pagination: false,
|
|
131
|
+
overrideAccess: true,
|
|
132
|
+
})
|
|
133
|
+
if (found.totalDocs !== 1) {
|
|
134
|
+
userIssues += 1
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
userMatches += 1
|
|
138
|
+
}
|
|
139
|
+
check(
|
|
140
|
+
'each source user has exactly one target row',
|
|
141
|
+
userIssues === 0,
|
|
142
|
+
`${userIssues} user(s) without exactly one ${String(slugs['user'])} row`
|
|
143
|
+
)
|
|
144
|
+
void userMatches
|
|
145
|
+
|
|
146
|
+
// 3. every credential account resolves to its user.
|
|
147
|
+
const accounts = await payload.find({
|
|
148
|
+
collection: String(slugs['account']),
|
|
149
|
+
limit: 1000,
|
|
150
|
+
depth: 0,
|
|
151
|
+
pagination: false,
|
|
152
|
+
overrideAccess: true,
|
|
153
|
+
})
|
|
154
|
+
const baUserIds = new Set<string>()
|
|
155
|
+
const baUsers = await payload.find({
|
|
156
|
+
collection: String(slugs['user']),
|
|
157
|
+
limit: 1000,
|
|
158
|
+
depth: 0,
|
|
159
|
+
pagination: false,
|
|
160
|
+
overrideAccess: true,
|
|
161
|
+
})
|
|
162
|
+
for (const doc of baUsers.docs) {
|
|
163
|
+
baUserIds.add(String((doc as Record<string, unknown>).baId))
|
|
164
|
+
}
|
|
165
|
+
let orphanAccounts = 0
|
|
166
|
+
let credentialAccounts = 0
|
|
167
|
+
for (const doc of accounts.docs) {
|
|
168
|
+
const record = doc as Record<string, unknown>
|
|
169
|
+
if (!baUserIds.has(String(record.userId))) {
|
|
170
|
+
orphanAccounts += 1
|
|
171
|
+
}
|
|
172
|
+
if (record.providerId === 'credential') {
|
|
173
|
+
credentialAccounts += 1
|
|
174
|
+
if (typeof record.password !== 'string' || record.password === '') {
|
|
175
|
+
orphanAccounts += 1
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
check(
|
|
180
|
+
'every account resolves to its user (credential hashes intact)',
|
|
181
|
+
orphanAccounts === 0,
|
|
182
|
+
`${orphanAccounts} account(s) with a missing user or empty credential hash`
|
|
183
|
+
)
|
|
184
|
+
check(
|
|
185
|
+
'credential accounts carry hashes',
|
|
186
|
+
sourceCounts.accounts === 0 || credentialAccounts > 0,
|
|
187
|
+
'no credential account carries a password hash'
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
// 4. API keys: hash present, owner resolves, rate-limit state kept.
|
|
191
|
+
const keys = await payload.find({
|
|
192
|
+
collection: String(slugs['apikey']),
|
|
193
|
+
limit: 1000,
|
|
194
|
+
depth: 0,
|
|
195
|
+
pagination: false,
|
|
196
|
+
overrideAccess: true,
|
|
197
|
+
})
|
|
198
|
+
let brokenKeys = 0
|
|
199
|
+
for (const doc of keys.docs) {
|
|
200
|
+
const record = doc as Record<string, unknown>
|
|
201
|
+
if (typeof record.key !== 'string' || record.key === '') {
|
|
202
|
+
brokenKeys += 1
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
// The satellite's user reference is `referenceId` on BA 1.7's own
|
|
206
|
+
// schema and `userId` in 1.6-era layouts.
|
|
207
|
+
const owner = record.referenceId ?? record.userId
|
|
208
|
+
if (!baUserIds.has(String(owner))) {
|
|
209
|
+
brokenKeys += 1
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
check(
|
|
213
|
+
'every API key carries its hash and resolves to a user',
|
|
214
|
+
brokenKeys === 0,
|
|
215
|
+
`${brokenKeys} API key(s) without a hash or user`
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
// 5. members/invitations reference existing orgs and users.
|
|
219
|
+
const orgIds = new Set<string>()
|
|
220
|
+
const orgs = await payload.find({
|
|
221
|
+
collection: String(slugs['organization']),
|
|
222
|
+
limit: 1000,
|
|
223
|
+
depth: 0,
|
|
224
|
+
pagination: false,
|
|
225
|
+
overrideAccess: true,
|
|
226
|
+
})
|
|
227
|
+
for (const doc of orgs.docs) {
|
|
228
|
+
orgIds.add(String((doc as Record<string, unknown>).baId))
|
|
229
|
+
}
|
|
230
|
+
const members = await payload.find({
|
|
231
|
+
collection: String(slugs['member']),
|
|
232
|
+
limit: 1000,
|
|
233
|
+
depth: 0,
|
|
234
|
+
pagination: false,
|
|
235
|
+
overrideAccess: true,
|
|
236
|
+
})
|
|
237
|
+
let brokenMembers = 0
|
|
238
|
+
for (const doc of members.docs) {
|
|
239
|
+
const record = doc as Record<string, unknown>
|
|
240
|
+
if (!orgIds.has(String(record.organizationId)) || !baUserIds.has(String(record.userId))) {
|
|
241
|
+
brokenMembers += 1
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
check(
|
|
245
|
+
'every member references an existing org and user',
|
|
246
|
+
brokenMembers === 0,
|
|
247
|
+
`${brokenMembers} member(s) with dangling references`
|
|
248
|
+
)
|
|
249
|
+
const invitations = await payload.find({
|
|
250
|
+
collection: String(slugs['invitation']),
|
|
251
|
+
limit: 1000,
|
|
252
|
+
depth: 0,
|
|
253
|
+
pagination: false,
|
|
254
|
+
overrideAccess: true,
|
|
255
|
+
})
|
|
256
|
+
let brokenInvitations = 0
|
|
257
|
+
for (const doc of invitations.docs) {
|
|
258
|
+
const record = doc as Record<string, unknown>
|
|
259
|
+
if (!orgIds.has(String(record.organizationId))) {
|
|
260
|
+
brokenInvitations += 1
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
check(
|
|
264
|
+
'every invitation references an existing org',
|
|
265
|
+
brokenInvitations === 0,
|
|
266
|
+
`${brokenInvitations} invitation(s) with dangling references`
|
|
267
|
+
)
|
|
268
|
+
} finally {
|
|
269
|
+
await client.end()
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return { ok: issues.length === 0, issues, checks }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Rollback (issue #50): delete the created rows in reverse order and
|
|
277
|
+
* restore every captured field value. The manifest records exactly
|
|
278
|
+
* what to undo — nothing else is touched, and the source tables were
|
|
279
|
+
* never modified.
|
|
280
|
+
*/
|
|
281
|
+
export async function rollbackImport(options: {
|
|
282
|
+
readonly payload: Payload
|
|
283
|
+
readonly manifest: ImportManifest
|
|
284
|
+
readonly log?: (message: string) => void
|
|
285
|
+
}): Promise<{ deleted: number; restored: number }> {
|
|
286
|
+
const { payload, manifest, log = () => {} } = options
|
|
287
|
+
if (manifest.finalized) {
|
|
288
|
+
// The point of no return: after `finalize`, the manifest's rows are
|
|
289
|
+
// the migration's history, not a TODO to undo.
|
|
290
|
+
throw new Error('the migration is finalized; rollback is no longer possible')
|
|
291
|
+
}
|
|
292
|
+
let deleted = 0
|
|
293
|
+
for (const row of [...manifest.created].reverse()) {
|
|
294
|
+
try {
|
|
295
|
+
await payload.delete({ collection: row.collection, id: row.id, overrideAccess: true })
|
|
296
|
+
deleted += 1
|
|
297
|
+
} catch {
|
|
298
|
+
// Already gone (a crash after delete, a manual cleanup) — the
|
|
299
|
+
// goal state is "row absent", so absence is success.
|
|
300
|
+
log(`rollback: ${row.collection}#${row.id} already absent`)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
let restored = 0
|
|
304
|
+
for (const write of manifest.fieldWrites) {
|
|
305
|
+
const data: Record<string, unknown> = {
|
|
306
|
+
[write.field]: write.before === null ? null : (JSON.parse(write.before) as unknown),
|
|
307
|
+
}
|
|
308
|
+
await payload.update({
|
|
309
|
+
collection: write.collection,
|
|
310
|
+
id: write.id,
|
|
311
|
+
data,
|
|
312
|
+
overrideAccess: true,
|
|
313
|
+
})
|
|
314
|
+
restored += 1
|
|
315
|
+
}
|
|
316
|
+
log(`rollback: ${deleted} row(s) deleted, ${restored} field value(s) restored`)
|
|
317
|
+
return { deleted, restored }
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Finalize (issue #50): the point of no return — mark the manifest. */
|
|
321
|
+
export function finalizeManifest(manifest: ImportManifest, path: string): ImportManifest {
|
|
322
|
+
if (manifest.finalized) {
|
|
323
|
+
return manifest
|
|
324
|
+
}
|
|
325
|
+
const finalized: ImportManifest = { ...manifest, finalized: true }
|
|
326
|
+
writeManifest(finalized, path)
|
|
327
|
+
return finalized
|
|
328
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { isRecord } from '@payload-auth-link/core'
|
|
2
|
+
import { pathToFileURL } from 'node:url'
|
|
3
|
+
import { applyImport, summarizePlanRows } from './apply.js'
|
|
4
|
+
import {
|
|
5
|
+
defaultManifestPath,
|
|
6
|
+
finalizeManifest,
|
|
7
|
+
readManifest,
|
|
8
|
+
rollbackImport,
|
|
9
|
+
verifyImport,
|
|
10
|
+
type VerifyResult,
|
|
11
|
+
} from './lifecycle.js'
|
|
12
|
+
import { countTables, findOrphans, openSource, unverifiedEmails } from './source.js'
|
|
13
|
+
import {
|
|
14
|
+
defaultTargetSlugs,
|
|
15
|
+
type ImportManifest,
|
|
16
|
+
type ImportPlan,
|
|
17
|
+
type ImportResult,
|
|
18
|
+
type TargetSlugMap,
|
|
19
|
+
} from './types.js'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The import commands (issue #50): `from-payload-auth` (dry-run by
|
|
23
|
+
* default, `--apply` writes), `verify`, `rollback`, `finalize`.
|
|
24
|
+
*
|
|
25
|
+
* Dry run prints planned row counts per model, collisions, and
|
|
26
|
+
* anything unmappable; unmappable data aborts `--apply` before any
|
|
27
|
+
* write. The manifest records every created row and field change;
|
|
28
|
+
* source tables are never modified — `finalize` only marks the
|
|
29
|
+
* manifest, and dropping them stays the operator's later step.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
export type ImportCommandOptions = {
|
|
33
|
+
readonly configPath: string
|
|
34
|
+
readonly databaseUrl: string
|
|
35
|
+
readonly apply: boolean
|
|
36
|
+
readonly sessions: 'carry' | 'drop'
|
|
37
|
+
readonly manifestPath?: string | undefined
|
|
38
|
+
readonly slugsJson?: string | undefined
|
|
39
|
+
readonly json?: boolean
|
|
40
|
+
readonly log?: (message: string) => void
|
|
41
|
+
readonly loadPayload?: (configPath: string) => Promise<import('payload').Payload>
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const log_ = (options: ImportCommandOptions): ((message: string) => void) =>
|
|
45
|
+
options.log ?? (() => {})
|
|
46
|
+
|
|
47
|
+
async function loadPayloadInstance(configPath: string): Promise<import('payload').Payload> {
|
|
48
|
+
// Lazy: `doctor` never needs Payload, and a cold CLI should not pay
|
|
49
|
+
// for the module graph the import commands use.
|
|
50
|
+
const { getPayload } = await import('payload')
|
|
51
|
+
const mod = (await import(pathToFileURL(configPath).href)) as { default: unknown }
|
|
52
|
+
let config: unknown = isRecord(mod) && 'default' in mod ? mod.default : mod
|
|
53
|
+
if (typeof (config as Partial<Promise<unknown>> | undefined)?.then === 'function') {
|
|
54
|
+
config = await config
|
|
55
|
+
}
|
|
56
|
+
return getPayload({ config: config as Parameters<typeof getPayload>[0]['config'] })
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function slugsOf(options: ImportCommandOptions): TargetSlugMap {
|
|
60
|
+
if (options.slugsJson === undefined) {
|
|
61
|
+
return { ...defaultTargetSlugs }
|
|
62
|
+
}
|
|
63
|
+
const parsed: unknown = JSON.parse(options.slugsJson)
|
|
64
|
+
if (!isRecord(parsed)) {
|
|
65
|
+
throw new Error('--slugs must be a JSON object of model -> collection slug')
|
|
66
|
+
}
|
|
67
|
+
return { ...defaultTargetSlugs, ...(parsed as Record<string, string>) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Plan-only pass: counts, orphans, unverified users, warnings. */
|
|
71
|
+
async function buildPlan(options: ImportCommandOptions): Promise<{
|
|
72
|
+
plan: ImportPlan
|
|
73
|
+
sourceCounts: Record<string, number>
|
|
74
|
+
}> {
|
|
75
|
+
const client = await openSource(options.databaseUrl)
|
|
76
|
+
try {
|
|
77
|
+
const sourceCounts = await countTables(client, [
|
|
78
|
+
'users',
|
|
79
|
+
'accounts',
|
|
80
|
+
'sessions',
|
|
81
|
+
'verifications',
|
|
82
|
+
'api_keys',
|
|
83
|
+
'jwks',
|
|
84
|
+
'organizations',
|
|
85
|
+
'members',
|
|
86
|
+
'invitations',
|
|
87
|
+
'users_role',
|
|
88
|
+
])
|
|
89
|
+
const orphans = await findOrphans(client)
|
|
90
|
+
return {
|
|
91
|
+
sourceCounts,
|
|
92
|
+
plan: {
|
|
93
|
+
rows: [],
|
|
94
|
+
unmappable: orphans,
|
|
95
|
+
sessions: options.sessions,
|
|
96
|
+
warnings: [],
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
await client.end()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The `from-payload-auth` command: dry-run plan, or `--apply` + manifest. */
|
|
105
|
+
export async function runFromPayloadAuth(options: ImportCommandOptions): Promise<{
|
|
106
|
+
exitCode: number
|
|
107
|
+
result?: ImportResult
|
|
108
|
+
plan: ImportPlan
|
|
109
|
+
unverified: readonly string[]
|
|
110
|
+
}> {
|
|
111
|
+
const log = log_(options)
|
|
112
|
+
const slugs = slugsOf(options)
|
|
113
|
+
const { plan: basePlan, sourceCounts } = await buildPlan(options)
|
|
114
|
+
|
|
115
|
+
const unverified = await (async () => {
|
|
116
|
+
const client = await openSource(options.databaseUrl)
|
|
117
|
+
try {
|
|
118
|
+
const { readSourceUsers } = await import('./source.js')
|
|
119
|
+
return unverifiedEmails(await readSourceUsers(client))
|
|
120
|
+
} finally {
|
|
121
|
+
await client.end()
|
|
122
|
+
}
|
|
123
|
+
})()
|
|
124
|
+
|
|
125
|
+
if (basePlan.unmappable.length > 0) {
|
|
126
|
+
// Unmappable data aborts the run — nothing is silently skipped.
|
|
127
|
+
const plan: ImportPlan = {
|
|
128
|
+
...basePlan,
|
|
129
|
+
warnings: [
|
|
130
|
+
...basePlan.warnings,
|
|
131
|
+
`${unverified.length} unverified user(s) will NOT be granted the linkable proof`,
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
if (options.apply) {
|
|
135
|
+
log('ABORT: unmappable rows present; nothing was written')
|
|
136
|
+
return { exitCode: 1, plan, unverified }
|
|
137
|
+
}
|
|
138
|
+
return { exitCode: 1, plan, unverified }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const manifestPath = options.manifestPath ?? defaultManifestPath
|
|
142
|
+
const existingManifest = readManifest(manifestPath)
|
|
143
|
+
|
|
144
|
+
const payload = await (options.loadPayload ?? loadPayloadInstance)(options.configPath)
|
|
145
|
+
const result = await applyImport({
|
|
146
|
+
payload,
|
|
147
|
+
sourceConnectionString: options.databaseUrl,
|
|
148
|
+
apply: options.apply,
|
|
149
|
+
sessions: options.sessions,
|
|
150
|
+
manifest: existingManifest,
|
|
151
|
+
manifestPath,
|
|
152
|
+
sourceCounts,
|
|
153
|
+
sourceDatabase: options.databaseUrl.replace(/\/\/[^@]*@/, '//***@'),
|
|
154
|
+
slugs,
|
|
155
|
+
log,
|
|
156
|
+
})
|
|
157
|
+
const plan: ImportPlan = {
|
|
158
|
+
...result.plan,
|
|
159
|
+
warnings: [
|
|
160
|
+
...result.plan.warnings,
|
|
161
|
+
`${unverified.length} unverified user(s) will NOT be granted the linkable proof`,
|
|
162
|
+
],
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (!options.json) {
|
|
166
|
+
for (const row of summarizePlanRows(result.plan.rows)) {
|
|
167
|
+
log(
|
|
168
|
+
` ${row.model.padEnd(14)} ${String(row.rows).padStart(5)} row(s) -> ${row.targetCollection}${row.skipped > 0 ? ` (${row.skipped} already present)` : ''}`
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
for (const warning of plan.warnings) {
|
|
172
|
+
log(` ! ${warning}`)
|
|
173
|
+
}
|
|
174
|
+
for (const row of plan.unmappable) {
|
|
175
|
+
log(` X ${row.model} #${row.key}: ${row.reason}`)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return {
|
|
179
|
+
exitCode: result.plan.unmappable.length > 0 ? 1 : 0,
|
|
180
|
+
result,
|
|
181
|
+
plan,
|
|
182
|
+
unverified,
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The `verify` command. */
|
|
187
|
+
export async function runVerify(options: ImportCommandOptions): Promise<VerifyResult> {
|
|
188
|
+
const log = log_(options)
|
|
189
|
+
const payload = await (options.loadPayload ?? loadPayloadInstance)(options.configPath)
|
|
190
|
+
const result = await verifyImport({
|
|
191
|
+
payload,
|
|
192
|
+
sourceConnectionString: options.databaseUrl,
|
|
193
|
+
slugs: slugsOf(options),
|
|
194
|
+
})
|
|
195
|
+
if (!options.json) {
|
|
196
|
+
for (const check of result.checks) {
|
|
197
|
+
log(` ${check.passed ? 'ok ' : 'FAIL'} ${check.name}`)
|
|
198
|
+
}
|
|
199
|
+
for (const issue of result.issues) {
|
|
200
|
+
log(` [${issue.code}] ${issue.detail}`)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return result
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** The `rollback` command. */
|
|
207
|
+
export async function runRollback(
|
|
208
|
+
options: ImportCommandOptions
|
|
209
|
+
): Promise<{ deleted: number; restored: number }> {
|
|
210
|
+
const log = log_(options)
|
|
211
|
+
const manifestPath = options.manifestPath ?? defaultManifestPath
|
|
212
|
+
const manifest = readManifest(manifestPath)
|
|
213
|
+
if (manifest === null) {
|
|
214
|
+
throw new Error(`no manifest at ${manifestPath}; nothing to roll back`)
|
|
215
|
+
}
|
|
216
|
+
if (manifest.finalized) {
|
|
217
|
+
throw new Error('the migration is finalized; rollback is no longer possible')
|
|
218
|
+
}
|
|
219
|
+
const payload = await (options.loadPayload ?? loadPayloadInstance)(options.configPath)
|
|
220
|
+
const result = await rollbackImport({ payload, manifest, log })
|
|
221
|
+
return result
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** The `finalize` command. */
|
|
225
|
+
export function runFinalize(options: ImportCommandOptions): Promise<ImportManifest> {
|
|
226
|
+
const log = log_(options)
|
|
227
|
+
const manifestPath = options.manifestPath ?? defaultManifestPath
|
|
228
|
+
const manifest = readManifest(manifestPath)
|
|
229
|
+
if (manifest === null) {
|
|
230
|
+
return Promise.reject(new Error(`no manifest at ${manifestPath}; run --apply first`))
|
|
231
|
+
}
|
|
232
|
+
const finalized = finalizeManifest(manifest, manifestPath)
|
|
233
|
+
log(
|
|
234
|
+
`finalized: the manifest at ${manifestPath} is now read-only. Legacy tables may be dropped in a separate, later migration — nothing is dropped for you.`
|
|
235
|
+
)
|
|
236
|
+
return Promise.resolve(finalized)
|
|
237
|
+
}
|