@crombolo/server 0.9.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/server",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "The crombolo HTTP server: routing, configuration, media stores and background jobs.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,16 +28,16 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/api-management": "0.9.1",
32
- "@crombolo/assistant": "0.9.1",
33
- "@crombolo/auth": "0.9.1",
34
- "@crombolo/backoffice-host": "0.9.1",
35
- "@crombolo/contracts": "0.9.1",
36
- "@crombolo/core": "0.9.1",
37
- "@crombolo/data": "0.9.1",
38
- "@crombolo/render": "0.9.1",
39
- "@crombolo/schema": "0.9.1",
40
- "@crombolo/transfer": "0.9.1",
31
+ "@crombolo/api-management": "0.10.0",
32
+ "@crombolo/assistant": "0.10.0",
33
+ "@crombolo/auth": "0.10.0",
34
+ "@crombolo/backoffice-host": "0.10.0",
35
+ "@crombolo/contracts": "0.10.0",
36
+ "@crombolo/core": "0.10.0",
37
+ "@crombolo/data": "0.10.0",
38
+ "@crombolo/render": "0.10.0",
39
+ "@crombolo/schema": "0.10.0",
40
+ "@crombolo/transfer": "0.10.0",
41
41
  "@logtape/logtape": "^2.3.8"
42
42
  }
43
43
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Bundles: the definitions, and the zip a download builds from one.
3
- * `docs/17-bundles.md`.
2
+ * Bundles: the definitions, and the npm package a download builds from one.
3
+ * `docs/bundles.md`.
4
4
  *
5
5
  * Nothing new is serialized here. Schema travels as the canonical TOML
6
6
  * `@crombolo/schema` already writes, content and media as the bundle
@@ -48,14 +48,27 @@ import {
48
48
  type BundleKind,
49
49
  type BundleNode,
50
50
  type BundleSection,
51
+ bundleIdFor,
51
52
  type ContentSet,
52
53
  exportBundle,
54
+ isPackageName,
55
+ packageNameFor,
53
56
  writeBundle,
57
+ writeBundlePackage,
54
58
  } from '@crombolo/transfer'
59
+ import { VERSION } from '../config.ts'
55
60
  import { dictionaryToUdt, writeDictionaryUdt } from '../dictionary-transfer.ts'
56
61
  import { readBlobs } from '../media-files.ts'
57
62
  import type { MediaStore } from '../media-store.ts'
58
- import { writeZip, type ZipEntry } from '../zip.ts'
63
+ import { writeNpmTarball } from '../tar.ts'
64
+
65
+ /** The npm package a definition is built as: what was set on it, or what its name gives. */
66
+ export function bundlePackageOf(row: Bundle): { name: string; version: string } {
67
+ return {
68
+ name: row.packageName ?? packageNameFor(row.name),
69
+ version: row.packageVersion ?? '1.0.0',
70
+ }
71
+ }
59
72
 
60
73
  /** The content kinds a bundle's picks can name. */
61
74
  const CONTENT_OBJECT_TYPES = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
@@ -376,17 +389,19 @@ export function createPackagePort(db: Db, options: PackagePortOptions): PackageP
376
389
  },
377
390
 
378
391
  /**
379
- * The definition as one bundle, zipped.
392
+ * The definition as one bundle, packed as the npm package it is.
380
393
  *
381
394
  * Everything goes through `writeBundle` so there is a single manifest over
382
- * the whole artifact: it declares the sections, carries the hash that covers
383
- * them, and names the package — a zip of loose directories would have no
384
- * record of what it was or whether it arrived whole.
395
+ * the whole payload: it declares the sections, carries the hash that covers
396
+ * them, and names the bundle. The package around it is what gives it a
397
+ * name, a version and the core it was written for, so the download is
398
+ * something `bun add ./file.tgz` and `bun publish` take as it is.
385
399
  */
