@crombolo/server 0.11.0 → 0.12.1

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.
@@ -8,7 +8,7 @@
8
8
  import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
9
9
  import { join, relative } from 'node:path'
10
10
  import type { PortFailure } from '@crombolo/api-management'
11
- import type { ContentTypeAggregate, DataTypeModel } from '@crombolo/core'
11
+ import type { ContentTypeAggregate, DataTypeModel, FormTypes } from '@crombolo/core'
12
12
  import { currentSchemaState, type Db, updateSchemaStateHash } from '@crombolo/data'
13
13
  import {
14
14
  type ChangeClass,
@@ -18,6 +18,7 @@ import {
18
18
  loadSchemaDirectory,
19
19
  nextSchemaVersion,
20
20
  type SchemaTypeKind,
21
+ type SchemaUrls,
21
22
  TYPE_KIND_FILES,
22
23
  validateSchemaSet,
23
24
  writeContentType,
@@ -32,6 +33,8 @@ export interface SchemaFiles {
32
33
  schemaDir: string
33
34
  writable: boolean
34
35
  componentAliases(): ReadonlySet<string>
36
+ /** The form types the site's wired bundles add. */
37
+ formTypes?: FormTypes
35
38
  /**
36
39
  * Called after any write, so a shared schema store outlives this container.
37
40
  * Absent when the schema is a directory on disk, which needs no publishing.
@@ -54,6 +57,8 @@ export interface SchemaFileWriter {
54
57
  writeDataType(model: DataTypeModel): Promise<void>
55
58
  removeDataType(alias: string): Promise<void>
56
59
  writeLanguages(): Promise<void>
60
+ /** `[urls]` in `schema.toml`, the version left as it is. */
61
+ writeUrlOptions(urls: SchemaUrls): Promise<void>
57
62
  /** After a move or a folder change: every file, from the database, canonically. */
58
63
  rewriteAll(): Promise<void>
59
64
  }
@@ -82,7 +87,7 @@ export function createSchemaFileWriter(
82
87
  await updateSchemaStateHash(
83
88
  db,
84
89
  state.id,
85
- hashSchemaSet(loadSchemaDirectory(files.schemaDir).set),
90
+ hashSchemaSet(loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set),
86
91
  )
87
92
  await files.onChanged?.()
88
93
  }
@@ -100,10 +105,10 @@ export function createSchemaFileWriter(
100
105
 
101
106
  async bumpVersion(classification) {
102
107
  if (!managed() || !files.writable || classification === 'none') return
103
- const loaded = loadSchemaDirectory(files.schemaDir)
108
+ const loaded = loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes })
104
109
  const next = nextSchemaVersion(loaded.set.version, classification)
105
110
  if (next === loaded.set.version) return
106
- writeFileSync(join(files.schemaDir, 'schema.toml'), writeSchemaVersion(next))
111
+ writeFileSync(join(files.schemaDir, 'schema.toml'), writeSchemaVersion(next, loaded.set.urls))
107
112
  logger('schema').info('Schema version {from} → {to} ({classification})', {
108
113
  from: loaded.set.version,
109
114
  to: next,
@@ -113,7 +118,10 @@ export function createSchemaFileWriter(
113
118
 
114
119
  async write(aggregate, previousAlias) {
115
120
  if (!managed() || !files.writable) return
116
- const set = await exportSchemaSet(db, loadSchemaDirectory(files.schemaDir).set.version)
121
+ const set = await exportSchemaSet(
122
+ db,
123
+ loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set.version,
124
+ )
117
125
  const list =
118
126
  kind === 'document'
119
127
  ? set.contentTypes
@@ -122,7 +130,10 @@ export function createSchemaFileWriter(
122
130
  : (set.memberTypes ?? [])
123
131
  const type = list.find((t) => t.key === aggregate.key)
124
132
  if (!type) return
125
- const problems = validateSchemaSet(set, { componentAliases: files.componentAliases() })
133
+ const problems = validateSchemaSet(set, {
134
+ componentAliases: files.componentAliases(),
135
+ types: files.formTypes,
136
+ })
126
137
  if (problems.length > 0) {
127
138
  const detail = problems.map((p) => `${p.file}: ${p.message}`).join('; ')
128
139
  logger('schema').error('Not writing {file}: {detail}', {
@@ -146,7 +157,10 @@ export function createSchemaFileWriter(
146
157
 
147
158
  async writeDataType(model) {
148
159
  if (!managed() || !files.writable || !model.alias) return
149
- const set = await exportSchemaSet(db, loadSchemaDirectory(files.schemaDir).set.version)
160
+ const set = await exportSchemaSet(
161
+ db,
162
+ loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set.version,
163
+ )
150
164
  const dataType = set.dataTypes.find((d) => d.key === model.key)
151
165
  // An unchanged built-in has no file; a changed one does.
152
166
  if (!dataType) {
@@ -157,7 +171,10 @@ export function createSchemaFileWriter(
157
171
  // The same guard `write` applies to a content type: a set that does not
158
172
  // validate never reaches the disk, because the file is what every other
159
173
  // node reads and what a deploy boots from.
160
- const problems = validateSchemaSet(set, { componentAliases: files.componentAliases() })
174
+ const problems = validateSchemaSet(set, {
175
+ componentAliases: files.componentAliases(),
176
+ types: files.formTypes,
177
+ })
161
178
  if (problems.length > 0) {
162
179
  const detail = problems.map((p) => `${p.file}: ${p.message}`).join('; ')
163
180
  logger('schema').error('Not writing {file}: {detail}', {
@@ -182,16 +199,29 @@ export function createSchemaFileWriter(
182
199
 
183
200
  async writeLanguages() {
184
201
  if (!managed() || !files.writable) return
185
- const set = await exportSchemaSet(db, loadSchemaDirectory(files.schemaDir).set.version)
202
+ const set = await exportSchemaSet(
203
+ db,
204
+ loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set.version,
205
+ )
186
206
  writeFileSync(join(files.schemaDir, 'languages.toml'), writeLanguages(set.languages))
187
207
  await settle()
188
208
  },
189
209
 
210
+ async writeUrlOptions(urls) {
211
+ if (!managed() || !files.writable) return
212
+ const { version } = loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes }).set
213
+ writeFileSync(join(files.schemaDir, 'schema.toml'), writeSchemaVersion(version, urls))
214
+ await settle()
215
+ },
216
+
190
217
  async rewriteAll() {
191
218
  if (!managed() || !files.writable) return
192
- const loaded = loadSchemaDirectory(files.schemaDir)
219
+ const loaded = loadSchemaDirectory(files.schemaDir, { formTypes: files.formTypes })
193
220
  const set = await exportSchemaSet(db, loaded.set.version)
194
- const problems = validateSchemaSet(set, { componentAliases: files.componentAliases() })
221
+ const problems = validateSchemaSet(set, {
222
+ componentAliases: files.componentAliases(),
223
+ types: files.formTypes,
224
+ })
195
225
  if (problems.length > 0) {
196
226
  const detail = problems.map((p) => `${p.file}: ${p.message}`).join('; ')
197
227
  logger('schema').error('Not rewriting the schema files: {detail}', { detail })
@@ -18,6 +18,7 @@ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
18
18
  import { dirname, join } from 'node:path'
19
19
  import type { SchemaProposalPort } from '@crombolo/assistant'
20
20
  import type { Db } from '@crombolo/data'
21
+ import type { SyncReport } from '@crombolo/schema'
21
22
  import {
22
23
  fileNameFor,
23
24
  loadSchemaDirectory,
@@ -32,6 +33,7 @@ import {
32
33
  writeSchemaVersion,
33
34
  } from '@crombolo/schema'
34
35
  import type { CromboloConfig } from '../config.ts'
36
+ import { formTypesFor } from '../form-types.ts'
35
37
  import { logger } from '../logging.ts'
36
38
  import { componentAliasesIn, importSchema } from '../schema.ts'
37
39
 
@@ -48,6 +50,8 @@ export interface SchemaProposalOptions {
48
50
  config: CromboloConfig
49
51
  /** The node's identity and state, refreshed in place by an import. */
50
52
  boot: { nodeId: string; nodeState: { version: string; revision: string } }
53
+ /** After an import applies: what a change to how URLs are built needs. */
54
+ afterSync?(report: SyncReport | undefined): Promise<void>
51
55
  }
52
56
 
53
57
  export function createSchemaProposals(options: SchemaProposalOptions): SchemaProposalPort {
@@ -70,12 +74,12 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
70
74
  ? parsed.problems
71
75
  : [{ file, path: '', message: 'the file defines no data type' }]
72
76
  }
73
- const set = loadSchemaDirectory(config.schemaDir).set
77
+ const set = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) }).set
74
78
  const dataTypes = [
75
79
  ...set.dataTypes.filter((existing) => existing.alias !== dataType.alias),
76
80
  dataType,
77
81
  ]
78
- return validateSchemaSet({ ...set, dataTypes })
82
+ return validateSchemaSet({ ...set, dataTypes }, { types: formTypesFor(config) })
79
83
  }
80
84
 
81
85
  const parsed = parseContentType(file, toml)
@@ -85,14 +89,14 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
85
89
  ? parsed.problems
86
90
  : [{ file, path: '', message: 'the file defines no content type' }]
87
91
  }
88
- const set = loadSchemaDirectory(config.schemaDir).set
92
+ const set = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) }).set
89
93
  const contentTypes = [
90
94
  ...set.contentTypes.filter((existing) => existing.alias !== contentType.alias),
91
95
  contentType,
92
96
  ]
93
97
  return validateSchemaSet(
94
98
  { ...set, contentTypes },
95
- { componentAliases: componentAliasesIn(config.componentsDir) },
99
+ { componentAliases: componentAliasesIn(config.componentsDir), types: formTypesFor(config) },
96
100
  )
97
101
  }
98
102
 
@@ -140,9 +144,11 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
140
144
  // import lands at a version the other nodes already have and they never
141
145
  // learn they are behind.
142
146
  const ahead = await importSchema(options.db, config, options.boot, { dryRun: true })
143
- const version = loadSchemaDirectory(config.schemaDir).set.version
147
+ const { version, urls } = loadSchemaDirectory(config.schemaDir, {
148
+ formTypes: formTypesFor(config),
149
+ }).set
144
150
  const next = nextSchemaVersion(version, ahead.classification)
145
- if (next !== version) await writeFile(versionFile, writeSchemaVersion(next), 'utf8')
151
+ if (next !== version) await writeFile(versionFile, writeSchemaVersion(next, urls), 'utf8')
146
152
 
147
153
  const result = await importSchema(options.db, config, options.boot)
148
154
  if (result.report && result.report.action !== 'applied') {
@@ -154,6 +160,7 @@ export function createSchemaProposals(options: SchemaProposalOptions): SchemaPro
154
160
  error: result.report.reason ?? `the import was ${result.report.action}`,
155
161
  }
156
162
  }
163
+ await options.afterSync?.(result.report)
157
164
  log.info('{alias} applied from a proposal: {classification}, schema at {version}', {
158
165
  alias,
159
166
  classification: ahead.classification,
@@ -0,0 +1,76 @@
1
+ /** Webhooks, shaped for the Management API: secrets masked, logs as Umbraco's client reads them. */
2
+ import type { WebhookPort, WebhookPortView } from '@crombolo/api-management'
3
+ import { nameBasedUuid, type WebhookLogEntry } from '@crombolo/data'
4
+ import { envReference } from '../webhook-files.ts'
5
+ import { MASKED, type WebhookService, type WebhookView } from '../webhooks.ts'
6
+
7
+ /** A typed-in secret never leaves the server; an `env:` reference is not a secret. */
8
+ const masked = (headers: Record<string, string>) =>
9
+ Object.fromEntries(
10
+ Object.entries(headers).map(([name, value]) => [name, envReference(value) ? value : MASKED]),
11
+ )
12
+
13
+ function toView(webhook: WebhookView): WebhookPortView {
14
+ return {
15
+ id: webhook.key,
16
+ name: webhook.name,
17
+ description: webhook.description,
18
+ url: webhook.url,
19
+ enabled: webhook.enabled,
20
+ events: webhook.events,
21
+ contentTypeKeys: webhook.contentTypeKeys,
22
+ headers: masked(webhook.headers),
23
+ }
24
+ }
25
+
26
+ /** A bundle's handler has no webhook key, so its log rows take a stable one of their own. */
27
+ const subscriberKey = (subscriber: string) =>
28
+ subscriber.startsWith('bundle:') ? nameBasedUuid(subscriber) : subscriber
29
+
30
+ function toLog(entry: WebhookLogEntry) {
31
+ return {
32
+ key: entry.key,
33
+ webhookKey: subscriberKey(entry.subscriber),
34
+ statusCode: entry.exception
35
+ ? 'Exception'
36
+ : entry.statusCode === null
37
+ ? entry.success
38
+ ? 'Handled'
39
+ : 'Failed'
40
+ : String(entry.statusCode),
41
+ httpStatusCode: entry.statusCode,
42
+ isSuccessStatusCode: entry.success,
43
+ date: entry.date.toISOString(),
44
+ eventAlias: entry.eventAlias,
45
+ url: entry.url,
46
+ retryCount: entry.retryCount,
47
+ requestHeaders: entry.requestHeaders,
48
+ requestBody: entry.requestBody,
49
+ responseHeaders: entry.responseHeaders,
50
+ responseBody: entry.responseBody,
51
+ exceptionOccured: entry.exception,
52
+ }
53
+ }
54
+
55
+ export function createWebhookPort(service: WebhookService): WebhookPort {
56
+ return {
57
+ list: async () => (await service.list()).map(toView),
58
+ async get(id) {
59
+ const webhook = await service.get(id)
60
+ return webhook ? toView(webhook) : undefined
61
+ },
62
+ async create(input) {
63
+ const result = await service.create({ ...input, key: input.id ?? crypto.randomUUID() })
64
+ return result.ok ? { ok: true, id: result.key } : result
65
+ },
66
+ async update(id, input) {
67
+ const result = await service.update(id, { ...input, key: id })
68
+ return result.ok ? { ok: true, id: result.key } : result
69
+ },
70
+ remove: (id) => service.remove(id),
71
+ async logs(id, paging) {
72
+ const result = await service.logs(id?.toLowerCase(), paging)
73
+ return { total: result.total, items: result.items.map(toLog) }
74
+ },
75
+ }
76
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Why a server could not start, said so a person can fix it, and how long to
3
+ * wait before trying again. Pure: `crombolo start` serves the page and does the
4
+ * waiting. Nothing here may carry a secret — the database is named by host and
5
+ * database only, never by its URL.
6
+ */
7
+ import { MigrationStepError } from '@crombolo/data'
8
+ import { DatabaseNotReadyError, UpgradePendingError } from './database.ts'
9
+ import { SchemaBootError } from './schema.ts'
10
+
11
+ export interface BootFailure {
12
+ title: string
13
+ detail: string
14
+ /** The setting or command that fixes it. */
15
+ remedy: string
16
+ }
17
+
18
+ const sqlState = (error: { code?: unknown; errno?: unknown }) =>
19
+ typeof error.errno === 'string' ? error.errno : typeof error.code === 'string' ? error.code : ''
20
+
21
+ export function describeBootFailure(error: unknown, database: string): BootFailure {
22
+ if (error instanceof UpgradePendingError)
23
+ return {
24
+ title: 'An upgrade is waiting',
25
+ detail: `The database at ${database} needs upgrading before this version can use it.`,
26
+ remedy: 'Run `crombolo upgrade check`, then `crombolo upgrade`. This page retries by itself.',
27
+ }
28
+ if (error instanceof DatabaseNotReadyError)
29
+ return {
30
+ title: 'The database has not been prepared yet',
31
+ detail: `The database at ${database} is behind this version, and this server only reads.`,
32
+ remedy: 'Start the server that edits the site first, or run `crombolo upgrade`.',
33
+ }
34
+ if (error instanceof MigrationStepError)
35
+ return {
36
+ title: 'An upgrade step failed',
37
+ detail: error.message,
38
+ remedy: 'Fix the cause named above; the next attempt carries on from that step.',
39
+ }
40
+ if (error instanceof SchemaBootError)
41
+ return {
42
+ title: "The site's content types could not be loaded",
43
+ detail: error.message,
44
+ remedy: 'Correct the files named above in `schema/`, then deploy again.',
45
+ }
46
+ const e = (error ?? {}) as { code?: unknown; errno?: unknown; message?: unknown }
47
+ const state = sqlState(e)
48
+ if (state === 'ERR_POSTGRES_CONNECTION_REFUSED' || state === 'ECONNREFUSED')
49
+ return {
50
+ title: "Can't reach the database",
51
+ detail: `Nothing answered at ${database}.`,
52
+ remedy:
53
+ 'Check the host and port in `CROMBOLO_POSTGRES_URL`, and that the database server is running and reachable from here.',
54
+ }
55
+ if (state === '28P01' || state === '28000')
56
+ return {
57
+ title: 'The database refused the sign-in',
58
+ detail: `${database} did not accept the user name and password it was given.`,
59
+ remedy: 'Check the user name and password in `CROMBOLO_POSTGRES_URL`.',
60
+ }
61
+ if (state === '3D000')
62
+ return {
63
+ title: 'The database does not exist',
64
+ detail: `The server at ${database} has no database by that name.`,
65
+ remedy: 'Create the database, or correct its name in `CROMBOLO_POSTGRES_URL`.',
66
+ }
67
+ if (state === 'SQLITE_CANTOPEN')
68
+ return {
69
+ title: "Can't open the database file",
70
+ detail: `${database} could not be opened or created.`,
71
+ remedy:
72
+ 'Check `CROMBOLO_SQLITE_FILE`, and that its folder exists and this server may write to it.',
73
+ }
74
+ if (/no connection string/i.test(String(e.message ?? '')))
75
+ return {
76
+ title: 'No database is configured',
77
+ detail: 'Postgres was selected, but no connection string was given.',
78
+ remedy: 'Set `CROMBOLO_POSTGRES_URL`, or unset `CROMBOLO_DB` to use SQLite.',
79
+ }
80
+ return {
81
+ title: 'The site could not start',
82
+ detail: 'Something unexpected stopped it. The server log has the details.',
83
+ remedy: 'Read the latest error in the server log.',
84
+ }
85
+ }
86
+
87
+ /** Waits between attempts: doubling from `baseMs`, never longer than `capMs`. */
88
+ export function bootRetryDelays(attempts: number, baseMs = 2_000, capMs = 60_000): number[] {
89
+ return Array.from({ length: Math.max(0, attempts - 1) }, (_, i) =>
90
+ Math.min(capMs, baseMs * 2 ** i),
91
+ )
92
+ }
93
+
94
+ const escapeHtml = (value: string): string =>
95
+ value.replace(
96
+ /[&<>"']/g,
97
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c] as string,
98
+ )
99
+
100
+ const withCode = (value: string) =>
101
+ escapeHtml(value).replace(/`([^`]+)`/g, (_, code: string) => `<code>${code}</code>`)
102
+
103
+ /** The page a starting server serves while it cannot start. */
104
+ export function bootFailurePage(
105
+ failure: BootFailure | undefined,
106
+ progress: { attempt: number; attempts: number; retryInSeconds: number | undefined },
107
+ ): string {
108
+ const title = failure?.title ?? 'The site is starting'
109
+ const next =
110
+ progress.retryInSeconds === undefined
111
+ ? 'It will not try again; the server is stopping.'
112
+ : `Trying again in ${progress.retryInSeconds} seconds (attempt ${progress.attempt} of ${progress.attempts}).`
113
+ return `<!doctype html>
114
+ <html lang="en">
115
+ <head>
116
+ <meta charset="utf-8">
117
+ <meta name="viewport" content="width=device-width, initial-scale=1">
118
+ <meta name="robots" content="noindex">
119
+ <meta http-equiv="refresh" content="10">
120
+ <title>${escapeHtml(title)}</title>
121
+ <style>
122
+ :root { color-scheme: light dark; }
123
+ body { margin: 0; min-height: 100vh; display: grid; place-items: center;
124
+ font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
125
+ background: #f6f5f3; color: #1b1b1b; }
126
+ main { max-width: 34rem; padding: 2.5rem 1rem; }
127
+ h1 { font-size: 1.6rem; margin: 0 0 .75rem; }
128
+ p { margin: 0 0 1rem; white-space: pre-line; }
129
+ code { font-size: .9em; background: #e9e7e2; padding: .1em .35em; border-radius: 3px; }
130
+ .fine { font-size: .85rem; color: #6b6b63; }
131
+ @media (prefers-color-scheme: dark) {
132
+ body { background: #16171a; color: #e8e6e3; }
133
+ code { background: #26282c; }
134
+ .fine { color: #9a9a93; }
135
+ }
136
+ </style>
137
+ </head>
138
+ <body>
139
+ <main>
140
+ <h1>${escapeHtml(title)}</h1>
141
+ ${failure ? `<p>${withCode(failure.detail)}</p>\n <p><strong>To fix it:</strong> ${withCode(failure.remedy)}</p>` : '<p>This takes a few seconds.</p>'}
142
+ <p class="fine">${escapeHtml(next)}</p>
143
+ </main>
144
+ </body>
145
+ </html>
146
+ `
147
+ }