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