@crombolo/server 0.0.0-stage → 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 (85) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +507 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundle-install.ts +417 -0
  27. package/src/bundle-sections.ts +424 -0
  28. package/src/bundle-tables.ts +161 -0
  29. package/src/bundles.ts +609 -0
  30. package/src/component-snippets.ts +163 -0
  31. package/src/components.ts +266 -0
  32. package/src/config.ts +556 -0
  33. package/src/content-type-udt.ts +256 -0
  34. package/src/database.ts +148 -0
  35. package/src/diagnostics.ts +143 -0
  36. package/src/dictionary-transfer.ts +123 -0
  37. package/src/domains.ts +384 -0
  38. package/src/editor-types.ts +108 -0
  39. package/src/email-http.ts +79 -0
  40. package/src/email-notice.ts +36 -0
  41. package/src/email-postmark.ts +78 -0
  42. package/src/email-resend.ts +69 -0
  43. package/src/email-ses.ts +307 -0
  44. package/src/email.ts +402 -0
  45. package/src/extensions-notice.ts +35 -0
  46. package/src/file-intake.ts +84 -0
  47. package/src/form-files.ts +241 -0
  48. package/src/form-submit.ts +397 -0
  49. package/src/form-workflows.ts +397 -0
  50. package/src/forms.ts +170 -0
  51. package/src/git-github.ts +171 -0
  52. package/src/git-routes.ts +154 -0
  53. package/src/git.ts +88 -0
  54. package/src/imaging.ts +434 -0
  55. package/src/index.ts +65 -0
  56. package/src/jobs.ts +140 -0
  57. package/src/listen.ts +36 -0
  58. package/src/log-expressions.ts +524 -0
  59. package/src/log-viewer.ts +190 -0
  60. package/src/logging.ts +126 -0
  61. package/src/marketplace.ts +382 -0
  62. package/src/media-files.ts +276 -0
  63. package/src/media-store-azure.ts +218 -0
  64. package/src/media-store-s3.ts +113 -0
  65. package/src/media-store.ts +248 -0
  66. package/src/member-auth.ts +384 -0
  67. package/src/models-builder.ts +106 -0
  68. package/src/no-nodes.ts +64 -0
  69. package/src/not-implemented.ts +44 -0
  70. package/src/oembed.ts +144 -0
  71. package/src/ports.ts +553 -0
  72. package/src/redirects.ts +132 -0
  73. package/src/schema-classify.ts +82 -0
  74. package/src/schema-store.ts +145 -0
  75. package/src/schema.ts +388 -0
  76. package/src/seo.ts +371 -0
  77. package/src/server-events.ts +131 -0
  78. package/src/server.ts +1642 -0
  79. package/src/status.ts +275 -0
  80. package/src/tar.ts +85 -0
  81. package/src/udt.ts +168 -0
  82. package/src/value-schemas.ts +64 -0
  83. package/src/view-runtime.ts +42 -0
  84. package/src/zip.ts +152 -0
  85. package/README.md +0 -3
