@crombolo/server 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/bundles.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Server-side bundle extensions: a bundle that has to answer a request, not just
3
3
  * render a screen.
4
4
  *
5
- * `docs/17-bundles.md` argued that server-side extensions should not exist here,
5
+ * `docs/bundles.md` argued that server-side extensions should not exist here,
6
6
  * and the objection was specific — loading third-party JS into a running node
7
7
  * has no isolation story, and a `role: web` node would never see a runtime
8
8
  * install. What answers it is wiring rather than sandboxing: **nothing in this
@@ -28,12 +28,20 @@
28
28
  * - A handler that throws becomes a 500 and a log line naming the bundle, never a
29
29
  * broken request path.
30
30
  *
31
- * Deliberately not offered: migrations, raw SQL, and anything that writes the
32
- * site's files. A bundle that needs a table of its own needs a release of this
33
- * CMS, because a migration that arrives with a dependency is a schema no
34
- * `migration_history` can account for.
31
+ * - Every wired bundle belongs to an npm package the site declares, whose
32
+ * `crombolo` manifest names the same id and declares a server half. Its name,
33
+ * version and peer range come from there, and a peer range that excludes this
34
+ * release refuses the boot.
35
+ *
36
+ * - A bundle that declares `tables` owns them: its own ledgered chain, written
37
+ * with the declarative builder in `@crombolo/data`, and a `tables`
38
+ * capability confined to them (`docs/bundles.md`, Part 4).
39
+ *
40
+ * Deliberately not offered: raw SQL, a `Db`, and anything that writes the
41
+ * site's files.
35
42
  */
36
43
  import { hasCultureAccess, hasSection, type Principal } from '@crombolo/api-management'
