@payload-auth-link/migrate 0.0.0-stage → 0.2.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.
Files changed (72) hide show
  1. package/README.md +49 -2
  2. package/dist/cli.d.ts +7 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +257 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/doctor/bands.d.ts +16 -0
  7. package/dist/doctor/bands.d.ts.map +1 -0
  8. package/dist/doctor/bands.js +49 -0
  9. package/dist/doctor/bands.js.map +1 -0
  10. package/dist/doctor/database.d.ts +3 -0
  11. package/dist/doctor/database.d.ts.map +1 -0
  12. package/dist/doctor/database.js +110 -0
  13. package/dist/doctor/database.js.map +1 -0
  14. package/dist/doctor/plugins.d.ts +12 -0
  15. package/dist/doctor/plugins.d.ts.map +1 -0
  16. package/dist/doctor/plugins.js +202 -0
  17. package/dist/doctor/plugins.js.map +1 -0
  18. package/dist/doctor/run-doctor.d.ts +48 -0
  19. package/dist/doctor/run-doctor.d.ts.map +1 -0
  20. package/dist/doctor/run-doctor.js +236 -0
  21. package/dist/doctor/run-doctor.js.map +1 -0
  22. package/dist/doctor/source-scan.d.ts +10 -0
  23. package/dist/doctor/source-scan.d.ts.map +1 -0
  24. package/dist/doctor/source-scan.js +142 -0
  25. package/dist/doctor/source-scan.js.map +1 -0
  26. package/dist/doctor/types.d.ts +86 -0
  27. package/dist/doctor/types.d.ts.map +1 -0
  28. package/dist/doctor/types.js +7 -0
  29. package/dist/doctor/types.js.map +1 -0
  30. package/dist/doctor/users.d.ts +10 -0
  31. package/dist/doctor/users.d.ts.map +1 -0
  32. package/dist/doctor/users.js +176 -0
  33. package/dist/doctor/users.js.map +1 -0
  34. package/dist/importer/apply.d.ts +39 -0
  35. package/dist/importer/apply.d.ts.map +1 -0
  36. package/dist/importer/apply.js +588 -0
  37. package/dist/importer/apply.js.map +1 -0
  38. package/dist/importer/lifecycle.d.ts +52 -0
  39. package/dist/importer/lifecycle.d.ts.map +1 -0
  40. package/dist/importer/lifecycle.js +265 -0
  41. package/dist/importer/lifecycle.js.map +1 -0
  42. package/dist/importer/run-import.d.ts +40 -0
  43. package/dist/importer/run-import.d.ts.map +1 -0
  44. package/dist/importer/run-import.js +176 -0
  45. package/dist/importer/run-import.js.map +1 -0
  46. package/dist/importer/source.d.ts +49 -0
  47. package/dist/importer/source.d.ts.map +1 -0
  48. package/dist/importer/source.js +147 -0
  49. package/dist/importer/source.js.map +1 -0
  50. package/dist/importer/types.d.ts +69 -0
  51. package/dist/importer/types.d.ts.map +1 -0
  52. package/dist/importer/types.js +13 -0
  53. package/dist/importer/types.js.map +1 -0
  54. package/dist/index.d.ts +19 -0
  55. package/dist/index.d.ts.map +1 -0
  56. package/dist/index.js +17 -0
  57. package/dist/index.js.map +1 -0
  58. package/package.json +70 -4
  59. package/src/cli.ts +287 -0
  60. package/src/doctor/bands.ts +66 -0
  61. package/src/doctor/database.ts +130 -0
  62. package/src/doctor/plugins.ts +216 -0
  63. package/src/doctor/run-doctor.ts +278 -0
  64. package/src/doctor/source-scan.ts +148 -0
  65. package/src/doctor/types.ts +92 -0
  66. package/src/doctor/users.ts +207 -0
  67. package/src/importer/apply.ts +731 -0
  68. package/src/importer/lifecycle.ts +328 -0
  69. package/src/importer/run-import.ts +237 -0
  70. package/src/importer/source.ts +211 -0
  71. package/src/importer/types.ts +96 -0
  72. 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
+ }