@payload-auth-link/migrate 0.4.3 → 0.6.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 +109 -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 +25 -5
- package/dist/importer/apply.d.ts.map +1 -1
- package/dist/importer/apply.js +164 -180
- 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 +26 -0
- package/dist/importer/lifecycle.d.ts.map +1 -1
- package/dist/importer/lifecycle.js +107 -22
- package/dist/importer/lifecycle.js.map +1 -1
- package/dist/importer/manifest-mac.d.ts +15 -0
- package/dist/importer/manifest-mac.d.ts.map +1 -0
- package/dist/importer/manifest-mac.js +73 -0
- package/dist/importer/manifest-mac.js.map +1 -0
- package/dist/importer/prelink.d.ts +57 -0
- package/dist/importer/prelink.d.ts.map +1 -0
- package/dist/importer/prelink.js +159 -0
- package/dist/importer/prelink.js.map +1 -0
- package/dist/importer/run-import.d.ts +15 -0
- package/dist/importer/run-import.d.ts.map +1 -1
- package/dist/importer/run-import.js +155 -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 +34 -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 +10 -10
- package/src/cli.ts +117 -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 +198 -182
- package/src/importer/host-writes.ts +231 -0
- package/src/importer/lifecycle.ts +131 -24
- package/src/importer/manifest-mac.ts +90 -0
- package/src/importer/prelink.ts +237 -0
- package/src/importer/run-import.ts +207 -29
- package/src/importer/source.ts +72 -14
- package/src/importer/types.ts +35 -4
- package/src/index.ts +13 -1
- package/src/private-file.ts +18 -0
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { Payload } from 'payload'
|
|
2
|
+
import { capturedKeys, captureBefore } from './host-writes.js'
|
|
3
|
+
import { authenticateManifest, signManifest } from './manifest-mac.js'
|
|
4
|
+
import type { FieldWrite, ImportManifest, Unmappable } from './types.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The adopt-mode pre-link (issues #49, #88): `migrate adopt-prelink`.
|
|
8
|
+
*
|
|
9
|
+
* In adopt mode the users collection IS Better Auth's user table
|
|
10
|
+
* (`schemaMap.user`, `idStrategy: 'payload'`), so Better Auth's user id
|
|
11
|
+
* for a document is the document id. What adopt mode does NOT do by
|
|
12
|
+
* itself is link the plugin side: without an identity row, the first
|
|
13
|
+
* provider sign-in reaches JIT linking, which links an existing
|
|
14
|
+
* document only on the verified-email proof (`linkable === true` with
|
|
15
|
+
* `linkableEmail` equal to the document email).
|
|
16
|
+
*
|
|
17
|
+
* This command mirrors exactly what the importer writes on the host
|
|
18
|
+
* users (issue #69):
|
|
19
|
+
*
|
|
20
|
+
* - ONE `better-auth` identity per user, `providerUserId` = the
|
|
21
|
+
* document id (other providers' identity rows are kept);
|
|
22
|
+
* - `linkable: true` + `linkableEmail: <email>` for users whose
|
|
23
|
+
* verified-email field (`emailVerified` on payload-auth layouts) is
|
|
24
|
+
* `true` — grant-only, never revoked.
|
|
25
|
+
*
|
|
26
|
+
* Dry run by default. `--apply` records every prior value in a manifest
|
|
27
|
+
* (first capture wins, sealed, mode 0600) so `rollback --manifest`
|
|
28
|
+
* restores the documents exactly, `updatedAt` included.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export type PrelinkOptions = {
|
|
32
|
+
readonly payload: Payload
|
|
33
|
+
readonly apply: boolean
|
|
34
|
+
readonly manifest: ImportManifest | null
|
|
35
|
+
readonly manifestPath: string
|
|
36
|
+
/** Users collection slug. Defaults to `users`. */
|
|
37
|
+
readonly usersSlug?: string | undefined
|
|
38
|
+
/** The users' verified-email checkbox. Defaults to `emailVerified`. */
|
|
39
|
+
readonly verifiedField?: string | undefined
|
|
40
|
+
/** Email field. Defaults to `email`. */
|
|
41
|
+
readonly emailField?: string | undefined
|
|
42
|
+
readonly now?: () => Date
|
|
43
|
+
readonly log?: (message: string) => void
|
|
44
|
+
/** Resume from a pre-0.6.0 manifest that has no MAC (`--allow-unsigned-manifest`). */
|
|
45
|
+
readonly allowUnsignedManifest?: boolean | undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type PrelinkResult = {
|
|
49
|
+
readonly users: number
|
|
50
|
+
/** Documents that received (or would receive) the better-auth identity. */
|
|
51
|
+
readonly linked: number
|
|
52
|
+
/** Documents already carrying the exact identity. */
|
|
53
|
+
readonly alreadyLinked: number
|
|
54
|
+
/** Verified documents granted (or to be granted) the linking proof. */
|
|
55
|
+
readonly proofGranted: number
|
|
56
|
+
/** Users without a verified email: identity only, no linking proof. */
|
|
57
|
+
readonly unverified: number
|
|
58
|
+
readonly unmappable: readonly Unmappable[]
|
|
59
|
+
readonly fieldWrites: number
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type IdentityRow = {
|
|
63
|
+
readonly id?: unknown
|
|
64
|
+
readonly providerId?: unknown
|
|
65
|
+
readonly providerUserId?: unknown
|
|
66
|
+
readonly email?: unknown
|
|
67
|
+
readonly linkedAt?: unknown
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function identitiesOf(doc: Record<string, unknown>): IdentityRow[] {
|
|
71
|
+
const raw = doc['identities']
|
|
72
|
+
return Array.isArray(raw)
|
|
73
|
+
? raw.filter((row): row is IdentityRow => typeof row === 'object' && row !== null)
|
|
74
|
+
: []
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function prelinkUsers(options: PrelinkOptions): Promise<PrelinkResult> {
|
|
78
|
+
const { payload, apply, log = () => {}, now = () => new Date() } = options
|
|
79
|
+
const usersSlug = options.usersSlug ?? 'users'
|
|
80
|
+
const verifiedField = options.verifiedField ?? 'emailVerified'
|
|
81
|
+
const emailField = options.emailField ?? 'email'
|
|
82
|
+
if (options.manifest !== null) {
|
|
83
|
+
// Resuming re-signs the old entries: verify them first.
|
|
84
|
+
authenticateManifest(payload.secret, options.manifest, {
|
|
85
|
+
allowUnsigned: options.allowUnsignedManifest,
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
const fieldWrites: FieldWrite[] = [...(options.manifest?.fieldWrites ?? [])]
|
|
89
|
+
const captured = capturedKeys(fieldWrites)
|
|
90
|
+
|
|
91
|
+
type Planned = {
|
|
92
|
+
readonly id: string | number
|
|
93
|
+
readonly email: string
|
|
94
|
+
readonly writeIdentity: boolean
|
|
95
|
+
readonly grantProof: boolean
|
|
96
|
+
readonly keep: IdentityRow[]
|
|
97
|
+
}
|
|
98
|
+
const planned: Planned[] = []
|
|
99
|
+
const unmappable: Unmappable[] = []
|
|
100
|
+
let users = 0
|
|
101
|
+
let alreadyLinked = 0
|
|
102
|
+
let unverified = 0
|
|
103
|
+
|
|
104
|
+
// Plan the WHOLE collection before any write: a conflicting identity
|
|
105
|
+
// anywhere aborts the run with nothing written.
|
|
106
|
+
for (let page = 1; ; page += 1) {
|
|
107
|
+
const batch = await payload.find({
|
|
108
|
+
collection: usersSlug,
|
|
109
|
+
depth: 0,
|
|
110
|
+
limit: 200,
|
|
111
|
+
page,
|
|
112
|
+
sort: 'id',
|
|
113
|
+
overrideAccess: true,
|
|
114
|
+
})
|
|
115
|
+
for (const doc of batch.docs as unknown as Record<string, unknown>[]) {
|
|
116
|
+
users += 1
|
|
117
|
+
const id = doc['id']
|
|
118
|
+
const email = doc[emailField]
|
|
119
|
+
if ((typeof id !== 'string' && typeof id !== 'number') || typeof email !== 'string') {
|
|
120
|
+
unmappable.push({
|
|
121
|
+
model: 'user',
|
|
122
|
+
key: String(id),
|
|
123
|
+
reason: `document has no usable id/'${emailField}'`,
|
|
124
|
+
})
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
const providerUserId = String(id)
|
|
128
|
+
const identities = identitiesOf(doc)
|
|
129
|
+
const own = identities.filter((row) => row.providerId === 'better-auth')
|
|
130
|
+
const conflicting = own.filter((row) => String(row.providerUserId) !== providerUserId)
|
|
131
|
+
if (conflicting.length > 0) {
|
|
132
|
+
unmappable.push({
|
|
133
|
+
model: 'user',
|
|
134
|
+
key: providerUserId,
|
|
135
|
+
reason: `user #${providerUserId} already carries a better-auth identity for a different provider user id; adopt mode serves the document id as the Better Auth user id — resolve the link by hand first`,
|
|
136
|
+
})
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
const verified = doc[verifiedField] === true
|
|
140
|
+
if (!verified) {
|
|
141
|
+
unverified += 1
|
|
142
|
+
}
|
|
143
|
+
const writeIdentity = own.length !== 1
|
|
144
|
+
const grantProof = verified && (doc['linkable'] !== true || doc['linkableEmail'] !== email)
|
|
145
|
+
if (!writeIdentity) {
|
|
146
|
+
alreadyLinked += 1
|
|
147
|
+
}
|
|
148
|
+
if (writeIdentity || grantProof) {
|
|
149
|
+
planned.push({
|
|
150
|
+
id,
|
|
151
|
+
email,
|
|
152
|
+
writeIdentity,
|
|
153
|
+
grantProof,
|
|
154
|
+
keep: identities.filter((row) => row.providerId !== 'better-auth'),
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (!batch.hasNextPage) {
|
|
159
|
+
break
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const linked = planned.filter((row) => row.writeIdentity).length
|
|
164
|
+
const proofGranted = planned.filter((row) => row.grantProof).length
|
|
165
|
+
if (unmappable.length > 0) {
|
|
166
|
+
log(`ABORT: ${unmappable.length} unmappable user(s); nothing was written`)
|
|
167
|
+
return {
|
|
168
|
+
users,
|
|
169
|
+
linked: 0,
|
|
170
|
+
alreadyLinked,
|
|
171
|
+
proofGranted: 0,
|
|
172
|
+
unverified,
|
|
173
|
+
unmappable,
|
|
174
|
+
fieldWrites: 0,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
let fieldWriteCount = 0
|
|
179
|
+
if (apply) {
|
|
180
|
+
for (const row of planned) {
|
|
181
|
+
const { newlyCaptured } = await captureBefore({
|
|
182
|
+
payload,
|
|
183
|
+
collection: usersSlug,
|
|
184
|
+
id: row.id,
|
|
185
|
+
fields: ['identities', 'linkable', 'linkableEmail'],
|
|
186
|
+
captured,
|
|
187
|
+
into: fieldWrites,
|
|
188
|
+
})
|
|
189
|
+
fieldWriteCount += newlyCaptured
|
|
190
|
+
const data: Record<string, unknown> = {}
|
|
191
|
+
if (row.writeIdentity) {
|
|
192
|
+
data.identities = [
|
|
193
|
+
...row.keep.map((identity) => ({
|
|
194
|
+
providerId: identity.providerId,
|
|
195
|
+
providerUserId: identity.providerUserId,
|
|
196
|
+
email: identity.email,
|
|
197
|
+
linkedAt: identity.linkedAt,
|
|
198
|
+
})),
|
|
199
|
+
{
|
|
200
|
+
providerId: 'better-auth',
|
|
201
|
+
providerUserId: String(row.id),
|
|
202
|
+
email: row.email,
|
|
203
|
+
linkedAt: now().toISOString(),
|
|
204
|
+
},
|
|
205
|
+
]
|
|
206
|
+
}
|
|
207
|
+
if (row.grantProof) {
|
|
208
|
+
data.linkable = true
|
|
209
|
+
data.linkableEmail = row.email
|
|
210
|
+
}
|
|
211
|
+
await payload.update({ collection: usersSlug, id: row.id, data, overrideAccess: true })
|
|
212
|
+
}
|
|
213
|
+
const manifest: ImportManifest = signManifest(payload.secret, {
|
|
214
|
+
version: 3,
|
|
215
|
+
kind: 'adopt-prelink',
|
|
216
|
+
createdAt: options.manifest?.createdAt ?? now().toISOString(),
|
|
217
|
+
sourceDatabase: '(adopt mode: the target app database)',
|
|
218
|
+
options: { sessions: 'carry' },
|
|
219
|
+
created: [],
|
|
220
|
+
fieldWrites,
|
|
221
|
+
finalized: options.manifest?.finalized ?? false,
|
|
222
|
+
sourceCounts: {},
|
|
223
|
+
totals: { user: users, linked, proofGranted },
|
|
224
|
+
})
|
|
225
|
+
const { writeManifest } = await import('./lifecycle.js')
|
|
226
|
+
writeManifest(manifest, options.manifestPath)
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
users,
|
|
230
|
+
linked,
|
|
231
|
+
alreadyLinked,
|
|
232
|
+
proofGranted,
|
|
233
|
+
unverified,
|
|
234
|
+
unmappable,
|
|
235
|
+
fieldWrites: fieldWriteCount,
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -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,
|
|
@@ -57,6 +59,8 @@ export type ImportCommandOptions = {
|
|
|
57
59
|
readonly json?: boolean
|
|
58
60
|
readonly log?: (message: string) => void
|
|
59
61
|
readonly loadPayload?: (configPath: string) => Promise<import('payload').Payload>
|
|
62
|
+
/** Accept a pre-0.6.0 manifest without a MAC (`--allow-unsigned-manifest`). */
|
|
63
|
+
readonly allowUnsignedManifest?: boolean | undefined
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
const log_ = (options: Pick<ImportCommandOptions, 'log'>): ((message: string) => void) =>
|
|
@@ -122,10 +126,17 @@ function assertTargetCollections(config: unknown, slugs: TargetSlugMap): void {
|
|
|
122
126
|
* Boot the loaded config with a watchdog (#75): a boot that never
|
|
123
127
|
* settles must fail loudly after a bounded wait, not park the CLI
|
|
124
128
|
* forever.
|
|
129
|
+
*
|
|
130
|
+
* A FRESH, uncached instance (issue #88): `getPayload`'s module cache
|
|
131
|
+
* would hand a later command in the same process the instance an
|
|
132
|
+
* earlier command already destroyed. The caller owns the instance and
|
|
133
|
+
* destroys it (pool, crons) when the command ends.
|
|
125
134
|
*/
|
|
126
135
|
async function bootPayload(config: unknown): Promise<import('payload').Payload> {
|
|
127
|
-
const {
|
|
128
|
-
const boot =
|
|
136
|
+
const { BasePayload } = await import('payload')
|
|
137
|
+
const boot = new BasePayload().init({
|
|
138
|
+
config: config as Parameters<InstanceType<typeof BasePayload>['init']>[0]['config'],
|
|
139
|
+
})
|
|
129
140
|
let timer: NodeJS.Timeout | undefined
|
|
130
141
|
const watchdog = new Promise<never>((_, reject) => {
|
|
131
142
|
timer = setTimeout(() => {
|
|
@@ -163,16 +174,93 @@ function slugsOf(options: ImportCommandOptions): TargetSlugMap {
|
|
|
163
174
|
* be a placeholder the loader never reads.
|
|
164
175
|
*/
|
|
165
176
|
async function bootTargetPayload(
|
|
166
|
-
options: ImportCommandOptions
|
|
177
|
+
options: ImportCommandOptions,
|
|
178
|
+
requireTargets = true
|
|
167
179
|
): Promise<import('payload').Payload> {
|
|
168
180
|
if (options.loadPayload !== undefined) {
|
|
169
181
|
return options.loadPayload(options.configPath)
|
|
170
182
|
}
|
|
171
183
|
const { config } = await loadConfigModule(options.configPath)
|
|
172
|
-
|
|
184
|
+
if (requireTargets) {
|
|
185
|
+
assertTargetCollections(config, slugsOf(options))
|
|
186
|
+
}
|
|
173
187
|
return bootPayload(config)
|
|
174
188
|
}
|
|
175
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Run `work` against the target Payload and ALWAYS release it
|
|
192
|
+
* (issue #88): an instance this command booted is destroyed —
|
|
193
|
+
* database pool closed, crons stopped — whether `work` succeeds or
|
|
194
|
+
* throws, so the CLI exits after a successful run instead of idling
|
|
195
|
+
* on open handles. An injected `loadPayload` instance belongs to the
|
|
196
|
+
* caller and is left running.
|
|
197
|
+
*/
|
|
198
|
+
async function withTargetPayload<T>(
|
|
199
|
+
options: ImportCommandOptions,
|
|
200
|
+
work: (payload: import('payload').Payload) => Promise<T>,
|
|
201
|
+
requireTargets = true
|
|
202
|
+
): Promise<T> {
|
|
203
|
+
const payload = await bootTargetPayload(options, requireTargets)
|
|
204
|
+
try {
|
|
205
|
+
return await work(payload)
|
|
206
|
+
} finally {
|
|
207
|
+
if (options.loadPayload === undefined) {
|
|
208
|
+
await releasePayload(payload)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Destroy a booted Payload AND close its connection pool (issue #88).
|
|
215
|
+
* `payload.destroy()` stops crons and resets the adapter, but the SQL
|
|
216
|
+
* adapters' `destroy` leaves the driver pool open — and
|
|
217
|
+
* `@payloadcms/db-postgres` checks one client out of the pool at
|
|
218
|
+
* connect time and never releases it, so even `pool.end()` would wait
|
|
219
|
+
* forever. Those are exactly the handles that kept a finished CLI
|
|
220
|
+
* alive. The pool is reached structurally: every checked-out client
|
|
221
|
+
* is released-and-destroyed, then `pool.end()` (bounded); SQLite/libsql
|
|
222
|
+
* closes its `client`.
|
|
223
|
+
*/
|
|
224
|
+
async function releasePayload(payload: import('payload').Payload): Promise<void> {
|
|
225
|
+
const db: unknown = payload.db
|
|
226
|
+
await payload.destroy()
|
|
227
|
+
if (!isRecord(db)) {
|
|
228
|
+
return
|
|
229
|
+
}
|
|
230
|
+
const pool = db['pool']
|
|
231
|
+
if (isRecord(pool) && typeof pool['end'] === 'function') {
|
|
232
|
+
const clients: unknown = pool['_clients']
|
|
233
|
+
if (Array.isArray(clients)) {
|
|
234
|
+
for (const client of (clients as unknown[]).slice()) {
|
|
235
|
+
if (isRecord(client) && typeof client['release'] === 'function') {
|
|
236
|
+
try {
|
|
237
|
+
// `true` destroys the connection instead of returning it.
|
|
238
|
+
;(client['release'] as (destroy: boolean) => void).call(client, true)
|
|
239
|
+
} catch {
|
|
240
|
+
// Already released (an idle client) — `end()` closes it.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
let timer: NodeJS.Timeout | undefined
|
|
246
|
+
await Promise.race([
|
|
247
|
+
(pool['end'] as () => Promise<void>).call(pool),
|
|
248
|
+
new Promise<void>((resolve) => {
|
|
249
|
+
timer = setTimeout(resolve, 5_000)
|
|
250
|
+
timer.unref()
|
|
251
|
+
}),
|
|
252
|
+
])
|
|
253
|
+
if (timer !== undefined) {
|
|
254
|
+
clearTimeout(timer)
|
|
255
|
+
}
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
const client = db['client']
|
|
259
|
+
if (isRecord(client) && typeof client['close'] === 'function') {
|
|
260
|
+
;(client['close'] as () => void).call(client)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
176
264
|
/**
|
|
177
265
|
* Pre-flight only (no boot): refuse configs that declare no target
|
|
178
266
|
* collections. Runs before EVERYTHING — including the source-database
|
|
@@ -306,20 +394,22 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
|
|
|
306
394
|
const manifestPath = options.manifestPath ?? defaultManifestPath
|
|
307
395
|
const existingManifest = readManifest(manifestPath)
|
|
308
396
|
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
397
|
+
const result = await withTargetPayload(options, (payload) =>
|
|
398
|
+
applyImport({
|
|
399
|
+
payload,
|
|
400
|
+
sourceConnectionString: options.databaseUrl,
|
|
401
|
+
apply: options.apply,
|
|
402
|
+
sessions: options.sessions,
|
|
403
|
+
manifest: existingManifest,
|
|
404
|
+
manifestPath,
|
|
405
|
+
sourceCounts,
|
|
406
|
+
sourceDatabase: options.databaseUrl.replace(/\/\/[^@]*@/, '//***@'),
|
|
407
|
+
slugs,
|
|
408
|
+
sourceTables,
|
|
409
|
+
log,
|
|
410
|
+
allowUnsignedManifest: options.allowUnsignedManifest,
|
|
411
|
+
})
|
|
412
|
+
)
|
|
323
413
|
const plan: ImportPlan = {
|
|
324
414
|
...result.plan,
|
|
325
415
|
warnings: [
|
|
@@ -340,6 +430,13 @@ export async function runFromPayloadAuth(options: ImportCommandOptions): Promise
|
|
|
340
430
|
for (const row of plan.unmappable) {
|
|
341
431
|
log(` X ${row.model} #${row.key}: ${row.reason}`)
|
|
342
432
|
}
|
|
433
|
+
if (options.apply && plan.unmappable.length === 0) {
|
|
434
|
+
log(
|
|
435
|
+
`applied: manifest at ${manifestPath} (mode 0600; ids and sealed values only). Next: verify, then finalize after your soak window.`
|
|
436
|
+
)
|
|
437
|
+
} else if (!options.apply) {
|
|
438
|
+
log('dry run: nothing was written. Re-run with --apply to copy.')
|
|
439
|
+
}
|
|
343
440
|
}
|
|
344
441
|
return {
|
|
345
442
|
exitCode: result.plan.unmappable.length > 0 ? 1 : 0,
|
|
@@ -375,14 +472,15 @@ export async function runVerify(options: ImportCommandOptions): Promise<VerifyRe
|
|
|
375
472
|
return result
|
|
376
473
|
}
|
|
377
474
|
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
475
|
+
const result = await withTargetPayload(options, (payload) =>
|
|
476
|
+
verifyImport({
|
|
477
|
+
payload,
|
|
478
|
+
sourceConnectionString: options.databaseUrl,
|
|
479
|
+
slugs: slugsOf(options),
|
|
480
|
+
sourceTables: sourceTablesOf(options),
|
|
481
|
+
sessions: options.sessions,
|
|
482
|
+
})
|
|
483
|
+
)
|
|
386
484
|
if (!options.json) {
|
|
387
485
|
for (const check of result.checks) {
|
|
388
486
|
log(` ${check.passed ? 'ok ' : 'FAIL'} ${check.name}`)
|
|
@@ -390,6 +488,7 @@ export async function runVerify(options: ImportCommandOptions): Promise<VerifyRe
|
|
|
390
488
|
for (const issue of result.issues) {
|
|
391
489
|
log(` [${issue.code}] ${issue.detail}`)
|
|
392
490
|
}
|
|
491
|
+
log(result.ok ? 'verify: ok' : `verify: ${result.issues.length} mismatch(es)`)
|
|
393
492
|
}
|
|
394
493
|
return result
|
|
395
494
|
}
|
|
@@ -407,9 +506,21 @@ export async function runRollback(
|
|
|
407
506
|
if (manifest.finalized) {
|
|
408
507
|
throw new Error('the migration is finalized; rollback is no longer possible')
|
|
409
508
|
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
509
|
+
// A prelink manifest touches only host fields; its rollback needs no
|
|
510
|
+
// ba-* target collections in the config.
|
|
511
|
+
const requireTargets = manifest.kind !== 'adopt-prelink'
|
|
512
|
+
return withTargetPayload(
|
|
513
|
+
options,
|
|
514
|
+
(payload) =>
|
|
515
|
+
rollbackImport({
|
|
516
|
+
payload,
|
|
517
|
+
manifest,
|
|
518
|
+
log,
|
|
519
|
+
manifestPath,
|
|
520
|
+
allowUnsignedManifest: options.allowUnsignedManifest,
|
|
521
|
+
}),
|
|
522
|
+
requireTargets
|
|
523
|
+
)
|
|
413
524
|
}
|
|
414
525
|
|
|
415
526
|
/** The `finalize` command — manifest-only; no Payload instance is loaded. */
|
|
@@ -428,3 +539,70 @@ export function runFinalize(
|
|
|
428
539
|
)
|
|
429
540
|
return Promise.resolve(finalized)
|
|
430
541
|
}
|
|
542
|
+
|
|
543
|
+
/** Options for `adopt-prelink` (issue #88). */
|
|
544
|
+
export type PrelinkCommandOptions = Pick<
|
|
545
|
+
ImportCommandOptions,
|
|
546
|
+
'configPath' | 'apply' | 'manifestPath' | 'json' | 'log' | 'loadPayload' | 'allowUnsignedManifest'
|
|
547
|
+
> & {
|
|
548
|
+
readonly usersSlug?: string | undefined
|
|
549
|
+
readonly verifiedField?: string | undefined
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The `adopt-prelink` command (issues #49, #88): pre-link every adopted
|
|
554
|
+
* user (one better-auth identity; the linking proof for verified
|
|
555
|
+
* emails). Dry run by default; `--apply` writes and records the
|
|
556
|
+
* manifest that `rollback --manifest` undoes exactly.
|
|
557
|
+
*/
|
|
558
|
+
export async function runAdoptPrelink(options: PrelinkCommandOptions): Promise<PrelinkResult> {
|
|
559
|
+
const log = log_(options)
|
|
560
|
+
const manifestPath = options.manifestPath ?? defaultPrelinkManifestPath
|
|
561
|
+
const existing = readManifest(manifestPath)
|
|
562
|
+
if (existing !== null && existing.kind !== 'adopt-prelink') {
|
|
563
|
+
throw new Error(
|
|
564
|
+
`the manifest at ${manifestPath} belongs to an import run, not adopt-prelink — pass a different --manifest`
|
|
565
|
+
)
|
|
566
|
+
}
|
|
567
|
+
if (existing?.finalized === true) {
|
|
568
|
+
throw new Error(`the manifest at ${manifestPath} is finalized; nothing to pre-link`)
|
|
569
|
+
}
|
|
570
|
+
const importOptions: ImportCommandOptions = {
|
|
571
|
+
configPath: options.configPath,
|
|
572
|
+
databaseUrl: '',
|
|
573
|
+
apply: options.apply,
|
|
574
|
+
sessions: 'carry',
|
|
575
|
+
...(options.loadPayload === undefined ? {} : { loadPayload: options.loadPayload }),
|
|
576
|
+
}
|
|
577
|
+
const result = await withTargetPayload(
|
|
578
|
+
importOptions,
|
|
579
|
+
(payload) =>
|
|
580
|
+
prelinkUsers({
|
|
581
|
+
payload,
|
|
582
|
+
apply: options.apply,
|
|
583
|
+
manifest: existing,
|
|
584
|
+
manifestPath,
|
|
585
|
+
usersSlug: options.usersSlug,
|
|
586
|
+
verifiedField: options.verifiedField,
|
|
587
|
+
log,
|
|
588
|
+
allowUnsignedManifest: options.allowUnsignedManifest,
|
|
589
|
+
}),
|
|
590
|
+
false
|
|
591
|
+
)
|
|
592
|
+
if (options.json !== true) {
|
|
593
|
+
log(
|
|
594
|
+
` users ${result.users}: ${result.linked} to link, ${result.alreadyLinked} already linked, ${result.proofGranted} verified-email proof grant(s), ${result.unverified} unverified (identity only)`
|
|
595
|
+
)
|
|
596
|
+
for (const row of result.unmappable) {
|
|
597
|
+
log(` X ${row.model} #${row.key}: ${row.reason}`)
|
|
598
|
+
}
|
|
599
|
+
if (result.unmappable.length === 0) {
|
|
600
|
+
log(
|
|
601
|
+
options.apply
|
|
602
|
+
? `applied: manifest at ${manifestPath} (mode 0600). Undo with: rollback --config <config> --manifest ${manifestPath}`
|
|
603
|
+
: 'dry run: nothing was written. Re-run with --apply to pre-link.'
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return result
|
|
608
|
+
}
|