@crombolo/transfer 0.13.2 → 0.14.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/transfer",
3
- "version": "0.13.2",
3
+ "version": "0.14.4",
4
4
  "description": "Content transfer for crombolo: bundles of content moved between environments.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,7 +28,7 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.13.2",
32
- "@crombolo/data": "0.13.2"
31
+ "@crombolo/core": "0.14.4",
32
+ "@crombolo/data": "0.14.4"
33
33
  }
34
34
  }
package/src/import.ts CHANGED
@@ -71,6 +71,11 @@ export interface ImportOptions extends CheckOptions {
71
71
  label?: string | undefined
72
72
  /** The npm package the bundle arrived in, recorded on the run. */
73
73
  package?: { name: string; version: string } | undefined
74
+ /**
75
+ * A node's values become exactly the bundle's, rather than the bundle's laid
76
+ * over what is here: a whole-site replace (story 105).
77
+ */
78
+ exact?: boolean
74
79
  }
75
80
 
76
81
  export interface ImportResult {
@@ -342,7 +347,7 @@ async function applyBundle(tx: Db, set: ContentSet, options: ImportOptions): Pro
342
347
  ? ((await templates.byAlias(node.template))?.key ?? current.componentKey)
343
348
  : current.componentKey,
344
349
  parentKey: current.parentKey,
345
- values: overlay(current.values, taken),
350
+ values: overlay(options.exact ? [] : current.values, taken),
346
351
  variants: keepNames
347
352
  ? current.variants.map((v) => ({
348
353
  culture: v.culture,
package/src/index.ts CHANGED
@@ -9,5 +9,6 @@ export * from './people-apply.ts'
9
9
  export * from './references.ts'
10
10
  export * from './resolutions.ts'
11
11
  export * from './revert.ts'
12
+ export * from './sanitise.ts'
12
13
  export * from './seal.ts'
13
14
  export * from './write.ts'
package/src/load.ts CHANGED
@@ -257,6 +257,7 @@ function parseManifest(text: string, problems: BundleProblem[]): BundleManifest
257
257
  schemaVersion: str(provenance.schemaVersion) ?? '',
258
258
  schemaRevision: str(provenance.schemaRevision) ?? '',
259
259
  schemaHash: nullableStr(provenance.schemaHash) ?? null,
260
+ ...(str(provenance.cmsVersion) ? { cmsVersion: str(provenance.cmsVersion) as string } : {}),
260
261
  },
261
262
  selector: {
262
263
  roots: keys(selector.roots),
@@ -313,9 +314,20 @@ function parseManifest(text: string, problems: BundleProblem[]): BundleManifest
313
314
  : {}),
314
315
  ...(parseSealParameters(raw.seal) ? { seal: parseSealParameters(raw.seal) } : {}),
315
316
  ...(raw.content === 'sealed' || raw.content === 'omitted' ? { content: raw.content } : {}),
317
+ ...(raw.scope === 'site' ? { scope: 'site' as const } : {}),
318
+ ...(raw.media === 'omitted' || raw.media === 'referenced' ? { media: raw.media } : {}),
319
+ ...(isRecord(raw.requires) ? { requires: parseRequires(raw.requires) } : {}),
316
320
  }
317
321
  }
318
322
 
323
+ /** Package names to versions, keeping only string pairs. */
324
+ function parseRequires(raw: Record<string, unknown>): Record<string, string> {
325
+ const out: Record<string, string> = {}
326
+ for (const [name, version] of Object.entries(raw))
327
+ if (typeof version === 'string') out[name] = version
328
+ return out
329
+ }
330
+
319
331
  /**
320
332
  * The declared sections, dropping any name this version does not know and any
321
333
  * path that tries to climb out of the bundle — a manifest is a file that
package/src/model.ts CHANGED
@@ -172,6 +172,8 @@ export interface BundleProvenance {
172
172
  schemaRevision: string
173
173
  schemaHash: string | null
174
174
  siteName: string
175
+ /** The CMS version that wrote the bundle (story 104). Absent on bundles written before it. */
176
+ cmsVersion?: string
175
177
  }
176
178
 
