@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.
@@ -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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.