@crombolo/server 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,30 @@
1
1
  /**
2
- * The Bundles section's marketplace, over npm. `docs/17-bundles.md`.
2
+ * The Bundles section's marketplace, over npm. `docs/bundles.md`.
3
3
  *
4
4
  * Discovery and declaration are two different things, because npm indexes only
5
5
  * one of them: a bundle is *found* by the `crombolo-bundle` keyword, which is
6
6
  * the only thing registry search will match, and it *declares* itself with a
7
7
  * `crombolo` field in its `package.json`. Search for the keyword, then read each
8
8
  * hit's packument — custom fields survive there — and offer only the packages
9
- * that actually declare extensions. A keyword squatter never reaches the list.
9
+ * whose manifest is a valid bundle. A keyword squatter never reaches the list.
10
+ *
11
+ * An install is `bun add`, and then — unless the site or the request says not
12
+ * to — the content the bundle carries, through the same installer as
13
+ * `crombolo bundle install`. A bundle's server half is never loaded by an
14
+ * install: that still takes an edit to `crombolo.config.ts` and a deploy.
10
15
  *
11
16
  * The search is proxied rather than done in the browser: it works where the
12
17
  * browser cannot reach npm, and the backoffice keeps making same-origin
13
18
  * requests only.
14
19
  */
15
20
  import { join } from 'node:path'
16
- import { EXTENSION_KEYWORD, type ExtensionRegistry } from '@crombolo/backoffice-host'
21
+ import {
22
+ type BundlePackage,
23
+ EXTENSION_KEYWORD,
24
+ type ExtensionRegistry,
25
+ type PackageJson,
26
+ validateBundlePackage,
27
+ } from '@crombolo/backoffice-host'
17
28
  import { logger } from './logging.ts'
18
29
 
19
30
  const log = logger('packages')
@@ -27,12 +38,12 @@ export interface MarketplaceListing {
27
38
  description: string
28
39
  publisher: string | null
29
40
  links: { npm: string; homepage: string | null; repository: string | null }
30
- /** Whether the latest version declares backoffice extensions. */
31
- declaresExtensions: boolean
41
+ /** What the latest version carries; only a valid bundle is listed at all. */
42
+ carries: BundlePackage['carries']
32
43
  installedVersion: string | null
33
44
  }
34
45
 
