@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.
Files changed (58) hide show
  1. package/bin/crombolo.ts +112 -37
  2. package/package.json +7 -7
  3. package/src/operations/commands.ts +203 -333
  4. package/src/templates.ts +1 -1
  5. package/templates/basic/bundle/package.json +16 -0
  6. package/templates/demo/harbourstone/bundle/package.json +16 -0
  7. package/templates/demo/harbourstone/files/schema/forms/contact.toml +2 -2
  8. package/templates/starter/bundle/package.json +16 -0
  9. package/src/operations/sections.ts +0 -423
  10. /package/templates/basic/bundle/{bundle.json → content/bundle.json} +0 -0
  11. /package/templates/basic/bundle/{nodes → content/nodes}/36955f6e-d110-469e-b280-72096eccd148.json +0 -0
  12. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/180cc8ef/distillery-from-the-cliff-path.jpg +0 -0
  13. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/68c9cfdf/bottle-brine-and-kiln.jpg +0 -0
  14. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/6be45535/bottle-oloroso-pier.jpg +0 -0
  15. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/6d8d6398/bottle-the-cove.jpg +0 -0
  16. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/7b4713f8/copper-pot-stills.jpg +0 -0
  17. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/c114b7d8/bottle-neap-tide.jpg +0 -0
  18. /package/templates/demo/harbourstone/bundle/{blobs → content/blobs}/fd337567/casks-in-the-sea-facing-warehouse.jpg +0 -0
  19. /package/templates/demo/harbourstone/bundle/{bundle.json → content/bundle.json} +0 -0
  20. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/069cc3c5-c9f5-4792-90c9-e2bfcc4855b6.json +0 -0
  21. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/13788fe7-1a45-4b48-8ae4-f4227e154e1e.json +0 -0
  22. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/180cc8ef-caf1-4316-b6a7-e3b5aee41ffd.json +0 -0
  23. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/22144db2-e214-4006-a0bb-53b5bd121fd6.json +0 -0
  24. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/26e87610-e2d6-4b47-bf92-3967f7186907.json +0 -0
  25. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/2caa099b-3884-472d-9cea-797a0f0c376f.json +0 -0
  26. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/2efbdb5b-7888-4a49-bca5-c0f85b76e472.json +0 -0
  27. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/2fe03891-1294-4344-8e24-47e8b212e5b0.json +0 -0
  28. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/38b009f7-03c0-4fda-b5c7-843ad6044e9c.json +0 -0
  29. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/3d312cc8-2937-40e1-ba14-fd781c9a862d.json +0 -0
  30. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/3dc30fc1-973a-4217-b26f-24902210be17.json +0 -0
  31. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/40107d59-f3e9-4592-aee8-5b03d4586cbd.json +0 -0
  32. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/483a0d3b-a10c-4c9d-8c81-d9607d37e968.json +0 -0
  33. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/4b760c0b-b83a-4eee-aa29-c225cfabf19b.json +0 -0
  34. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/4e5c7f3f-54cb-4328-ad61-bc4c2736b890.json +0 -0
  35. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/62f479ff-0875-453e-99ae-3bf1bec5e3c8.json +0 -0
  36. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/68c9cfdf-6b40-47da-a1d2-17256a6cc6db.json +0 -0
  37. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/6ad8f0f3-9334-47f1-b805-80c4b4b4fa88.json +0 -0
  38. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/6be45535-83e5-4469-acf0-4127016721ae.json +0 -0
  39. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/6d8d6398-738f-4f38-ad3c-e5d6e9a971fe.json +0 -0
  40. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/7b4713f8-d917-457d-a9d8-7dd01a3617b4.json +0 -0
  41. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/88ec1185-53b5-439a-a2ce-3f56603be4ff.json +0 -0
  42. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/97469df2-107c-4010-8c14-4de88f735529.json +0 -0
  43. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/a2eb439f-71d9-4ea6-89d7-c5bc72618281.json +0 -0
  44. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/a562f0a2-cb6e-4ead-a1ce-4d180632419f.json +0 -0
  45. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/a5e36a18-a2ef-4eb5-85b4-7e12b21cca06.json +0 -0
  46. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/b1876d3a-50a7-4358-84bf-a0899932ffa9.json +0 -0
  47. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/b4e38bbd-c1a3-431a-b7a9-5868aa32993a.json +0 -0
  48. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/c114b7d8-0138-404b-9d8f-a39d992577a2.json +0 -0
  49. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/cb4c569e-c28a-43b6-a4d7-d33bb544e973.json +0 -0
  50. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/cd681b4b-9c4e-4624-9c13-450aa6a9c135.json +0 -0
  51. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/d60ef12b-0f0a-4db5-9763-21fa52b8d4da.json +0 -0
  52. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/d7f13222-d060-4496-b86d-bef21da963c1.json +0 -0
  53. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/f36dd4a9-927c-4ca3-a790-c9c859ac7302.json +0 -0
  54. /package/templates/demo/harbourstone/bundle/{nodes → content/nodes}/fd337567-df67-41ca-ae94-9d84d8d6042f.json +0 -0
  55. /package/templates/starter/bundle/{bundle.json → content/bundle.json} +0 -0
  56. /package/templates/starter/bundle/{nodes → content/nodes}/634dd948-cf83-4cad-9b7b-7c938b33da76.json +0 -0
  57. /package/templates/starter/bundle/{nodes → content/nodes}/64d95359-d91a-4050-b440-3e5124a74999.json +0 -0
  58. /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 { ObjectTypes, ROOT_ACCESS } from '@crombolo/core'
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
- placeBlobs,
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
- type CheckOptions,
44
- type ContentSet,
45
- checkBundle,
60
+ bundleIdFor,
46
61
  exportBundle,
47
- importBundle,
48
- loadBundle,
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
- export {
70
- isResolution,
71
- RESOLUTIONS,
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
- } from './sections.ts'
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
- /** The trees a content selector or placement may name. */
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
- const prepared = await transferOptions(db, config, set, input)
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
- export interface ContentImportInput extends TransferInput {
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
- const { set, blobs, carried } = readBundle(input.dir)
292
- // The coarse layer, under the fine-grained revert: in-place conversion is not
293
- // reversible by itself, so the same rule as `upgrade` applies here.
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
- const prepared = await transferOptions(db, config, set, input)
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 bundle's files, for the caller to write wherever it is going. */
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
- /** Writes nothing: returns the bundle's files for the caller to place. */
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
- name: string
485
- kind: string
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((step) => ({
501
- name: step.name,
502
- kind: step.kind,
503
- release: step.release ?? null,
504
- statements: (step.statements ?? []).map((sql) => sql.replace(/\s+/g, ' ').trim()),
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(config, input.set ?? {})
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(config, input.set ?? {})
392
+ const { db, loaded, syncNode, checkOptions, chains } = await upgradeContext(
393
+ config,
394
+ input.set ?? {},
395
+ )
587
396
  try {
588
- await installDatabase(db, config)
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
- if (result.action === 'nothing') return { action: 'nothing', backup }
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: result.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
+ }