@@ -0,0 +1,424 @@
1
+ /**
2
+ * The sections a bundle carries beyond its content: schema, views, partials,
3
+ * styles, scripts and dictionary items. `docs/bundles.md`.
4
+ *
5
+ * A bundle names what a file *is*, never where it goes — the destination site
6
+ * decides that from its own configuration, so a bundle written against one
7
+ * layout installs into another. This module owns that mapping, and the order
8
+ * applying them has to happen in: structure before the content that needs it,
9
+ * views before the content that names a template.
10
+ *
11
+ * Checking is read-only and has to answer "what would the schema do" *before*
12
+ * anything is written, which `checkSchemaDirectory` cannot do against files that
13
+ * are not there yet. So the check runs against a copy of the site's `schema/`
14
+ * with the bundle's files laid over it — the site's own directory is never
15
+ * touched until an apply.
16
+ */
17
+ import {
18
+ copyFileSync,
19
+ cpSync,
20
+ existsSync,
21
+ mkdirSync,
22
+ mkdtempSync,
23
+ readFileSync,
24
+ renameSync,
25
+ rmSync,
26
+ writeFileSync,
27
+ } from 'node:fs'
28
+ import { tmpdir } from 'node:os'
29
+ import { dirname, join } from 'node:path'
30
+ import type { Db } from '@crombolo/data'
31
+ import type { BundleSection, LoadedBundle } from '@crombolo/transfer'
32
+ import { BUNDLE_SECTIONS } from '@crombolo/transfer'
33
+ import type { CromboloConfig } from './config.ts'
34
+ import { importDictionaryUdt } from './dictionary-transfer.ts'
35
+ import { checkSchemaDirectory, syncSchemaDirectory } from './schema.ts'
36
+
37
+ /** What applying one carried file would do to the destination. */
38
+ export type FileAction = 'create' | 'overwrite' | 'unchanged'
39
+
40
+ export interface SectionFile {
41
+ /** The path inside the bundle, e.g. `views/homePage.tsx`. */
42
+ path: string
43
+ /** Where it would land on this site. */
44
+ target: string
45
+ action: FileAction
46
+ }
47
+
48
+ export interface SectionPlan {
49
+ files: SectionFile[]
50
+ /** How many dictionary items the bundle carries, when it carries any. */
51
+ dictionaryItems: number
52
+ /**
53
+ * What the schema half would do, checked against an overlay rather than the
54
+ * site: `none` when the bundle carries no schema.
55
+ */
56
+ schema: { classification: string; findings: string[] } | undefined
57
+ /** Sections present in the bundle, for a caller that only wants to say so. */
58
+ sections: BundleSection[]
59
+ }
60
+
61
+ export interface SectionOptions {
62
+ /** Skip the schema half, leaving the site's own structure alone. */
63
+ withoutSchema?: boolean
64
+ /** Skip views, partials, styles and scripts. */
65
+ withoutFiles?: boolean
66
+ /**
67
+ * Allow replacing files this site already has.
68
+ *
69
+ * Off by default, and an install is refused while any file would be
70
+ * replaced. These files are editable in the backoffice — a stylesheet, a
71
+ * template, a content type — so overwriting one destroys somebody's work,
72
+ * and this site may well have no git to recover it from.
73
+ */
74
+ replaceFiles?: boolean
75
+ }
76
+
77
+ /**
78
+ * Where an install keeps the files it replaced, under the site.
79
+ *
80
+ * A sibling of the components cache, not inside it: `.crombolo/components` is
81
+ * rebuilt from `components/` and cleared at boot, and these have to outlive a
82
+ * restart to be worth taking.
83
+ */
84
+ export const RUN_BACKUP_DIR = join('.crombolo', 'transfer')
85
+
86
+ /** What one install did to the files, so a revert can undo exactly that. */
87
+ interface FileRecord {
88
+ path: string
89
+ action: FileAction
90
+ }
91
+
92
+ const RECORD_FILE = 'files.json'
93
+ const REPLACED_DIR = 'replaced'
94
+
95
+ /** Where each section lands, from the site's own configuration. */
96
+ function destinationOf(config: CromboloConfig, section: BundleSection): string | undefined {
97
+ switch (section) {
98
+ case 'schema':
99
+ return config.schemaDir
100
+ case 'components':
101
+ return config.componentsDir
102
+ case 'styles':
103
+ return config.stylesheetsDir
104
+ case 'scripts':
105
+ return config.scriptsDir
106
+ case 'dictionary':
107
+ return undefined
108
+ }
109
+ }
110
+
111
+ const FILE_SECTIONS: readonly BundleSection[] = ['components', 'styles', 'scripts']
112
+
113
+ /** The path inside a section, with the section's own directory stripped. */
114
+ const withinSection = (path: string, section: BundleSection): string =>
115
+ path.slice(`${BUNDLE_SECTIONS[section]}/`.length)
116
+
117
+ function actionFor(target: string, content: string): FileAction {
118
+ if (!existsSync(target)) return 'create'
119
+ try {
120
+ return readFileSync(target, 'utf8') === content ? 'unchanged' : 'overwrite'
121
+ } catch {
122
+ return 'overwrite'
123
+ }
124
+ }
125
+
126
+ export interface CarriedSections {
127
+ /** Section → the bundle paths it carries. */
128
+ carries: Partial<Record<BundleSection, string[]>>
129
+ /** Bundle path → the file on disk holding it. */
130
+ files: Map<string, string>
131
+ }
132
+
133
+ /** What a loaded bundle carries, or nothing when it is content-only. */
134
+ export function carriedSections(loaded: LoadedBundle): CarriedSections {
135
+ return { carries: loaded.set?.manifest.carries ?? {}, files: loaded.files }
136
+ }
137
+
138
+ const read = (carried: CarriedSections, path: string): string => {
139
+ const file = carried.files.get(path)
140
+ if (!file) throw new Error(`${path} is declared but was not read`)
141
+ return readFileSync(file, 'utf8')
142
+ }
143
+
144
+ /** Read-only: what applying the carried sections here would do. */
145
+ export async function planSections(
146
+ db: Db,
147
+ config: CromboloConfig,
148
+ carried: CarriedSections,
149
+ options: SectionOptions = {},
150
+ ): Promise<SectionPlan> {
151
+ const sections = Object.keys(carried.carries) as BundleSection[]
152
+ const files: SectionFile[] = []
153
+
154
+ if (!options.withoutFiles) {
155
+ for (const section of FILE_SECTIONS) {
156
+ const destination = destinationOf(config, section)
157
+ if (!destination) continue
158
+ for (const path of carried.carries[section] ?? []) {
159
+ const target = join(destination, withinSection(path, section))
160
+ files.push({ path, target, action: actionFor(target, read(carried, path)) })
161
+ }
162
+ }
163
+ }
164
+
165
+ const schemaPaths = options.withoutSchema ? [] : (carried.carries.schema ?? [])
166
+ if (!options.withoutSchema) {
167
+ for (const path of schemaPaths) {
168
+ const target = join(config.schemaDir, withinSection(path, 'schema'))
169
+ files.push({ path, target, action: actionFor(target, read(carried, path)) })
170
+ }
171
+ }
172
+
173
+ return {
174
+ files,
175
+ dictionaryItems: (carried.carries.dictionary ?? []).length,
176
+ schema:
177
+ schemaPaths.length > 0
178
+ ? await checkSchemaOverlay(db, config, carried, schemaPaths)
179
+ : undefined,
180
+ sections,
181
+ }
182
+ }
183
+
184
+ /**
185
+ * The schema check, run against a copy of the site's `schema/` with the
186
+ * bundle's files laid over it.
187
+ *
188
+ * The site's directory is not written to: a check has to be able to say "this
189
+ * would need data work" without having already changed the thing it is
190
+ * reporting on.
191
+ */
192
+ async function checkSchemaOverlay(
193
+ db: Db,
194
+ config: CromboloConfig,
195
+ carried: CarriedSections,
196
+ paths: readonly string[],
197
+ ): Promise<{ classification: string; findings: string[] }> {
198
+ const overlay = mkdtempSync(join(tmpdir(), 'crombolo-schema-overlay-'))
199
+ try {
200
+ if (existsSync(config.schemaDir)) cpSync(config.schemaDir, overlay, { recursive: true })
201
+ for (const path of paths) {
202
+ const target = join(overlay, withinSection(path, 'schema'))
203
+ mkdirSync(dirname(target), { recursive: true })
204
+ writeFileSync(target, read(carried, path))
205
+ }
206
+ const report = await checkSchemaDirectory(
207
+ db,
208
+ { ...config, schemaDir: overlay },
209
+ { nodeId: config.nodeId, revision: config.schemaRevision },
210
+ )
211
+ return {
212
+ classification: report.classification,
213
+ findings: report.outstanding.map((finding) => finding.message),
214
+ }
215
+ } finally {
216
+ rmSync(overlay, { recursive: true, force: true })
217
+ }
218
+ }
219
+
220
+ export interface SectionResult {
221
+ /** The files written, by the path they were written to. */
222
+ written: string[]
223
+ /** Those that replaced something, whose originals were kept. */
224
+ replaced: string[]
225
+ /** The schema sync's outcome, when the bundle carried schema. */
226
+ schema: { action: string } | undefined
227
+ dictionary: { imported: number; skipped: string[] } | undefined
228
+ }
229
+
230
+ /**
231
+ * Applies the carried sections, structure first.
232
+ *
233
+ * Schema goes to `schema/` and is then synced — the same path a commit takes,
234
+ * so an install cannot produce a database the files do not describe. Views land
235
+ * before content is imported, because a node naming a template whose view is
236
+ * absent arrives without one.
237
+ */
238
+ export async function applySections(
239
+ db: Db,
240
+ config: CromboloConfig,
241
+ carried: CarriedSections,
242
+ options: SectionOptions & { backupDir?: string } = {},
243
+ ): Promise<SectionResult> {
244
+ const written: string[] = []
245
+ const replaced: string[] = []
246
+ const records: FileRecord[] = []
247
+
248
+ /**
249
+ * Writes one file, keeping what was there.
250
+ *
251
+ * The original is copied under its *bundle* path rather than its path on this
252
+ * site, so a revert re-derives the destination through the same mapping — and
253
+ * restores to where the file would go now, not where it went then.
254
+ */
255
+ const put = (target: string, content: string, bundlePath: string) => {
256
+ const action = actionFor(target, content)
257
+ if (action === 'overwrite' && options.backupDir) {
258
+ const keep = join(options.backupDir, REPLACED_DIR, bundlePath)
259
+ mkdirSync(dirname(keep), { recursive: true })
260
+ copyFileSync(target, keep)
261
+ replaced.push(target)
262
+ }
263
+ records.push({ path: bundlePath, action })
264
+ mkdirSync(dirname(target), { recursive: true })
265
+ writeFileSync(target, content)
266
+ written.push(target)
267
+ }
268
+
269
+ const schemaPaths = options.withoutSchema ? [] : (carried.carries.schema ?? [])
270
+ for (const path of schemaPaths)
271
+ put(join(config.schemaDir, withinSection(path, 'schema')), read(carried, path), path)
272
+
273
+ let schema: { action: string } | undefined
274
+ if (schemaPaths.length > 0) {
275
+ const report = await syncSchemaDirectory(db, config, {
276
+ nodeId: config.nodeId,
277
+ revision: config.schemaRevision,
278
+ })
279
+ schema = { action: report.action }
280
+ }
281
+
282
+ if (!options.withoutFiles) {
283
+ for (const section of FILE_SECTIONS) {
284
+ const destination = destinationOf(config, section)
285
+ if (!destination) continue
286
+ for (const path of carried.carries[section] ?? [])
287
+ put(join(destination, withinSection(path, section)), read(carried, path), path)
288
+ }
289
+ }
290
+
291
+ let dictionary: SectionResult['dictionary']
292
+ for (const path of carried.carries.dictionary ?? []) {
293
+ const outcome = await importDictionaryUdt(db, read(carried, path))
294
+ dictionary = {
295
+ imported: (dictionary?.imported ?? 0) + outcome.imported,
296
+ skipped: [...(dictionary?.skipped ?? []), ...outcome.skipped],
297
+ }
298
+ }
299
+
300
+ // Written last: a record that exists means the files beside it are complete.
301
+ if (options.backupDir && records.length > 0) {
302
+ mkdirSync(options.backupDir, { recursive: true })
303
+ writeFileSync(join(options.backupDir, RECORD_FILE), `${JSON.stringify(records, null, 2)}\n`)
304
+ }
305
+
306
+ return { written, replaced, schema, dictionary }
307
+ }
308
+
309
+ /** Whether a bundle carries anything beyond its content. */
310
+ export const carriesSections = (carried: CarriedSections): boolean =>
311
+ Object.keys(carried.carries).length > 0
312
+
313
+ /**
314
+ * The files an install would replace, and so may not touch without being told
315
+ * it may.
316
+ *
317
+ * Its own function rather than a condition inside the importer, because this is
318
+ * the rule that stops an install destroying an editor's work: a stylesheet, a
319
+ * template or a content type changed in the backoffice, on a site that may
320
+ * have no git behind it. `unchanged` is not a replacement, and a declined
321
+ * section never reaches the plan.
322
+ */
323
+ export function replacementsNeedingPermission(
324
+ plan: SectionPlan,
325
+ options: SectionOptions = {},
326
+ ): SectionFile[] {
327
+ if (options.replaceFiles) return []
328
+ return plan.files.filter((file) => file.action === 'overwrite')
329
+ }
330
+
331
+ /** A staging directory for an install's backups, before the run it belongs to exists. */
332
+ export function stageBackups(config: CromboloConfig): string {
333
+ const dir = join(config.siteDir, RUN_BACKUP_DIR, `pending-${Date.now().toString(36)}`)
334
+ mkdirSync(dir, { recursive: true })
335
+ return dir
336
+ }
337
+
338
+ /**
339
+ * Files an install replaced belong to the run that imported its content, and
340
+ * the run id only exists once that import has happened — so the backups are
341
+ * staged first and moved here afterwards.
342
+ *
343
+ * A staging directory that is never promoted is an install whose content was
344
+ * refused after its files had landed. It is left where it is rather than
345
+ * deleted: it holds the only copy of what was replaced.
346
+ */
347
+ export function promoteBackups(
348
+ config: CromboloConfig,
349
+ staging: string,
350
+ runId: string,
351
+ ): string | undefined {
352
+ if (!existsSync(join(staging, RECORD_FILE))) {
353
+ rmSync(staging, { recursive: true, force: true })
354
+ return undefined
355
+ }
356
+ const target = join(config.siteDir, RUN_BACKUP_DIR, runId)
357
+ rmSync(target, { recursive: true, force: true })
358
+ mkdirSync(dirname(target), { recursive: true })
359
+ renameSync(staging, target)
360
+ return target
361
+ }
362
+
363
+ export interface RestoredFiles {
364
+ /** Files put back to what they were before the install. */
365
+ restored: string[]
366
+ /** Files the install created, and which reverting therefore removes. */
367
+ removed: string[]
368
+ }
369
+
370
+ /**
371
+ * Puts back what an install replaced, for a run that is being reverted.
372
+ *
373
+ * Undoing an install is one action because of this: the content comes back
374
+ * through the ledger, and the files come back from here. Nothing depends on the
375
+ * site being a git checkout — these files are editable in the backoffice, so
376
+ * assuming a repository would have been assuming somebody else's workflow.
377
+ */
378
+ export function restoreSectionFiles(config: CromboloConfig, runId: string): RestoredFiles {
379
+ const dir = join(config.siteDir, RUN_BACKUP_DIR, runId)
380
+ const record = join(dir, RECORD_FILE)
381
+ if (!existsSync(record)) return { restored: [], removed: [] }
382
+
383
+ let records: FileRecord[]
384
+ try {
385
+ records = JSON.parse(readFileSync(record, 'utf8')) as FileRecord[]
386
+ } catch {
387
+ return { restored: [], removed: [] }
388
+ }
389
+
390
+ const restored: string[] = []
391
+ const removed: string[] = []
392
+ for (const entry of records) {
393
+ const section = sectionFor(entry.path)
394
+ if (!section) continue
395
+ const destination = destinationOf(config, section)
396
+ if (!destination) continue
397
+ const target = join(destination, withinSection(entry.path, section))
398
+
399
+ if (entry.action === 'overwrite') {
400
+ const kept = join(dir, REPLACED_DIR, entry.path)
401
+ if (!existsSync(kept)) continue
402
+ mkdirSync(dirname(target), { recursive: true })
403
+ copyFileSync(kept, target)
404
+ restored.push(target)
405
+ continue
406
+ }
407
+ // A file the install created: reverting takes it away again. `unchanged`
408
+ // is left alone — the install did not put it there.
409
+ if (entry.action === 'create' && existsSync(target)) {
410
+ rmSync(target, { force: true })
411
+ removed.push(target)
412
+ }
413
+ }
414
+ return { restored, removed }
415
+ }
416
+
417
+ /** The section a bundle path belongs to, by its leading directory. */
418
+ function sectionFor(path: string): BundleSection | undefined {
419
+ if (path === `${BUNDLE_SECTIONS.dictionary}.udt`) return 'dictionary'
420
+ const top = path.split('/')[0]
421
+ for (const [section, dir] of Object.entries(BUNDLE_SECTIONS))
422
+ if (top === dir) return section as BundleSection
423
+ return undefined
424
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The chains of the bundles a site wires that own tables: worked out once from
3
+ * the configuration and the packages those bundles came from, and run after
4
+ * core's, in the order `bundles` lists them. `docs/bundles.md`, Part 4.
5
+ *
6
+ * Only a node that mounts bundles — one that serves the backoffice — applies
7
+ * them or checks them at boot. A `web` node reads nothing of a bundle's.
8
+ */
9
+ import { type BundlePackage, discoverBundles } from '@crombolo/backoffice-host'
10
+ import {
11
+ bundlePlan,
12
+ type CompiledTables,
13
+ compileBundleTables,
14
+ type Db,
15
+ type Migration,
16
+ type MigrationPlan,
17
+ migrate,
18
+ type PlannedStep,
19
+ pendingMigrations,
20
+ planUpgrade,
21
+ readLedger,
22
+ readState,
23
+ } from '@crombolo/data'
24
+ import { type ServerBundle, validateServerBundles } from './bundles.ts'
25
+ import { type CromboloConfig, VERSION } from './config.ts'
26
+
27
+ /** One bundle's chain, with what it is called. */
28
+ export interface BundleChain {
29
+ id: string
30
+ name: string
31
+ compiled: CompiledTables
32
+ plan: MigrationPlan
33
+ }
34
+
35
+ /**
36
+ * The chains of every wired bundle that declares tables, in wiring order.
37
+ * Throws when a declaration is invalid; `validateServerBundles` is what reports
38
+ * the reasons, and boot runs it first.
39
+ */
40
+ export function bundleChains(
41
+ bundles: readonly ServerBundle[],
42
+ packages: readonly BundlePackage[],
43
+ ): BundleChain[] {
44
+ return bundles.flatMap((bundle) => {
45
+ if (!bundle.tables) return []
46
+ const owner = packages.find((pkg) => pkg.id === bundle.id)
47
+ const compiled = compileBundleTables(bundle.tables, owner?.version)
48
+ return [{ id: bundle.id, name: owner?.name ?? bundle.id, compiled, plan: bundlePlan(compiled) }]
49
+ })
50
+ }
51
+
52
+ /** The wired bundles cannot be used: every reason, at once. */
53
+ export class BundleConfigurationError extends Error {
54
+ constructor(readonly problems: string[]) {
55
+ super(`The configured bundles cannot be used:\n- ${problems.join('\n- ')}`)
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The chains for a site's configuration, discovering its packages and checking
61
+ * the bundles exactly as boot checks them, so a command off the server refuses
62
+ * what the server would.
63
+ */
64
+ export function bundleChainsFor(
65
+ config: Pick<CromboloConfig, 'bundles' | 'siteDir'>,
66
+ ): BundleChain[] {
67
+ if (config.bundles.length === 0) return []
68
+ const packages = discoverBundles(config.siteDir).bundles
69
+ const problems = validateServerBundles(config.bundles, packages, VERSION)
70
+ if (problems.length > 0) throw new BundleConfigurationError(problems)
71
+ return bundleChains(config.bundles, packages)
72
+ }
73
+
74
+ export interface BundlePending {
75
+ id: string
76
+ name: string
77
+ state: string
78
+ pending: Migration[]
79
+ }
80
+
81
+ /** What each chain still has to run. Throws `UpgradeStateError`, naming the bundle, when one is ahead. */
82
+ export async function pendingBundleSteps(
83
+ db: Db,
84
+ chains: readonly BundleChain[],
85
+ ): Promise<BundlePending[]> {
86
+ const out: BundlePending[] = []
87
+ for (const chain of chains)
88
+ out.push({
89
+ id: chain.id,
90
+ name: chain.name,
91
+ state: await readState(db, chain.plan),
92
+ pending: await pendingMigrations(db, chain.plan),
93
+ })
94
+ return out
95
+ }
96
+
97
+ /** Runs each chain's pending steps, in order, after core's. */
98
+ export async function migrateBundles(
99
+ db: Db,
100
+ chains: readonly BundleChain[],
101
+ options: { by?: string } = {},
102
+ ): Promise<{ id: string; applied: string[] }[]> {
103
+ const out: { id: string; applied: string[] }[] = []
104
+ for (const chain of chains) {
105
+ const result = await migrate(db, chain.plan, { by: options.by })
106
+ out.push({ id: chain.id, applied: result.applied })
107
+ }
108
+ return out
109
+ }
110
+
111
+ /** What each chain would run, without running it. */
112
+ export async function planBundleUpgrades(
113
+ db: Db,
114
+ chains: readonly BundleChain[],
115
+ ): Promise<{ id: string; name: string; steps: PlannedStep[] }[]> {
116
+ const out: { id: string; name: string; steps: PlannedStep[] }[] = []
117
+ for (const chain of chains)
118
+ out.push({ id: chain.id, name: chain.name, steps: await planUpgrade(db, chain.plan) })
119
+ return out
120
+ }
121
+
122
+ export interface BundleTablesReport {
123
+ id: string
124
+ name: string
125
+ pending: { name: string; kind: string; release: string | null }[]
126
+ /** What has run here since the bundle was last purged, oldest first. */
127
+ applied: { name: string; kind: string; release: string | null; appliedAt: string }[]
128
+ }
129
+
130
+ /** Each chain's steps, applied and pending, as the Changes dashboard shows them. */
131
+ export async function bundleTablesReport(
132
+ db: Db,
133
+ chains: readonly BundleChain[],
134
+ ): Promise<BundleTablesReport[]> {
135
+ const ledger = await readLedger(db)
136
+ const out: BundleTablesReport[] = []
137
+ for (const chain of chains) {
138
+ const rows = ledger.filter((row) => row.bundle === chain.id)
139
+ const purged = rows.map((row) => row.kind).lastIndexOf('purge')
140
+ const releases = new Map(chain.compiled.steps.map((step) => [step.name, step.release]))
141
+ out.push({
142
+ id: chain.id,
143
+ name: chain.name,
144
+ pending: (await pendingMigrations(db, chain.plan)).map((step) => ({
145
+ name: step.name,
146
+ kind: step.kind ?? 'expand',
147
+ release: step.release ?? null,
148
+ })),
149
+ applied: rows
150
+ .slice(purged + 1)
151
+ .filter((row) => row.kind === 'expand' || row.kind === 'contract')
152
+ .map((row) => ({
153
+ name: row.name,
154
+ kind: row.kind,
155
+ release: releases.get(row.name) ?? null,
156
+ appliedAt: row.appliedAt,
157
+ })),
158
+ })
159
+ }
160
+ return out
161
+ }