386
400
  async build(id): Promise<BuiltPackage | undefined> {
387
401
  const row = await bundles.byId(id)
388
402
  if (!row) return undefined
389
403
  const from = definition(row)
404
+ const pkg = bundlePackageOf(row)
390
405
 
391
406
  // The schema version the site is actually at, so the files a package
392
407
  // carries say where they came from rather than claiming version 0.
@@ -422,11 +437,71 @@ export function createPackagePort(db: Db, options: PackagePortOptions): PackageP
422
437
 
423
438
  const labelled: ContentSet = { ...set, manifest: { ...set.manifest, label: from.name } }
424
439
  const encoder = new TextEncoder()
425
- const entries: ZipEntry[] = writeBundle(labelled, content.blobs, sections).map((file) => ({
440
+ const files = writeBundlePackage(writeBundle(labelled, content.blobs, sections), {
441
+ name: pkg.name,
442
+ version: pkg.version,
443
+ id: bundleIdFor(pkg.name.replace(/^@[^/]+\//, '')),
444
+ label: from.name,
445
+ peerRange: `^${VERSION}`,
446
+ })
447
+ const entries = files.map((file) => ({
426
448
  path: file.path,
427
449
  bytes: file.bytes ?? encoder.encode(file.text ?? ''),
428
450
  }))
429
- return { fileName: packageFileName(from.name), bytes: writeZip(entries) }
451
+ return {
452
+ fileName: packageFileName(pkg.name, pkg.version),
453
+ contentType: 'application/gzip',
454
+ bytes: writeNpmTarball(entries),
455
+ }
430
456
  },
431
457
  }
432
458
  }
459
+
460
+ /** A created bundle's npm package as the Created view edits it. */
461
+ export interface CreatedBundlePackage {
462
+ name: string
463
+ version: string
464
+ /** Whether each was set, rather than derived from the definition's name. */
465
+ set: { name: boolean; version: boolean }
466
+ }
467
+
468
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
469
+
470
+ /** The npm package a definition builds as, or undefined when it is gone. */
471
+ export async function readCreatedBundlePackage(
472
+ db: Db,
473
+ id: string,
474
+ ): Promise<CreatedBundlePackage | undefined> {
475
+ const row = await new BundleRepository(db).byId(id)
476
+ if (!row) return undefined
477
+ return {
478
+ ...bundlePackageOf(row),
479
+ set: { name: row.packageName !== null, version: row.packageVersion !== null },
480
+ }
481
+ }
482
+
483
+ /**
484
+ * Sets the npm package a definition builds as. An empty value goes back to its
485
+ * default; anything else must be what npm itself would accept.
486
+ */
487
+ export async function writeCreatedBundlePackage(
488
+ db: Db,
489
+ id: string,
490
+ input: { name?: unknown; version?: unknown },
491
+ ): Promise<{ ok: true; package: CreatedBundlePackage } | { ok: false; message: string }> {
492
+ const bundles = new BundleRepository(db)
493
+ if (!(await bundles.byId(id))) return { ok: false, message: 'That bundle no longer exists.' }
494
+ const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')
495
+ const name = text(input.name)
496
+ const version = text(input.version)
497
+ if (name && !isPackageName(name))
498
+ return {
499
+ ok: false,
500
+ message:
501
+ 'A package name is lowercase letters, digits, dots, hyphens and underscores, optionally with an @scope/ in front.',
502
+ }
503
+ if (version && !SEMVER.test(version))
504
+ return { ok: false, message: 'A version is three numbers, like 1.0.0.' }
505
+ await bundles.setPackage(id, { name: name || null, version: version || null })
506
+ return { ok: true, package: (await readCreatedBundlePackage(db, id)) as CreatedBundlePackage }
507
+ }
@@ -2,7 +2,7 @@
2
2
  * The backoffice writes schema files. A document-type save lands in the
3
3
  * database as before and, when the directory is writable, in
4
4
  * `schema/content-types/<alias>.toml` — the file the next boot will sync.
5
- * docs/09-schema-as-code.md, "The backoffice writes files".
5
+ * docs/schema-as-code.md, "The backoffice writes files".
6
6
  */
7
7
 
8
8
  import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
package/src/backup.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Rollback is a backup (docs/10-packaging-and-upgrades.md): SQLite is copied
2
+ * Rollback is a backup (docs/packaging-and-upgrades.md): SQLite is copied
3
3
  * beside the database; Postgres runs the operator's `CROMBOLO_PG_DUMP` command
4
4
  * or refuses unless the caller vouches with `--backup-taken`.
5
5
  */
@@ -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
+ }