@crombolo/cli 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/bin/crombolo.ts +112 -37
- package/package.json +7 -7
- package/src/operations/commands.ts +203 -333
- package/src/templates.ts +1 -1
- package/templates/basic/bundle/package.json +16 -0
- package/templates/demo/harbourstone/bundle/package.json +16 -0
- package/templates/demo/harbourstone/files/schema/forms/contact.toml +2 -2
- package/templates/starter/bundle/package.json +16 -0
- package/src/operations/sections.ts +0 -423
- /package/templates/basic/bundle/{bundle.json → content/bundle.json} +0 -0
- /package/templates/basic/bundle/{nodes → content/nodes}/36955f6e-d110-469e-b280-72096eccd148.json +0 -0
- /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/180cc8ef/distillery-from-the-cliff-path.jpg +0 -0
- /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/68c9cfdf/bottle-brine-and-kiln.jpg +0 -0
- /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/6be45535/bottle-oloroso-pier.jpg +0 -0
- /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/6d8d6398/bottle-the-cove.jpg +0 -0
- /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/7b4713f8/copper-pot-stills.jpg +0 -0
- /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/c114b7d8/bottle-neap-tide.jpg +0 -0
- /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/fd337567/casks-in-the-sea-facing-warehouse.jpg +0 -0
- /package/templates/demo/harbourstone/bundle/{bundle.json → content/bundle.json} +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/069cc3c5-c9f5-4792-90c9-e2bfcc4855b6.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/13788fe7-1a45-4b48-8ae4-f4227e154e1e.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/180cc8ef-caf1-4316-b6a7-e3b5aee41ffd.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/22144db2-e214-4006-a0bb-53b5bd121fd6.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/26e87610-e2d6-4b47-bf92-3967f7186907.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/2caa099b-3884-472d-9cea-797a0f0c376f.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/2efbdb5b-7888-4a49-bca5-c0f85b76e472.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/2fe03891-1294-4344-8e24-47e8b212e5b0.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/38b009f7-03c0-4fda-b5c7-843ad6044e9c.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/3d312cc8-2937-40e1-ba14-fd781c9a862d.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/3dc30fc1-973a-4217-b26f-24902210be17.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/483a0d3b-a10c-4c9d-8c81-d9607d37e968.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/4b760c0b-b83a-4eee-aa29-c225cfabf19b.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/4e5c7f3f-54cb-4328-ad61-bc4c2736b890.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/62f479ff-0875-453e-99ae-3bf1bec5e3c8.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/68c9cfdf-6b40-47da-a1d2-17256a6cc6db.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/6be45535-83e5-4469-acf0-4127016721ae.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/6d8d6398-738f-4f38-ad3c-e5d6e9a971fe.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/7b4713f8-d917-457d-a9d8-7dd01a3617b4.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/88ec1185-53b5-439a-a2ce-3f56603be4ff.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/97469df2-107c-4010-8c14-4de88f735529.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/a2eb439f-71d9-4ea6-89d7-c5bc72618281.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/a5e36a18-a2ef-4eb5-85b4-7e12b21cca06.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/b1876d3a-50a7-4358-84bf-a0899932ffa9.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/b4e38bbd-c1a3-431a-b7a9-5868aa32993a.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/c114b7d8-0138-404b-9d8f-a39d992577a2.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/cb4c569e-c28a-43b6-a4d7-d33bb544e973.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/d60ef12b-0f0a-4db5-9763-21fa52b8d4da.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/d7f13222-d060-4496-b86d-bef21da963c1.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/f36dd4a9-927c-4ca3-a790-c9c859ac7302.json +0 -0
- /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/fd337567-df67-41ca-ae94-9d84d8d6042f.json +0 -0
- /package/templates/starter/bundle/{bundle.json → content/bundle.json} +0 -0
- /package/templates/starter/bundle/{nodes → content/nodes}/634dd948-cf83-4cad-9b7b-7c938b33da76.json +0 -0
- /package/templates/starter/bundle/{nodes → content/nodes}/64d95359-d91a-4050-b440-3e5124a74999.json +0 -0
- /package/templates/starter/bundle/{nodes → content/nodes}/d2997ebb-36ca-47cb-bbc8-31fefca787c0.json +0 -0
|
@@ -5,17 +5,21 @@
|
|
|
5
5
|
* a platform's own tooling — gets the same results as data. One implementation,
|
|
6
6
|
* so the two can never disagree about what an import or an upgrade does.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { ROOT_ACCESS } from '@crombolo/core'
|
|
9
9
|
import {
|
|
10
|
+
bundleStateKey,
|
|
11
|
+
bundleTablesIn,
|
|
10
12
|
connect,
|
|
11
13
|
cromboloPlan,
|
|
12
14
|
type Db,
|
|
13
15
|
describeRefFailure,
|
|
14
16
|
type NodeRow,
|
|
17
|
+
type PlannedStep,
|
|
15
18
|
pauseWrites,
|
|
19
|
+
pendingMigrations,
|
|
16
20
|
planUpgrade,
|
|
21
|
+
purgeBundleTables,
|
|
17
22
|
readWritesPaused,
|
|
18
|
-
recordInLedger,
|
|
19
23
|
resolveNodeRef,
|
|
20
24
|
resumeWrites,
|
|
21
25
|
type WritesPaused,
|
|
@@ -24,66 +28,64 @@ import {
|
|
|
24
28
|
import { loadSchemaDirectory, loadValueMigrations, runFix, runUpgrade } from '@crombolo/schema'
|
|
25
29
|
import {
|
|
26
30
|
type BackupResult,
|
|
31
|
+
type BundleChain,
|
|
32
|
+
BundleConfigurationError,
|
|
27
33
|
backupBefore,
|
|
28
34
|
bootSchema,
|
|
29
35
|
bootstrapDatabase,
|
|
36
|
+
bundleChainsFor,
|
|
37
|
+
CONTENT_OBJECT_TYPES,
|
|
38
|
+
CommandError,
|
|
39
|
+
type ContentCheckResult,
|
|
40
|
+
type ContentImportInput,
|
|
41
|
+
type ContentImportResult,
|
|
30
42
|
type CromboloConfig,
|
|
43
|
+
checkBundleContent,
|
|
31
44
|
checkSchemaDirectory,
|
|
32
|
-
componentAliasesIn,
|
|
33
45
|
createFileIntake,
|
|
34
46
|
createMediaPort,
|
|
47
|
+
installBundleContent,
|
|
35
48
|
installDatabase,
|
|
36
49
|
MediaFileStore,
|
|
37
50
|
mediaStoreFor,
|
|
38
|
-
|
|
51
|
+
pendingBundleSteps,
|
|
52
|
+
planBundleUpgrades,
|
|
39
53
|
readBlobs,
|
|
54
|
+
readBundle,
|
|
40
55
|
syncOptionsFor,
|
|
56
|
+
type TransferInput,
|
|
57
|
+
VERSION,
|
|
41
58
|
} from '@crombolo/server'
|
|
42
59
|
import {
|
|
43
|
-
|
|
44
|
-
type ContentSet,
|
|
45
|
-
checkBundle,
|
|
60
|
+
bundleIdFor,
|
|
46
61
|
exportBundle,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
loadResolutions,
|
|
50
|
-
type Resolution,
|
|
51
|
-
type TransferCheck,
|
|
62
|
+
isPackageName,
|
|
63
|
+
packageNameFor,
|
|
52
64
|
writeBundle,
|
|
65
|
+
writeBundlePackage,
|
|
53
66
|
} from '@crombolo/transfer'
|
|
54
|
-
import {
|
|
55
|
-
applySections,
|
|
56
|
-
type CarriedSections,
|
|
57
|
-
carriedSections,
|
|
58
|
-
carriesSections,
|
|
59
|
-
planSections,
|
|
60
|
-
promoteBackups,
|
|
61
|
-
replacementsNeedingPermission,
|
|
62
|
-
type SectionFile,
|
|
63
|
-
type SectionOptions,
|
|
64
|
-
type SectionPlan,
|
|
65
|
-
type SectionResult,
|
|
66
|
-
stageBackups,
|
|
67
|
-
} from './sections.ts'
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
type Resolution,
|
|
73
|
-
type TransferCheck,
|
|
74
|
-
} from '@crombolo/transfer'
|
|
75
|
-
// The section half of an install, exported for the same reason the commands
|
|
76
|
-
// are: tooling that applies a bundle without a terminal needs the plan too.
|
|
68
|
+
// The installer lives in the server, so the backoffice can run it too; these
|
|
69
|
+
// are exported for the same reason the commands are: tooling that applies a
|
|
70
|
+
// bundle without a terminal needs the plan and the refusals as well.
|
|
77
71
|
export {
|
|
78
72
|
applySections,
|
|
73
|
+
type BundleSummary,
|
|
79
74
|
type CarriedSections,
|
|
75
|
+
CONTENT_OBJECT_TYPES,
|
|
76
|
+
CommandError,
|
|
77
|
+
type ContentCheckResult,
|
|
78
|
+
type ContentImportInput,
|
|
79
|
+
type ContentImportResult,
|
|
80
80
|
carriedSections,
|
|
81
81
|
carriesSections,
|
|
82
82
|
type FileAction,
|
|
83
83
|
planSections,
|
|
84
84
|
promoteBackups,
|
|
85
|
+
type ResolvedBundle,
|
|
85
86
|
type RestoredFiles,
|
|
86
87
|
RUN_BACKUP_DIR,
|
|
88
|
+
readBundle,
|
|
87
89
|
replacementsNeedingPermission,
|
|
88
90
|
restoreSectionFiles,
|
|
89
91
|
type SectionFile,
|
|
@@ -91,299 +93,41 @@ export {
|
|
|
91
93
|
type SectionPlan,
|
|
92
94
|
type SectionResult,
|
|
93
95
|
stageBackups,
|
|
94
|
-
|
|
96
|
+
type TransferInput,
|
|
97
|
+
transferOptions,
|
|
98
|
+
} from '@crombolo/server'
|
|
99
|
+
export {
|
|
100
|
+
isResolution,
|
|
101
|
+
RESOLUTIONS,
|
|
102
|
+
type Resolution,
|
|
103
|
+
type TransferCheck,
|
|
104
|
+
} from '@crombolo/transfer'
|
|
95
105
|
export type { BackupResult, WritesPaused }
|
|
96
106
|
|
|
97
|
-
/**
|
|
98
|
-
export const CONTENT_OBJECT_TYPES = [ObjectTypes.Document, ObjectTypes.Media, ObjectTypes.Element]
|
|
99
|
-
|
|
100
|
-
/** A refusal the caller should read: nothing was written. */
|
|
101
|
-
export class CommandError extends Error {
|
|
102
|
-
constructor(
|
|
103
|
-
message: string,
|
|
104
|
-
readonly problems: string[] = [],
|
|
105
|
-
) {
|
|
106
|
-
super(message)
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export interface BundleSummary {
|
|
111
|
-
id: string
|
|
112
|
-
from: string
|
|
113
|
-
schemaVersion: string
|
|
114
|
-
schemaRevision: string
|
|
115
|
-
snapshot: string
|
|
116
|
-
nodes: number
|
|
117
|
-
blobsCarried: number
|
|
118
|
-
blobBytes: number
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* The check an install that never reached the content reports.
|
|
123
|
-
*
|
|
124
|
-
* Nothing was examined, so nothing is outstanding: the refusal is in
|
|
125
|
-
* `problems`, and a caller reading `outstanding.length` must not be told there
|
|
126
|
-
* were content findings when there were none.
|
|
127
|
-
*/
|
|
128
|
-
const emptyCheck = (bundleId = ''): TransferCheck => ({
|
|
129
|
-
bundleId,
|
|
130
|
-
findings: [],
|
|
131
|
-
outstanding: [],
|
|
132
|
-
plan: [],
|
|
133
|
-
counts: { create: 0, update: 0, unchanged: 0, skip: 0 },
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
function summarise(set: ContentSet): BundleSummary {
|
|
137
|
-
const carried = set.manifest.blobs.filter((blob) => blob.included)
|
|
138
|
-
return {
|
|
139
|
-
id: set.manifest.id,
|
|
140
|
-
from: set.manifest.provenance.siteName || '',
|
|
141
|
-
schemaVersion: set.manifest.provenance.schemaVersion,
|
|
142
|
-
schemaRevision: set.manifest.provenance.schemaRevision,
|
|
143
|
-
snapshot: set.manifest.snapshot,
|
|
144
|
-
nodes: set.nodes.length,
|
|
145
|
-
blobsCarried: carried.length,
|
|
146
|
-
blobBytes: carried.reduce((total, blob) => total + (blob.size ?? 0), 0),
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/** Reads a bundle whole, or refuses: a half-copied bundle must never be checked as though complete. */
|
|
151
|
-
export function readBundle(dir: string): {
|
|
152
|
-
set: ContentSet
|
|
153
|
-
blobs: ReadonlyMap<string, string>
|
|
154
|
-
/** The sections it carries beyond its content; empty for a content-only bundle. */
|
|
155
|
-
carried: CarriedSections
|
|
156
|
-
} {
|
|
157
|
-
const loaded = loadBundle(dir)
|
|
158
|
-
const problems = loaded.problems.map((problem) => `${problem.file}: ${problem.message}`)
|
|
159
|
-
if (!loaded.set || problems.length > 0)
|
|
160
|
-
throw new CommandError(
|
|
161
|
-
'The bundle is incomplete or damaged; nothing was read from it.',
|
|
162
|
-
problems,
|
|
163
|
-
)
|
|
164
|
-
return { set: loaded.set, blobs: loaded.blobs, carried: carriedSections(loaded) }
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export interface TransferInput extends SectionOptions {
|
|
168
|
-
/** The bundle's directory on this machine. */
|
|
169
|
-
dir: string
|
|
170
|
-
/** Where the bundle's roots go: a path or a key. */
|
|
171
|
-
under?: string
|
|
172
|
-
/** A decision per node key, on top of any the bundle carries. */
|
|
173
|
-
resolutions?: Record<string, Resolution>
|
|
174
|
-
resolveAll?: Resolution
|
|
175
|
-
allowMissingBlobs?: boolean
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** The options `check` and `import` share: saved decisions, then the caller's, then placement. */
|
|
179
|
-
export async function transferOptions(
|
|
180
|
-
db: Db,
|
|
181
|
-
config: CromboloConfig,
|
|
182
|
-
set: ContentSet,
|
|
183
|
-
input: TransferInput,
|
|
184
|
-
): Promise<{ options: CheckOptions; under: NodeRow | undefined; problems: string[] }> {
|
|
185
|
-
const saved = loadResolutions(input.dir, set.manifest.id)
|
|
186
|
-
const resolutions = { ...(saved.file?.byNode ?? {}), ...(input.resolutions ?? {}) }
|
|
187
|
-
const resolveAll = input.resolveAll ?? saved.file?.all ?? undefined
|
|
188
|
-
const underRef = input.under ?? saved.file?.under ?? undefined
|
|
189
|
-
let under: NodeRow | undefined
|
|
190
|
-
if (underRef) {
|
|
191
|
-
const resolved = await resolveNodeRef(db, CONTENT_OBJECT_TYPES, underRef)
|
|
192
|
-
if (!resolved.ok) throw new CommandError(describeRefFailure(underRef, resolved))
|
|
193
|
-
under = resolved.node
|
|
194
|
-
}
|
|
195
|
-
// Whether this environment has a media file is a question only its store can
|
|
196
|
-
// answer; without it `missing-blob` is a finding the check could never raise.
|
|
197
|
-
const store = await mediaStoreFor(config)
|
|
198
|
-
return {
|
|
199
|
-
options: {
|
|
200
|
-
under,
|
|
201
|
-
resolutions,
|
|
202
|
-
resolveAll,
|
|
203
|
-
componentAliases: componentAliasesIn(config.componentsDir),
|
|
204
|
-
allowMissingBlobs: input.allowMissingBlobs ?? false,
|
|
205
|
-
hasBlob: async (key: string) => Boolean(await store.get(key)),
|
|
206
|
-
backOfficePath: config.backOfficePath,
|
|
207
|
-
},
|
|
208
|
-
under,
|
|
209
|
-
problems: saved.problems,
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
export interface ContentCheckResult {
|
|
214
|
-
bundle: BundleSummary
|
|
215
|
-
under: string | null
|
|
216
|
-
check: TransferCheck
|
|
217
|
-
/** What the carried sections would do here; undefined for a content-only bundle. */
|
|
218
|
-
sections: SectionPlan | undefined
|
|
219
|
-
/** Nothing outstanding: the bundle would import cleanly. */
|
|
220
|
-
clean: boolean
|
|
221
|
-
/** The decisions this check ran with — the bundle's saved ones under the caller's. */
|
|
222
|
-
decisions: { all: Resolution | null; byNode: Record<string, Resolution> }
|
|
223
|
-
problems: string[]
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
/** Read-only: what importing a bundle here would do. Its findings go to the Changes dashboard. */
|
|
107
|
+
/** Read-only: what installing a bundle here would do. Its findings go to the Changes dashboard. */
|
|
227
108
|
export async function contentCheck(
|
|
228
109
|
config: CromboloConfig,
|
|
229
110
|
input: TransferInput,
|
|
230
111
|
): Promise<ContentCheckResult> {
|
|
231
|
-
const { set, carried } = readBundle(input.dir)
|
|
232
112
|
const { db } = await bootstrapDatabase(config)
|
|
233
113
|
try {
|
|
234
|
-
|
|
235
|
-
const check = await checkBundle(db, set, prepared.options)
|
|
236
|
-
// Under this bundle's own scope, so two bundles in flight do not resolve each other's.
|
|
237
|
-
await writeReport(db, check.findings, { source: 'transfer', scope: set.manifest.id })
|
|
238
|
-
return {
|
|
239
|
-
bundle: summarise(set),
|
|
240
|
-
under: prepared.under ? (prepared.under.text ?? prepared.under.key) : null,
|
|
241
|
-
check,
|
|
242
|
-
sections: carriesSections(carried)
|
|
243
|
-
? await planSections(db, config, carried, input)
|
|
244
|
-
: undefined,
|
|
245
|
-
clean: check.outstanding.length === 0,
|
|
246
|
-
decisions: {
|
|
247
|
-
all: prepared.options.resolveAll ?? null,
|
|
248
|
-
byNode: prepared.options.resolutions ?? {},
|
|
249
|
-
},
|
|
250
|
-
problems: prepared.problems,
|
|
251
|
-
}
|
|
114
|
+
return await checkBundleContent(db, config, input)
|
|
252
115
|
} finally {
|
|
253
116
|
await db.close()
|
|
254
117
|
}
|
|
255
118
|
}
|
|
256
119
|
|
|
257
|
-
|
|
258
|
-
publish?: boolean
|
|
259
|
-
label?: string
|
|
260
|
-
/** Postgres: the caller has taken a backup itself. */
|
|
261
|
-
backupTaken?: boolean
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
export interface ContentImportResult {
|
|
265
|
-
bundle: BundleSummary
|
|
266
|
-
backup: BackupResult
|
|
267
|
-
/**
|
|
268
|
-
* What the carried sections did, when the bundle carried any.
|
|
269
|
-
*
|
|
270
|
-
* Applied before the content and outside the content run's ledger entry: the
|
|
271
|
-
* files are now in `schema/` and the views directory, where the repository
|
|
272
|
-
* owns them — so a revert takes the content back and git takes the files.
|
|
273
|
-
*/
|
|
274
|
-
sections: SectionResult | undefined
|
|
275
|
-
/** Where the originals of any replaced files were kept, for a revert. */
|
|
276
|
-
replacedFilesKept?: string | undefined
|
|
277
|
-
/** Undefined when the import was refused; then nothing was written. */
|
|
278
|
-
runId: string | undefined
|
|
279
|
-
check: TransferCheck
|
|
280
|
-
blobs: { placed: number; bytes: number } | null
|
|
281
|
-
published: number
|
|
282
|
-
publishFailures: { key: string; reason: string }[]
|
|
283
|
-
problems: string[]
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** Re-check, back up, then apply in one transaction and record the run. */
|
|
120
|
+
/** Back up, re-check, then apply in one transaction and record the run. */
|
|
287
121
|
export async function contentImport(
|
|
288
122
|
config: CromboloConfig,
|
|
289
123
|
input: ContentImportInput,
|
|
290
124
|
): Promise<ContentImportResult> {
|
|
291
|
-
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
const backup = await backupBefore(config, {
|
|
295
|
-
reason: 'content-import',
|
|
296
|
-
backupTaken: input.backupTaken,
|
|
297
|
-
})
|
|
125
|
+
// Read before anything is opened or backed up, so a bundle that is not one is
|
|
126
|
+
// refused before a backup is taken on its behalf.
|
|
127
|
+
readBundle(input.bundle, config.siteDir)
|
|
298
128
|
const { db } = await bootstrapDatabase(config)
|
|
299
129
|
try {
|
|
300
|
-
|
|
301
|
-
const started = performance.now()
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* Structure first, but planned before anything is written.
|
|
305
|
-
*
|
|
306
|
-
* The order is forced: content naming a type this bundle brings cannot pass
|
|
307
|
-
* a check until that type exists, so the schema has to land before the
|
|
308
|
-
* content is checked. Which means a blocking schema change has to be caught
|
|
309
|
-
* *before* the first file is written — otherwise a refused install leaves a
|
|
310
|
-
* half-applied site behind. `planSections` answers that from an overlay
|
|
311
|
-
* copy, so the site is untouched until it is known the schema can apply.
|
|
312
|
-
*/
|
|
313
|
-
const plan = carriesSections(carried)
|
|
314
|
-
? await planSections(db, config, carried, input)
|
|
315
|
-
: undefined
|
|
316
|
-
const refuse = (problems: string[]): ContentImportResult => ({
|
|
317
|
-
bundle: summarise(set),
|
|
318
|
-
backup,
|
|
319
|
-
runId: undefined,
|
|
320
|
-
check: emptyCheck(set.manifest.id),
|
|
321
|
-
blobs: null,
|
|
322
|
-
published: 0,
|
|
323
|
-
publishFailures: [],
|
|
324
|
-
problems,
|
|
325
|
-
sections: undefined,
|
|
326
|
-
})
|
|
327
|
-
|
|
328
|
-
if (plan?.schema?.classification === 'breaking')
|
|
329
|
-
return refuse([
|
|
330
|
-
'the schema this bundle carries cannot be applied here without data work',
|
|
331
|
-
...plan.schema.findings,
|
|
332
|
-
])
|
|
333
|
-
|
|
334
|
-
/**
|
|
335
|
-
* A file this site already has is not replaced without being asked.
|
|
336
|
-
*
|
|
337
|
-
* Every section is editable in the backoffice — a stylesheet, a template, a
|
|
338
|
-
* content type — so an overwrite destroys somebody's work, and the site
|
|
339
|
-
* may have no git to recover it from. Refused by name, like any other
|
|
340
|
-
* finding a person has to answer.
|
|
341
|
-
*/
|
|
342
|
-
const needingPermission = plan ? replacementsNeedingPermission(plan, input) : []
|
|
343
|
-
if (needingPermission.length > 0)
|
|
344
|
-
return refuse([
|
|
345
|
-
`${needingPermission.length} file(s) here would be replaced; pass --replace-files to allow it`,
|
|
346
|
-
...needingPermission.map((file: SectionFile) => file.path),
|
|
347
|
-
])
|
|
348
|
-
|
|
349
|
-
// Staged whenever something *will* be replaced — which is decided by the
|
|
350
|
-
// plan, not by the permission check: by this point permission has been
|
|
351
|
-
// given, so the files needing it are exactly the ones to back up.
|
|
352
|
-
const willReplace = (plan?.files ?? []).some((file) => file.action === 'overwrite')
|
|
353
|
-
const staging = plan && willReplace ? stageBackups(config) : undefined
|
|
354
|
-
const sections = plan
|
|
355
|
-
? await applySections(db, config, carried, { ...input, backupDir: staging })
|
|
356
|
-
: undefined
|
|
357
|
-
const result = await importBundle(db, set, {
|
|
358
|
-
...prepared.options,
|
|
359
|
-
publish: input.publish ?? false,
|
|
360
|
-
nodeId: config.nodeId,
|
|
361
|
-
label: input.label,
|
|
362
|
-
})
|
|
363
|
-
await writeReport(db, result.check.findings, { source: 'transfer', scope: set.manifest.id })
|
|
364
|
-
const outcome: ContentImportResult = {
|
|
365
|
-
bundle: summarise(set),
|
|
366
|
-
backup,
|
|
367
|
-
runId: result.runId,
|
|
368
|
-
check: result.check,
|
|
369
|
-
blobs: null,
|
|
370
|
-
published: result.published.length,
|
|
371
|
-
publishFailures: result.publishFailures,
|
|
372
|
-
problems: prepared.problems,
|
|
373
|
-
sections,
|
|
374
|
-
}
|
|
375
|
-
if (!result.runId) return outcome
|
|
376
|
-
// The backups become the run's, so `content revert` can find them.
|
|
377
|
-
if (staging) outcome.replacedFilesKept = promoteBackups(config, staging, result.runId)
|
|
378
|
-
if (blobs.size > 0) outcome.blobs = await placeBlobs(await mediaStoreFor(config), blobs)
|
|
379
|
-
await recordInLedger(db, {
|
|
380
|
-
name: `content import ${set.manifest.id}`,
|
|
381
|
-
kind: 'transfer',
|
|
382
|
-
durationMs: Math.round(performance.now() - started),
|
|
383
|
-
appliedBy: config.nodeId,
|
|
384
|
-
note: `run ${result.runId}`,
|
|
385
|
-
})
|
|
386
|
-
return outcome
|
|
130
|
+
return await installBundleContent(db, config, input)
|
|
387
131
|
} finally {
|
|
388
132
|
await db.close()
|
|
389
133
|
}
|
|
@@ -396,24 +140,38 @@ export interface ContentExportInput {
|
|
|
396
140
|
drafts?: boolean
|
|
397
141
|
withBlobs?: boolean
|
|
398
142
|
withBlueprints?: boolean
|
|
143
|
+
/**
|
|
144
|
+
* The private package the export is written as. The name defaults to one
|
|
145
|
+
* derived from the bundle's id, the version to `1.0.0`.
|
|
146
|
+
*/
|
|
147
|
+
package?: { name?: string; version?: string }
|
|
399
148
|
}
|
|
400
149
|
|
|
401
150
|
export interface ContentExportResult {
|
|
402
151
|
bundleId: string
|
|
152
|
+
/** The package the files make up. */
|
|
153
|
+
package: { name: string; version: string }
|
|
403
154
|
snapshot: string
|
|
404
155
|
counts: Record<string, number>
|
|
405
156
|
expected: { key: string; name: string; why: string }[]
|
|
406
|
-
/** The
|
|
157
|
+
/** The package's files — `package.json` and the payload under `content/` — for the caller to place. */
|
|
407
158
|
files: { path: string; bytes?: Uint8Array; text?: string }[]
|
|
408
159
|
blobs: { carried: number; bytes: number; missing: string[]; notCarried: number }
|
|
409
160
|
}
|
|
410
161
|
|
|
411
|
-
/**
|
|
162
|
+
/**
|
|
163
|
+
* Writes nothing: returns the export as a private package's files, for the
|
|
164
|
+
* caller to place. An environment's export is a bundle like any other, but one
|
|
165
|
+
* applied from its directory and never published or depended on.
|
|
166
|
+
*/
|
|
412
167
|
export async function contentExport(
|
|
413
168
|
config: CromboloConfig,
|
|
414
169
|
input: ContentExportInput,
|
|
415
170
|
): Promise<ContentExportResult> {
|
|
416
171
|
if (input.roots.length === 0) throw new CommandError('An export needs at least one root.')
|
|
172
|
+
const requested = input.package?.name
|
|
173
|
+
if (requested !== undefined && !isPackageName(requested))
|
|
174
|
+
throw new CommandError(`"${requested}" is not a package name npm accepts.`)
|
|
417
175
|
const { db } = await bootstrapDatabase(config)
|
|
418
176
|
try {
|
|
419
177
|
// A path is resolved against the tree it is exported from, and the key is
|
|
@@ -443,8 +201,13 @@ export async function contentExport(
|
|
|
443
201
|
carried = read.bytes
|
|
444
202
|
missing = read.missing
|
|
445
203
|
}
|
|
204
|
+
const pkg = {
|
|
205
|
+
name: requested ?? packageNameFor(`export-${set.manifest.id}`),
|
|
206
|
+
version: input.package?.version ?? '1.0.0',
|
|
207
|
+
}
|
|
446
208
|
return {
|
|
447
209
|
bundleId: set.manifest.id,
|
|
210
|
+
package: pkg,
|
|
448
211
|
snapshot: set.manifest.snapshot,
|
|
449
212
|
counts: { ...set.manifest.counts },
|
|
450
213
|
expected: set.manifest.dependencies.expected.map((e) => ({
|
|
@@ -452,7 +215,13 @@ export async function contentExport(
|
|
|
452
215
|
name: e.name,
|
|
453
216
|
why: e.why,
|
|
454
217
|
})),
|
|
455
|
-
files: writeBundle(set, carried),
|
|
218
|
+
files: writeBundlePackage(writeBundle(set, carried), {
|
|
219
|
+
...pkg,
|
|
220
|
+
id: bundleIdFor(pkg.name.replace(/^@[^/]+\//, '')),
|
|
221
|
+
label: `Content from ${config.siteName}`,
|
|
222
|
+
private: true,
|
|
223
|
+
peerRange: `^${VERSION}`,
|
|
224
|
+
}),
|
|
456
225
|
blobs: {
|
|
457
226
|
carried: carried.size,
|
|
458
227
|
bytes: [...carried.values()].reduce((total, file) => total + file.length, 0),
|
|
@@ -465,7 +234,21 @@ export async function contentExport(
|
|
|
465
234
|
}
|
|
466
235
|
}
|
|
467
236
|
|
|
237
|
+
/** The wired bundles' chains, checked as boot checks them, or a refusal naming every problem. */
|
|
238
|
+
function chainsFor(config: CromboloConfig): BundleChain[] {
|
|
239
|
+
try {
|
|
240
|
+
return bundleChainsFor(config)
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (error instanceof BundleConfigurationError)
|
|
243
|
+
throw new CommandError('The configured bundles cannot be used.', error.problems)
|
|
244
|
+
throw error
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const flat = (sql: string) => sql.replace(/\s+/g, ' ').trim()
|
|
249
|
+
|
|
468
250
|
async function upgradeContext(config: CromboloConfig, set: Record<string, unknown>) {
|
|
251
|
+
const chains = chainsFor(config)
|
|
469
252
|
const db = await connect({ file: config.sqliteFile })
|
|
470
253
|
const node = { nodeId: config.nodeId, revision: config.schemaRevision }
|
|
471
254
|
const loaded = () => loadSchemaDirectory(config.schemaDir)
|
|
@@ -476,33 +259,45 @@ async function upgradeContext(config: CromboloConfig, set: Record<string, unknow
|
|
|
476
259
|
migrations: await loadValueMigrations(config.schemaDir),
|
|
477
260
|
backOfficePath: config.backOfficePath,
|
|
478
261
|
})
|
|
479
|
-
return { db, node, loaded, syncNode, checkOptions }
|
|
262
|
+
return { db, node, loaded, syncNode, checkOptions, chains }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export interface PlannedStepResult {
|
|
266
|
+
name: string
|
|
267
|
+
kind: string
|
|
268
|
+
release: string | null
|
|
269
|
+
statements: string[]
|
|
270
|
+
unplannable: string | null
|
|
480
271
|
}
|
|
481
272
|
|
|
482
273
|
export interface UpgradePlanResult {
|
|
483
|
-
steps:
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
release: string | null
|
|
487
|
-
statements: string[]
|
|
488
|
-
unplannable: string | null
|
|
489
|
-
}[]
|
|
274
|
+
steps: PlannedStepResult[]
|
|
275
|
+
/** Each wired bundle that owns tables, with the steps it would run after core's. */
|
|
276
|
+
bundles: { id: string; name: string; steps: PlannedStepResult[] }[]
|
|
490
277
|
site: { checked: boolean; action: string | null; classification: string | null }
|
|
491
278
|
}
|
|
492
279
|
|
|
280
|
+
const plannedStep = (step: PlannedStep): PlannedStepResult => ({
|
|
281
|
+
name: step.name,
|
|
282
|
+
kind: step.kind,
|
|
283
|
+
release: step.release ?? null,
|
|
284
|
+
statements: (step.statements ?? []).map(flat),
|
|
285
|
+
unplannable: step.unplannable ?? null,
|
|
286
|
+
})
|
|
287
|
+
|
|
493
288
|
/** The DDL every pending framework step would run, and what the site's schema would do. Writes nothing. */
|
|
494
289
|
export async function upgradePlan(config: CromboloConfig): Promise<UpgradePlanResult> {
|
|
495
|
-
const { db, node } = await upgradeContext(config, {})
|
|
290
|
+
const { db, node, chains } = await upgradeContext(config, {})
|
|
496
291
|
try {
|
|
497
292
|
const steps = await planUpgrade(db, cromboloPlan)
|
|
293
|
+
const bundles = await planBundleUpgrades(db, chains)
|
|
498
294
|
const check = await checkSchemaDirectory(db, config, node)
|
|
499
295
|
return {
|
|
500
|
-
steps: steps.map(
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
unplannable: step.unplannable ?? null,
|
|
296
|
+
steps: steps.map(plannedStep),
|
|
297
|
+
bundles: bundles.map((bundle) => ({
|
|
298
|
+
id: bundle.id,
|
|
299
|
+
name: bundle.name,
|
|
300
|
+
steps: bundle.steps.map(plannedStep),
|
|
506
301
|
})),
|
|
507
302
|
site: {
|
|
508
303
|
checked: check.siteChecked,
|
|
@@ -520,11 +315,17 @@ export async function upgradeCheck(
|
|
|
520
315
|
config: CromboloConfig,
|
|
521
316
|
input: { set?: Record<string, unknown> } = {},
|
|
522
317
|
) {
|
|
523
|
-
const { db, node } = await upgradeContext(config, input.set ?? {})
|
|
318
|
+
const { db, node, chains } = await upgradeContext(config, input.set ?? {})
|
|
524
319
|
try {
|
|
525
320
|
const report = await checkSchemaDirectory(db, config, node, { set: input.set ?? {} })
|
|
526
321
|
if (report.siteChecked) await writeReport(db, report.findings, { source: 'upgrade' })
|
|
322
|
+
const bundles = await pendingBundleSteps(db, chains)
|
|
527
323
|
return {
|
|
324
|
+
bundles: bundles.map((bundle) => ({
|
|
325
|
+
id: bundle.id,
|
|
326
|
+
name: bundle.name,
|
|
327
|
+
pending: bundle.pending.map((step) => ({ name: step.name, kind: step.kind ?? 'expand' })),
|
|
328
|
+
})),
|
|
528
329
|
siteChecked: report.siteChecked,
|
|
529
330
|
classification: report.classification,
|
|
530
331
|
findings: report.findings,
|
|
@@ -542,9 +343,12 @@ export async function upgradeFix(
|
|
|
542
343
|
input: { set?: Record<string, unknown>; backupTaken?: boolean } = {},
|
|
543
344
|
) {
|
|
544
345
|
const backup = await backupBefore(config, { reason: 'fix', backupTaken: input.backupTaken })
|
|
545
|
-
const { db, loaded, syncNode, checkOptions } = await upgradeContext(
|
|
346
|
+
const { db, loaded, syncNode, checkOptions, chains } = await upgradeContext(
|
|
347
|
+
config,
|
|
348
|
+
input.set ?? {},
|
|
349
|
+
)
|
|
546
350
|
try {
|
|
547
|
-
await installDatabase(db, config)
|
|
351
|
+
await installDatabase(db, config, chains)
|
|
548
352
|
const fix = await runFix(db, loaded(), syncNode(), {
|
|
549
353
|
...(await checkOptions()),
|
|
550
354
|
by: config.nodeId,
|
|
@@ -574,6 +378,8 @@ export type UpgradeRunResult =
|
|
|
574
378
|
action: 'upgraded'
|
|
575
379
|
backup: BackupResult
|
|
576
380
|
frameworkApplied: string[]
|
|
381
|
+
/** Each bundle's steps that ran, after core's. */
|
|
382
|
+
bundlesApplied: { id: string; applied: string[] }[]
|
|
577
383
|
schema: { action: string; version: string | null; revision: string | null } | null
|
|
578
384
|
}
|
|
579
385
|
|
|
@@ -583,9 +389,19 @@ export async function upgradeRun(
|
|
|
583
389
|
input: { force?: string; set?: Record<string, unknown>; backupTaken?: boolean } = {},
|
|
584
390
|
): Promise<UpgradeRunResult> {
|
|
585
391
|
const backup = await backupBefore(config, { reason: 'upgrade', backupTaken: input.backupTaken })
|
|
586
|
-
const { db, loaded, syncNode, checkOptions } = await upgradeContext(
|
|
392
|
+
const { db, loaded, syncNode, checkOptions, chains } = await upgradeContext(
|
|
393
|
+
config,
|
|
394
|
+
input.set ?? {},
|
|
395
|
+
)
|
|
587
396
|
try {
|
|
588
|
-
|
|
397
|
+
// Read before anything runs: `installDatabase` applies core's steps and then
|
|
398
|
+
// each bundle's, so this is what the upgrade itself applied.
|
|
399
|
+
const corePending = (await pendingMigrations(db, cromboloPlan)).map((step) => step.name)
|
|
400
|
+
const bundlesPending = await pendingBundleSteps(db, chains)
|
|
401
|
+
await installDatabase(db, config, chains)
|
|
402
|
+
const bundlesApplied = bundlesPending
|
|
403
|
+
.filter((bundle) => bundle.pending.length > 0)
|
|
404
|
+
.map((bundle) => ({ id: bundle.id, applied: bundle.pending.map((step) => step.name) }))
|
|
589
405
|
const result = await runUpgrade(db, loaded(), syncNode(), {
|
|
590
406
|
...(await checkOptions()),
|
|
591
407
|
by: config.nodeId,
|
|
@@ -598,11 +414,14 @@ export async function upgradeRun(
|
|
|
598
414
|
backup,
|
|
599
415
|
reasons: result.reasons.map((f) => ({ kind: f.kind, message: f.message, link: f.link })),
|
|
600
416
|
}
|
|
601
|
-
|
|
417
|
+
const frameworkApplied = [...corePending, ...result.frameworkApplied]
|
|
418
|
+
if (result.action === 'nothing' && frameworkApplied.length === 0 && bundlesApplied.length === 0)
|
|
419
|
+
return { action: 'nothing', backup }
|
|
602
420
|
return {
|
|
603
421
|
action: 'upgraded',
|
|
604
422
|
backup,
|
|
605
|
-
frameworkApplied
|
|
423
|
+
frameworkApplied,
|
|
424
|
+
bundlesApplied,
|
|
606
425
|
schema: result.sync
|
|
607
426
|
? {
|
|
608
427
|
action: result.sync.action,
|
|
@@ -773,3 +592,54 @@ export function resumeEditing(config: CromboloConfig): Promise<void> {
|
|
|
773
592
|
export function editingPaused(config: CromboloConfig): Promise<WritesPaused | undefined> {
|
|
774
593
|
return withDatabase(config, readWritesPaused)
|
|
775
594
|
}
|
|
595
|
+
|
|
596
|
+
export interface BundlePurgePlan {
|
|
597
|
+
id: string
|
|
598
|
+
/** The tables that would be dropped, as the database names them. */
|
|
599
|
+
tables: string[]
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** Refuses a purge of a bundle the site still wires: its code would recreate what was dropped. */
|
|
603
|
+
function refuseWired(config: CromboloConfig, id: string): void {
|
|
604
|
+
if (config.bundles.some((bundle) => bundle.id === id))
|
|
605
|
+
throw new CommandError(
|
|
606
|
+
`The bundle "${id}" is still wired in crombolo.config.ts. Take it out of \`bundles\` and deploy that first.`,
|
|
607
|
+
)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Read-only: what purging a bundle would drop. */
|
|
611
|
+
export async function bundlePurgePlan(
|
|
612
|
+
config: CromboloConfig,
|
|
613
|
+
id: string,
|
|
614
|
+
): Promise<BundlePurgePlan> {
|
|
615
|
+
refuseWired(config, id)
|
|
616
|
+
const db = await connect({ file: config.sqliteFile })
|
|
617
|
+
try {
|
|
618
|
+
return { id, tables: await bundleTablesIn(db, id) }
|
|
619
|
+
} finally {
|
|
620
|
+
await db.close()
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Backs up, then drops every table under the bundle's prefix and forgets its
|
|
626
|
+
* state, ledgered as a `purge` against the bundle.
|
|
627
|
+
*/
|
|
628
|
+
export async function bundlePurge(
|
|
629
|
+
config: CromboloConfig,
|
|
630
|
+
id: string,
|
|
631
|
+
input: { backupTaken?: boolean } = {},
|
|
632
|
+
): Promise<{ backup: BackupResult; dropped: string[] }> {
|
|
633
|
+
refuseWired(config, id)
|
|
634
|
+
const backup = await backupBefore(config, { reason: 'purge', backupTaken: input.backupTaken })
|
|
635
|
+
const db = await connect({ file: config.sqliteFile })
|
|
636
|
+
try {
|
|
637
|
+
const dropped = await purgeBundleTables(db, id, {
|
|
638
|
+
by: config.nodeId,
|
|
639
|
+
stateKey: bundleStateKey(id),
|
|
640
|
+
})
|
|
641
|
+
return { backup, dropped }
|
|
642
|
+
} finally {
|
|
643
|
+
await db.close()
|
|
644
|
+
}
|
|
645
|
+
}
|