@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/package.json +11 -11
- package/src/adapters/bundles.ts +84 -9
- package/src/adapters/schema-files.ts +1 -1
- package/src/backup.ts +1 -1
- package/src/bundle-install.ts +417 -0
- package/src/bundle-sections.ts +424 -0
- package/src/bundle-tables.ts +161 -0
- package/src/bundles.ts +139 -38
- package/src/config.ts +11 -3
- package/src/database.ts +50 -11
- package/src/dictionary-transfer.ts +1 -1
- package/src/email.ts +1 -1
- package/src/extensions-notice.ts +2 -2
- package/src/form-files.ts +2 -2
- package/src/form-submit.ts +1 -1
- package/src/form-workflows.ts +2 -2
- package/src/forms.ts +1 -1
- package/src/index.ts +3 -0
- package/src/marketplace.ts +117 -32
- package/src/no-nodes.ts +1 -1
- package/src/ports.ts +1 -1
- package/src/schema.ts +1 -1
- package/src/server.ts +73 -10
- package/src/status.ts +29 -0
- package/src/tar.ts +85 -0
- package/src/zip.ts +1 -1
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/
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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[] = [
|
|
170
|
-
|
|
171
|
-
|
|
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:
|
|
204
|
+
host: HostFor<C, T>
|
|
180
205
|
}
|
|
181
206
|
|
|
182
|
-
export interface ServerBundleRoute<
|
|
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<
|
|
190
|
-
|
|
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(
|
|
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 ||
|
|
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
|
|
231
|
-
|
|
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
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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'
|
|
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.
|
|
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/
|
|
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/
|
|
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.
|
|
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
|
|
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(
|
|
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
|
-
`${
|
|
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
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
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(
|
|
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
|
-
* `
|
|
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/
|
|
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)
|
package/src/extensions-notice.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The one trace App_Plugins leaves behind.
|
|
3
3
|
*
|
|
4
|
-
* Extensions are npm dependencies now (`docs/
|
|
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/
|
|
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/
|
|
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/
|
|
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 }
|
package/src/form-submit.ts
CHANGED
package/src/form-workflows.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Running a form's workflows. `docs/
|
|
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/
|
|
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/
|
|
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'
|