@payload-auth-link/migrate 0.4.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +7 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +103 -19
- package/dist/cli.js.map +1 -1
- package/dist/doctor/database.d.ts.map +1 -1
- package/dist/doctor/database.js +31 -4
- package/dist/doctor/database.js.map +1 -1
- package/dist/doctor/run-doctor.d.ts.map +1 -1
- package/dist/doctor/run-doctor.js +67 -0
- package/dist/doctor/run-doctor.js.map +1 -1
- package/dist/doctor/types.d.ts +12 -0
- package/dist/doctor/types.d.ts.map +1 -1
- package/dist/doctor/users.d.ts.map +1 -1
- package/dist/doctor/users.js +20 -4
- package/dist/doctor/users.js.map +1 -1
- package/dist/importer/apply.d.ts +23 -5
- package/dist/importer/apply.d.ts.map +1 -1
- package/dist/importer/apply.js +154 -178
- package/dist/importer/apply.js.map +1 -1
- package/dist/importer/host-writes.d.ts +47 -0
- package/dist/importer/host-writes.d.ts.map +1 -0
- package/dist/importer/host-writes.js +198 -0
- package/dist/importer/host-writes.js.map +1 -0
- package/dist/importer/lifecycle.d.ts +24 -0
- package/dist/importer/lifecycle.d.ts.map +1 -1
- package/dist/importer/lifecycle.js +104 -22
- package/dist/importer/lifecycle.js.map +1 -1
- package/dist/importer/prelink.d.ts +55 -0
- package/dist/importer/prelink.d.ts.map +1 -0
- package/dist/importer/prelink.js +152 -0
- package/dist/importer/prelink.js.map +1 -0
- package/dist/importer/run-import.d.ts +13 -0
- package/dist/importer/run-import.d.ts.map +1 -1
- package/dist/importer/run-import.js +147 -14
- package/dist/importer/run-import.js.map +1 -1
- package/dist/importer/source.d.ts +35 -7
- package/dist/importer/source.d.ts.map +1 -1
- package/dist/importer/source.js +37 -6
- package/dist/importer/source.js.map +1 -1
- package/dist/importer/types.d.ts +26 -4
- package/dist/importer/types.d.ts.map +1 -1
- package/dist/importer/types.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/private-file.d.ts +8 -0
- package/dist/private-file.d.ts.map +1 -0
- package/dist/private-file.js +19 -0
- package/dist/private-file.js.map +1 -0
- package/package.json +6 -6
- package/src/cli.ts +111 -9
- package/src/doctor/database.ts +36 -4
- package/src/doctor/run-doctor.ts +69 -0
- package/src/doctor/types.ts +12 -0
- package/src/doctor/users.ts +23 -4
- package/src/importer/apply.ts +186 -180
- package/src/importer/host-writes.ts +231 -0
- package/src/importer/lifecycle.ts +126 -24
- package/src/importer/prelink.ts +228 -0
- package/src/importer/run-import.ts +196 -29
- package/src/importer/source.ts +72 -14
- package/src/importer/types.ts +27 -4
- package/src/index.ts +13 -1
- package/src/private-file.ts +18 -0
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { isRecord } from '@payload-auth-link/core'
|
|
2
2
|
import { pathToFileURL } from 'node:url'
|
|
3
3
|
import { applyImport, summarizePlanRows } from './apply.js'
|
|
4
|
+
import { prelinkUsers, type PrelinkResult } from './prelink.js'
|
|
4
5
|
import {
|
|
5
6
|
defaultManifestPath,
|
|
7
|
+
defaultPrelinkManifestPath,
|
|
6
8
|
finalizeManifest,
|
|
7
9
|
readManifest,
|
|
8
10
|
rollbackImport,
|
|
@@ -122,10 +124,17 @@ function assertTargetCollections(config: unknown, slugs: TargetSlugMap): void {
|
|
|
122
124
|
* Boot the loaded config with a watchdog (#75): a boot that never
|
|
123
125
|
* settles must fail loudly after a bounded wait, not park the CLI
|
|
124
126
|
* forever.
|
|
127
|
+
*
|
|
128
|
+
* A FRESH, uncached instance (issue #88): `getPayload`'s module cache
|
|
129
|
+
* would hand a later command in the same process the instance an
|
|
130
|
+
* earlier command already destroyed. The caller owns the instance and
|
|
131
|
+
* destroys it (pool, crons) when the command ends.
|
|
125
132
|
*/
|
|
126
133
|
async function bootPayload(config: unknown): Promise<import('payload').Payload> {
|
|
127
|
-
const {
|
|
128
|
-
const boot =
|
|
134
|
+
const { BasePayload } = await import('payload')
|
|
135
|
+
const boot = new BasePayload().init({
|
|
136
|
+
config: config as Parameters<InstanceType<typeof BasePayload>['init']>[0]['config'],
|
|
137
|
+
})
|
|
129
138
|
let timer: NodeJS.Timeout | undefined
|
|
130
139
|
const watchdog = new Promise<never>((_, reject) => {
|
|
131
140
|
timer = setTimeout(() => {
|
|
@@ -163,16 +172,93 @@ function slugsOf(options: ImportCommandOptions): TargetSlugMap {
|
|
|
163
172
|
* be a placeholder the loader never reads.
|
|
164
173
|
*/
|
|
165
174
|
async function bootTargetPayload(
|
|
166
|
-
options: ImportCommandOptions
|
|
175
|
+
options: ImportCommandOptions,
|
|
176
|
+
requireTargets = true
|
|
167
177
|
): Promise<import('payload').Payload> {
|
|
168
178
|
if (options.loadPayload !== undefined) {
|
|
169
179
|
return options.loadPayload(options.configPath)
|
|
170
180
|
}
|
|
171
181
|
const { config } = await loadConfigModule(options.configPath)
|
|
172
|
-
|
|
182
|
+
if (requireTargets) {
|
|
183
|
+
assertTargetCollections(config, slugsOf(options))
|
|
184
|
+
}
|
|
173
185
|
return bootPayload(config)
|
|
174
186
|
}
|
|
175
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Run `work` against the target Payload and ALWAYS release it
|
|
190
|
+
* (issue #88): an instance this command booted is destroyed —
|
|
191
|
+
* database pool closed, crons stopped — whether `work` succeeds or
|
|
192
|
+
* throws, so the CLI exits after a successful run instead of idling
|
|
193
|
+
* on open handles. An injected `loadPayload` instance belongs to the
|
|
194
|
+
* caller and is left running.
|
|
195
|
+
*/
|
|
196
|
+
async function withTargetPayload<T>(
|
|
197
|
+
options: ImportCommandOptions,
|
|
198
|
+
work: (payload: import('payload').Payload) => Promise<T>,
|
|
199
|
+
requireTargets = true
|
|
200
|
+
): Promise<T> {
|
|
201
|
+
const payload = await bootTargetPayload(options, requireTargets)
|
|
202
|
+
try {
|
|
203
|
+
return await work(payload)
|
|
204
|
+
} finally {
|
|
205
|
+
if (options.loadPayload === undefined) {
|
|
206
|
+
await releasePayload(payload)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Destroy a booted Payload AND close its connection pool (issue #88).
|
|
213
|
+
* `payload.destroy()` stops crons and resets the adapter, but the SQL
|
|
214
|
+
* adapters' `destroy` leaves the driver pool open — and
|
|
215
|
+
* `@payloadcms/db-postgres` checks one client out of the pool at
|
|
216
|
+
* connect time and never releases it, so even `pool.end()` would wait
|
|
217
|
+
* forever. Those are exactly the handles that kept a finished CLI
|
|
218
|
+
* alive. The pool is reached structurally: every checked-out client
|
|
219
|
+
* is released-and-destroyed, then `pool.end()` (bounded); SQLite/libsql
|
|
220
|
+
* closes its `client`.
|
|
221
|
+
*/
|
|
222
|
+
async function releasePayload(payload: import('payload').Payload): Promise<void> {
|
|
223
|
+
const db: unknown = payload.db
|
|
224
|
+
await payload.destroy()
|
|
225
|
+
if (!isRecord(db)) {
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
const pool = db['pool']
|
|
229
|
+
if (isRecord(pool) && typeof pool['end'] === 'function') {
|
|
230
|
+
const clients: unknown = pool['_clients']
|
|
231
|
+
if (Array.isArray(clients)) {
|
|
232
|
+
for (const client of (clients as unknown[]).slice()) {
|
|
233
|
+
if (isRecord(client) && typeof client['release'] === 'function') {
|
|
234
|
+
try {
|
|
235
|
+
// `true` destroys the connection instead of returning it.
|
|
236
|
+
;(client['release'] as (destroy: boolean) => void).call(client, true)
|
|
237
|
+
} catch {
|
|
238
|
+
// Already released (an idle client) — `end()` closes it.
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
let timer: NodeJS.Timeout | undefined
|
|
244
|
+
await Promise.race([
|
|
245
|
+
(pool['end'] as () => Promise<void>).call(pool),
|
|
246
|
+
new Promise<void>((resolve) => {
|
|
247
|
+
timer = setTimeout(resolve, 5_000)
|
|
248
|
+
timer.unref()
|
|
249
|
+
}),
|
|
250
|
+
])
|
|
251
|
+
if (timer !== undefined) {
|
|
252
|
+
clearTimeout(timer)
|
|
253
|
+
}
|
|
254
|
+
return
|
|
255
|
+
}
|
|
256
|
+
const client = db['client']
|
|
257
|
+
if (isRecord(client) && typeof client['close'] === 'function') {
|
|
258
|
+
;(client['close'] as () => void).call(client)
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
176
262
|
/**
|
|
177
263
|
* Pre-flight only (no boot): refuse configs that declare no target
|
|
178
264
|
* collections. Runs before EVERYTHING — including the source-database
|
|
@@ -306,20 +392,21 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
|
|
|
306
392
|
const manifestPath = options.manifestPath ?? defaultManifestPath
|
|
307
393
|
const existingManifest = readManifest(manifestPath)
|
|
308
394
|
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
395
|
+
const result = await withTargetPayload(options, (payload) =>
|
|
396
|
+
applyImport({
|
|
397
|
+
payload,
|
|
398
|
+
sourceConnectionString: options.databaseUrl,
|
|
399
|
+
apply: options.apply,
|
|
400
|
+
sessions: options.sessions,
|
|
401
|
+
manifest: existingManifest,
|
|
402
|
+
manifestPath,
|
|
403
|
+
sourceCounts,
|
|
404
|
+
sourceDatabase: options.databaseUrl.replace(/\/\/[^@]*@/, '//***@'),
|
|
405
|
+
slugs,
|
|
406
|
+
sourceTables,
|
|
407
|
+
log,
|
|
408
|
+
})
|
|
409
|
+
)
|
|
323
410
|
const plan: ImportPlan = {
|
|
324
411
|
...result.plan,
|
|
325
412
|
warnings: [
|
|
@@ -340,6 +427,13 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
|
|
|
340
427
|
for (const row of plan.unmappable) {
|
|
341
428
|
log(` X ${row.model} #${row.key}: ${row.reason}`)
|
|
342
429
|
}
|
|
430
|
+
if (options.apply && plan.unmappable.length === 0) {
|
|
431
|
+
log(
|
|
432
|
+
`applied: manifest at ${manifestPath} (mode 0600; ids and sealed values only). Next: verify, then finalize after your soak window.`
|
|
433
|
+
)
|
|
434
|
+
} else if (!options.apply) {
|
|
435
|
+
log('dry run: nothing was written. Re-run with --apply to copy.')
|
|
436
|
+
}
|
|
343
437
|
}
|
|
344
438
|
return {
|
|
345
439
|
exitCode: result.plan.unmappable.length > 0 ? 1 : 0,
|
|
@@ -375,14 +469,15 @@ export async function runVerify(options: ImportCommandOptions): Promise<VerifyRe
|
|
|
375
469
|
return result
|
|
376
470
|
}
|
|
377
471
|
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
472
|
+
const result = await withTargetPayload(options, (payload) =>
|
|
473
|
+
verifyImport({
|
|
474
|
+
payload,
|
|
475
|
+
sourceConnectionString: options.databaseUrl,
|
|
476
|
+
slugs: slugsOf(options),
|
|
477
|
+
sourceTables: sourceTablesOf(options),
|
|
478
|
+
sessions: options.sessions,
|
|
479
|
+
})
|
|
480
|
+
)
|
|
386
481
|
if (!options.json) {
|
|
387
482
|
for (const check of result.checks) {
|
|
388
483
|
log(` ${check.passed ? 'ok ' : 'FAIL'} ${check.name}`)
|
|
@@ -390,6 +485,7 @@ export async function runVerify(options: ImportCommandOptions): Promise<VerifyRe
|
|
|
390
485
|
for (const issue of result.issues) {
|
|
391
486
|
log(` [${issue.code}] ${issue.detail}`)
|
|
392
487
|
}
|
|
488
|
+
log(result.ok ? 'verify: ok' : `verify: ${result.issues.length} mismatch(es)`)
|
|
393
489
|
}
|
|
394
490
|
return result
|
|
395
491
|
}
|
|
@@ -407,9 +503,14 @@ export async function runRollback(
|
|
|
407
503
|
if (manifest.finalized) {
|
|
408
504
|
throw new Error('the migration is finalized; rollback is no longer possible')
|
|
409
505
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
506
|
+
// A prelink manifest touches only host fields; its rollback needs no
|
|
507
|
+
// ba-* target collections in the config.
|
|
508
|
+
const requireTargets = manifest.kind !== 'adopt-prelink'
|
|
509
|
+
return withTargetPayload(
|
|
510
|
+
options,
|
|
511
|
+
(payload) => rollbackImport({ payload, manifest, log, manifestPath }),
|
|
512
|
+
requireTargets
|
|
513
|
+
)
|
|
413
514
|
}
|
|
414
515
|
|
|
415
516
|
/** The `finalize` command — manifest-only; no Payload instance is loaded. */
|
|
@@ -428,3 +529,69 @@ export function runFinalize(
|
|
|
428
529
|
)
|
|
429
530
|
return Promise.resolve(finalized)
|
|
430
531
|
}
|
|
532
|
+
|
|
533
|
+
/** Options for `adopt-prelink` (issue #88). */
|
|
534
|
+
export type PrelinkCommandOptions = Pick<
|
|
535
|
+
ImportCommandOptions,
|
|
536
|
+
'configPath' | 'apply' | 'manifestPath' | 'json' | 'log' | 'loadPayload'
|
|
537
|
+
> & {
|
|
538
|
+
readonly usersSlug?: string | undefined
|
|
539
|
+
readonly verifiedField?: string | undefined
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* The `adopt-prelink` command (issues #49, #88): pre-link every adopted
|
|
544
|
+
* user (one better-auth identity; the linking proof for verified
|
|
545
|
+
* emails). Dry run by default; `--apply` writes and records the
|
|
546
|
+
* manifest that `rollback --manifest` undoes exactly.
|
|
547
|
+
*/
|
|
548
|
+
export async function runAdoptPrelink(options: PrelinkCommandOptions): Promise<PrelinkResult> {
|
|
549
|
+
const log = log_(options)
|
|
550
|
+
const manifestPath = options.manifestPath ?? defaultPrelinkManifestPath
|
|
551
|
+
const existing = readManifest(manifestPath)
|
|
552
|
+
if (existing !== null && existing.kind !== 'adopt-prelink') {
|
|
553
|
+
throw new Error(
|
|
554
|
+
`the manifest at ${manifestPath} belongs to an import run, not adopt-prelink — pass a different --manifest`
|
|
555
|
+
)
|
|
556
|
+
}
|
|
557
|
+
if (existing?.finalized === true) {
|
|
558
|
+
throw new Error(`the manifest at ${manifestPath} is finalized; nothing to pre-link`)
|
|
559
|
+
}
|
|
560
|
+
const importOptions: ImportCommandOptions = {
|
|
561
|
+
configPath: options.configPath,
|
|
562
|
+
databaseUrl: '',
|
|
563
|
+
apply: options.apply,
|
|
564
|
+
sessions: 'carry',
|
|
565
|
+
...(options.loadPayload === undefined ? {} : { loadPayload: options.loadPayload }),
|
|
566
|
+
}
|
|
567
|
+
const result = await withTargetPayload(
|
|
568
|
+
importOptions,
|
|
569
|
+
(payload) =>
|
|
570
|
+
prelinkUsers({
|
|
571
|
+
payload,
|
|
572
|
+
apply: options.apply,
|
|
573
|
+
manifest: existing,
|
|
574
|
+
manifestPath,
|
|
575
|
+
usersSlug: options.usersSlug,
|
|
576
|
+
verifiedField: options.verifiedField,
|
|
577
|
+
log,
|
|
578
|
+
}),
|
|
579
|
+
false
|
|
580
|
+
)
|
|
581
|
+
if (options.json !== true) {
|
|
582
|
+
log(
|
|
583
|
+
` users ${result.users}: ${result.linked} to link, ${result.alreadyLinked} already linked, ${result.proofGranted} verified-email proof grant(s), ${result.unverified} unverified (identity only)`
|
|
584
|
+
)
|
|
585
|
+
for (const row of result.unmappable) {
|
|
586
|
+
log(` X ${row.model} #${row.key}: ${row.reason}`)
|
|
587
|
+
}
|
|
588
|
+
if (result.unmappable.length === 0) {
|
|
589
|
+
log(
|
|
590
|
+
options.apply
|
|
591
|
+
? `applied: manifest at ${manifestPath} (mode 0600). Undo with: rollback --config <config> --manifest ${manifestPath}`
|
|
592
|
+
: 'dry run: nothing was written. Re-run with --apply to pre-link.'
|
|
593
|
+
)
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return result
|
|
597
|
+
}
|
package/src/importer/source.ts
CHANGED
|
@@ -31,10 +31,22 @@ export type SourceUser = {
|
|
|
31
31
|
/** Stored role values from the `users_role` child table. */
|
|
32
32
|
readonly roles: readonly string[]
|
|
33
33
|
readonly accounts: readonly SourceAccount[]
|
|
34
|
+
/**
|
|
35
|
+
* Better Auth admin-plugin ban state (issue #88), `null` when the
|
|
36
|
+
* source layout has no such column (the plugin was never installed).
|
|
37
|
+
*/
|
|
38
|
+
readonly banned: boolean | null
|
|
39
|
+
readonly banReason: string | null
|
|
40
|
+
readonly banExpires: Date | null
|
|
41
|
+
/** Better Auth anonymous-plugin marker; `null` when no column exists. */
|
|
42
|
+
readonly isAnonymous: boolean | null
|
|
34
43
|
}
|
|
35
44
|
|
|
36
45
|
export type SourceAccount = {
|
|
37
46
|
readonly id: string
|
|
47
|
+
/** Source row timestamps, carried onto the target row (issue #88). */
|
|
48
|
+
readonly createdAt: Date | null
|
|
49
|
+
readonly updatedAt: Date | null
|
|
38
50
|
readonly accountId: string
|
|
39
51
|
readonly providerId: string
|
|
40
52
|
readonly password: string | null
|
|
@@ -76,14 +88,24 @@ const requiredColumns: Readonly<Record<SourceModel, readonly string[]>> = {
|
|
|
76
88
|
users_role: ['parent_id', 'value'],
|
|
77
89
|
}
|
|
78
90
|
|
|
79
|
-
|
|
91
|
+
/**
|
|
92
|
+
* The slice of a `pg` client the read side uses. Public signatures take
|
|
93
|
+
* this structural type (a `pg.Client` satisfies it) so the published
|
|
94
|
+
* `.d.ts` never references `pg` — consumers need no `@types/pg`.
|
|
95
|
+
*/
|
|
96
|
+
export interface SourceClient {
|
|
97
|
+
query(text: string, values?: unknown[]): Promise<{ rows: unknown[] }>
|
|
98
|
+
end(): Promise<void>
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function openSource(connectionString: string): Promise<SourceClient> {
|
|
80
102
|
const client = new Client({ connectionString })
|
|
81
103
|
await client.connect()
|
|
82
104
|
return client
|
|
83
105
|
}
|
|
84
106
|
|
|
85
107
|
/** The columns one table actually has (customized layouts drop columns). */
|
|
86
|
-
async function columnsOf(client:
|
|
108
|
+
async function columnsOf(client: SourceClient, table: string): Promise<Set<string>> {
|
|
87
109
|
const result = await client.query(
|
|
88
110
|
`SELECT column_name FROM information_schema.columns
|
|
89
111
|
WHERE table_schema = 'public' AND table_name = $1`,
|
|
@@ -92,7 +114,7 @@ async function columnsOf(client: Client, table: string): Promise<Set<string>> {
|
|
|
92
114
|
return new Set(result.rows.map((row) => String((row as { column_name: unknown }).column_name)))
|
|
93
115
|
}
|
|
94
116
|
|
|
95
|
-
async function tableExists(client:
|
|
117
|
+
async function tableExists(client: SourceClient, table: string): Promise<boolean> {
|
|
96
118
|
const result = await client.query(
|
|
97
119
|
`SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1`,
|
|
98
120
|
[table]
|
|
@@ -108,7 +130,7 @@ async function tableExists(client: Client, table: string): Promise<boolean> {
|
|
|
108
130
|
* read, planned, or written past a failed probe.
|
|
109
131
|
*/
|
|
110
132
|
export async function probeSourceLayout(
|
|
111
|
-
client:
|
|
133
|
+
client: SourceClient,
|
|
112
134
|
tables: ResolvedSourceTables
|
|
113
135
|
): Promise<readonly SourceLayoutIssue[]> {
|
|
114
136
|
const issues: SourceLayoutIssue[] = []
|
|
@@ -146,7 +168,7 @@ export async function probeSourceLayout(
|
|
|
146
168
|
* throw here means the probe was skipped.
|
|
147
169
|
*/
|
|
148
170
|
export async function countTables(
|
|
149
|
-
client:
|
|
171
|
+
client: SourceClient,
|
|
150
172
|
tables: readonly string[]
|
|
151
173
|
): Promise<Record<string, number>> {
|
|
152
174
|
const counts: Record<string, number> = {}
|
|
@@ -170,7 +192,7 @@ function text(value: unknown): string | null {
|
|
|
170
192
|
|
|
171
193
|
/** Every source user, with role rows and accounts attached. */
|
|
172
194
|
export async function readSourceUsers(
|
|
173
|
-
client:
|
|
195
|
+
client: SourceClient,
|
|
174
196
|
tables: ResolvedSourceTables = defaultSourceTables
|
|
175
197
|
): Promise<SourceUser[]> {
|
|
176
198
|
const usersTable = tables['users']
|
|
@@ -179,8 +201,14 @@ export async function readSourceUsers(
|
|
|
179
201
|
if (usersTable === null) {
|
|
180
202
|
return []
|
|
181
203
|
}
|
|
204
|
+
// Plugin columns (admin bans, anonymous marker) exist only when the
|
|
205
|
+
// payload-auth app installed those plugins — read them when present.
|
|
206
|
+
const userColumns = await columnsOf(client, usersTable)
|
|
207
|
+
const optionalUserColumns = ['banned', 'ban_reason', 'ban_expires', 'is_anonymous'].filter(
|
|
208
|
+
(column) => userColumns.has(column)
|
|
209
|
+
)
|
|
182
210
|
const users = await client.query(
|
|
183
|
-
`SELECT id, email, name, email_verified, image, created_at, updated_at FROM ${usersTable} ORDER BY id`
|
|
211
|
+
`SELECT ${['id', 'email', 'name', 'email_verified', 'image', 'created_at', 'updated_at', ...optionalUserColumns].join(', ')} FROM ${usersTable} ORDER BY id`
|
|
184
212
|
)
|
|
185
213
|
const rolesTable = tables['users_role']
|
|
186
214
|
const roleRows =
|
|
@@ -193,19 +221,37 @@ export async function readSourceUsers(
|
|
|
193
221
|
list.push(row.value)
|
|
194
222
|
rolesByUser.set(String(row.parent_id), list)
|
|
195
223
|
}
|
|
224
|
+
const accountColumns =
|
|
225
|
+
accountsTable === null ? new Set<string>() : await columnsOf(client, accountsTable)
|
|
226
|
+
const accountTimestamps = ['created_at', 'updated_at'].filter((column) =>
|
|
227
|
+
accountColumns.has(column)
|
|
228
|
+
)
|
|
196
229
|
const accounts =
|
|
197
230
|
accountsTable === null
|
|
198
231
|
? { rows: [] as unknown[] }
|
|
199
232
|
: await client.query(
|
|
200
|
-
`SELECT
|
|
201
|
-
|
|
202
|
-
|
|
233
|
+
`SELECT ${[
|
|
234
|
+
'id',
|
|
235
|
+
'account_id',
|
|
236
|
+
'provider_id',
|
|
237
|
+
'password',
|
|
238
|
+
'access_token',
|
|
239
|
+
'refresh_token',
|
|
240
|
+
'id_token',
|
|
241
|
+
'scope',
|
|
242
|
+
'access_token_expires_at',
|
|
243
|
+
'refresh_token_expires_at',
|
|
244
|
+
'user_id',
|
|
245
|
+
...accountTimestamps,
|
|
246
|
+
].join(', ')} FROM ${accountsTable} ORDER BY id`
|
|
203
247
|
)
|
|
204
248
|
const accountsByUser = new Map<string, SourceAccount[]>()
|
|
205
249
|
for (const row of accounts.rows as Record<string, unknown>[]) {
|
|
206
250
|
const userId = String(row.user_id)
|
|
207
251
|
const account: SourceAccount = {
|
|
208
252
|
id: String(row.id),
|
|
253
|
+
createdAt: iso(row.created_at),
|
|
254
|
+
updatedAt: iso(row.updated_at),
|
|
209
255
|
accountId: String(row.account_id),
|
|
210
256
|
providerId: String(row.provider_id),
|
|
211
257
|
password: text(row.password),
|
|
@@ -233,6 +279,10 @@ export async function readSourceUsers(
|
|
|
233
279
|
updatedAt: iso(row.updated_at) ?? new Date(),
|
|
234
280
|
roles: rolesByUser.get(id) ?? [],
|
|
235
281
|
accounts: accountsByUser.get(id) ?? [],
|
|
282
|
+
banned: userColumns.has('banned') ? row.banned === true : null,
|
|
283
|
+
banReason: userColumns.has('ban_reason') ? text(row.ban_reason) : null,
|
|
284
|
+
banExpires: userColumns.has('ban_expires') ? iso(row.ban_expires) : null,
|
|
285
|
+
isAnonymous: userColumns.has('is_anonymous') ? row.is_anonymous === true : null,
|
|
236
286
|
})
|
|
237
287
|
}
|
|
238
288
|
return out
|
|
@@ -244,14 +294,22 @@ export async function readSourceUsers(
|
|
|
244
294
|
* asking for it, and an unmapped missing table aborts at the probe.
|
|
245
295
|
*/
|
|
246
296
|
export async function readFlatRows(
|
|
247
|
-
client:
|
|
297
|
+
client: SourceClient,
|
|
248
298
|
table: string,
|
|
249
|
-
columns: readonly string[]
|
|
299
|
+
columns: readonly string[],
|
|
300
|
+
/**
|
|
301
|
+
* Columns read only when the layout has them (issue #88: row
|
|
302
|
+
* timestamps) — an absent optional column is simply not selected,
|
|
303
|
+
* and the row carries no key for it.
|
|
304
|
+
*/
|
|
305
|
+
optionalColumns: readonly string[] = []
|
|
250
306
|
): Promise<SourceRow[]> {
|
|
251
307
|
if (!(await tableExists(client, table))) {
|
|
252
308
|
throw new Error(`source table '${table}' is missing — run probeSourceLayout first`)
|
|
253
309
|
}
|
|
254
|
-
const
|
|
310
|
+
const existing = optionalColumns.length > 0 ? await columnsOf(client, table) : new Set<string>()
|
|
311
|
+
const selected = [...columns, ...optionalColumns.filter((column) => existing.has(column))]
|
|
312
|
+
const result = await client.query(`SELECT ${selected.join(', ')} FROM ${table} ORDER BY id`)
|
|
255
313
|
return result.rows as SourceRow[]
|
|
256
314
|
}
|
|
257
315
|
|
|
@@ -274,7 +332,7 @@ export type OrphanScan = {
|
|
|
274
332
|
* of passing silently.
|
|
275
333
|
*/
|
|
276
334
|
export async function findOrphans(
|
|
277
|
-
client:
|
|
335
|
+
client: SourceClient,
|
|
278
336
|
tables: ResolvedSourceTables = defaultSourceTables
|
|
279
337
|
): Promise<OrphanScan> {
|
|
280
338
|
const orphans: Unmappable[] = []
|
package/src/importer/types.ts
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
* Importer manifest (issue #50): every row created and every field
|
|
3
3
|
* changed, so `rollback` is exact. The manifest is the run's contract —
|
|
4
4
|
* `verify`, `rollback`, and `finalize` all read it.
|
|
5
|
+
*
|
|
6
|
+
* Privacy (issue #88): a version-2 manifest holds NO email address or
|
|
7
|
+
* other field value in the clear. Created rows are named by ids only,
|
|
8
|
+
* and every captured prior value is sealed (AES-256-GCM, keyed from the
|
|
9
|
+
* target app's Payload `secret`), so only the same app can restore it.
|
|
10
|
+
* The file is written with mode 0600.
|
|
5
11
|
*/
|
|
6
12
|
export type CreatedRow = {
|
|
7
13
|
/** Target collection slug. */
|
|
@@ -16,12 +22,27 @@ export type FieldWrite = {
|
|
|
16
22
|
readonly collection: string
|
|
17
23
|
readonly id: string | number
|
|
18
24
|
readonly field: string
|
|
19
|
-
/**
|
|
20
|
-
|
|
25
|
+
/**
|
|
26
|
+
* The prior value, JSON-serialized — version-1 manifests only.
|
|
27
|
+
* Version 2 never stores values in the clear; see `sealedBefore`.
|
|
28
|
+
*/
|
|
29
|
+
readonly before?: string | null | undefined
|
|
30
|
+
/**
|
|
31
|
+
* The prior value, JSON-serialized and sealed with the target app's
|
|
32
|
+
* Payload secret (version 2, issue #88), restored by rollback. Only
|
|
33
|
+
* the FIRST capture per document field is kept, so a rollback after
|
|
34
|
+
* any number of `--apply` passes restores the pre-import state.
|
|
35
|
+
*/
|
|
36
|
+
readonly sealedBefore?: string | undefined
|
|
21
37
|
}
|
|
22
38
|
|
|
39
|
+
/** Which command produced the manifest (issue #88). */
|
|
40
|
+
export type ManifestKind = 'import' | 'adopt-prelink'
|
|
41
|
+
|
|
23
42
|
export type ImportManifest = {
|
|
24
|
-
readonly version: 1
|
|
43
|
+
readonly version: 1 | 2
|
|
44
|
+
/** The producing command; absent on version-1 manifests (imports). */
|
|
45
|
+
readonly kind?: ManifestKind | undefined
|
|
25
46
|
readonly createdAt: string
|
|
26
47
|
readonly sourceDatabase: string
|
|
27
48
|
readonly options: {
|
|
@@ -32,8 +53,10 @@ export type ImportManifest = {
|
|
|
32
53
|
readonly finalized: boolean
|
|
33
54
|
/** Counts per source table at plan time (the rollback baseline). */
|
|
34
55
|
readonly sourceCounts: Readonly<Record<string, number>>
|
|
35
|
-
/**
|
|
56
|
+
/** Rows the LATEST pass planned per model — never summed across passes (#88). */
|
|
36
57
|
readonly totals: Readonly<Record<string, number>>
|
|
58
|
+
/** When the last rollback reset this manifest (issue #88). */
|
|
59
|
+
readonly rolledBackAt?: string | undefined
|
|
37
60
|
}
|
|
38
61
|
|
|
39
62
|
/** Per-model plan row for the dry run. */
|
package/src/index.ts
CHANGED
|
@@ -16,7 +16,16 @@ export { classifyPlugins, pluginsFromSourceTree } from './doctor/plugins.js'
|
|
|
16
16
|
export { scanSource, projectedUserFields, listSourceFiles } from './doctor/source-scan.js'
|
|
17
17
|
export { analyzeUsersCollection, findUsersCollection, localStrategyOn } from './doctor/users.js'
|
|
18
18
|
export { introspectDatabase } from './doctor/database.js'
|
|
19
|
-
export {
|
|
19
|
+
export {
|
|
20
|
+
runFromPayloadAuth,
|
|
21
|
+
runVerify,
|
|
22
|
+
runRollback,
|
|
23
|
+
runFinalize,
|
|
24
|
+
runAdoptPrelink,
|
|
25
|
+
type ImportCommandOptions,
|
|
26
|
+
type PrelinkCommandOptions,
|
|
27
|
+
} from './importer/run-import.js'
|
|
28
|
+
export { prelinkUsers, type PrelinkOptions, type PrelinkResult } from './importer/prelink.js'
|
|
20
29
|
export { applyImport, summarizePlanRows } from './importer/apply.js'
|
|
21
30
|
export {
|
|
22
31
|
countTables,
|
|
@@ -27,6 +36,7 @@ export {
|
|
|
27
36
|
readSourceUsers,
|
|
28
37
|
unverifiedEmails,
|
|
29
38
|
type OrphanScan,
|
|
39
|
+
type SourceClient,
|
|
30
40
|
type SourceLayoutIssue,
|
|
31
41
|
type SourceAccount,
|
|
32
42
|
type SourceRow,
|
|
@@ -39,6 +49,7 @@ export {
|
|
|
39
49
|
rollbackImport,
|
|
40
50
|
finalizeManifest,
|
|
41
51
|
defaultManifestPath,
|
|
52
|
+
defaultPrelinkManifestPath,
|
|
42
53
|
type VerifyIssue,
|
|
43
54
|
type VerifyResult,
|
|
44
55
|
} from './importer/lifecycle.js'
|
|
@@ -57,6 +68,7 @@ export {
|
|
|
57
68
|
type TargetSlugMap,
|
|
58
69
|
type FieldWrite,
|
|
59
70
|
type ImportManifest,
|
|
71
|
+
type ManifestKind,
|
|
60
72
|
type ImportPlan,
|
|
61
73
|
type ImportResult,
|
|
62
74
|
type PlanRow,
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { closeSync, fchmodSync, ftruncateSync, openSync, writeSync } from 'node:fs'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Write `content` to `path` readable by the owner only (0600). The
|
|
5
|
+
* mode is tightened on the open descriptor BEFORE any content lands, so
|
|
6
|
+
* a pre-existing world-readable file (an older kit's manifest, a
|
|
7
|
+
* reused `--out` path) never holds the new content with its old mode.
|
|
8
|
+
*/
|
|
9
|
+
export function writePrivateFile(path: string, content: string): void {
|
|
10
|
+
const fd = openSync(path, 'a', 0o600)
|
|
11
|
+
try {
|
|
12
|
+
fchmodSync(fd, 0o600)
|
|
13
|
+
ftruncateSync(fd, 0)
|
|
14
|
+
writeSync(fd, content, 0)
|
|
15
|
+
} finally {
|
|
16
|
+
closeSync(fd)
|
|
17
|
+
}
|
|
18
|
+
}
|