@crombolo/transfer 0.0.0-stage → 0.9.1
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 +327 -0
- package/src/index.ts +9 -0
- package/src/load.ts +360 -0
- package/src/model.ts +194 -0
- package/src/references.ts +71 -0
- package/src/resolutions.ts +89 -0
- package/src/revert.ts +385 -0
- package/src/write.ts +220 -0
- package/README.md +0 -3
|
@@ -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 `10-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,385 @@
|
|
|
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
|
+
direction: 'revert',
|
|
279
|
+
revertsRunId: runId,
|
|
280
|
+
schemaStateId: state.id,
|
|
281
|
+
startedAt: new Date(),
|
|
282
|
+
appliedBy: options.nodeId ?? null,
|
|
283
|
+
nodeCount: 0,
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
const skipped = new Set(
|
|
287
|
+
check.plan.filter((p) => p.action === 'skip' || p.action === 'gone').map((p) => p.key),
|
|
288
|
+
)
|
|
289
|
+
const todo = changes.filter((c) => c.action !== 'skip' && !skipped.has(c.nodeKey))
|
|
290
|
+
const byKey = new Map<string, NodeRow>()
|
|
291
|
+
for (const change of todo) {
|
|
292
|
+
const node = await repo(change.kind).nodes.byKey(change.nodeKey)
|
|
293
|
+
if (node) byKey.set(change.nodeKey, node)
|
|
294
|
+
}
|
|
295
|
+
/** Shallowest first: publishing needs parents live before children. */
|
|
296
|
+
const inTreeOrder = (list: readonly TransferChange[]) =>
|
|
297
|
+
[...list].sort(
|
|
298
|
+
(a, b) => (byKey.get(a.nodeKey)?.level ?? 0) - (byKey.get(b.nodeKey)?.level ?? 0),
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
const restored: string[] = []
|
|
302
|
+
const recycled: string[] = []
|
|
303
|
+
|
|
304
|
+
// Where each node stands *now*, before this revert touches it, so the
|
|
305
|
+
// revert records its own "before" and can itself be reverted.
|
|
306
|
+
const before = new Map<string, { head: number | undefined; published: number | undefined }>()
|
|
307
|
+
for (const change of todo) {
|
|
308
|
+
const markers = await repo(change.kind).eventMarkers(change.nodeKey)
|
|
309
|
+
if (markers) before.set(change.nodeKey, markers)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// What the previous run recycled comes back out of the bin first, so the
|
|
313
|
+
// rollback and publish below have a node in the tree to work on.
|
|
314
|
+
for (const change of inTreeOrder(todo)) {
|
|
315
|
+
if (change.action !== 'recycle') continue
|
|
316
|
+
await repo(change.kind).restore(change.nodeKey, undefined)
|
|
317
|
+
restored.push(change.nodeKey)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Values, for everything the run changed rather than created.
|
|
321
|
+
for (const change of todo) {
|
|
322
|
+
if (change.action === 'create' || change.beforeEventId === null) continue
|
|
323
|
+
await repo(change.kind).rollback(String(change.beforeEventId))
|
|
324
|
+
if (!restored.includes(change.nodeKey)) restored.push(change.nodeKey)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Then the published state, parents before children: a published node
|
|
328
|
+
// under an unpublished one is unreachable, so the repository refuses it.
|
|
329
|
+
for (const change of inTreeOrder(todo)) {
|
|
330
|
+
if (change.action === 'create' || change.kind === 'media') continue
|
|
331
|
+
const documents = repo(change.kind)
|
|
332
|
+
try {
|
|
333
|
+
if (change.beforePublishedEventId !== null) {
|
|
334
|
+
// It was live before, so put it back in front of visitors. The
|
|
335
|
+
// values are the ones just restored, so what it serves is what it
|
|
336
|
+
// served.
|
|
337
|
+
await documents.publish(change.nodeKey, null)
|
|
338
|
+
} else {
|
|
339
|
+
await documents.unpublish(change.nodeKey, null)
|
|
340
|
+
}
|
|
341
|
+
} catch (error) {
|
|
342
|
+
// An ancestor this same revert is about to recycle can make a publish
|
|
343
|
+
// impossible, and the node is going away regardless.
|
|
344
|
+
if (!(error instanceof PublishBlockedError)) throw error
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Finally what the run created, shallowest first: the recycle bin takes a
|
|
349
|
+
// whole branch, so a deeper node is already gone by the time we reach it.
|
|
350
|
+
for (const change of inTreeOrder(todo)) {
|
|
351
|
+
if (change.action !== 'create') continue
|
|
352
|
+
const node = await repo(change.kind).nodes.byKey(change.nodeKey)
|
|
353
|
+
if (!node || node.trashed) continue
|
|
354
|
+
await repo(change.kind).moveToRecycleBin(change.nodeKey)
|
|
355
|
+
recycled.push(change.nodeKey)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
for (const change of todo) {
|
|
359
|
+
const markers = await repo(change.kind).eventMarkers(change.nodeKey)
|
|
360
|
+
const was = before.get(change.nodeKey)
|
|
361
|
+
await runs.record({
|
|
362
|
+
runId: newRunId,
|
|
363
|
+
nodeKey: change.nodeKey,
|
|
364
|
+
kind: change.kind,
|
|
365
|
+
// `recycle` tells a revert of *this* run to bring the node back out
|
|
366
|
+
// of the bin, not merely to restore its values.
|
|
367
|
+
action: recycled.includes(change.nodeKey) ? 'recycle' : 'update',
|
|
368
|
+
beforeEventId: was?.head ?? null,
|
|
369
|
+
beforePublishedEventId: was?.published ?? null,
|
|
370
|
+
eventId: markers?.head ?? null,
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
await runs.finish(newRunId, todo.length)
|
|
374
|
+
await runs.setStatus(runId, 'reverted')
|
|
375
|
+
|
|
376
|
+
await appendCacheInstruction(tx, {
|
|
377
|
+
kind: 'content',
|
|
378
|
+
payload: { revert: runId },
|
|
379
|
+
by: options.nodeId,
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
return { runId: newRunId, check, restored, recycled }
|
|
383
|
+
}),
|
|
384
|
+
)
|
|
385
|
+
}
|
package/src/write.ts
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model → canonical JSON.
|
|
3
|
+
*
|
|
4
|
+
* Our own writer, for a fixed vocabulary, for the same reason the schema package
|
|
5
|
+
* has one: a bundle is reviewed in a diff and committed, so the same content must
|
|
6
|
+
* serialise the same way every time. Keys are emitted in a declared order rather
|
|
7
|
+
* than sorted generically, and a property's `value` is passed through untouched —
|
|
8
|
+
* it is an editor's own payload, often JSON in a string, and reordering inside it
|
|
9
|
+
* would rewrite somebody else's data to no purpose.
|
|
10
|
+
*
|
|
11
|
+
* `read(write(x))` must equal `x` — the tests hold it to that.
|
|
12
|
+
*/
|
|
13
|
+
import {
|
|
14
|
+
BUNDLE_FORMAT_VERSION,
|
|
15
|
+
BUNDLE_SECTIONS,
|
|
16
|
+
type BundleManifest,
|
|
17
|
+
type BundleNode,
|
|
18
|
+
type BundleSection,
|
|
19
|
+
type BundleValue,
|
|
20
|
+
type BundleVariant,
|
|
21
|
+
CONTENT_ONLY_FORMAT_VERSION,
|
|
22
|
+
type ContentSet,
|
|
23
|
+
} from './model.ts'
|
|
24
|
+
|
|
25
|
+
/** One file of a written bundle, ready to be put on disk or into a store. */
|
|
26
|
+
export interface BundleFile {
|
|
27
|
+
/** Relative to the bundle root, `/`-separated. */
|
|
28
|
+
path: string
|
|
29
|
+
text?: string
|
|
30
|
+
/** A media file's bytes; `text` and `bytes` are never both set. */
|
|
31
|
+
bytes?: Uint8Array
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const json = (value: unknown): string => `${JSON.stringify(value, null, 2)}\n`
|
|
35
|
+
|
|
36
|
+
function variant(v: BundleVariant): Record<string, unknown> {
|
|
37
|
+
return { culture: v.culture, segment: v.segment, name: v.name, published: v.published }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function value(v: BundleValue): Record<string, unknown> {
|
|
41
|
+
const out: Record<string, unknown> = {
|
|
42
|
+
property: v.property,
|
|
43
|
+
culture: v.culture,
|
|
44
|
+
segment: v.segment,
|
|
45
|
+
editor: v.editor,
|
|
46
|
+
value: v.value,
|
|
47
|
+
}
|
|
48
|
+
// Absent rather than empty: a value that refers to nothing should not carry a key.
|
|
49
|
+
if (v.references && v.references.length > 0) out.references = [...v.references].sort()
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A node, with its variants and values in a stable order so a diff means something. */
|
|
54
|
+
export function writeNode(node: BundleNode): string {
|
|
55
|
+
const values = [...node.values].sort(
|
|
56
|
+
(a, b) =>
|
|
57
|
+
a.property.localeCompare(b.property) ||
|
|
58
|
+
(a.culture ?? '').localeCompare(b.culture ?? '') ||
|
|
59
|
+
(a.segment ?? '').localeCompare(b.segment ?? ''),
|
|
60
|
+
)
|
|
61
|
+
const variants = [...node.variants].sort(
|
|
62
|
+
(a, b) =>
|
|
63
|
+
(a.culture ?? '').localeCompare(b.culture ?? '') ||
|
|
64
|
+
(a.segment ?? '').localeCompare(b.segment ?? ''),
|
|
65
|
+
)
|
|
66
|
+
return json({
|
|
67
|
+
key: node.key,
|
|
68
|
+
kind: node.kind,
|
|
69
|
+
contentType: { key: node.contentType.key, alias: node.contentType.alias },
|
|
70
|
+
parent: node.parent,
|
|
71
|
+
sortOrder: node.sortOrder,
|
|
72
|
+
template: node.template,
|
|
73
|
+
variants: variants.map(variant),
|
|
74
|
+
values: values.map(value),
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function writeManifest(manifest: BundleManifest): string {
|
|
79
|
+
const d = manifest.dependencies
|
|
80
|
+
return json({
|
|
81
|
+
formatVersion: manifest.formatVersion,
|
|
82
|
+
id: manifest.id,
|
|
83
|
+
...(manifest.label ? { label: manifest.label } : {}),
|
|
84
|
+
createdAt: manifest.createdAt,
|
|
85
|
+
createdBy: manifest.createdBy,
|
|
86
|
+
integrity: manifest.integrity,
|
|
87
|
+
snapshot: manifest.snapshot,
|
|
88
|
+
provenance: {
|
|
89
|
+
siteName: manifest.provenance.siteName,
|
|
90
|
+
schemaVersion: manifest.provenance.schemaVersion,
|
|
91
|
+
schemaRevision: manifest.provenance.schemaRevision,
|
|
92
|
+
schemaHash: manifest.provenance.schemaHash,
|
|
93
|
+
},
|
|
94
|
+
selector: {
|
|
95
|
+
roots: manifest.selector.roots,
|
|
96
|
+
descendants: manifest.selector.descendants,
|
|
97
|
+
asGiven: manifest.selector.asGiven,
|
|
98
|
+
},
|
|
99
|
+
counts: manifest.counts,
|
|
100
|
+
dependencies: {
|
|
101
|
+
carried: [...d.carried].sort(),
|
|
102
|
+
expected: [...d.expected]
|
|
103
|
+
.sort((a, b) => a.key.localeCompare(b.key))
|
|
104
|
+
.map((e) => ({ key: e.key, objectType: e.objectType, name: e.name, why: e.why })),
|
|
105
|
+
schema: {
|
|
106
|
+
contentTypes: [...d.schema.contentTypes]
|
|
107
|
+
.sort((a, b) => a.alias.localeCompare(b.alias))
|
|
108
|
+
.map((t) => ({ key: t.key, alias: t.alias })),
|
|
109
|
+
components: [...d.schema.components].sort(),
|
|
110
|
+
languages: [...d.schema.languages].sort(),
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
blobs: [...manifest.blobs]
|
|
114
|
+
.sort((a, b) => a.key.localeCompare(b.key))
|
|
115
|
+
.map((b) => ({
|
|
116
|
+
key: b.key,
|
|
117
|
+
node: b.node,
|
|
118
|
+
etag: b.etag,
|
|
119
|
+
size: b.size,
|
|
120
|
+
included: b.included,
|
|
121
|
+
})),
|
|
122
|
+
// Absent on a content-only bundle, so version 1 output is unchanged.
|
|
123
|
+
...(manifest.carries && Object.keys(manifest.carries).length > 0
|
|
124
|
+
? { carries: sortedCarries(manifest.carries) }
|
|
125
|
+
: {}),
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Sections in a declared order, each one's paths sorted, so a diff means something. */
|
|
130
|
+
function sortedCarries(
|
|
131
|
+
carries: Partial<Record<BundleSection, string[]>>,
|
|
132
|
+
): Partial<Record<BundleSection, string[]>> {
|
|
133
|
+
const out: Partial<Record<BundleSection, string[]>> = {}
|
|
134
|
+
for (const section of Object.keys(BUNDLE_SECTIONS) as BundleSection[]) {
|
|
135
|
+
const paths = carries[section]
|
|
136
|
+
if (paths && paths.length > 0) out[section] = [...paths].sort()
|
|
137
|
+
}
|
|
138
|
+
return out
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const nodePath = (key: string): string => `nodes/${key}.json`
|
|
142
|
+
|
|
143
|
+
/** The section a carried file belongs to, from the directory it sits in. */
|
|
144
|
+
export function sectionOf(path: string): BundleSection | undefined {
|
|
145
|
+
if (path === `${BUNDLE_SECTIONS.dictionary}.udt`) return 'dictionary'
|
|
146
|
+
const top = path.split('/')[0]
|
|
147
|
+
for (const [section, dir] of Object.entries(BUNDLE_SECTIONS))
|
|
148
|
+
if (top === dir) return section as BundleSection
|
|
149
|
+
return undefined
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Where a media file's bytes sit when the bundle carries them. */
|
|
153
|
+
export const blobPath = (key: string): string => `blobs/${key}`
|
|
154
|
+
|
|
155
|
+
export const MANIFEST_FILE = 'bundle.json'
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* A hash over everything the bundle carries, in path order, so a bundle that
|
|
159
|
+
* was copied half-way is refused rather than imported as though it were whole.
|
|
160
|
+
* The manifest is excluded — it carries the hash. Media files are hashed by
|
|
161
|
+
* their bytes, which is what makes `--with-blobs` a claim the reader can check.
|
|
162
|
+
*/
|
|
163
|
+
export function bundleIntegrity(files: readonly BundleFile[]): string {
|
|
164
|
+
const hasher = new Bun.CryptoHasher('sha256')
|
|
165
|
+
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
166
|
+
if (file.path === MANIFEST_FILE) continue
|
|
167
|
+
hasher.update(file.path)
|
|
168
|
+
hasher.update('\0')
|
|
169
|
+
hasher.update(file.bytes ?? (file.text as string))
|
|
170
|
+
hasher.update('\n')
|
|
171
|
+
}
|
|
172
|
+
return `sha256-${hasher.digest('hex')}`
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The whole bundle as files. The integrity hash is computed over them and
|
|
177
|
+
* written into the manifest, so the caller passes a manifest whose `integrity`
|
|
178
|
+
* is ignored.
|
|
179
|
+
*
|
|
180
|
+
* `blobs` are the media files to carry, by store key. Whether each one is
|
|
181
|
+
* carried is decided here rather than taken from the manifest: the exporter
|
|
182
|
+
* knows the flag was passed, and only the caller holding the bytes knows
|
|
183
|
+
* whether the file was actually there to read.
|
|
184
|
+
*/
|
|
185
|
+
export function writeBundle(
|
|
186
|
+
set: ContentSet,
|
|
187
|
+
blobs: ReadonlyMap<string, Uint8Array> = new Map(),
|
|
188
|
+
sections: readonly BundleFile[] = [],
|
|
189
|
+
): BundleFile[] {
|
|
190
|
+
const nodes: BundleFile[] = [...set.nodes]
|
|
191
|
+
.sort((a, b) => a.key.localeCompare(b.key))
|
|
192
|
+
.map((node) => ({ path: nodePath(node.key), text: writeNode(node) }))
|
|
193
|
+
const carried: BundleFile[] = [...blobs]
|
|
194
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
195
|
+
.map(([key, bytes]) => ({ path: blobPath(key), bytes }))
|
|
196
|
+
|
|
197
|
+
// A file in no known section would travel without being declared, so it
|
|
198
|
+
// would not be hashed and a reader would never look for it. Refused here
|
|
199
|
+
// rather than written and silently ignored at the far end.
|
|
200
|
+
const extra = [...sections].sort((a, b) => a.path.localeCompare(b.path))
|
|
201
|
+
const carries: Partial<Record<BundleSection, string[]>> = {}
|
|
202
|
+
for (const file of extra) {
|
|
203
|
+
const section = sectionOf(file.path)
|
|
204
|
+
if (!section) throw new Error(`${file.path} is not in a bundle section`)
|
|
205
|
+
carries[section] = [...(carries[section] ?? []), file.path]
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const manifest: BundleManifest = {
|
|
209
|
+
...set.manifest,
|
|
210
|
+
// Content-only bundles keep saying 1, so an older node can still read one.
|
|
211
|
+
formatVersion: extra.length > 0 ? BUNDLE_FORMAT_VERSION : CONTENT_ONLY_FORMAT_VERSION,
|
|
212
|
+
blobs: set.manifest.blobs.map((blob) => {
|
|
213
|
+
const bytes = blobs.get(blob.key)
|
|
214
|
+
return bytes ? { ...blob, included: true, size: bytes.length } : { ...blob, included: false }
|
|
215
|
+
}),
|
|
216
|
+
carries: extra.length > 0 ? carries : undefined,
|
|
217
|
+
integrity: bundleIntegrity([...nodes, ...carried, ...extra]),
|
|
218
|
+
}
|
|
219
|
+
return [{ path: MANIFEST_FILE, text: writeManifest(manifest) }, ...nodes, ...carried, ...extra]
|
|
220
|
+
}
|
package/README.md
DELETED