177
179
  export interface BundleSelector {
@@ -228,6 +230,25 @@ export interface BundleManifest {
228
230
  * Absent: in the open.
229
231
  */
230
232
  content?: BundleContentMode
233
+ /**
234
+ * `site`: a whole site (story 104) — every node, the whole schema and every
235
+ * file — which an import replaces a site with rather than overlays. Absent: a
236
+ * selection, overlaid as always.
237
+ */
238
+ scope?: 'site'
239
+ /**
240
+ * `omitted`: a whole-site file written without its media files (`--no-media`).
241
+ * `referenced`: without them because they move one at a time between the
242
+ * stores of a remote and the CLI (story 107): every blob is named, none is in
243
+ * the file, and the file is no use away from its remote.
244
+ */
245
+ media?: 'omitted' | 'referenced'
246
+ /**
247
+ * The server bundles the site wires, by package name, at the versions that
248
+ * wrote it. An import refuses when the destination lacks one or has an older
249
+ * version, naming it.
250
+ */
251
+ requires?: Record<string, string>
231
252
  }
232
253
 
233
254
  export type BundleContentMode = 'sealed' | 'omitted'
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Anonymising a bundle before it is written (story 104, reused by 100's clone):
3
+ * the structure travels, the people in it do not. It runs on what the export
4
+ * gathered and before anything is serialised, so an unsanitised copy never
5
+ * exists in the output.
6
+ *
7
+ * - Back-office users are removed; the destination makes one fresh local
8
+ * administrator. User groups stay, as structure.
9
+ * - Members stay as placeholders — type, groups and approval kept — with name,
10
+ * username and email replaced, the password and every property value gone.
11
+ * - Every email address in content, people and the dictionary is replaced, the
12
+ * same address by the same stand-in each time so references still agree.
13
+ *
14
+ * Tokens, sessions, external logins and form entries are never exported, so
15
+ * there is nothing of theirs to remove. Code and schema are left alone: an
16
+ * address in a template is the site's, not a person's.
17
+ */
18
+ import type { BundleNode, BundleValue, ContentSet } from './model.ts'
19
+ import type { BundleMember, BundlePeople } from './people.ts'
20
+
21
+ const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g
22
+
23
+ /** The domain stand-ins use: reserved, so nothing is ever delivered to it. */
24
+ export const ANONYMISED_DOMAIN = 'example.invalid'
25
+
26
+ export interface SanitiseInput {
27
+ set: ContentSet
28
+ people?: BundlePeople
29
+ /** The dictionary `.udt`, whose translations may hold addresses. */
30
+ dictionary?: string
31
+ }
32
+
33
+ export interface SanitiseResult {
34
+ set: ContentSet
35
+ people: BundlePeople | undefined
36
+ dictionary: string | undefined
37
+ /** How many distinct email addresses were replaced. */
38
+ emails: number
39
+ }
40
+
41
+ export function sanitise(input: SanitiseInput): SanitiseResult {
42
+ const standIns = new Map<string, string>()
43
+ const standInFor = (address: string): string => {
44
+ const known = address.toLowerCase()
45
+ let replacement = standIns.get(known)
46
+ if (!replacement) {
47
+ replacement = `person-${standIns.size + 1}@${ANONYMISED_DOMAIN}`
48
+ standIns.set(known, replacement)
49
+ }
50
+ return replacement
51
+ }
52
+ const scrub = (text: string) =>
53
+ text.replace(EMAIL, (address) =>
54
+ address.toLowerCase().endsWith(`@${ANONYMISED_DOMAIN}`) ? address : standInFor(address),
55
+ )
56
+ const deep = (value: unknown): unknown => {
57
+ if (typeof value === 'string') return scrub(value)
58
+ if (Array.isArray(value)) return value.map(deep)
59
+ if (value && typeof value === 'object')
60
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deep(v)]))
61
+ return value
62
+ }
63
+
64
+ const usernames = new Map<string, string>()
65
+ const node = (n: BundleNode): BundleNode => ({
66
+ ...n,
67
+ variants: deep(n.variants) as BundleNode['variants'],
68
+ values: n.values.map((v: BundleValue) => ({ ...v, value: deep(v.value) })),
69
+ ...(n.access
70
+ ? {
71
+ access: {
72
+ ...n.access,
73
+ // A protected page naming a member names their stand-in; one naming
74
+ // someone who did not travel names nobody.
75
+ ...(n.access.memberUserNames
76
+ ? {
77
+ memberUserNames: n.access.memberUserNames.flatMap((name) => {
78
+ const standIn = usernames.get(name.toLowerCase())
79
+ return standIn ? [standIn] : []
80
+ }),
81
+ }
82
+ : {}),
83
+ },
84
+ }
85
+ : {}),
86
+ })
87
+
88
+ const member = (m: BundleMember, index: number): BundleMember => {
89
+ const n = index + 1
90
+ standIns.set(m.email.toLowerCase(), `member-${n}@${ANONYMISED_DOMAIN}`)
91
+ usernames.set(m.username.toLowerCase(), `member-${n}`)
92
+ return {
93
+ ...m,
94
+ name: `Member ${n}`,
95
+ username: `member-${n}`,
96
+ email: `member-${n}@${ANONYMISED_DOMAIN}`,
97
+ passwordHash: null,
98
+ passwordConfig: null,
99
+ values: [],
100
+ }
101
+ }
102
+
103
+ // Members first, so an address that belongs to one maps to their stand-in
104
+ // wherever content mentions it.
105
+ const people = input.people
106
+ ? {
107
+ memberGroups: input.people.memberGroups,
108
+ members: input.people.members.map(member),
109
+ userGroups: input.people.userGroups,
110
+ users: [],
111
+ }
112
+ : undefined
113
+ for (const user of input.people?.users ?? []) standInFor(user.email)
114
+
115
+ return {
116
+ set: { ...input.set, nodes: input.set.nodes.map(node) },
117
+ people,
118
+ dictionary: input.dictionary === undefined ? undefined : scrub(input.dictionary),
119
+ emails: standIns.size,
120
+ }
121
+ }
package/src/write.ts CHANGED
@@ -157,6 +157,7 @@ export function writeManifest(manifest: BundleManifest): string {
157
157
  schemaVersion: manifest.provenance.schemaVersion,
158
158
  schemaRevision: manifest.provenance.schemaRevision,
159
159
  schemaHash: manifest.provenance.schemaHash,
160
+ ...(manifest.provenance.cmsVersion ? { cmsVersion: manifest.provenance.cmsVersion } : {}),
160
161
  },