44
+ import { BUNDLE_ID, type BundlePackage, peerRangeProblem } from '@crombolo/backoffice-host'
37
45
  import {
38
46
  type AppAlias,
39
47
  type ExtensionSectionAlias,
@@ -41,6 +49,10 @@ import {
41
49
  SECTION_ALIASES,
42
50
  } from '@crombolo/core'
43
51
  import {
52
+ type BundleTables,
53
+ type BundleTablesCapability,
54
+ bundleTablesCompatibility,
55
+ compileBundleTables,
44
56
  type Db,
45
57
  type RedirectMatchKind,
46
58
  RedirectRepository,
@@ -52,12 +64,6 @@ import { normaliseRedirectPath, type PublishedCache } from '@crombolo/render'
52
64
  /** The segment every bundle's routes hang below, inside the plugin API. */
53
65
  export const BUNDLE_API_SEGMENT = 'bundle'
54
66
 
55
- /**
56
- * A bundle's id, which is also its URL segment: lowercase, no dots or slashes, so
57
- * it cannot climb out of its namespace however it is spelled.
58
- */
59
- const BUNDLE_ID = /^[a-z][a-z0-9-]{1,38}$/
60
-
61
67
  /**
62
68
  * A route path below the bundle's namespace. Literal segments, optionally one
63
69
  * `:name` parameter each, and no leading or trailing slash — so a path cannot
@@ -133,6 +139,8 @@ export interface BundleHost {
133
139
  redirects: BundleRedirects
134
140
  documents: BundleDocuments
135
141
  log: BundleLog
142
+ /** The bundle's own tables, and only for a bundle that declares some. */
143
+ tables: BundleTablesCapability
136
144
  }
137
145
 
138
146
  export type BundleCapability = keyof BundleHost
@@ -166,9 +174,26 @@ export type { AppAlias, ExtensionSectionAlias, Principal }
166
174
  export { hasCultureAccess, hasSection }
167
175
 
168
176
  /** Every capability name, for validating what a bundle asked for. */
169
- export const BUNDLE_CAPABILITIES: readonly BundleCapability[] = ['redirects', 'documents', 'log']
170
-
171
- export interface BundleRequest<C extends BundleCapability = BundleCapability> {
177
+ export const BUNDLE_CAPABILITIES: readonly BundleCapability[] = [
178
+ 'redirects',
179
+ 'documents',
180
+ 'log',
181
+ 'tables',
182
+ ]
183
+
184
+ /** What a handler is handed: the declared capabilities, with `tables` typed from the declaration. */
185
+ export type HostFor<C extends BundleCapability, T extends BundleTables> = Pick<
186
+ BundleHost,
187
+ Exclude<C, 'tables'>
188
+ > &
189
+ ('tables' extends C
190
+ ? { tables: T extends BundleTables<infer S> ? BundleTablesCapability<S> : never }
191
+ : unknown)
192
+
193
+ export interface BundleRequest<
194
+ C extends BundleCapability = BundleCapability,
195
+ T extends BundleTables = BundleTables,
196
+ > {
172
197
  request: Request
173
198
  url: URL
174
199
  /** Already authenticated, and already holding the bundle's declared section. */
@@ -176,34 +201,45 @@ export interface BundleRequest<C extends BundleCapability = BundleCapability> {
176
201
  /** The `:name` segments of the matched route. */
177
202
  params: Record<string, string>
178
203
  /** Only what the bundle declared, frozen. */
179
- host: Pick<BundleHost, C>
204
+ host: HostFor<C, T>
180
205
  }
181
206
 
182
- export interface ServerBundleRoute<C extends BundleCapability = BundleCapability> {
207
+ export interface ServerBundleRoute<
208
+ C extends BundleCapability = BundleCapability,
209
+ T extends BundleTables = BundleTables,
210
+ > {
183
211
  method: BundleMethod
184
212
  /** Below the bundle's namespace; `''` is the namespace root. */
185
213
  path: string
186
- handler(context: BundleRequest<C>): Response | Promise<Response>
214
+ handler(context: BundleRequest<C, T>): Response | Promise<Response>
187
215
  }
188
216
 
189
- export interface ServerBundle<C extends BundleCapability = BundleCapability> {
190
- /** URL segment and log name; unique across the site's bundles. */
217
+ export interface ServerBundle<
218
+ C extends BundleCapability = BundleCapability,
219
+ T extends BundleTables = BundleTables,
220
+ > {
221
+ /**
222
+ * URL segment and log name: the `crombolo.id` of the package that exports
223
+ * this bundle, which is where its name and version are read from.
224
+ */
191
225
  id: string
192
- /** What the boot log and an error message call it. */
193
- name: string
194
226
  /**
195
227
  * The section a caller must have access to. Every route is gated on it; there
196
228
  * is no per-route override and no public route.
197
229
  */
198
230
  section: BundleSection
199
231
  capabilities: readonly C[]
200
- routes: readonly ServerBundleRoute<C>[]
232
+ routes: readonly ServerBundleRoute<C, T>[]
233
+ /** Tables the bundle owns, from `defineBundleTables`, under its own id. */
234
+ tables?: T
201
235
  }
202
236
 
203
237
  /** A bundle as mounted, with the prefix its routes answer below. */
204
238
  export interface MountedBundle {
205
239
  id: string
240
+ /** From its package's manifest. */
206
241
  name: string
242
+ package: { name: string; version: string; peerRange: string }
207
243
  section: BundleSection
208
244
  capabilities: readonly BundleCapability[]
209
245
  /** `<backoffice>/crombolo/api/bundle/<id>`. */
@@ -216,19 +252,40 @@ export interface MountedBundle {
216
252
  * thrown so the boot can report all of them at once; `createServerBundles`
217
253
  * turns a non-empty list into a failed boot, because a bundle that is half
218
254
  * mounted is worse than one that is absent.
255
+ *
256
+ * `packages` are the bundles the site declares as dependencies. A wired bundle
257
+ * must be one of them, so its version and peer range are always known.
219
258
  */
220
- export function validateServerBundles(bundles: readonly ServerBundle[]): string[] {
259
+ export function validateServerBundles(
260
+ bundles: readonly ServerBundle[],
261
+ packages: readonly BundlePackage[],
262
+ coreVersion: string,
263
+ ): string[] {
221
264
  const problems: string[] = []
222
265
  const seen = new Set<string>()
223
266
  for (const bundle of bundles) {
224
- const label = bundle.id || bundle.name || '<unnamed>'
267
+ const label = bundle.id || '<unnamed>'
225
268
  if (!BUNDLE_ID.test(bundle.id))
226
269
  problems.push(
227
270
  `Bundle "${label}" has the id "${bundle.id}", which is not 2–39 characters of lowercase letters, digits and hyphens starting with a letter.`,
228
271
  )
229
272
  else if (seen.has(bundle.id)) problems.push(`Two bundles claim the id "${bundle.id}".`)
230
- else seen.add(bundle.id)
231
- if (!bundle.name) problems.push(`Bundle "${label}" has no name.`)
273
+ else {
274
+ seen.add(bundle.id)
275
+ const owner = packages.find((pkg) => pkg.id === bundle.id)
276
+ if (!owner)
277
+ problems.push(
278
+ `Bundle "${label}" is wired in crombolo.config.ts, but no package the site depends on declares the bundle id "${bundle.id}". Add the package to package.json.`,
279
+ )
280
+ else if (!owner.carries.server)
281
+ problems.push(
282
+ `Bundle "${label}" is wired in crombolo.config.ts, but ${owner.packageName} declares no "server" in its crombolo manifest.`,
283
+ )
284
+ else {
285
+ const peer = peerRangeProblem(owner, coreVersion)
286
+ if (peer) problems.push(`Bundle "${label}" cannot run here: ${peer}.`)
287
+ }
288
+ }
232
289
  if (!(bundle.section in SECTION_ALIASES) && !isExtensionSectionAlias(bundle.section))
233
290
  problems.push(
234
291
  `Bundle "${label}" is gated on the section "${bundle.section}", which is neither a core section nor a manifest alias of the form Vendor.Section.Name.`,
@@ -238,6 +295,28 @@ export function validateServerBundles(bundles: readonly ServerBundle[]): string[
238
295
  problems.push(
239
296
  `Bundle "${label}" asked for the capability "${capability}", which no host offers.`,
240
297
  )
298
+ if (bundle.capabilities.includes('tables') && !bundle.tables)
299
+ problems.push(
300
+ `Bundle "${label}" asked for the capability "tables", but declares no tables of its own.`,
301
+ )
302
+ if (bundle.tables) {
303
+ if (bundle.tables.id !== bundle.id)
304
+ problems.push(
305
+ `Bundle "${label}" declares its tables under the id "${bundle.tables.id}"; a bundle's tables are under its own id.`,
306
+ )
307
+ const owner = packages.find((pkg) => pkg.id === bundle.id)
308
+ for (const problem of compileBundleTables(bundle.tables, owner?.version).problems)
309
+ problems.push(`Bundle "${label}" tables: ${problem}.`)
310
+ // Read by an operator's tooling before anything is installed, so it must
311
+ // say what the plan does — derived, not asserted, as core's is.
312
+ if (owner) {
313
+ const derived = bundleTablesCompatibility(bundle.tables, owner.version)
314
+ if (owner.field.migrations !== derived)
315
+ problems.push(
316
+ `Bundle "${label}"'s package.json declares "crombolo.migrations": ${JSON.stringify(owner.field.migrations ?? null)}, but its tables make ${owner.version} "${derived}".`,
317
+ )
318
+ }
319
+ }
241
320
  const routes = new Set<string>()
242
321
  for (const route of bundle.routes) {
243
322
  if (route.path !== '' && !ROUTE_PATH.test(route.path))
@@ -404,11 +483,17 @@ export function createBundleRedirects(options: {
404
483
 
405
484
  export interface ServerBundlesOptions {
406
485
  bundles: readonly ServerBundle[]
486
+ /** The bundle packages the site depends on, from discovery. */
487
+ packages: readonly BundlePackage[]
488
+ /** The running release, which every bundle's peer range must admit. */
489
+ coreVersion: string
407
490
  /** `<backoffice>/crombolo/api`, from `createBackOfficePaths`. */
408
491
  pluginApiPath: string
409
- host: BundleHost
492
+ host: Omit<BundleHost, 'tables'>
410
493
  /** Named per bundle, so a log line says which one wrote it. */
411
494
  log(bundleId: string): BundleLog
495
+ /** A bundle's own tables, resolved against its schema alone; absent where none are offered. */
496
+ tables?(bundle: ServerBundle): BundleTablesCapability | undefined
412
497
  }
413
498
 
414
499
  export interface ServerBundles {
@@ -430,7 +515,7 @@ export interface ServerBundles {
430
515
  * would fail on its first request rather than at boot.
431
516
  */
432
517
  export function createServerBundles(options: ServerBundlesOptions): ServerBundles {
433
- const problems = validateServerBundles(options.bundles)
518
+ const problems = validateServerBundles(options.bundles, options.packages, options.coreVersion)
434
519
  if (problems.length > 0)
435
520
  throw new Error(`The configured bundles cannot be mounted:\n- ${problems.join('\n- ')}`)
436
521
 
@@ -438,21 +523,37 @@ export function createServerBundles(options: ServerBundlesOptions): ServerBundle
438
523
  const byId = new Map(options.bundles.map((bundle) => [bundle.id, bundle]))
439
524
  const logs = new Map(options.bundles.map((bundle) => [bundle.id, options.log(bundle.id)]))
440
525
 
441
- const mounted: MountedBundle[] = options.bundles.map((bundle) => ({
442
- id: bundle.id,
443
- name: bundle.name,
444
- section: bundle.section,
445
- capabilities: bundle.capabilities,
446
- prefix: `${basePath}${bundle.id}`,
447
- routes: bundle.routes.map((route) => ({ method: route.method, path: route.path })),
448
- }))
526
+ const owners = new Map(options.packages.map((pkg) => [pkg.id, pkg]))
527
+ const mounted: MountedBundle[] = options.bundles.map((bundle) => {
528
+ const owner = owners.get(bundle.id) as BundlePackage
529
+ return {
530
+ id: bundle.id,
531
+ name: owner.name,
532
+ package: { name: owner.packageName, version: owner.version, peerRange: owner.peerRange },
533
+ section: bundle.section,
534
+ capabilities: bundle.capabilities,
535
+ prefix: `${basePath}${bundle.id}`,
536
+ routes: bundle.routes.map((route) => ({ method: route.method, path: route.path })),
537
+ }
538
+ })
539
+ const names = new Map(mounted.map((bundle) => [bundle.id, bundle.name]))
449
540
 
450
541
  /** Only the declared capabilities, frozen, so a handler cannot reach further. */
542
+ // Built once per bundle, each over that bundle's schema alone.
543
+ const tables = new Map(
544
+ options.bundles
545
+ .filter((bundle) => bundle.capabilities.includes('tables'))
546
+ .map((bundle) => [bundle.id, options.tables?.(bundle)]),
547
+ )
451
548
  const contextFor = (bundle: ServerBundle): Pick<BundleHost, BundleCapability> => {
452
549
  const host: Record<string, unknown> = {}
453
550
  for (const capability of bundle.capabilities)
454
551
  host[capability] =
455
- capability === 'log' ? (logs.get(bundle.id) as BundleLog) : options.host[capability]
552
+ capability === 'log'
553
+ ? (logs.get(bundle.id) as BundleLog)
554
+ : capability === 'tables'
555
+ ? tables.get(bundle.id)
556
+ : options.host[capability]
456
557
  return Object.freeze(host) as Pick<BundleHost, BundleCapability>
457
558
  }
458
559
 
@@ -492,7 +593,7 @@ export function createServerBundles(options: ServerBundlesOptions): ServerBundle
492
593
  // A bundle's mistake is its own 500, named, rather than an unhandled
493
594
  // rejection in the request path.
494
595
  logs.get(bundle.id)?.error('The bundle {bundle} failed to answer {method} {path}', {
495
- bundle: bundle.name,
596
+ bundle: names.get(bundle.id),
496
597
  method,
497
598
  path: pathname,
498
599
  error,
package/src/config.ts CHANGED
@@ -205,7 +205,7 @@ export interface CromboloConfig {
205
205
  *
206
206
  * Nothing here throws: a site with no provider reports the features as
207
207
  * unavailable, with the reason, rather than failing when someone presses the
208
- * button. See `email.ts` and `docs/14-configuration.md`.
208
+ * button. See `email.ts` and `docs/configuration.md`.
209
209
  */
210
210
  email: EmailPort | null | undefined
211
211
  /**
@@ -245,9 +245,16 @@ export interface CromboloConfig {
245
245
  * factory its package exports. Imported and listed here by hand on purpose:
246
246
  * installing a bundle never makes its server code run, so the only way code
247
247
  * reaches the request path is a committed, deployed edit to this file
248
- * (`bundles.ts`, `docs/17-bundles.md`).
248
+ * (`bundles.ts`, `docs/bundles.md`).
249
249
  */
250
250
  bundles: ServerBundle[]
251
+ /**
252
+ * Whether installing a bundle from the Marketplace also applies the content it
253
+ * carries — its schema, components and content — straight after `bun add`. On
254
+ * by default; a load-balanced site that wants content to arrive only with a
255
+ * deploy turns it off, and an install can decline it for itself.
256
+ */
257
+ applyBundleContentOnInstall: boolean
251
258
  /**
252
259
  * Where the Packages section's Marketplace sends someone browsing for
253
260
  * extensions. Never framed — npm refuses to be framed — so the native view
@@ -463,6 +470,7 @@ export function loadConfig(
463
470
  ),
464
471
  redirects: [],
465
472
  trackRedirects: Bun.env.CROMBOLO_TRACK_REDIRECTS !== 'false',
473
+ applyBundleContentOnInstall: Bun.env.CROMBOLO_APPLY_BUNDLE_CONTENT !== 'false',
466
474
  seo: stripUndefined({
467
475
  sitemap: envFlag(Bun.env.CROMBOLO_SITEMAP),
468
476
  indexable: envFlag(Bun.env.CROMBOLO_INDEXABLE),
@@ -545,4 +553,4 @@ export function describeDatabase(
545
553
  }
546
554
  }
547
555
 
548
- export const VERSION = '0.9.1'
556
+ export const VERSION = '0.10.0'
package/src/database.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Database bootstrap: connect, migrate, seed.
3
3
  *
4
4
  * Migration and seeding run at startup rather than through the install wizard;
5
- * the wizard is Phase 6. Both are idempotent, so restarting is safe.
5
+ * the wizard is story 080. Both are idempotent, so restarting is safe.
6
6
  */
7
7
  import { hashPassword, passwordConfigJson } from '@crombolo/auth'
8
8
  import {
@@ -20,13 +20,27 @@ import {
20
20
  seedContent,
21
21
  seedIdentity,
22
22
  } from '@crombolo/data'
23
+ import { type BundleChain, migrateBundles, pendingBundleSteps } from './bundle-tables.ts'
23
24
  import type { CromboloConfig } from './config.ts'
24
25
 
25
26
  /** Production never auto-upgrades: an existing database with steps pending refuses to boot. */
26
27
  export class UpgradePendingError extends Error {
27
- constructor(readonly steps: string[]) {
28
+ constructor(
29
+ readonly steps: string[],
30
+ /** Each bundle with steps pending, and those steps. */
31
+ readonly bundles: { id: string; steps: string[] }[] = [],
32
+ ) {
33
+ const parts: string[] = []
34
+ if (steps.length > 0)
35
+ parts.push(
36
+ `${steps.length} framework migration(s) pending on this database: ${steps.join(', ')}.`,
37
+ )
38
+ for (const bundle of bundles)
39
+ parts.push(
40
+ `The bundle "${bundle.id}" has ${bundle.steps.length} step(s) pending on this database: ${bundle.steps.join(', ')}.`,
41
+ )
28
42
  super(
29
- `${steps.length} framework migration(s) pending on this database: ${steps.join(', ')}. ` +
43
+ `${parts.join(' ')} ` +
30
44
  'Production does not upgrade at boot; run `crombolo upgrade check`, then `crombolo upgrade`.',
31
45
  )
32
46
  }
@@ -65,16 +79,35 @@ export async function attachDatabase(config: CromboloConfig): Promise<Bootstrap>
65
79
  return { db, seededAdminPassword: undefined }
66
80
  }
67
81
 
68
- export async function bootstrapDatabase(config: CromboloConfig): Promise<Bootstrap> {
82
+ /**
83
+ * `chains` are the wired bundles' tables, which only a node that mounts bundles
84
+ * passes: a bundle's steps pending in production refuse the boot just as
85
+ * core's do, and a database ahead of a bundle's code refuses it by name.
86
+ */
87
+ export async function bootstrapDatabase(
88
+ config: CromboloConfig,
89
+ chains: readonly BundleChain[] = [],
90
+ ): Promise<Bootstrap> {
69
91
  const db = await connect({ file: config.sqliteFile })
70
- // pendingMigrations throws when the database is newer than this code.
71
- const pending = await pendingMigrations(db, cromboloPlan)
72
- const fresh = (await readState(db)) === INITIAL_STATE
73
- if (pending.length > 0 && !fresh && !config.development) {
92
+ try {
93
+ // pendingMigrations throws when the database is newer than this code.
94
+ const pending = await pendingMigrations(db, cromboloPlan)
95
+ const fresh = (await readState(db)) === INITIAL_STATE
96
+ const bundles = fresh ? [] : await pendingBundleSteps(db, chains)
97
+ const bundlesPending = bundles.filter((bundle) => bundle.pending.length > 0)
98
+ if ((pending.length > 0 || bundlesPending.length > 0) && !fresh && !config.development)
99
+ throw new UpgradePendingError(
100
+ pending.map((m) => m.name),
101
+ bundlesPending.map((bundle) => ({
102
+ id: bundle.id,
103
+ steps: bundle.pending.map((m) => m.name),
104
+ })),
105
+ )
106
+ } catch (error) {
74
107
  await db.close()
75
- throw new UpgradePendingError(pending.map((m) => m.name))
108
+ throw error
76
109
  }
77
- return { db, seededAdminPassword: await installDatabase(db, config) }
110
+ return { db, seededAdminPassword: await installDatabase(db, config, chains) }
78
111
  }
79
112
 
80
113
  /**
@@ -83,8 +116,14 @@ export async function bootstrapDatabase(config: CromboloConfig): Promise<Bootstr
83
116
  * steps is exactly its job. Returns the admin password when this run created
84
117
  * the account and none was configured.
85
118
  */
86
- export async function installDatabase(db: Db, config: CromboloConfig): Promise<string | undefined> {
119
+ export async function installDatabase(
120
+ db: Db,
121
+ config: CromboloConfig,
122
+ chains: readonly BundleChain[] = [],
123
+ ): Promise<string | undefined> {
87
124
  await migrate(db, cromboloPlan, { by: config.nodeId })
125
+ // Core first, so a bundle step always runs against a core it declared itself compatible with.
126
+ await migrateBundles(db, chains, { by: config.nodeId })
88
127
 
89
128
  const password = config.adminPassword ?? crypto.randomUUID().replaceAll('-', '').slice(0, 20)
90
129
  const seeded = await seedIdentity(db, {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Dictionary items as a `.udt` file, for seeding one environment from another.
3
3
  *
4
- * `09-schema-as-code.md` leaves the dictionary in the database rather than in
4
+ * `schema-as-code.md` leaves the dictionary in the database rather than in
5
5
  * `schema/`, because translators work in production where the schema files are
6
6
  * read-only — and promises this pair of commands as the way a lower environment
7
7
  * gets the translations.
package/src/email.ts CHANGED
@@ -221,7 +221,7 @@ export const EMAIL_DEPENDENT_FEATURES = [
221
221
  ]
222
222
 
223
223
  export const EMAIL_SETUP_HINT =
224
- 'Set CROMBOLO_EMAIL_PROVIDER to resend, postmark or custom with its key and CROMBOLO_EMAIL_FROM, or pass `email` in crombolo.config.ts. See docs/14-configuration.md.'
224
+ 'Set CROMBOLO_EMAIL_PROVIDER to resend, postmark or custom with its key and CROMBOLO_EMAIL_FROM, or pass `email` in crombolo.config.ts. See docs/configuration.md.'
225
225
 
226
226
  export function emailAvailability(port: EmailPort | undefined): EmailAvailability {
227
227
  if (!port)
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * The one trace App_Plugins leaves behind.
3
3
  *
4
- * Extensions are npm dependencies now (`docs/17-bundles.md`), and nothing
4
+ * Extensions are npm dependencies now (`docs/bundles.md`), and nothing
5
5
  * reads this directory any more. A site upgrading with files still in it would
6
6
  * otherwise find its extensions had quietly stopped loading, so the boot says
7
7
  * what happened. Remove at 1.0.
@@ -29,7 +29,7 @@ export function warnAboutAppPlugins(siteDir: string, log: NoticeLog): void {
29
29
  count: entries.length,
30
30
  dir,
31
31
  remedy:
32
- 'Backoffice extensions are npm dependencies now — install one with `bun add`, and see docs/17-bundles.md.',
32
+ 'Backoffice extensions are npm dependencies now — install one with `bun add`, and see docs/bundles.md.',
33
33
  },
34
34
  )
35
35
  }
package/src/form-files.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Writing a form definition back to `schema/forms/`. `docs/18-forms.md`.
2
+ * Writing a form definition back to `schema/forms/`. `docs/forms.md`.
3
3
  *
4
4
  * The designer saves a form the way the type editors save a content type: the
5
5
  * file is the truth, so the write goes to the file and `onChanged` publishes it
@@ -234,7 +234,7 @@ export async function deleteForm(
234
234
  ],
235
235
  }
236
236
  // The entries stay: they are the business record, and deleting a definition
237
- // must not take what people sent it (`docs/18-forms.md`).
237
+ // must not take what people sent it (`docs/forms.md`).
238
238
  rmSync(file)
239
239
  await options.onChanged?.()
240
240
  return { ok: true, file }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Taking a form submission. `docs/18-forms.md`.
2
+ * Taking a form submission. `docs/forms.md`.
3
3
  *
4
4
  * `POST /crombolo/forms/<key>`, outside the Management API because a visitor is
5
5
  * not a backoffice user and there is no operation in the vendored contract for
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Running a form's workflows. `docs/18-forms.md`.
2
+ * Running a form's workflows. `docs/forms.md`.
3
3
  *
4
4
  * The submission is already stored by the time anything here runs: these are
5
5
  * the side effects, and a side effect that fails must never cost the entry.
@@ -7,7 +7,7 @@
7
7
  * a mail server that is down for ten minutes delays an email rather than
8
8
  * losing it — and a multi-node site sends it once.
9
9
  *
10
- * Three types. The XSLT ones Umbraco has are gone (`docs/18-forms.md` says
10
+ * Three types. The XSLT ones Umbraco has are gone (`docs/forms.md` says
11
11
  * why), and Slack is `sendToUrl` with a webhook URL rather than a type of its
12
12
  * own.
13
13
  */
package/src/forms.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * What the backoffice reads about forms. `docs/18-forms.md`.
2
+ * What the backoffice reads about forms. `docs/forms.md`.
3
3
  *
4
4
  * Non-contract, under the plugin path, because there is no form operation in
5
5
  * the vendored OpenAPI document — Umbraco Forms ships its own API and its own
package/src/index.ts CHANGED
@@ -11,6 +11,9 @@ export * from './adapters/redirects.ts'
11
11
  export * from './adapters/references.ts'
12
12
  export * from './adapters/template-files.ts'
13
13
  export * from './backup.ts'
14
+ export * from './bundle-install.ts'
15
+ export * from './bundle-sections.ts'
16
+ export * from './bundle-tables.ts'
14
17
  export * from './bundles.ts'
15
18
  export * from './components.ts'
16
19
  export * from './config.ts'