@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +32 -4
- package/src/check.ts +552 -0
- package/src/export.ts +316 -0
- package/src/import.ts +331 -0
- package/src/index.ts +10 -0
- package/src/load.ts +360 -0
- package/src/model.ts +194 -0
- package/src/package.ts +84 -0
- package/src/references.ts +71 -0
- package/src/resolutions.ts +89 -0
- package/src/revert.ts +387 -0
- package/src/write.ts +220 -0
- package/README.md +0 -3
|
@@ -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
|
+
}
|