@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 +11 -11
- package/src/adapters/bundles.ts +84 -9
- package/src/adapters/schema-files.ts +1 -1
- package/src/backup.ts +1 -1
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +139 -38
- package/src/config.ts +11 -3
- package/src/database.ts +50 -11
- package/src/dictionary-transfer.ts +1 -1
- package/src/email.ts +1 -1
- package/src/extensions-notice.ts +2 -2
- package/src/form-files.ts +2 -2
- package/src/form-submit.ts +1 -1
- package/src/form-workflows.ts +2 -2
- package/src/forms.ts +1 -1
- package/src/index.ts +3 -0
- package/src/marketplace.ts +117 -32
- package/src/no-nodes.ts +1 -1
- package/src/ports.ts +1 -1
- package/src/schema.ts +1 -1
- package/src/server.ts +73 -10
- package/src/status.ts +29 -0
- package/src/tar.ts +85 -0
- package/src/zip.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/server",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
32
|
-
"@crombolo/assistant": "0.
|
|
33
|
-
"@crombolo/auth": "0.
|
|
34
|
-
"@crombolo/backoffice-host": "0.
|
|
35
|
-
"@crombolo/contracts": "0.
|
|
36
|
-
"@crombolo/core": "0.
|
|
37
|
-
"@crombolo/data": "0.
|
|
38
|
-
"@crombolo/render": "0.
|
|
39
|
-
"@crombolo/schema": "0.
|
|
40
|
-
"@crombolo/transfer": "0.
|
|
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
|
}
|
package/src/adapters/bundles.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Bundles: the definitions, and the
|
|
3
|
-
* `docs/
|
|
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 {
|
|
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,
|
|
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
|
|
383
|
-
* them, and names the
|
|
384
|
-
*
|
|
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
|
|
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 {
|
|
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/
|
|
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/
|
|
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
|
+
}
|