@crombolo/transfer 0.0.0-stage → 0.10.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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * What a stored value refers to.
3
+ *
4
+ * `ReferenceRepository` answers the other direction — what refers to *this* node
5
+ * — by scanning for a known key. Exporting needs the outbound direction, from a
6
+ * value to the keys inside it, and nothing knew how to do that.
7
+ *
8
+ * Two spellings travel, as `references.ts` in `@crombolo/data` documents: a
9
+ * picker stores `umb://document/<hex>` or a bare uuid, dashed or not. Element
10
+ * pickers store a bare `Guid[]`, and rich text and block editors hold both
11
+ * inside JSON, so the scan goes through structures and through JSON held in a
12
+ * string.
13
+ *
14
+ * **These are candidates, not references.** An undashed 32-hex run is
15
+ * indistinguishable from any other 32 hex characters, so the caller resolves
16
+ * each against `node.unique_id` and discards what does not exist. Being wrong
17
+ * in this direction is cheap; missing a reference is not.
18
+ */
19
+ import { keyFromReference } from '@crombolo/core'
20
+
21
+ /** Dashed uuids, and 32-hex runs that are not part of a longer hex string. */
22
+ const DASHED = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
23
+ const PLAIN = /(?<![0-9a-f])[0-9a-f]{32}(?![0-9a-f])/gi
24
+
25
+ function fromString(text: string, into: Set<string>): void {
26
+ for (const match of text.matchAll(DASHED)) {
27
+ const key = keyFromReference(match[0])
28
+ if (key) into.add(key)
29
+ }
30
+ for (const match of text.matchAll(PLAIN)) {
31
+ const key = keyFromReference(match[0])
32
+ if (key) into.add(key)
33
+ }
34
+ }
35
+
36
+ function walk(value: unknown, into: Set<string>, depth: number): void {
37
+ // Block editors nest, but not deeply; the guard is against a cycle in a
38
+ // structure we did not build, not against legitimate nesting.
39
+ if (depth > 32) return
40
+ if (typeof value === 'string') {
41
+ fromString(value, into)
42
+ // A value is often JSON in a string. Scanning the text already found every
43
+ // key in it, so parsing adds nothing — except where a key is escaped or
44
+ // split across the encoding, which is why the parse is attempted at all.
45
+ if (/^\s*[[{]/.test(value)) {
46
+ try {
47
+ walk(JSON.parse(value), into, depth + 1)
48
+ } catch {
49
+ // Not JSON after all; the text scan above stands.
50
+ }
51
+ }
52
+ return
53
+ }
54
+ if (Array.isArray(value)) {
55
+ for (const item of value) walk(item, into, depth + 1)
56
+ return
57
+ }
58
+ if (typeof value === 'object' && value !== null) {
59
+ for (const item of Object.values(value)) walk(item, into, depth + 1)
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Every key-shaped candidate inside a value, dashed and lowercased. Resolve
65
+ * them against real nodes before treating any of them as a reference.
66
+ */
67
+ export function referenceCandidates(value: unknown): string[] {
68
+ const found = new Set<string>()
69
+ walk(value, found, 0)
70
+ return [...found].sort()
71
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `resolutions.json`, beside the bundle it answers.
3
+ *
4
+ * A decision is committed with the bundle rather than retyped per environment,
5
+ * for the reason `packaging-and-upgrades.md` gives for site-authored value
6
+ * migrations: the same fix-up should not be worked out again in test and then
7
+ * again in production, each time against more content.
8
+ *
9
+ * It holds resolutions **by node key**, so a path that means something
10
+ * different in the next environment cannot change what was decided.
11
+ */
12
+ import { existsSync, readFileSync } from 'node:fs'
13
+ import { join } from 'node:path'
14
+ import { isResolution, type Resolution } from './check.ts'
15
+
16
+ export const RESOLUTIONS_FILE = 'resolutions.json'
17
+
18
+ export interface ResolutionsFile {
19
+ /** The bundle these answers belong to; a mismatch is reported, not applied. */
20
+ bundleId: string
21
+ /** Where the bundle was placed, when it was re-rooted. */
22
+ under?: string | null
23
+ all?: Resolution | null
24
+ byNode: Record<string, Resolution>
25
+ }
26
+
27
+ export function writeResolutions(file: ResolutionsFile): string {
28
+ return `${JSON.stringify(
29
+ {
30
+ bundleId: file.bundleId,
31
+ under: file.under ?? null,
32
+ all: file.all ?? null,
33
+ byNode: Object.fromEntries(
34
+ Object.entries(file.byNode).sort(([a], [b]) => a.localeCompare(b)),
35
+ ),
36
+ },
37
+ null,
38
+ 2,
39
+ )}\n`
40
+ }
41
+
42
+ export interface LoadedResolutions {
43
+ file: ResolutionsFile | undefined
44
+ problems: string[]
45
+ }
46
+
47
+ /** Reads `resolutions.json` from a bundle directory, if it has one. */
48
+ export function loadResolutions(dir: string, bundleId: string): LoadedResolutions {
49
+ const path = join(dir, RESOLUTIONS_FILE)
50
+ if (!existsSync(path)) return { file: undefined, problems: [] }
51
+ const problems: string[] = []
52
+ let raw: unknown
53
+ try {
54
+ raw = JSON.parse(readFileSync(path, 'utf8'))
55
+ } catch (error) {
56
+ return {
57
+ file: undefined,
58
+ problems: [`${RESOLUTIONS_FILE}: not JSON: ${(error as Error).message}`],
59
+ }
60
+ }
61
+ if (typeof raw !== 'object' || raw === null)
62
+ return { file: undefined, problems: [`${RESOLUTIONS_FILE}: not an object`] }
63
+ const record = raw as Record<string, unknown>
64
+
65
+ // Answers to a different bundle are not answers to this one. Silently
66
+ // applying them would be worse than ignoring them.
67
+ const found = typeof record.bundleId === 'string' ? record.bundleId : ''
68
+ if (found && found !== bundleId)
69
+ problems.push(`${RESOLUTIONS_FILE}: answers bundle ${found}, not ${bundleId} — ignoring it`)
70
+
71
+ const byNode: Record<string, Resolution> = {}
72
+ const entries = typeof record.byNode === 'object' && record.byNode !== null ? record.byNode : {}
73
+ for (const [key, value] of Object.entries(entries as Record<string, unknown>)) {
74
+ if (typeof value === 'string' && isResolution(value)) byNode[key] = value
75
+ else problems.push(`${RESOLUTIONS_FILE}: "${key}" has no usable resolution`)
76
+ }
77
+ const all = typeof record.all === 'string' && isResolution(record.all) ? record.all : null
78
+
79
+ if (found && found !== bundleId) return { file: undefined, problems }
80
+ return {
81
+ file: {
82
+ bundleId: found || bundleId,
83
+ under: typeof record.under === 'string' ? record.under : null,
84
+ all,
85
+ byNode,
86
+ },
87
+ problems,
88
+ }
89
+ }
package/src/revert.ts ADDED
@@ -0,0 +1,387 @@
1
+ /**
2
+ * Putting an import back.
3
+ *
4
+ * This is the existing rollback path driven in bulk. `DocumentRepository.rollback`
5
+ * already appends the values as of a given event under a new event — history is
6
+ * never rewritten — so reverting a run is: for each node it touched, roll back
7
+ * to the event it was at beforehand, then restore the published state it was
8
+ * serving. Nodes the run *created* are unpublished and recycled, never deleted.
9
+ *
10
+ * Because the values are append-only, the revert is itself a run and can be
11
+ * reverted in turn.
12
+ *
13
+ * It has its own dry run for the same reason the import does: somebody may have
14
+ * edited a page after the import, and discarding that without asking would be
15
+ * the worst thing this command could do.
16
+ */
17
+ import {
18
+ appendCacheInstruction,
19
+ currentSchemaState,
20
+ type Db,
21
+ DocumentRepository,
22
+ type Finding,
23
+ Locks,
24
+ type NodeRow,
25
+ PublishBlockedError,
26
+ type TransferChange,
27
+ type TransferRun,
28
+ TransferRunRepository,
29
+ } from '@crombolo/data'
30
+
31
+ /** What to do about a node whose state has moved on since the import. */
32
+ export type RevertResolution = 'discard' | 'skip'
33
+
34
+ export const REVERT_RESOLUTIONS: readonly RevertResolution[] = ['discard', 'skip']
35
+
36
+ export const isRevertResolution = (value: string): value is RevertResolution =>
37
+ (REVERT_RESOLUTIONS as readonly string[]).includes(value)
38
+
39
+ export function revertResolutionsFor(code: string): readonly string[] {
40
+ switch (code) {
41
+ case 'edited-since':
42
+ return [
43
+ '--resolve <key>=discard to revert it anyway, losing the later edit',
44
+ '--resolve <key>=skip to leave the node as it is now',
45
+ ]
46
+ case 'children-added':
47
+ return [
48
+ '--resolve <key>=discard to recycle it with the children added since',
49
+ '--resolve <key>=skip to leave the whole branch alone',
50
+ ]
51
+ case 'later-run':
52
+ return ['revert the later run first', '--force to revert this one regardless']
53
+ case 'run-not-applied':
54
+ return ['nothing to do: this run has already been reverted']
55
+ default:
56
+ return []
57
+ }
58
+ }
59
+
60
+ export interface RevertOptions {
61
+ resolutions?: Readonly<Record<string, RevertResolution>>
62
+ resolveAll?: RevertResolution | undefined
63
+ /** Revert even though a later run touched the same nodes. */
64
+ force?: boolean
65
+ nodeId?: string
66
+ backOfficePath?: string
67
+ }
68
+
69
+ export interface RevertPlan {
70
+ key: string
71
+ name: string
72
+ /**
73
+ * `restore` puts values and published state back, `recycle` removes what the
74
+ * run created, and `unrecycle` brings back what a *reverted* run recycled —
75
+ * which is what makes a revert revertible in its own right.
76
+ */
77
+ action: 'restore' | 'recycle' | 'unrecycle' | 'skip' | 'gone'
78
+ resolution: RevertResolution | undefined
79
+ }
80
+
81
+ export interface RevertCheck {
82
+ runId: string
83
+ findings: Finding[]
84
+ outstanding: Finding[]
85
+ plan: RevertPlan[]
86
+ }
87
+
88
+ export interface RevertResult {
89
+ /** The new run, or undefined when the revert was refused. */
90
+ runId: string | undefined
91
+ check: RevertCheck
92
+ restored: string[]
93
+ recycled: string[]
94
+ }
95
+
96
+ const link = (backOfficePath: string | undefined, key: string): string | null =>
97
+ backOfficePath ? `${backOfficePath}/section/content/workspace/document/edit/${key}` : null
98
+
99
+ export async function checkRevert(
100
+ db: Db,
101
+ runId: string,
102
+ options: RevertOptions = {},
103
+ ): Promise<RevertCheck> {
104
+ const findings: Finding[] = []
105
+ const plan: RevertPlan[] = []
106
+ const resolutions = options.resolutions ?? {}
107
+ const runs = new TransferRunRepository(db)
108
+ const run = await runs.byId(runId)
109
+
110
+ const runFinding = (kind: Finding['kind'], code: string, message: string): Finding => ({
111
+ kind,
112
+ code,
113
+ subjectType: 'bundle',
114
+ subjectKey: runId,
115
+ subjectName: runId,
116
+ propertyAlias: null,
117
+ culture: null,
118
+ message,
119
+ link: null,
120
+ })
121
+
122
+ if (!run) {
123
+ findings.push(runFinding('blocking', 'run-not-applied', `there is no run ${runId} here`))
124
+ return { runId, findings, outstanding: findings, plan }
125
+ }
126
+ if (run.status !== 'applied') {
127
+ findings.push(
128
+ runFinding(
129
+ 'blocking',
130
+ 'run-not-applied',
131
+ `run ${runId} is ${run.status}, so there is nothing to put back`,
132
+ ),
133
+ )
134
+ return { runId, findings, outstanding: findings, plan }
135
+ }
136
+
137
+ // Reverting while a later run stands would quietly undo its work too.
138
+ if (!options.force)
139
+ for (const later of await runs.laterRunsTouching(runId))
140
+ findings.push(
141
+ runFinding(
142
+ 'blocking',
143
+ 'later-run',
144
+ `run ${later} touched the same content afterwards; revert that one first`,
145
+ ),
146
+ )
147
+
148
+ const state = await currentSchemaState(db)
149
+ const nodeState = { version: state.version, revision: state.revision }
150
+ const docs = new DocumentRepository(db, { nodeState })
151
+
152
+ const changes = await runs.changes(runId)
153
+ const ownKeys = new Set(changes.map((c) => c.nodeKey))
154
+
155
+ for (const change of changes) {
156
+ if (change.action === 'skip') continue
157
+ const resolution = resolutions[change.nodeKey] ?? options.resolveAll
158
+ const node = await docs.nodes.byKey(change.nodeKey)
159
+ const name = node?.text ?? change.nodeKey
160
+
161
+ if (!node) {
162
+ findings.push({
163
+ kind: 'auto',
164
+ code: 'node-gone',
165
+ subjectType: 'document',
166
+ subjectKey: change.nodeKey,
167
+ subjectName: name,
168
+ propertyAlias: null,
169
+ culture: null,
170
+ message: `${change.nodeKey} is no longer here, so there is nothing to put back`,
171
+ link: null,
172
+ })
173
+ plan.push({ key: change.nodeKey, name, action: 'gone', resolution })
174
+ continue
175
+ }
176
+
177
+ if (resolution === 'skip') {
178
+ plan.push({ key: change.nodeKey, name, action: 'skip', resolution })
179
+ continue
180
+ }
181
+
182
+ const created = change.action === 'create'
183
+ const repo = repoFor(db, nodeState, change.kind)
184
+
185
+ // Somebody saved after the import: reverting discards their work, so it is
186
+ // a decision rather than something to do quietly.
187
+ const markers = await repo.eventMarkers(change.nodeKey)
188
+ const movedOn =
189
+ change.eventId !== null && markers?.head !== undefined && markers.head > change.eventId
190
+ if (movedOn && resolution === undefined)
191
+ findings.push({
192
+ kind: 'person',
193
+ code: 'edited-since',
194
+ subjectType: change.kind === 'media' ? 'media' : 'document',
195
+ subjectKey: change.nodeKey,
196
+ subjectName: name,
197
+ propertyAlias: null,
198
+ culture: null,
199
+ message: `"${name}" has been saved since the import; reverting discards that`,
200
+ link: link(options.backOfficePath, change.nodeKey),
201
+ })
202
+
203
+ // A node the run created is going to the recycle bin, and anything added
204
+ // under it since goes with it.
205
+ if (created) {
206
+ // Only what somebody else put there counts. The run's own children are
207
+ // going to the bin anyway, and flagging them would make reverting any
208
+ // multi-node import need a decision for no reason.
209
+ const added = (await repo.nodes.descendants(node, [repo.objectType])).filter(
210
+ (d) => !ownKeys.has(d.key),
211
+ )
212
+ if (added.length > 0 && resolution === undefined)
213
+ findings.push({
214
+ kind: 'person',
215
+ code: 'children-added',
216
+ subjectType: change.kind === 'media' ? 'media' : 'document',
217
+ subjectKey: change.nodeKey,
218
+ subjectName: name,
219
+ propertyAlias: null,
220
+ culture: null,
221
+ message: `"${name}" was created by this run and has ${added.length} node(s) added under it since, which go to the recycle bin with it`,
222
+ link: link(options.backOfficePath, change.nodeKey),
223
+ })
224
+ }
225
+
226
+ plan.push({
227
+ key: change.nodeKey,
228
+ name,
229
+ action: created ? 'recycle' : change.action === 'recycle' ? 'unrecycle' : 'restore',
230
+ resolution,
231
+ })
232
+ }
233
+
234
+ return {
235
+ runId,
236
+ findings,
237
+ outstanding: findings.filter((f) => f.kind === 'blocking' || f.kind === 'person'),
238
+ plan,
239
+ }
240
+ }
241
+
242
+ function repoFor(
243
+ db: Db,
244
+ nodeState: { version: string; revision: string },
245
+ kind: string,
246
+ inTreeLock = false,
247
+ ): DocumentRepository {
248
+ return new DocumentRepository(db, {
249
+ kind: kind === 'media' || kind === 'element' || kind === 'blueprint' ? kind : 'document',
250
+ nodeState,
251
+ inTreeLock,
252
+ })
253
+ }
254
+
255
+ export async function revertRun(
256
+ db: Db,
257
+ runId: string,
258
+ options: RevertOptions = {},
259
+ ): Promise<RevertResult> {
260
+ return db.locks.withLock(Locks.ContentTree, async () =>
261
+ db.transaction(async (tx) => {
262
+ const check = await checkRevert(tx, runId, options)
263
+ if (check.outstanding.length > 0)
264
+ return { runId: undefined, check, restored: [], recycled: [] }
265
+
266
+ const runs = new TransferRunRepository(tx)
267
+ const original = (await runs.byId(runId)) as TransferRun
268
+ const changes = await runs.changes(runId)
269
+ const state = await currentSchemaState(tx)
270
+ const nodeState = { version: state.version, revision: state.revision }
271
+ const repo = (kind: string) => repoFor(tx, nodeState, kind, true)
272
+
273
+ const newRunId = crypto.randomUUID()
274
+ await runs.start({
275
+ id: newRunId,
276
+ bundleId: original.bundleId,
277
+ bundleLabel: `revert of ${runId}`,
278
+ packageName: original.packageName,
279
+ packageVersion: original.packageVersion,
280
+ direction: 'revert',
281
+ revertsRunId: runId,
282
+ schemaStateId: state.id,
283
+ startedAt: new Date(),
284
+ appliedBy: options.nodeId ?? null,
285
+ nodeCount: 0,
286
+ })
287
+
288
+ const skipped = new Set(
289
+ check.plan.filter((p) => p.action === 'skip' || p.action === 'gone').map((p) => p.key),
290
+ )
291
+ const todo = changes.filter((c) => c.action !== 'skip' && !skipped.has(c.nodeKey))
292
+ const byKey = new Map<string, NodeRow>()
293
+ for (const change of todo) {
294
+ const node = await repo(change.kind).nodes.byKey(change.nodeKey)
295
+ if (node) byKey.set(change.nodeKey, node)
296
+ }
297
+ /** Shallowest first: publishing needs parents live before children. */
298
+ const inTreeOrder = (list: readonly TransferChange[]) =>
299
+ [...list].sort(
300
+ (a, b) => (byKey.get(a.nodeKey)?.level ?? 0) - (byKey.get(b.nodeKey)?.level ?? 0),
301
+ )
302
+
303
+ const restored: string[] = []
304
+ const recycled: string[] = []
305
+
306
+ // Where each node stands *now*, before this revert touches it, so the
307
+ // revert records its own "before" and can itself be reverted.
308
+ const before = new Map<string, { head: number | undefined; published: number | undefined }>()
309
+ for (const change of todo) {
310
+ const markers = await repo(change.kind).eventMarkers(change.nodeKey)
311
+ if (markers) before.set(change.nodeKey, markers)
312
+ }
313
+
314
+ // What the previous run recycled comes back out of the bin first, so the
315
+ // rollback and publish below have a node in the tree to work on.
316
+ for (const change of inTreeOrder(todo)) {
317
+ if (change.action !== 'recycle') continue
318
+ await repo(change.kind).restore(change.nodeKey, undefined)
319
+ restored.push(change.nodeKey)
320
+ }
321
+
322
+ // Values, for everything the run changed rather than created.
323
+ for (const change of todo) {
324
+ if (change.action === 'create' || change.beforeEventId === null) continue
325
+ await repo(change.kind).rollback(String(change.beforeEventId))
326
+ if (!restored.includes(change.nodeKey)) restored.push(change.nodeKey)
327
+ }
328
+
329
+ // Then the published state, parents before children: a published node
330
+ // under an unpublished one is unreachable, so the repository refuses it.
331
+ for (const change of inTreeOrder(todo)) {
332
+ if (change.action === 'create' || change.kind === 'media') continue
333
+ const documents = repo(change.kind)
334
+ try {
335
+ if (change.beforePublishedEventId !== null) {
336
+ // It was live before, so put it back in front of visitors. The
337
+ // values are the ones just restored, so what it serves is what it
338
+ // served.
339
+ await documents.publish(change.nodeKey, null)
340
+ } else {
341
+ await documents.unpublish(change.nodeKey, null)
342
+ }
343
+ } catch (error) {
344
+ // An ancestor this same revert is about to recycle can make a publish
345
+ // impossible, and the node is going away regardless.
346
+ if (!(error instanceof PublishBlockedError)) throw error
347
+ }
348
+ }
349
+
350
+ // Finally what the run created, shallowest first: the recycle bin takes a
351
+ // whole branch, so a deeper node is already gone by the time we reach it.
352
+ for (const change of inTreeOrder(todo)) {
353
+ if (change.action !== 'create') continue
354
+ const node = await repo(change.kind).nodes.byKey(change.nodeKey)
355
+ if (!node || node.trashed) continue
356
+ await repo(change.kind).moveToRecycleBin(change.nodeKey)
357
+ recycled.push(change.nodeKey)
358
+ }
359
+
360
+ for (const change of todo) {
361
+ const markers = await repo(change.kind).eventMarkers(change.nodeKey)
362
+ const was = before.get(change.nodeKey)
363
+ await runs.record({
364
+ runId: newRunId,
365
+ nodeKey: change.nodeKey,
366
+ kind: change.kind,
367
+ // `recycle` tells a revert of *this* run to bring the node back out
368
+ // of the bin, not merely to restore its values.
369
+ action: recycled.includes(change.nodeKey) ? 'recycle' : 'update',
370
+ beforeEventId: was?.head ?? null,
371
+ beforePublishedEventId: was?.published ?? null,
372
+ eventId: markers?.head ?? null,
373
+ })
374
+ }
375
+ await runs.finish(newRunId, todo.length)
376
+ await runs.setStatus(runId, 'reverted')
377
+
378
+ await appendCacheInstruction(tx, {
379
+ kind: 'content',
380
+ payload: { revert: runId },
381
+ by: options.nodeId,
382
+ })
383
+
384
+ return { runId: newRunId, check, restored, recycled }
385
+ }),
386
+ )
387
+ }