@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
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