@crombolo/server 0.9.1 → 0.11.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.
@@ -0,0 +1,417 @@
1
+ /**
2
+ * Checking and installing a bundle's content half, against a database the
3
+ * caller holds. `docs/bundles.md`, "Installing one".
4
+ *
5
+ * A bundle is an npm package; what it installs is the `bundle.json` payload its
6
+ * `crombolo.content` names. It is found either as a dependency the site
7
+ * declares, by package name, or as a package directory applied in place, which
8
+ * is how an environment's export arrives. A bare payload with no package around
9
+ * it is refused, so there is one kind of bundle and not two.
10
+ *
11
+ * This lives in the server rather than the CLI so the backoffice can install
12
+ * with the connection it already has: the CLI's `content check` and
13
+ * `content import` open a database and call these.
14
+ */
15
+ import { existsSync } from 'node:fs'
16
+ import { join, resolve } from 'node:path'
17
+ import {
18
+ type BundlePackage,
19
+ discoverBundles,
20
+ isDirectory,
21
+ PAYLOAD_MANIFEST,
22
+ peerRangeProblem,
23
+ readBundlePackage,
24
+ readPackageJson,
25
+ } from '@crombolo/backoffice-host'
26
+ import { ObjectTypes } from '@crombolo/core'
27
+ import {
28
+ type Db,
29
+ describeRefFailure,
30
+ type NodeRow,
31
+ recordInLedger,
32
+ resolveNodeRef,
33
+ writeReport,
34
+ } from '@crombolo/data'
35
+ import {
36
+ type CheckOptions,
37
+ type ContentSet,
38
+ checkBundle,
39
+ importBundle,
40
+ isPackageName,
41
+ loadBundle,
42
+ loadResolutions,
43
+ type Resolution,
44
+ type TransferCheck,
45
+ } from '@crombolo/transfer'
46
+ import { type BackupResult, backupBefore } from './backup.ts'
47
+ import {
48
+ applySections,
49
+ type CarriedSections,
50
+ carriedSections,
51
+ carriesSections,
52
+ planSections,
53
+ promoteBackups,
54
+ replacementsNeedingPermission,
55
+ type SectionFile,
56
+ type SectionOptions,
57
+ type SectionPlan,
58
+ type SectionResult,
59
+ stageBackups,
60
+ } from './bundle-sections.ts'
61
+ import { type CromboloConfig, VERSION } from './config.ts'
62
+ import { mediaStoreFor, placeBlobs } from './media-files.ts'
63
+ import { componentAliasesIn } from './schema.ts'
64
+
65
+ /** The trees a content selector or placement may name. */
66
+ export const CONTENT_OBJECT_TYPES = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
67
+
68
+ /** A refusal the caller should read: nothing was written. */
69
+ export class CommandError extends Error {
70
+ constructor(
71
+ message: string,
72
+ readonly problems: string[] = [],
73
+ ) {
74
+ super(message)
75
+ }
76
+ }
77
+
78
+ export interface BundleSummary {
79
+ id: string
80
+ from: string
81
+ schemaVersion: string
82
+ schemaRevision: string
83
+ snapshot: string
84
+ nodes: number
85
+ blobsCarried: number
86
+ blobBytes: number
87
+ /** The package the payload came in. */
88
+ package: { name: string; version: string }
89
+ }
90
+
91
+ /** A bundle found and read: its package, and the payload inside it. */
92
+ export interface ResolvedBundle {
93
+ package: BundlePackage
94
+ /** Where the `bundle.json` payload is. */
95
+ payloadDir: string
96
+ /** Whether it was found as one of the site's dependencies rather than a directory. */
97
+ dependency: boolean
98
+ set: ContentSet
99
+ blobs: ReadonlyMap<string, string>
100
+ /** The sections it carries beyond its content; empty for a content-only bundle. */
101
+ carried: CarriedSections
102
+ }
103
+
104
+ /** Where a package directory or a dependency's name points, before anything is read. */
105
+ function locate(target: string, siteDir: string): { directory: string; dependency: boolean } {
106
+ const path = resolve(target)
107
+ if (isDirectory(path)) return { directory: path, dependency: false }
108
+ if (!isPackageName(target))
109
+ throw new CommandError(`${target} is neither a directory nor a package name.`)
110
+ const declared = discoverBundles(siteDir)
111
+ const found = declared.bundles.find((bundle) => bundle.packageName === target)
112
+ if (found) return { directory: found.directory, dependency: true }
113
+ const broken = declared.problems.find((problem) => problem.packageName === target)
114
+ if (broken)
115
+ throw new CommandError(`${target} is not a valid bundle; nothing was read.`, broken.problems)
116
+ throw new CommandError(
117
+ `${target} is not a bundle this site depends on. Add it with \`bun add ${target}\`, or name a package directory.`,
118
+ )
119
+ }
120
+
121
+ /**
122
+ * The bundle a path or a package name names, read whole, or a refusal: a
123
+ * half-copied bundle must never be checked as though complete.
124
+ */
125
+ export function readBundle(target: string, siteDir: string): ResolvedBundle {
126
+ const { directory, dependency } = locate(target, siteDir)
127
+ const json = readPackageJson(join(directory, 'package.json'))
128
+ if (!json) {
129
+ if (existsSync(join(directory, PAYLOAD_MANIFEST)))
130
+ throw new CommandError(
131
+ `${target} is a bare ${PAYLOAD_MANIFEST} with no package.json. Every bundle is an npm package: ` +
132
+ `a package.json whose "crombolo" field names the payload directory, with the payload under content/.`,
133
+ )
134
+ throw new CommandError(`${target} has no package.json, so it is not a bundle.`)
135
+ }
136
+ const read = readBundlePackage(directory)
137
+ if (!read.bundle)
138
+ throw new CommandError(
139
+ `${target} is not a valid bundle; nothing was read from it.`,
140
+ read.problems.length > 0 ? read.problems : ['its package.json has no "crombolo" field'],
141
+ )
142
+ const pkg = read.bundle
143
+ if (!pkg.contentDir)
144
+ throw new CommandError(
145
+ `${pkg.packageName} carries no content to install: its manifest names no "content".`,
146
+ )
147
+ const peer = peerRangeProblem(pkg, VERSION)
148
+ if (peer) throw new CommandError(`${pkg.packageName} cannot be installed here: ${peer}.`)
149
+
150
+ const loaded = loadBundle(pkg.contentDir)
151
+ const problems = loaded.problems.map((problem) => `${problem.file}: ${problem.message}`)
152
+ if (!loaded.set || problems.length > 0)
153
+ throw new CommandError(
154
+ 'The bundle is incomplete or damaged; nothing was read from it.',
155
+ problems,
156
+ )
157
+ return {
158
+ package: pkg,
159
+ payloadDir: pkg.contentDir,
160
+ dependency,
161
+ set: loaded.set,
162
+ blobs: loaded.blobs,
163
+ carried: carriedSections(loaded),
164
+ }
165
+ }
166
+
167
+ /**
168
+ * The check an install that never reached the content reports.
169
+ *
170
+ * Nothing was examined, so nothing is outstanding: the refusal is in
171
+ * `problems`, and a caller reading `outstanding.length` must not be told there
172
+ * were content findings when there were none.
173
+ */
174
+ const emptyCheck = (bundleId = ''): TransferCheck => ({
175
+ bundleId,
176
+ findings: [],
177
+ outstanding: [],
178
+ plan: [],
179
+ counts: { create: 0, update: 0, unchanged: 0, skip: 0 },
180
+ })
181
+
182
+ function summarise(bundle: ResolvedBundle): BundleSummary {
183
+ const set = bundle.set
184
+ const carried = set.manifest.blobs.filter((blob) => blob.included)
185
+ return {
186
+ id: set.manifest.id,
187
+ from: set.manifest.provenance.siteName || '',
188
+ schemaVersion: set.manifest.provenance.schemaVersion,
189
+ schemaRevision: set.manifest.provenance.schemaRevision,
190
+ snapshot: set.manifest.snapshot,
191
+ nodes: set.nodes.length,
192
+ blobsCarried: carried.length,
193
+ blobBytes: carried.reduce((total, blob) => total + (blob.size ?? 0), 0),
194
+ package: { name: bundle.package.packageName, version: bundle.package.version },
195
+ }
196
+ }
197
+
198
+ export interface TransferInput extends SectionOptions {
199
+ /** A package directory on this machine, or the name of a bundle the site depends on. */
200
+ bundle: string
201
+ /** Where the bundle's roots go: a path or a key. */
202
+ under?: string
203
+ /** A decision per node key, on top of any the bundle carries. */
204
+ resolutions?: Record<string, Resolution>
205
+ resolveAll?: Resolution
206
+ allowMissingBlobs?: boolean
207
+ }
208
+
209
+ /** The options `check` and `import` share: saved decisions, then the caller's, then placement. */
210
+ export async function transferOptions(
211
+ db: Db,
212
+ config: CromboloConfig,
213
+ bundle: ResolvedBundle,
214
+ input: Omit<TransferInput, 'bundle'>,
215
+ ): Promise<{ options: CheckOptions; under: NodeRow | undefined; problems: string[] }> {
216
+ const saved = loadResolutions(bundle.payloadDir, bundle.set.manifest.id)
217
+ const resolutions = { ...(saved.file?.byNode ?? {}), ...(input.resolutions ?? {}) }
218
+ const resolveAll = input.resolveAll ?? saved.file?.all ?? undefined
219
+ const underRef = input.under ?? saved.file?.under ?? undefined
220
+ let under: NodeRow | undefined
221
+ if (underRef) {
222
+ const resolved = await resolveNodeRef(db, CONTENT_OBJECT_TYPES, underRef)
223
+ if (!resolved.ok) throw new CommandError(describeRefFailure(underRef, resolved))
224
+ under = resolved.node
225
+ }
226
+ // Whether this environment has a media file is a question only its store can
227
+ // answer; without it `missing-blob` is a finding the check could never raise.
228
+ const store = await mediaStoreFor(config)
229
+ return {
230
+ options: {
231
+ under,
232
+ resolutions,
233
+ resolveAll,
234
+ componentAliases: componentAliasesIn(config.componentsDir),
235
+ allowMissingBlobs: input.allowMissingBlobs ?? false,
236
+ hasBlob: async (key: string) => Boolean(await store.get(key)),
237
+ backOfficePath: config.backOfficePath,
238
+ },
239
+ under,
240
+ problems: saved.problems,
241
+ }
242
+ }
243
+
244
+ export interface ContentCheckResult {
245
+ bundle: BundleSummary
246
+ /** Where the payload was read from, which is where saved decisions are written. */
247
+ payloadDir: string
248
+ under: string | null
249
+ check: TransferCheck
250
+ /** What the carried sections would do here; undefined for a content-only bundle. */
251
+ sections: SectionPlan | undefined
252
+ /** Nothing outstanding: the bundle would import cleanly. */
253
+ clean: boolean
254
+ /** The decisions this check ran with — the bundle's saved ones under the caller's. */
255
+ decisions: { all: Resolution | null; byNode: Record<string, Resolution> }
256
+ problems: string[]
257
+ }
258
+
259
+ /** Read-only: what installing a bundle here would do. Its findings go to the Changes dashboard. */
260
+ export async function checkBundleContent(
261
+ db: Db,
262
+ config: CromboloConfig,
263
+ input: TransferInput,
264
+ ): Promise<ContentCheckResult> {
265
+ const bundle = readBundle(input.bundle, config.siteDir)
266
+ const set = bundle.set
267
+ const prepared = await transferOptions(db, config, bundle, input)
268
+ const check = await checkBundle(db, set, prepared.options)
269
+ // Under this bundle's own scope, so two bundles in flight do not resolve each other's.
270
+ await writeReport(db, check.findings, { source: 'transfer', scope: set.manifest.id })
271
+ return {
272
+ bundle: summarise(bundle),
273
+ payloadDir: bundle.payloadDir,
274
+ under: prepared.under ? (prepared.under.text ?? prepared.under.key) : null,
275
+ check,
276
+ sections: carriesSections(bundle.carried)
277
+ ? await planSections(db, config, bundle.carried, input)
278
+ : undefined,
279
+ clean: check.outstanding.length === 0,
280
+ decisions: {
281
+ all: prepared.options.resolveAll ?? null,
282
+ byNode: prepared.options.resolutions ?? {},
283
+ },
284
+ problems: prepared.problems,
285
+ }
286
+ }
287
+
288
+ export interface ContentImportInput extends TransferInput {
289
+ publish?: boolean
290
+ label?: string
291
+ /** Postgres: the caller has taken a backup itself. */
292
+ backupTaken?: boolean
293
+ }
294
+
295
+ export interface ContentImportResult {
296
+ bundle: BundleSummary
297
+ backup: BackupResult
298
+ /**
299
+ * What the carried sections did, when the bundle carried any.
300
+ *
301
+ * Applied before the content and outside the content run's ledger entry: the
302
+ * files are now in `schema/` and the views directory, where the repository
303
+ * owns them — so a revert takes the content back and git takes the files.
304
+ */
305
+ sections: SectionResult | undefined
306
+ /** Where the originals of any replaced files were kept, for a revert. */
307
+ replacedFilesKept?: string | undefined
308
+ /** Undefined when the import was refused; then nothing was written. */
309
+ runId: string | undefined
310
+ check: TransferCheck
311
+ blobs: { placed: number; bytes: number } | null
312
+ published: number
313
+ publishFailures: { key: string; reason: string }[]
314
+ problems: string[]
315
+ }
316
+
317
+ /** Back up, re-check, then apply in one transaction and record the run. */
318
+ export async function installBundleContent(
319
+ db: Db,
320
+ config: CromboloConfig,
321
+ input: ContentImportInput,
322
+ ): Promise<ContentImportResult> {
323
+ const bundle = readBundle(input.bundle, config.siteDir)
324
+ const { set, blobs, carried } = bundle
325
+ // The coarse layer, under the fine-grained revert: in-place conversion is not
326
+ // reversible by itself, so the same rule as `upgrade` applies here.
327
+ const backup = await backupBefore(config, {
328
+ reason: 'content-import',
329
+ backupTaken: input.backupTaken,
330
+ })
331
+ const prepared = await transferOptions(db, config, bundle, input)
332
+ const started = performance.now()
333
+
334
+ /**
335
+ * Structure first, but planned before anything is written.
336
+ *
337
+ * The order is forced: content naming a type this bundle brings cannot pass
338
+ * a check until that type exists, so the schema has to land before the
339
+ * content is checked. Which means a blocking schema change has to be caught
340
+ * *before* the first file is written — otherwise a refused install leaves a
341
+ * half-applied site behind. `planSections` answers that from an overlay
342
+ * copy, so the site is untouched until it is known the schema can apply.
343
+ */
344
+ const plan = carriesSections(carried) ? await planSections(db, config, carried, input) : undefined
345
+ const refuse = (problems: string[]): ContentImportResult => ({
346
+ bundle: summarise(bundle),
347
+ backup,
348
+ runId: undefined,
349
+ check: emptyCheck(set.manifest.id),
350
+ blobs: null,
351
+ published: 0,
352
+ publishFailures: [],
353
+ problems,
354
+ sections: undefined,
355
+ })
356
+
357
+ if (plan?.schema?.classification === 'breaking')
358
+ return refuse([
359
+ 'the schema this bundle carries cannot be applied here without data work',
360
+ ...plan.schema.findings,
361
+ ])
362
+
363
+ /**
364
+ * A file this site already has is not replaced without being asked.
365
+ *
366
+ * Every section is editable in the backoffice — a stylesheet, a template, a
367
+ * content type — so an overwrite destroys somebody's work, and the site
368
+ * may have no git to recover it from. Refused by name, like any other
369
+ * finding a person has to answer.
370
+ */
371
+ const needingPermission = plan ? replacementsNeedingPermission(plan, input) : []
372
+ if (needingPermission.length > 0)
373
+ return refuse([
374
+ `${needingPermission.length} file(s) here would be replaced; pass --replace-files to allow it`,
375
+ ...needingPermission.map((file: SectionFile) => file.path),
376
+ ])
377
+
378
+ // Staged whenever something *will* be replaced — which is decided by the
379
+ // plan, not by the permission check: by this point permission has been
380
+ // given, so the files needing it are exactly the ones to back up.
381
+ const willReplace = (plan?.files ?? []).some((file) => file.action === 'overwrite')
382
+ const staging = plan && willReplace ? stageBackups(config) : undefined
383
+ const sections = plan
384
+ ? await applySections(db, config, carried, { ...input, backupDir: staging })
385
+ : undefined
386
+ const result = await importBundle(db, set, {
387
+ ...prepared.options,
388
+ publish: input.publish ?? false,
389
+ nodeId: config.nodeId,
390
+ label: input.label,
391
+ package: { name: bundle.package.packageName, version: bundle.package.version },
392
+ })
393
+ await writeReport(db, result.check.findings, { source: 'transfer', scope: set.manifest.id })
394
+ const outcome: ContentImportResult = {
395
+ bundle: summarise(bundle),
396
+ backup,
397
+ runId: result.runId,
398
+ check: result.check,
399
+ blobs: null,
400
+ published: result.published.length,
401
+ publishFailures: result.publishFailures,
402
+ problems: prepared.problems,
403
+ sections,
404
+ }
405
+ if (!result.runId) return outcome
406
+ // The backups become the run's, so `content revert` can find them.
407
+ if (staging) outcome.replacedFilesKept = promoteBackups(config, staging, result.runId)
408
+ if (blobs.size > 0) outcome.blobs = await placeBlobs(await mediaStoreFor(config), blobs)
409
+ await recordInLedger(db, {
410
+ name: `content import ${set.manifest.id}`,
411
+ kind: 'transfer',
412
+ durationMs: Math.round(performance.now() - started),
413
+ appliedBy: config.nodeId,
414
+ note: `run ${result.runId}, ${bundle.package.packageName}@${bundle.package.version}`,
415
+ })
416
+ return outcome
417
+ }