35
- /** An installed extension as the Installed view lists it. */
46
+ /** A bundle the site depends on, as the Installed view lists it. */
36
47
  export interface InstalledListing {
37
48
  /** The npm package name, which is what an uninstall names. */
38
49
  packageName: string
@@ -41,6 +52,19 @@ export interface InstalledListing {
41
52
  id: string
42
53
  version: string
43
54
  extensionCount: number
55
+ carries: BundlePackage['carries']
56
+ /** Whether its server half is imported into `crombolo.config.ts`; only meaningful when it carries one. */
57
+ wired: boolean
58
+ /** The version whose content was applied here, or null when none has been. */
59
+ contentApplied: string | null
60
+ }
61
+
62
+ /** What became of the content half of an install. */
63
+ export interface ContentOutcome {
64
+ applied: boolean
65
+ /** Set when it was applied: the transfer run that can revert it. */
66
+ runId?: string
67
+ message: string
44
68
  }
45
69
 
46
70
  export interface MarketplaceOptions {
@@ -53,6 +77,17 @@ export interface MarketplaceOptions {
53
77
  siteDir: string
54
78
  /** Overridden by tests; the real one shells out to Bun. */
55
79
  install?: PackageInstaller
80
+ /** The ids of the bundles whose server half this node has mounted. */
81
+ wired?: () => readonly string[]
82
+ /** The version of a package whose content has been applied here. */
83
+ contentApplied?: (packageName: string) => Promise<string | null>
84
+ /**
85
+ * Installs the content half of a bundle the site now depends on. Absent where
86
+ * content cannot be applied, which declines it for every install.
87
+ */
88
+ applyContent?: (packageName: string) => Promise<ContentOutcome>
89
+ /** The site's `applyBundleContentOnInstall`. */
90
+ applyContentOnInstall?: boolean
56
91
  }
57
92
 
58
93
  export interface InstallOutcome {
@@ -64,6 +99,8 @@ export interface InstallOutcome {
64
99
  */
65
100
  dependency?: { name: string; range: string; version: string }
66
101
  message: string
102
+ /** What happened to the content the bundle carries; absent when it carries none. */
103
+ content?: ContentOutcome
67
104
  }
68
105
 
69
106
  export type PackageInstaller = (
@@ -102,8 +139,12 @@ export interface Marketplace {
102
139
  /** Where someone browsing should be sent, when they want more than this list. */
103
140
  url(): string
104
141
  search(query: string | undefined): Promise<MarketplaceListing[]>
105
- installed(): InstalledListing[]
106
- install(name: string, version: string | undefined): Promise<InstallOutcome>
142
+ installed(): Promise<InstalledListing[]>
143
+ install(
144
+ name: string,
145
+ version: string | undefined,
146
+ options?: { applyContent?: boolean },
147
+ ): Promise<InstallOutcome>
107
148
  uninstall(name: string): Promise<InstallOutcome>
108
149
  }
109
150
 
@@ -156,24 +197,35 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
156
197
  let cached: { at: number; query: string; listings: MarketplaceListing[] } | undefined
157
198
 
158
199
  const installedVersion = (name: string): string | null =>
159
- options.extensions.list().find((extension) => extension.packageName === name)?.version ?? null
200
+ options.extensions.bundles().bundles.find((bundle) => bundle.packageName === name)?.version ??
201
+ null
160
202
 
161
- /** Whether the registry says this package's latest version declares extensions. */
162
- const declares = async (name: string): Promise<boolean> => {
203
+ /** What the registry says this package's latest version carries, when it is a valid bundle. */
204
+ const declares = async (name: string): Promise<BundlePackage['carries'] | undefined> => {
163
205
  try {
164
206
  const response = await call(`${options.registry}/${encodeURIComponent(name)}`, {
165
207
  headers: { accept: 'application/json' },
166
208
  })
167
- if (!response.ok) return false
209
+ if (!response.ok) return undefined
168
210
  const packument = (await response.json()) as {
169
211
  'dist-tags'?: { latest?: string }
170
- versions?: Record<string, { crombolo?: { extensions?: unknown[] } }>
212
+ versions?: Record<string, PackageJson>
171
213
  }
172
214
  const latest = packument['dist-tags']?.latest
173
215
  const version = latest ? packument.versions?.[latest] : undefined
174
- return Array.isArray(version?.crombolo?.extensions) && version.crombolo.extensions.length > 0
216
+ if (!version || validateBundlePackage(version).length > 0) return undefined
217
+ const field = version.crombolo as {
218
+ extensions?: unknown[]
219
+ content?: string
220
+ server?: string
221
+ }
222
+ return {
223
+ extensions: Array.isArray(field.extensions) && field.extensions.length > 0,
224
+ content: field.content !== undefined,
225
+ server: field.server !== undefined,
226
+ }
175
227
  } catch {
176
- return false
228
+ return undefined
177
229
  }
178
230
  }
179
231
 
@@ -213,8 +265,10 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
213
265
  .filter((pkg): pkg is NonNullable<typeof pkg> =>
214
266
  Boolean(pkg?.name && isValidPackageName(pkg.name)),
215
267
  )
216
- .map(async (pkg): Promise<MarketplaceListing> => {
268
+ .map(async (pkg): Promise<MarketplaceListing | undefined> => {
217
269
  const name = pkg.name as string
270
+ const carries = await declares(name)
271
+ if (!carries) return undefined
218
272
  return {
219
273
  name,
220
274
  version: pkg.version ?? '0.0.0',
@@ -225,29 +279,35 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
225
279
  homepage: pkg.links?.homepage ?? null,
226
280
  repository: pkg.links?.repository ?? null,
227
281
  },
228
- declaresExtensions: await declares(name),
282
+ carries,
229
283
  installedVersion: installedVersion(name),
230
284
  }
231
285
  }),
232
286
  )
233
- // Only what really declares extensions: the keyword is a claim, the field
234
- // is the thing itself.
235
- const offered = listings.filter((listing) => listing.declaresExtensions)
287
+ // Only what really is a bundle: the keyword is a claim, the manifest is
288
+ // the thing itself.
289
+ const offered = listings.filter((listing): listing is MarketplaceListing => Boolean(listing))
236
290
  cached = { at: Date.now(), query: text, listings: offered }
237
291
  return offered
238
292
  },
239
293
 
240
- installed() {
241
- return options.extensions.list().map((extension) => ({
242
- packageName: extension.packageName,
243
- name: extension.manifest.name,
244
- id: extension.manifest.id ?? extension.packageName,
245
- version: extension.version,
246
- extensionCount: extension.manifest.extensions.length,
247
- }))
294
+ async installed() {
295
+ const wired = new Set(options.wired?.() ?? [])
296
+ return Promise.all(
297
+ options.extensions.bundles().bundles.map(async (bundle) => ({
298
+ packageName: bundle.packageName,
299
+ name: bundle.name,
300
+ id: bundle.id,
301
+ version: bundle.version,
302
+ extensionCount: bundle.field.extensions?.length ?? 0,
303
+ carries: bundle.carries,
304
+ wired: wired.has(bundle.id),
305
+ contentApplied: (await options.contentApplied?.(bundle.packageName)) ?? null,
306
+ })),
307
+ )
248
308
  },
249
309
 
250
- async install(name, version) {
310
+ async install(name, version, request = {}) {
251
311
  if (!isValidPackageName(name))
252
312
  return { ok: false, message: `'${name}' is not a package name npm would accept.` }
253
313
  if (version !== undefined && !isValidVersion(version))
@@ -263,19 +323,44 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
263
323
  }
264
324
 
265
325
  const range = (await rangeFor(name)) ?? version ?? 'latest'
266
- const found = options.extensions.list().find((extension) => extension.packageName === name)
326
+ const found = options.extensions
327
+ .bundles()
328
+ .bundles.find((bundle) => bundle.packageName === name)
267
329
  if (!found)
268
330
  return {
269
331
  ok: true,
270
332
  dependency: { name, range, version: version ?? range },
271
- message: `${name} was installed, but it declares no backoffice extensions, so nothing was added to the backoffice.`,
333
+ message: `${name} was installed, but it is not a valid bundle, so nothing was added to the backoffice.`,
272
334
  }
335
+
336
+ // The content half lands after the dependency, so a refused check leaves
337
+ // the package installed and says why, rather than undoing an install that
338
+ // succeeded.
339
+ let content: ContentOutcome | undefined
340
+ if (found.carries.content) {
341
+ const declined = request.applyContent === false || options.applyContentOnInstall === false
342
+ content =
343
+ declined || !options.applyContent
344
+ ? {
345
+ applied: false,
346
+ message: `Its content was not applied. Run \`crombolo bundle install ${name}\` to apply it.`,
347
+ }
348
+ : await options.applyContent(name).catch((error: unknown) => ({
349
+ applied: false,
350
+ message: `Its content could not be applied: ${error instanceof Error ? error.message : String(error)}`,
351
+ }))
352
+ }
273
353
  return {
274
354
  ok: true,
275
355
  dependency: { name, range, version: found.version },
276
356
  // The install lives in this node's node_modules until the dependency is
277
357
  // committed; saying so is the difference between a surprise and a plan.
278
- message: `${name}@${found.version} is installed and active. Commit the changed package.json and bun.lock to keep it across deployments.`,
358
+ message: `${name}@${found.version} is installed. Commit the changed package.json and bun.lock to keep it across deployments.${
359
+ found.carries.server
360
+ ? ' Its server half runs only once crombolo.config.ts imports it and the site is deployed.'
361
+ : ''
362
+ }`,
363
+ ...(content ? { content } : {}),
279
364
  }
280
365
  },
281
366
 
package/src/no-nodes.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The holding page for a site with nothing published — Umbraco's "no nodes"
3
- * screen, which `docs/06-features.md` asks for.
3
+ * screen, which `docs/features.md` asks for.
4
4
  *
5
5
  * It answers 404, not 200: a brand-new site is not a working one, and anything
6
6
  * watching the URL should keep saying so until a page is published. It is only
package/src/ports.ts CHANGED
@@ -89,7 +89,7 @@ const CULTURES = [
89
89
 
90
90
  function createServerPort(config: CromboloConfig, canSendUserLinks: boolean): ServerPort {
91
91
  return {
92
- // Phase 6 derives this from migration state; until then the site is always up.
92
+ // Story 080 derives this from migration state; until then the site is always up.
93
93
  status: () => ({ serverStatus: 'Run' }),
94
94
  configuration: () => ({
95
95
  // Reported false when no link could be delivered, so the client does not
package/src/schema.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Schema-as-code at runtime: the boot sync, the development watcher that
3
3
  * re-syncs on file changes, and the cache-instruction poll that keeps this
4
- * node's caches coherent with the others. docs/09-schema-as-code.md.
4
+ * node's caches coherent with the others. docs/schema-as-code.md.
5
5
  */
6
6
 
7
7
  import { existsSync, watch } from 'node:fs'
package/src/server.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The Crombolo server.
3
3
  *
4
- * Route order matters and mirrors docs/01-architecture.md: static assets, the
4
+ * Route order matters and mirrors docs/architecture.md: static assets, the
5
5
  * non-contract security and branding endpoints, the contract-first Management
6
6
  * API, the SPA shells, then the front-end site.
7
7
  */
@@ -41,11 +41,13 @@ import {
41
41
  } from '@crombolo/core'
42
42
  import {
43
43
  appendCacheInstruction,
44
+ createTablesCapability,
44
45
  type Db,
45
46
  FormEntryRepository,
46
47
  FormWorkflowRepository,
47
48
  RedirectRepository,
48
49
  readReport,
50
+ TransferRunRepository,
49
51
  type WritesPaused,
50
52
  } from '@crombolo/data'
51
53
  import {
@@ -63,9 +65,12 @@ import {
63
65
  import type { SyncReport } from '@crombolo/schema'
64
66
  import type { Server } from 'bun'
65
67
  import { createNodeLookup, loadAccess } from './access.ts'
68
+ import { readCreatedBundlePackage, writeCreatedBundlePackage } from './adapters/bundles.ts'
66
69
  import { createPublishedContentSource, loadDraftNode } from './adapters/documents.ts'
67
70
  import { createSchemaProposals } from './adapters/schema-proposals.ts'
68
- import { createBundleRedirects, createServerBundles } from './bundles.ts'
71
+ import { installBundleContent } from './bundle-install.ts'
72
+ import { bundleChains, bundleTablesReport } from './bundle-tables.ts'
73
+ import { createBundleRedirects, createServerBundles, validateServerBundles } from './bundles.ts'
69
74
  import { type CromboloConfig, loadConfig, type ServerRole, VERSION } from './config.ts'
70
75
  import { attachDatabase, bootstrapDatabase } from './database.ts'
71
76
  import { createDiagnostics } from './diagnostics.ts'
@@ -198,7 +203,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
198
203
  }
199
204
  }
200
205
  const domainLog = logger('domains')
201
- // Extensions are the site's own npm dependencies (`docs/17-bundles.md`).
206
+ // Extensions are the site's own npm dependencies (`docs/bundles.md`).
202
207
  // Uncached in development so an author editing one sees a reload pick it up.
203
208
  const extensions = createExtensionRegistry(config.siteDir, { cache: !config.development })
204
209
  const marketplace = createMarketplace({
@@ -207,6 +212,26 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
207
212
  marketplaceUrl: config.marketplaceUrl,
208
213
  extensions,
209
214
  siteDir: config.siteDir,
215
+ wired: () => bundles?.mounted.map((bundle) => bundle.id) ?? [],
216
+ contentApplied: async (packageName) =>
217
+ (await new TransferRunRepository(db).appliedForPackage(packageName))?.packageVersion ?? null,
218
+ applyContentOnInstall: config.applyBundleContentOnInstall,
219
+ applyContent: async (packageName) => {
220
+ const result = await installBundleContent(db, config, { bundle: packageName })
221
+ if (!result.runId) {
222
+ const reasons = [
223
+ ...result.problems,
224
+ ...result.check.outstanding.map((finding) => finding.message),
225
+ ]
226
+ return {
227
+ applied: false,
228
+ message: `Its content was not applied: ${reasons.join('; ') || 'the check refused it'}. Resolve that, then run \`crombolo bundle install ${packageName}\`.`,
229
+ }
230
+ }
231
+ // The content landed under a cache this process had already constructed.
232
+ cache.invalidate()
233
+ return { applied: true, runId: result.runId, message: 'Its content was applied.' }
234
+ },
210
235
  })
211
236
  const paths = createBackOfficePaths({ backOfficePath: config.backOfficePath })
212
237
  // Two questions, not one: what this process answers, and what it is
@@ -214,8 +239,17 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
214
239
  // `all` and `web` serve the public site.
215
240
  const servesBackOffice = config.role !== 'web'
216
241
  const owns = config.role !== 'web'
242
+ // Bundles are checked before the database is touched, because a bundle that
243
+ // owns tables is migrated at boot, and a bad declaration must not get that far.
244
+ const bundlePackages = servesBackOffice ? extensions.bundles().bundles : []
245
+ if (servesBackOffice) {
246
+ const problems = validateServerBundles(config.bundles, bundlePackages, VERSION)
247
+ if (problems.length > 0)
248
+ throw new Error(`The configured bundles cannot be mounted:\n- ${problems.join('\n- ')}`)
249
+ }
250
+ const chains = servesBackOffice ? bundleChains(config.bundles, bundlePackages) : []
217
251
  const { db, seededAdminPassword } = owns
218
- ? await bootstrapDatabase(config)
252
+ ? await bootstrapDatabase(config, chains)
219
253
  : await attachDatabase(config)
220
254
  // A configured schema store is copied down before anything reads `schema/`, and
221
255
  // becomes the schema directory for this boot. Everything after here — loading,
@@ -351,6 +385,12 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
351
385
  const bundles = servesBackOffice
352
386
  ? createServerBundles({
353
387
  bundles: config.bundles,
388
+ packages: bundlePackages,
389
+ coreVersion: VERSION,
390
+ tables: (bundle) => {
391
+ const chain = chains.find((candidate) => candidate.id === bundle.id)
392
+ return chain ? createTablesCapability(db, chain.compiled.schema) : undefined
393
+ },
354
394
  pluginApiPath: `${paths.pluginPath}/api`,
355
395
  host: {
356
396
  redirects: createBundleRedirects({ db, cache }),
@@ -592,7 +632,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
592
632
  nodeId: schema.nodeId,
593
633
  role: config.role,
594
634
  })
595
- // App_Plugins is gone (`docs/17-bundles.md`). Saying so beats a site whose
635
+ // App_Plugins is gone (`docs/bundles.md`). Saying so beats a site whose
596
636
  // extensions silently stopped loading after an upgrade; remove at 1.0.
597
637
  warnAboutAppPlugins(config.siteDir, log)
598
638
  noticeAboutEmail(emailStatus, logger('email'), { development: config.development })
@@ -847,24 +887,44 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
847
887
  return Response.json({
848
888
  url: marketplace.url(),
849
889
  keyword: config.packageKeyword,
890
+ appliesContent: config.applyBundleContentOnInstall,
850
891
  items: await marketplace.search(url.searchParams.get('q') ?? undefined),
851
892
  })
852
893
  }
853
894
 
895
+ // A created bundle's npm name and version: not in Umbraco's definition, so
896
+ // not in the contract, and set from the Created view beside it.
897
+ const created = /^created\/([0-9a-f-]{36})\/package$/i.exec(action)
898
+ if (created) {
899
+ const id = created[1] as string
900
+ if (request.method === 'GET') {
901
+ const found = await readCreatedBundlePackage(db, id)
902
+ return found ? Response.json(found) : new Response('Not Found', { status: 404 })
903
+ }
904
+ if (request.method === 'PUT') {
905
+ const body = (await request.json().catch(() => ({}))) as Record<string, unknown>
906
+ const outcome = await writeCreatedBundlePackage(db, id, body)
907
+ return Response.json(outcome, { status: outcome.ok ? 200 : 400 })
908
+ }
909
+ }
910
+
854
911
  if (action === 'installed' && request.method === 'GET') {
855
- return Response.json({ items: marketplace.installed() })
912
+ return Response.json({ items: await marketplace.installed() })
856
913
  }
857
914
 
858
915
  if ((action === 'install' || action === 'uninstall') && request.method === 'POST') {
859
916
  const body = (await request.json().catch(() => ({}))) as {
860
917
  name?: unknown
861
918
  version?: unknown
919
+ applyContent?: unknown
862
920
  }
863
921
  const name = typeof body.name === 'string' ? body.name : ''
864
922
  const version = typeof body.version === 'string' ? body.version : undefined
865
923
  const outcome =
866
924
  action === 'install'
867
- ? await marketplace.install(name, version)
925
+ ? await marketplace.install(name, version, {
926
+ applyContent: body.applyContent !== false,
927
+ })
868
928
  : await marketplace.uninstall(name)
869
929
  log.info('{user} {action} the package {name}: {outcome}', {
870
930
  user: principal.email,
@@ -901,7 +961,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
901
961
  /**
902
962
  * Non-contract: forms and their entries. There is no form operation in the
903
963
  * vendored contract — Umbraco Forms ships its own API and its own client,
904
- * and neither is here — so this is ours (`docs/18-forms.md`).
964
+ * and neither is here — so this is ours (`docs/forms.md`).
905
965
  *
906
966
  * Definitions are read from `schema/` per request rather than cached: the
907
967
  * files are the truth, the backoffice may have just rewritten one, and a
@@ -924,7 +984,7 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
924
984
  const byKey = formsByKey(definitions)
925
985
  // Submissions are personal data, so reading them asks for more than a
926
986
  // session. Four verbs, because designing a form and reading what people
927
- // sent it are different jobs (`docs/18-forms.md`).
987
+ // sent it are different jobs (`docs/forms.md`).
928
988
  const may = (verb: string) => principal.isAdmin || principal.permissions.includes(verb)
929
989
  const formFiles = {
930
990
  schemaDir: config.schemaDir,
@@ -1122,7 +1182,10 @@ export async function createServer(config: CromboloConfig = loadConfig()): Promi
1122
1182
  source[f.kind] = (source[f.kind] ?? 0) + 1
1123
1183
  bySource[f.source] = source
1124
1184
  }
1125
- return Response.json({ health: health(), counts, bySource, findings })
1185
+ // A bundle's tables are part of what an upgrade changes, so the dashboard
1186
+ // that shows an upgrade's findings shows their steps too.
1187
+ const bundles = await bundleTablesReport(db, chains)
1188
+ return Response.json({ health: health(), counts, bySource, findings, bundles })
1126
1189
  }
1127
1190
 
1128
1191
  const staticMatch = resolveStaticFile(paths, pathname, {
package/src/status.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  TransferRunRepository,
28
28
  } from '@crombolo/data'
29
29
  import { loadSchemaDirectory } from '@crombolo/schema'
30
+ import { BundleConfigurationError, bundleChainsFor, pendingBundleSteps } from './bundle-tables.ts'
30
31
  import { type CromboloConfig, describeDatabase, VERSION } from './config.ts'
31
32
  import { type EmailAvailability, emailAvailability, resolveEmailPort } from './email.ts'
32
33
  import { assertViewRuntime } from './view-runtime.ts'
@@ -60,6 +61,12 @@ export interface SiteStatus {
60
61
  pending: boolean
61
62
  }
62
63
  framework: { pending: string[] }
64
+ /**
65
+ * Each wired bundle that owns tables, by id: its chain's state and the steps
66
+ * still to run. Only a node that mounts bundles applies them, so this is what
67
+ * such a node would refuse to boot over in production.
68
+ */
69
+ bundles: Record<string, { name: string; state: string | null; pending: string[] }>
63
70
  content: { documents: number; media: number; elements: number; lastImport?: string }
64
71
  /**
65
72
  * Open findings by kind. `auto` ones are reported, not counted against the
@@ -140,6 +147,7 @@ export async function siteStatus(config: CromboloConfig): Promise<SiteStatus> {
140
147
  pending: false,
141
148
  },
142
149
  framework: { pending: [] },
150
+ bundles: {},
143
151
  content: { documents: 0, media: 0, elements: 0 },
144
152
  findings: { blocking: 0, person: 0, auto: 0, resolved: 0 },
145
153
  versions: { crombolo: VERSION, backoffice: backofficeVersion(), bun: Bun.version },
@@ -189,6 +197,27 @@ export async function siteStatus(config: CromboloConfig): Promise<SiteStatus> {
189
197
  `${status.framework.pending.length} framework migration(s) pending: run \`crombolo upgrade\``,
190
198
  )
191
199
 
200
+ try {
201
+ const chains = bundleChainsFor(config)
202
+ for (const chain of chains)
203
+ status.bundles[chain.id] = { name: chain.name, state: null, pending: [] }
204
+ for (const bundle of await pendingBundleSteps(db, chains)) {
205
+ status.bundles[bundle.id] = {
206
+ name: bundle.name,
207
+ state: bundle.state,
208
+ pending: bundle.pending.map((step) => step.name),
209
+ }
210
+ if (bundle.pending.length > 0 && !config.development)
211
+ status.problems.push(
212
+ `the bundle "${bundle.id}" has ${bundle.pending.length} step(s) pending: run \`crombolo upgrade\``,
213
+ )
214
+ }
215
+ } catch (error) {
216
+ if (error instanceof BundleConfigurationError)
217
+ for (const problem of error.problems) status.problems.push(problem)
218
+ else status.problems.push((error as Error).message)
219
+ }
220
+
192
221
  const state = await currentSchemaState(db)
193
222
  status.schema.database = { version: state.version, revision: state.revision }
194
223
  if (loaded) {
package/src/tar.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * A minimal npm package tarball writer: a gzipped ustar archive with every entry
3
+ * under `package/`, which is the layout `npm pack` produces and `bun add
4
+ * ./file.tgz` and `bun publish` read (`docs/bundles.md`).
5
+ *
6
+ * No dependency: the format is 512-byte headers and padded file bodies, and
7
+ * `Bun.gzipSync` does the compression. Timestamps are fixed, as npm fixes them,
8
+ * so the same selection packs to the same bytes.
9
+ */
10
+
11
+ export interface TarEntry {
12
+ /** The path inside the package, `/`-separated and never absolute. */
13
+ path: string
14
+ bytes: Uint8Array
15
+ }
16
+
17
+ const BLOCK = 512
18
+
19
+ /** 1985-10-26T08:15:00Z, the mtime npm writes into every packed file. */
20
+ const NPM_MTIME = 499162500
21
+
22
+ const encoder = new TextEncoder()
23
+
24
+ /** An octal field, NUL-terminated and zero-padded to its width. */
25
+ function octal(value: number, width: number): Uint8Array {
26
+ return encoder.encode(`${value.toString(8).padStart(width - 1, '0')}\0`)
27
+ }
28
+
29
+ /**
30
+ * Splits a path across ustar's `prefix` (155 bytes) and `name` (100 bytes) at a
31
+ * slash, which is how a path longer than 100 bytes is written without an
32
+ * extension header.
33
+ */
34
+ function splitPath(path: string): { name: string; prefix: string } {
35
+ if (encoder.encode(path).length <= 100) return { name: path, prefix: '' }
36
+ for (let i = path.lastIndexOf('/'); i > 0; i = path.lastIndexOf('/', i - 1)) {
37
+ const prefix = path.slice(0, i)
38
+ const name = path.slice(i + 1)
39
+ if (encoder.encode(prefix).length <= 155 && encoder.encode(name).length <= 100)
40
+ return { name, prefix }
41
+ }
42
+ throw new Error(`${path} is too long to pack`)
43
+ }
44
+
45
+ function header(path: string, size: number): Uint8Array {
46
+ const block = new Uint8Array(BLOCK)
47
+ const put = (bytes: Uint8Array, offset: number) => block.set(bytes, offset)
48
+ const { name, prefix } = splitPath(path)
49
+ put(encoder.encode(name), 0)
50
+ put(octal(0o644, 8), 100)
51
+ put(octal(0, 8), 108)
52
+ put(octal(0, 8), 116)
53
+ put(octal(size, 12), 124)
54
+ put(octal(NPM_MTIME, 12), 136)
55
+ // The checksum is computed with its own field read as spaces.
56
+ put(encoder.encode(' '), 148)
57
+ put(encoder.encode('0'), 156)
58
+ put(encoder.encode('ustar\0'), 257)
59
+ put(encoder.encode('00'), 263)
60
+ put(encoder.encode(prefix), 345)
61
+ const sum = block.reduce((total, byte) => total + byte, 0)
62
+ put(encoder.encode(`${sum.toString(8).padStart(6, '0')}\0 `), 148)
63
+ return block
64
+ }
65
+
66
+ /** The entries as an npm package tarball, each under `package/`. */
67
+ export function writeNpmTarball(entries: readonly TarEntry[]): Uint8Array<ArrayBuffer> {
68
+ const parts: Uint8Array[] = []
69
+ for (const entry of entries) {
70
+ if (entry.path.startsWith('/') || entry.path.split('/').includes('..'))
71
+ throw new Error(`${entry.path} is not a path inside the package`)
72
+ parts.push(header(`package/${entry.path}`, entry.bytes.length), entry.bytes)
73
+ const pad = (BLOCK - (entry.bytes.length % BLOCK)) % BLOCK
74
+ if (pad > 0) parts.push(new Uint8Array(pad))
75
+ }
76
+ // Two empty blocks end the archive.
77
+ parts.push(new Uint8Array(BLOCK * 2))
78
+ const tar = new Uint8Array(parts.reduce((total, part) => total + part.length, 0))
79
+ let offset = 0
80
+ for (const part of parts) {
81
+ tar.set(part, offset)
82
+ offset += part.length
83
+ }
84
+ return Bun.gzipSync(tar) as Uint8Array<ArrayBuffer>
85
+ }
package/src/zip.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * A minimal zip writer, because a created package is a zip and nothing else
3
- * here needs one (`docs/17-bundles.md`).
3
+ * here needs one (`docs/bundles.md`).
4
4
  *
5
5
  * No dependency: `node:zlib` provides `deflateRawSync` and `crc32`, which is
6
6
  * everything the format needs. Entries are written in the order given, each