@payload-auth-link/migrate 0.5.0 → 0.7.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/README.md +20 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +88 -27
- package/dist/cli.js.map +1 -1
- package/dist/doctor/plugins.d.ts.map +1 -1
- package/dist/doctor/plugins.js +6 -2
- package/dist/doctor/plugins.js.map +1 -1
- package/dist/doctor/run-doctor.d.ts.map +1 -1
- package/dist/doctor/run-doctor.js +27 -2
- package/dist/doctor/run-doctor.js.map +1 -1
- package/dist/doctor/users.d.ts +13 -1
- package/dist/doctor/users.d.ts.map +1 -1
- package/dist/doctor/users.js +17 -7
- package/dist/doctor/users.js.map +1 -1
- package/dist/exporters.d.ts +43 -5
- package/dist/exporters.d.ts.map +1 -1
- package/dist/exporters.js +88 -5
- package/dist/exporters.js.map +1 -1
- package/dist/importer/apply.d.ts +2 -0
- package/dist/importer/apply.d.ts.map +1 -1
- package/dist/importer/apply.js +88 -24
- package/dist/importer/apply.js.map +1 -1
- package/dist/importer/guards.d.ts +49 -0
- package/dist/importer/guards.d.ts.map +1 -0
- package/dist/importer/guards.js +175 -0
- package/dist/importer/guards.js.map +1 -0
- package/dist/importer/host-writes.d.ts +6 -2
- package/dist/importer/host-writes.d.ts.map +1 -1
- package/dist/importer/host-writes.js +9 -4
- package/dist/importer/host-writes.js.map +1 -1
- package/dist/importer/lifecycle.d.ts +60 -13
- package/dist/importer/lifecycle.d.ts.map +1 -1
- package/dist/importer/lifecycle.js +130 -24
- package/dist/importer/lifecycle.js.map +1 -1
- package/dist/importer/manifest-mac.d.ts +27 -0
- package/dist/importer/manifest-mac.d.ts.map +1 -0
- package/dist/importer/manifest-mac.js +93 -0
- package/dist/importer/manifest-mac.js.map +1 -0
- package/dist/importer/prelink.d.ts +2 -0
- package/dist/importer/prelink.d.ts.map +1 -1
- package/dist/importer/prelink.js +20 -3
- package/dist/importer/prelink.js.map +1 -1
- package/dist/importer/run-import.d.ts +24 -5
- package/dist/importer/run-import.d.ts.map +1 -1
- package/dist/importer/run-import.js +36 -7
- package/dist/importer/run-import.js.map +1 -1
- package/dist/importer/types.d.ts +12 -1
- package/dist/importer/types.d.ts.map +1 -1
- package/dist/importer/types.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/cli.ts +87 -27
- package/src/doctor/plugins.ts +8 -3
- package/src/doctor/run-doctor.ts +30 -3
- package/src/doctor/users.ts +25 -7
- package/src/exporters.ts +113 -12
- package/src/importer/apply.ts +103 -23
- package/src/importer/guards.ts +223 -0
- package/src/importer/host-writes.ts +15 -4
- package/src/importer/lifecycle.ts +193 -32
- package/src/importer/manifest-mac.ts +125 -0
- package/src/importer/prelink.ts +24 -3
- package/src/importer/run-import.ts +64 -12
- package/src/importer/types.ts +12 -1
- package/src/index.ts +10 -1
package/src/importer/apply.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
+
import { isRecord } from '@payload-auth-link/core'
|
|
1
2
|
import type { Payload } from 'payload'
|
|
3
|
+
import { assertCreatedRowsScoped, finalizeMarkerState, hostRolesField } from './guards.js'
|
|
2
4
|
import { capturedKeys, captureBefore } from './host-writes.js'
|
|
5
|
+
import { authenticateManifest, signManifest } from './manifest-mac.js'
|
|
3
6
|
import {
|
|
4
7
|
readFlatRows,
|
|
5
8
|
openSource,
|
|
@@ -69,6 +72,8 @@ export type ApplyOptions = {
|
|
|
69
72
|
/** Injectable clock for deterministic `linkedAt` stamps. */
|
|
70
73
|
readonly now?: () => Date
|
|
71
74
|
readonly log?: (message: string) => void
|
|
75
|
+
/** Resume from a pre-0.6.0 manifest that has no MAC (`--allow-unsigned-manifest`). */
|
|
76
|
+
readonly allowUnsignedManifest?: boolean | undefined
|
|
72
77
|
}
|
|
73
78
|
|
|
74
79
|
const slugsOf = (overrides?: Readonly<Record<string, string>>): TargetSlugMap => ({
|
|
@@ -251,6 +256,41 @@ function identityRows(
|
|
|
251
256
|
]
|
|
252
257
|
}
|
|
253
258
|
|
|
259
|
+
/** The host user document as stored (depth 0). */
|
|
260
|
+
async function hostDoc(payload: Payload, id: number): Promise<Record<string, unknown> | null> {
|
|
261
|
+
const doc: unknown = await payload.findByID({
|
|
262
|
+
collection: 'users',
|
|
263
|
+
id,
|
|
264
|
+
depth: 0,
|
|
265
|
+
overrideAccess: true,
|
|
266
|
+
})
|
|
267
|
+
return isRecord(doc) ? doc : null
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The doc already carries exactly the identity the import writes. */
|
|
271
|
+
function hasExactIdentity(doc: Record<string, unknown> | null, user: SourceUser): boolean {
|
|
272
|
+
const identities = doc?.['identities']
|
|
273
|
+
if (!Array.isArray(identities) || identities.length !== 1) {
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
const only: unknown = identities[0]
|
|
277
|
+
return (
|
|
278
|
+
isRecord(only) &&
|
|
279
|
+
only['providerId'] === 'better-auth' &&
|
|
280
|
+
String(only['providerUserId']) === String(user.id) &&
|
|
281
|
+
only['email'] === user.email
|
|
282
|
+
)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Same stored values, same order. */
|
|
286
|
+
function sameValues(stored: unknown, wanted: readonly string[]): boolean {
|
|
287
|
+
return (
|
|
288
|
+
Array.isArray(stored) &&
|
|
289
|
+
stored.length === wanted.length &&
|
|
290
|
+
stored.every((value, index) => String(value) === wanted[index])
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
|
|
254
294
|
export async function applyImport(options: ApplyOptions): Promise<ImportResult> {
|
|
255
295
|
const {
|
|
256
296
|
payload,
|
|
@@ -262,6 +302,28 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
|
|
|
262
302
|
} = options
|
|
263
303
|
const slugs = slugsOf(options.slugs)
|
|
264
304
|
const sourceTables: ResolvedSourceTables = resolveSourceTables(options.sourceTables)
|
|
305
|
+
if (options.manifest !== null) {
|
|
306
|
+
// Resuming merges the old entries and re-signs them: never launder
|
|
307
|
+
// an edited manifest into a signed one, never sign created rows
|
|
308
|
+
// outside the import's own targets, and never resume a migration
|
|
309
|
+
// the database records as finalized.
|
|
310
|
+
const trust = authenticateManifest(payload.secret, options.manifest, {
|
|
311
|
+
allowUnsigned: options.allowUnsignedManifest,
|
|
312
|
+
})
|
|
313
|
+
assertCreatedRowsScoped(options.manifest, slugs)
|
|
314
|
+
const finalized =
|
|
315
|
+
options.manifest.finalized ||
|
|
316
|
+
(apply &&
|
|
317
|
+
(await finalizeMarkerState(payload, options.manifest, {
|
|
318
|
+
anyOfKind: trust !== 'signed',
|
|
319
|
+
})) === 'finalized')
|
|
320
|
+
if (apply && finalized) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
'the migration is finalized; a finalized manifest is not resumed — keep it as the record, and pass a different --manifest for a new run'
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const rolesField = hostRolesField(payload)
|
|
265
327
|
const created: CreatedRow[] = [...(options.manifest?.created ?? [])]
|
|
266
328
|
const fieldWrites: FieldWrite[] = [...(options.manifest?.fieldWrites ?? [])]
|
|
267
329
|
// First capture wins (issue #88): fields captured by an earlier pass
|
|
@@ -343,35 +405,53 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
|
|
|
343
405
|
// restores the pre-import document exactly.
|
|
344
406
|
const hostId = Number(baId)
|
|
345
407
|
if (Number.isSafeInteger(hostId)) {
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
fieldWriteCount += newlyCaptured
|
|
355
|
-
const writes: Record<string, unknown> = {
|
|
356
|
-
identities: identityRows(user, now),
|
|
408
|
+
// Only the values that DIFFER are written (issue #110): a
|
|
409
|
+
// second --apply over an imported user is a true no-op — no
|
|
410
|
+
// Local API update, so no fresh `updatedAt`, `linkedAt`, or
|
|
411
|
+
// re-created hasMany child rows.
|
|
412
|
+
const current = await hostDoc(payload, hostId)
|
|
413
|
+
const writes: Record<string, unknown> = {}
|
|
414
|
+
if (!hasExactIdentity(current, user)) {
|
|
415
|
+
writes.identities = identityRows(user, now)
|
|
357
416
|
}
|
|
358
|
-
if (
|
|
417
|
+
if (
|
|
418
|
+
user.roles.length > 0 &&
|
|
419
|
+
current !== null &&
|
|
420
|
+
rolesField in current &&
|
|
421
|
+
!sameValues(current[rolesField], user.roles)
|
|
422
|
+
) {
|
|
359
423
|
// Stored role values pass through unchanged (issue #56: the
|
|
360
424
|
// storage edge owns translation; the map is the app's).
|
|
361
|
-
writes
|
|
425
|
+
writes[rolesField] = user.roles
|
|
362
426
|
}
|
|
363
|
-
if (
|
|
427
|
+
if (
|
|
428
|
+
user.emailVerified &&
|
|
429
|
+
(current?.['linkable'] !== true || current['linkableEmail'] !== user.email)
|
|
430
|
+
) {
|
|
364
431
|
// Grant-only: the proof is bound to the address (both
|
|
365
432
|
// fields), and an existing proof is never revoked here.
|
|
366
433
|
writes.linkable = true
|
|
367
434
|
writes.linkableEmail = user.email
|
|
368
435
|
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
436
|
+
if (Object.keys(writes).length > 0) {
|
|
437
|
+
// The prior values (and the doc's updatedAt) are captured
|
|
438
|
+
// first — once — so rollback restores the pre-import doc.
|
|
439
|
+
const { newlyCaptured } = await captureBefore({
|
|
440
|
+
payload,
|
|
441
|
+
collection: 'users',
|
|
442
|
+
id: hostId,
|
|
443
|
+
fields: ['identities', rolesField, 'linkable', 'linkableEmail'],
|
|
444
|
+
captured,
|
|
445
|
+
into: fieldWrites,
|
|
446
|
+
})
|
|
447
|
+
fieldWriteCount += newlyCaptured
|
|
448
|
+
await payload.update({
|
|
449
|
+
collection: 'users',
|
|
450
|
+
id: hostId,
|
|
451
|
+
data: writes,
|
|
452
|
+
overrideAccess: true,
|
|
453
|
+
})
|
|
454
|
+
}
|
|
375
455
|
}
|
|
376
456
|
}
|
|
377
457
|
record({
|
|
@@ -732,8 +812,8 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
|
|
|
732
812
|
if (apply) {
|
|
733
813
|
// The manifest is the run's contract: merge this pass onto any
|
|
734
814
|
// resumed run's rows and write it back (issue #50).
|
|
735
|
-
const manifest: ImportManifest = {
|
|
736
|
-
version:
|
|
815
|
+
const manifest: ImportManifest = signManifest(payload.secret, {
|
|
816
|
+
version: 3,
|
|
737
817
|
kind: 'import',
|
|
738
818
|
createdAt: options.manifest?.createdAt ?? now().toISOString(),
|
|
739
819
|
sourceDatabase: options.sourceDatabase,
|
|
@@ -743,7 +823,7 @@ export async function applyImport(options: ApplyOptions): Promise<ImportResult>
|
|
|
743
823
|
finalized: options.manifest?.finalized ?? false,
|
|
744
824
|
sourceCounts: options.sourceCounts,
|
|
745
825
|
totals,
|
|
746
|
-
}
|
|
826
|
+
})
|
|
747
827
|
const { writeManifest } = await import('./lifecycle.js')
|
|
748
828
|
writeManifest(manifest, options.manifestPath)
|
|
749
829
|
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { isRecord } from '@payload-auth-link/core'
|
|
2
|
+
import type { Payload } from 'payload'
|
|
3
|
+
import {
|
|
4
|
+
defaultTargetSlugs,
|
|
5
|
+
type CreatedRow,
|
|
6
|
+
type ImportManifest,
|
|
7
|
+
type TargetModel,
|
|
8
|
+
type TargetSlugMap,
|
|
9
|
+
} from './types.js'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The guards every destructive manifest operation runs, independent of
|
|
13
|
+
* the manifest's signature:
|
|
14
|
+
*
|
|
15
|
+
* - created-row scoping: a rollback only ever deletes rows the importer
|
|
16
|
+
* itself creates — rows in the import's own target collections whose
|
|
17
|
+
* deterministic `baId` matches the source key the manifest names;
|
|
18
|
+
* - the finalize marker: `finalize` records the point of no return in
|
|
19
|
+
* the target DATABASE (Payload's key-value store), so a manifest file
|
|
20
|
+
* alone can never reopen a finalized migration;
|
|
21
|
+
* - the host roles field the app configured on `authLink`.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** The `baId` prefix the importer stamps per model (see apply.ts). */
|
|
25
|
+
const baIdPrefix: Readonly<Record<TargetModel, string>> = {
|
|
26
|
+
user: '',
|
|
27
|
+
account: 'acct-',
|
|
28
|
+
session: 'sess-',
|
|
29
|
+
verification: 'ver-',
|
|
30
|
+
apikey: 'key-',
|
|
31
|
+
jwks: 'jwks-',
|
|
32
|
+
organization: 'org-',
|
|
33
|
+
member: 'member-',
|
|
34
|
+
invitation: 'inv-',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const isTargetModel = (value: string): value is TargetModel => value in baIdPrefix
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The model and deterministic `baId` a created row's `sourceKey`
|
|
41
|
+
* (`<model>:<source id>`) names, or `null` when it names none.
|
|
42
|
+
*/
|
|
43
|
+
export function expectedBaId(
|
|
44
|
+
sourceKey: string
|
|
45
|
+
): { readonly model: TargetModel; readonly baId: string } | null {
|
|
46
|
+
const colon = sourceKey.indexOf(':')
|
|
47
|
+
if (colon <= 0) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
const model = sourceKey.slice(0, colon)
|
|
51
|
+
const sourceId = sourceKey.slice(colon + 1)
|
|
52
|
+
if (!isTargetModel(model) || sourceId === '') {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
return { model, baId: `${baIdPrefix[model]}${sourceId}` }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The target slugs with the defaults filled in. */
|
|
59
|
+
export function targetSlugsOf(overrides?: Readonly<Record<string, string>>): TargetSlugMap {
|
|
60
|
+
return { ...defaultTargetSlugs, ...(overrides ?? {}) }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Refuse a manifest whose created rows reach outside what the importer
|
|
65
|
+
* creates, BEFORE anything is deleted (or re-signed): an `adopt-prelink`
|
|
66
|
+
* manifest creates no rows at all, and an import's rows must sit in the
|
|
67
|
+
* import's own target collections, under the model their source key
|
|
68
|
+
* names. Signed or not — the signature proves who wrote the file, this
|
|
69
|
+
* proves the file only names the kit's own rows.
|
|
70
|
+
*/
|
|
71
|
+
export function assertCreatedRowsScoped(
|
|
72
|
+
manifest: Pick<ImportManifest, 'kind' | 'created'>,
|
|
73
|
+
slugs: TargetSlugMap
|
|
74
|
+
): void {
|
|
75
|
+
if (manifest.kind === 'adopt-prelink') {
|
|
76
|
+
if (manifest.created.length > 0) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`the adopt-prelink manifest lists ${manifest.created.length} created row(s), which adopt-prelink never creates (edited manifest?); refusing to use it — nothing was deleted`
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
const bySlug = new Map<string, TargetModel>()
|
|
84
|
+
for (const model of Object.keys(baIdPrefix) as TargetModel[]) {
|
|
85
|
+
bySlug.set(slugs[model], model)
|
|
86
|
+
}
|
|
87
|
+
const outside: string[] = []
|
|
88
|
+
for (const row of manifest.created) {
|
|
89
|
+
const expected = expectedBaId(row.sourceKey)
|
|
90
|
+
const model = bySlug.get(row.collection)
|
|
91
|
+
if (model === undefined || expected === null || expected.model !== model) {
|
|
92
|
+
outside.push(`${row.collection}#${String(row.id)} (${row.sourceKey})`)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (outside.length > 0) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`the manifest lists ${outside.length} created row(s) outside the import's target collections (${[...bySlug.keys()].join(', ')}): ${outside.slice(0, 10).join(', ')}${outside.length > 10 ? ', …' : ''}. The importer only creates rows there; refusing to use the manifest — nothing was deleted. If your engine wiring renames the targets, pass the same --slugs the import ran with`
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A created row whose `baId` no longer matches its source key. */
|
|
103
|
+
export function baIdMismatch(row: CreatedRow, doc: unknown): boolean {
|
|
104
|
+
const expected = expectedBaId(row.sourceKey)
|
|
105
|
+
if (expected === null) {
|
|
106
|
+
return true
|
|
107
|
+
}
|
|
108
|
+
return !isRecord(doc) || String(doc['baId']) !== expected.baId
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Payload's key-value store, reached structurally (Payload 3.6x+). */
|
|
112
|
+
type KeyValueStore = {
|
|
113
|
+
readonly get: (key: string) => Promise<unknown>
|
|
114
|
+
readonly set: (key: string, value: NonNullable<unknown>) => Promise<void>
|
|
115
|
+
readonly keys: () => Promise<string[]>
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function keyValueStore(payload: Payload): KeyValueStore | null {
|
|
119
|
+
const kv: unknown = (payload as { kv?: unknown }).kv
|
|
120
|
+
if (
|
|
121
|
+
isRecord(kv) &&
|
|
122
|
+
typeof kv['get'] === 'function' &&
|
|
123
|
+
typeof kv['set'] === 'function' &&
|
|
124
|
+
typeof kv['keys'] === 'function'
|
|
125
|
+
) {
|
|
126
|
+
return kv as unknown as KeyValueStore
|
|
127
|
+
}
|
|
128
|
+
return null
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const markerPrefix = 'payload-auth-link/migrate/finalized/'
|
|
132
|
+
|
|
133
|
+
/** The finalize marker key of one manifest (its kind + first-apply stamp). */
|
|
134
|
+
export function finalizeMarkerKey(manifest: Pick<ImportManifest, 'kind' | 'createdAt'>): string {
|
|
135
|
+
return `${markerPrefix}${manifest.kind ?? 'import'}/${manifest.createdAt}`
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Record the point of no return in the target database. One-way: the
|
|
140
|
+
* kit never deletes the marker. Throws when the store is unavailable —
|
|
141
|
+
* `finalize` must not report success it cannot back.
|
|
142
|
+
*/
|
|
143
|
+
export async function recordFinalizeMarker(
|
|
144
|
+
payload: Payload,
|
|
145
|
+
manifest: Pick<ImportManifest, 'kind' | 'createdAt'>,
|
|
146
|
+
finalizedAt: string
|
|
147
|
+
): Promise<void> {
|
|
148
|
+
const kv = keyValueStore(payload)
|
|
149
|
+
if (kv === null) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
"this Payload version exposes no key-value store (payload.kv); finalize records its marker there — upgrade Payload to a release in the kit's supported band"
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
await kv.set(finalizeMarkerKey(manifest), {
|
|
156
|
+
kind: manifest.kind ?? 'import',
|
|
157
|
+
createdAt: manifest.createdAt,
|
|
158
|
+
finalizedAt,
|
|
159
|
+
})
|
|
160
|
+
} catch (error) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`could not record the finalize marker in Payload's key-value store (the payload-kv collection): ${error instanceof Error ? error.message : String(error)} — create its table (run your Payload migrations) and re-run finalize; the manifest was not changed`,
|
|
163
|
+
{ cause: error }
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Whether the database says this migration was finalized. `anyOfKind`
|
|
170
|
+
* widens the check to every finalized manifest of the same kind — used
|
|
171
|
+
* when the manifest's own `createdAt` is not covered by a signature.
|
|
172
|
+
* Returns `'unavailable'` when the store cannot be read; the caller
|
|
173
|
+
* decides (and logs) what that means.
|
|
174
|
+
*/
|
|
175
|
+
export async function finalizeMarkerState(
|
|
176
|
+
payload: Payload,
|
|
177
|
+
manifest: Pick<ImportManifest, 'kind' | 'createdAt'>,
|
|
178
|
+
options: { readonly anyOfKind: boolean }
|
|
179
|
+
): Promise<'finalized' | 'open' | 'unavailable'> {
|
|
180
|
+
const kv = keyValueStore(payload)
|
|
181
|
+
if (kv === null) {
|
|
182
|
+
return 'unavailable'
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
if ((await kv.get(finalizeMarkerKey(manifest))) !== null) {
|
|
186
|
+
return 'finalized'
|
|
187
|
+
}
|
|
188
|
+
if (options.anyOfKind) {
|
|
189
|
+
const kindPrefix = `${markerPrefix}${manifest.kind ?? 'import'}/`
|
|
190
|
+
const keys = await kv.keys()
|
|
191
|
+
if (keys.some((key) => key.startsWith(kindPrefix))) {
|
|
192
|
+
return 'finalized'
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return 'open'
|
|
196
|
+
} catch {
|
|
197
|
+
return 'unavailable'
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The host roles field the app configured (`authLink({ rolesField })`),
|
|
203
|
+
* read off the plugin entry in the booted config (Payload keeps each
|
|
204
|
+
* plugin's `slug` and `options` for cross-plugin discovery). Defaults to
|
|
205
|
+
* the plugin's own default, `roles`.
|
|
206
|
+
*/
|
|
207
|
+
export function hostRolesField(payload: Payload): string {
|
|
208
|
+
const plugins: unknown = (payload.config as { plugins?: unknown }).plugins
|
|
209
|
+
if (Array.isArray(plugins)) {
|
|
210
|
+
for (const plugin of plugins as unknown[]) {
|
|
211
|
+
if (typeof plugin !== 'function') {
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
const entry = plugin as { slug?: unknown; options?: unknown }
|
|
215
|
+
if (entry.slug !== 'payload-auth-link') {
|
|
216
|
+
continue
|
|
217
|
+
}
|
|
218
|
+
const rolesField = isRecord(entry.options) ? entry.options['rolesField'] : undefined
|
|
219
|
+
return typeof rolesField === 'string' && rolesField !== '' ? rolesField : 'roles'
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return 'roles'
|
|
223
|
+
}
|
|
@@ -92,9 +92,18 @@ export const restorableFields: ReadonlySet<string> = new Set([
|
|
|
92
92
|
'updatedAt',
|
|
93
93
|
])
|
|
94
94
|
|
|
95
|
+
/** {@link restorableFields} plus the app's configured roles field. */
|
|
96
|
+
export function restorableFieldsFor(rolesField: string): ReadonlySet<string> {
|
|
97
|
+
return new Set([...restorableFields, rolesField])
|
|
98
|
+
}
|
|
99
|
+
|
|
95
100
|
/** The captured prior value of one manifest entry, JSON-parsed. */
|
|
96
|
-
export function priorValue(
|
|
97
|
-
|
|
101
|
+
export function priorValue(
|
|
102
|
+
secret: string,
|
|
103
|
+
write: FieldWrite,
|
|
104
|
+
restorable: ReadonlySet<string> = restorableFields
|
|
105
|
+
): unknown {
|
|
106
|
+
if (!restorable.has(write.field)) {
|
|
98
107
|
throw new Error(
|
|
99
108
|
`manifest entry for '${write.collection}.${write.field}' is not a field this kit writes; refusing to restore it (edited manifest?)`
|
|
100
109
|
)
|
|
@@ -170,8 +179,10 @@ export async function captureBefore(options: {
|
|
|
170
179
|
*/
|
|
171
180
|
export async function restoreCaptured(
|
|
172
181
|
payload: Payload,
|
|
173
|
-
manifest: Pick<ImportManifest, 'fieldWrites'
|
|
182
|
+
manifest: Pick<ImportManifest, 'fieldWrites'>,
|
|
183
|
+
options: { readonly rolesField?: string | undefined } = {}
|
|
174
184
|
): Promise<number> {
|
|
185
|
+
const restorable = restorableFieldsFor(options.rolesField ?? 'roles')
|
|
175
186
|
const byDoc = new Map<
|
|
176
187
|
string,
|
|
177
188
|
{ collection: string; id: string | number; data: Record<string, unknown> }
|
|
@@ -179,7 +190,7 @@ export async function restoreCaptured(
|
|
|
179
190
|
for (const write of manifest.fieldWrites) {
|
|
180
191
|
const key = `${write.collection}\u0000${String(write.id)}`
|
|
181
192
|
const entry = byDoc.get(key) ?? { collection: write.collection, id: write.id, data: {} }
|
|
182
|
-
entry.data[write.field] = priorValue(payload.secret, write)
|
|
193
|
+
entry.data[write.field] = priorValue(payload.secret, write, restorable)
|
|
183
194
|
byDoc.set(key, entry)
|
|
184
195
|
}
|
|
185
196
|
let restored = 0
|