161
162
  selector: {
162
163
  roots: manifest.selector.roots,
@@ -193,6 +194,11 @@ export function writeManifest(manifest: BundleManifest): string {
193
194
  ...(manifest.people ? { people: manifest.people } : {}),
194
195
  ...(manifest.seal ? { seal: manifest.seal } : {}),
195
196
  ...(manifest.content ? { content: manifest.content } : {}),
197
+ ...(manifest.scope ? { scope: manifest.scope } : {}),
198
+ ...(manifest.media ? { media: manifest.media } : {}),
199
+ ...(manifest.requires && Object.keys(manifest.requires).length > 0
200
+ ? { requires: Object.fromEntries(Object.entries(manifest.requires).sort()) }
201
+ : {}),
196
202
  })
197
203
  }
198
204
 
@@ -289,13 +295,52 @@ export function writeBundle(
289
295
  sections: readonly BundleFile[] = [],
290
296
  options: WriteOptions = {},
291
297
  ): BundleFile[] {
298
+ const sizes = new Map([...blobs].map(([key, bytes]) => [key, bytes.length]))
299
+ const planned = planBundle(set, sizes, sections, options)
300
+ const carried: BundleFile[] = planned.blobs.map((key) => {
301
+ const path = blobPath(key)
302
+ const bytes = blobs.get(key) as Uint8Array
303
+ return {
304
+ path,
305
+ bytes: planned.sealing && options.seal ? sealBytes(options.seal.key, path, bytes) : bytes,
306
+ }
307
+ })
308
+ const manifest = {
309
+ ...planned.manifest,
310
+ integrity: bundleIntegrity([...planned.files, ...carried]),
311
+ }
312
+ return [{ path: MANIFEST_FILE, text: writeManifest(manifest) }, ...planned.files, ...carried]
313
+ }
314
+
315
+ /**
316
+ * A bundle whose media files the caller streams (story 104): the manifest and
317
+ * every other file, from each carried file's size alone. The caller writes
318
+ * `blobs` at `blobPath` — sealed with `sealBytes` under the seal when `sealing`
319
+ * — computes `bundleIntegrity` over those and `files` in path order, and writes
320
+ * the manifest with it.
321
+ */
322
+ export function planStreamedBundle(
323
+ set: ContentSet,
324
+ blobSizes: ReadonlyMap<string, number>,
325
+ sections: readonly BundleFile[] = [],
326
+ options: WriteOptions = {},
327
+ ): { manifest: BundleManifest; files: BundleFile[]; blobs: string[]; sealing: boolean } {
328
+ return planBundle(set, blobSizes, sections, { ...options, content: options.content ?? 'open' })
329
+ }
330
+
331
+ function planBundle(
332
+ set: ContentSet,
333
+ blobSizes: ReadonlyMap<string, number>,
334
+ sections: readonly BundleFile[],
335
+ options: WriteOptions,
336
+ ): { manifest: BundleManifest; files: BundleFile[]; blobs: string[]; sealing: boolean } {
292
337
  const seal = options.seal
293
338
  const content = options.content ?? (seal ? 'sealed' : 'open')
294
339
  if ((content === 'sealed' || options.people) && !seal)
295
340
  throw new Error('Sealed content and people need a seal: give a passphrase.')
296
341
  const sealing = content === 'sealed'
297
342
  const source = content === 'omitted' ? set.nodes.map(withoutContent) : set.nodes
298
- const carriedBlobs: ReadonlyMap<string, Uint8Array> = content === 'omitted' ? new Map() : blobs
343
+ const carriedSizes: ReadonlyMap<string, number> = content === 'omitted' ? new Map() : blobSizes
299
344
 
300
345
  const sorted = [...source].sort((a, b) => a.key.localeCompare(b.key))
301
346
  const nodes: BundleFile[] = sorted.map((node) => ({
@@ -309,12 +354,6 @@ export function writeBundle(
309
354
  return { path, bytes: sealBytes(seal.key, path, Buffer.from(json(sealedValues(node)))) }
310
355
  })
311
356
  : []
312
- const carried: BundleFile[] = [...carriedBlobs]
313
- .sort(([a], [b]) => a.localeCompare(b))
314
- .map(([key, bytes]) => {
315
- const path = blobPath(key)
316
- return { path, bytes: sealing && seal ? sealBytes(seal.key, path, bytes) : bytes }
317
- })
318
357
 
319
358
  // A file in no known section would travel without being declared, so it
320
359
  // would not be hashed and a reader would never look for it. Refused here
@@ -345,27 +384,19 @@ export function writeBundle(
345
384
  : CONTENT_ONLY_FORMAT_VERSION,
346
385
  // Without values nothing names a media file, so none is expected either.
347
386
  blobs: (content === 'omitted' ? [] : set.manifest.blobs).map((blob) => {
348
- const bytes = carriedBlobs.get(blob.key)
349
- return bytes ? { ...blob, included: true, size: bytes.length } : { ...blob, included: false }
387
+ const size = carriedSizes.get(blob.key)
388
+ return size !== undefined ? { ...blob, included: true, size } : { ...blob, included: false }
350
389
  }),
351
390
  carries: extra.length > 0 ? carries : undefined,
352
391
  people: people?.block,
353
392
  seal: seal && (sealing || people) ? seal.params : undefined,
354
393
  content: content === 'open' ? undefined : content,
355
- integrity: bundleIntegrity([
356
- ...nodes,
357
- ...sealedFiles,
358
- ...carried,
359
- ...extra,
360
- ...(people?.files ?? []),
361
- ]),
394
+ integrity: '',
395
+ }
396
+ return {
397
+ manifest,
398
+ files: [...nodes, ...sealedFiles, ...extra, ...(people?.files ?? [])],
399
+ blobs: [...carriedSizes.keys()].sort((a, b) => a.localeCompare(b)),
400
+ sealing,
362
401
  }
363
- return [
364
- { path: MANIFEST_FILE, text: writeManifest(manifest) },
365
- ...nodes,
366
- ...sealedFiles,
367
- ...carried,
368
- ...extra,
369
- ...(people?.files ?? []),
370
- ]
371
402
  }