@crombolo/server 0.10.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.
Files changed (44) hide show
  1. package/package.json +12 -11
  2. package/src/adapters/audit.ts +186 -0
  3. package/src/adapters/content.ts +2 -0
  4. package/src/adapters/documents.ts +28 -25
  5. package/src/adapters/elements.ts +3 -0
  6. package/src/adapters/health.ts +333 -0
  7. package/src/adapters/media.ts +3 -0
  8. package/src/adapters/members.ts +10 -2
  9. package/src/adapters/references.ts +13 -0
  10. package/src/adapters/relations.ts +48 -0
  11. package/src/adapters/schema-files.ts +41 -11
  12. package/src/adapters/schema-proposals.ts +13 -6
  13. package/src/adapters/search-index.ts +31 -0
  14. package/src/adapters/webhooks.ts +76 -0
  15. package/src/boot-failure.ts +147 -0
  16. package/src/bundle-install.ts +148 -13
  17. package/src/bundles.ts +14 -0
  18. package/src/busy.ts +13 -0
  19. package/src/config.ts +46 -2
  20. package/src/database.ts +9 -7
  21. package/src/delivery-members.ts +387 -0
  22. package/src/editor-types.ts +7 -2
  23. package/src/extensions-notice.ts +3 -2
  24. package/src/form-files.ts +38 -3
  25. package/src/form-submit.ts +62 -5
  26. package/src/form-types.ts +187 -0
  27. package/src/form-workflows.ts +66 -6
  28. package/src/forms.ts +15 -7
  29. package/src/index.ts +12 -0
  30. package/src/jobs.ts +91 -10
  31. package/src/media-files.ts +3 -1
  32. package/src/member-auth.ts +17 -1
  33. package/src/models-builder.ts +12 -8
  34. package/src/ports.ts +57 -2
  35. package/src/schema.ts +58 -11
  36. package/src/secrets.ts +36 -0
  37. package/src/server.ts +388 -29
  38. package/src/setup.ts +288 -0
  39. package/src/status.ts +5 -2
  40. package/src/transfer-dashboard.ts +219 -0
  41. package/src/upgrade-summary.ts +43 -0
  42. package/src/url-settings.ts +91 -0
  43. package/src/webhook-files.ts +138 -0
  44. package/src/webhooks.ts +544 -0
@@ -0,0 +1,333 @@
1
+ /**
2
+ * The diagnostics port: health checks for what actually goes wrong on a site,
3
+ * the troubleshooting report and the upgrade check. docs/diagnostics.md.
4
+ */
5
+ import type { DiagnosticsPort, HealthCheckGroup, HealthCheckResult } from '@crombolo/api-management'
6
+ import {
7
+ compareStates,
8
+ cromboloPlan,
9
+ currentSchemaState,
10
+ type Db,
11
+ liveNodes,
12
+ type NodeSchemaState,
13
+ pendingMigrations,
14
+ } from '@crombolo/data'
15
+ import { BundleConfigurationError, bundleChainsFor, pendingBundleSteps } from '../bundle-tables.ts'
16
+ import { type CromboloConfig, describeDatabase, VERSION } from '../config.ts'
17
+ import { type EmailPort, emailAvailability } from '../email.ts'
18
+ import type { MediaStore } from '../media-store.ts'
19
+ import { backofficeVersion } from '../status.ts'
20
+
21
+ export const UPGRADE_PACKAGE = '@crombolo/crombolo'
22
+
23
+ /** How recently a node must have polled to count as part of the cluster. */
24
+ const LIVE_WITHIN_MS = 60_000
25
+
26
+ const CHECKS = {
27
+ database: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000001', name: 'Database connection' },
28
+ migrations: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000002', name: 'Database upgrades' },
29
+ media: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000003', name: 'Media storage' },
30
+ email: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000004', name: 'E-mail delivery' },
31
+ nodes: { id: '6f1d4a8e-0047-4c1a-9e7d-000000000005', name: 'Servers' },
32
+ } as const
33
+
34
+ const GROUPS: HealthCheckGroup[] = [
35
+ {
36
+ name: 'Database',
37
+ checks: [
38
+ { ...CHECKS.database, description: 'Whether the database answers, and which one it is.' },
39
+ {
40
+ ...CHECKS.migrations,
41
+ description: 'Whether the database is up to date with this release and its schema files.',
42
+ },
43
+ ],
44
+ },
45
+ {
46
+ name: 'Media',
47
+ checks: [{ ...CHECKS.media, description: 'Whether uploaded files can be written and read.' }],
48
+ },
49
+ {
50
+ name: 'E-mail',
51
+ checks: [{ ...CHECKS.email, description: 'Whether this site can send e-mail.' }],
52
+ },
53
+ {
54
+ name: 'Servers',
55
+ checks: [
56
+ {
57
+ ...CHECKS.nodes,
58
+ description: 'The servers running this site, and whether they run the same release.',
59
+ },
60
+ ],
61
+ },
62
+ ]
63
+
64
+ export interface HealthCheckOptions {
65
+ config: CromboloConfig
66
+ db: Db
67
+ media: MediaStore
68
+ email?: EmailPort
69
+ /** What this node deploys at; read from the database's own state when omitted. */
70
+ nodeState?: NodeSchemaState
71
+ fetch?: typeof fetch
72
+ now?: () => Date
73
+ }
74
+
75
+ const result = (
76
+ resultType: HealthCheckResult['resultType'],
77
+ message: string,
78
+ ): HealthCheckResult => ({ resultType, message })
79
+
80
+ const failure = (error: unknown) => (error instanceof Error ? error.message : String(error))
81
+
82
+ /** `0.13.0` against `0.12.0`: which part moved, or None when the first is not newer. */
83
+ export function upgradeType(latest: string, current: string): 'None' | 'Patch' | 'Minor' | 'Major' {
84
+ const parts = (value: string) =>
85
+ (value.split('-')[0] ?? '').split('.').map((part) => Number(part) || 0)
86
+ const [a, b] = [parts(latest), parts(current)]
87
+ for (const [index, type] of (['Major', 'Minor', 'Patch'] as const).entries()) {
88
+ const difference = (a[index] ?? 0) - (b[index] ?? 0)
89
+ if (difference > 0) return type
90
+ if (difference < 0) return 'None'
91
+ }
92
+ return 'None'
93
+ }
94
+
95
+ function ago(then: Date, now: Date): string {
96
+ const seconds = Math.max(0, Math.round((now.getTime() - then.getTime()) / 1000))
97
+ return seconds < 2 ? 'just now' : `${seconds} seconds ago`
98
+ }
99
+
100
+ export function createDiagnosticsPort(options: HealthCheckOptions): DiagnosticsPort {
101
+ const { config, db, media } = options
102
+ const now = options.now ?? (() => new Date())
103
+ const call = options.fetch ?? fetch
104
+ let checked:
105
+ | { at: number; answer: Awaited<ReturnType<DiagnosticsPort['upgradeCheck']>> }
106
+ | undefined
107
+
108
+ async function database(): Promise<HealthCheckResult[]> {
109
+ const dialect = db.dialect.name === 'postgres' ? 'PostgreSQL' : 'SQLite'
110
+ try {
111
+ await db.query('SELECT 1 AS ok')
112
+ return [result('Success', `${dialect} is answering (${describeDatabase(config)}).`)]
113
+ } catch (error) {
114
+ return [result('Error', `${dialect} is not answering: ${failure(error)}`)]
115
+ }
116
+ }
117
+
118
+ async function migrations(): Promise<HealthCheckResult[]> {
119
+ const results: HealthCheckResult[] = []
120
+ try {
121
+ const pending = await pendingMigrations(db, cromboloPlan)
122
+ results.push(
123
+ pending.length === 0
124
+ ? result('Success', `The database is up to date with Crombolo ${VERSION}.`)
125
+ : result(
126
+ 'Error',
127
+ `${pending.length} database upgrade(s) for Crombolo ${VERSION} have not run yet: ${pending.map((m) => m.name).join(', ')}. Run \`crombolo upgrade\`.`,
128
+ ),
129
+ )
130
+ for (const bundle of await pendingBundleSteps(db, bundleChainsFor(config)))
131
+ if (bundle.pending.length > 0)
132
+ results.push(
133
+ result(
134
+ 'Error',
135
+ `The bundle "${bundle.name}" has ${bundle.pending.length} upgrade step(s) that have not run yet. Run \`crombolo upgrade\`.`,
136
+ ),
137
+ )
138
+ if (options.nodeState) {
139
+ const state = await currentSchemaState(db)
140
+ const difference = compareStates(options.nodeState, state)
141
+ if (difference < 0)
142
+ results.push(
143
+ result(
144
+ 'Warning',
145
+ `The database's schema is ${state.version} (revision ${state.revision}), newer than this server's ${options.nodeState.version} (revision ${options.nodeState.revision}): this server reads but refuses changes until it is replaced.`,
146
+ ),
147
+ )
148
+ else if (difference > 0)
149
+ results.push(
150
+ result(
151
+ 'Warning',
152
+ `This server's schema files are ${options.nodeState.version} (revision ${options.nodeState.revision}), ahead of the database's ${state.version} (revision ${state.revision}).`,
153
+ ),
154
+ )
155
+ }
156
+ } catch (error) {
157
+ results.push(
158
+ result(
159
+ 'Error',
160
+ error instanceof BundleConfigurationError
161
+ ? error.problems.join(' ')
162
+ : `The upgrade state could not be read: ${failure(error)}`,
163
+ ),
164
+ )
165
+ }
166
+ return results
167
+ }
168
+
169
+ async function mediaStorage(): Promise<HealthCheckResult[]> {
170
+ const key = `health-check/${crypto.randomUUID()}.txt`
171
+ const written = new TextEncoder().encode('ok')
172
+ try {
173
+ await media.put(key, written, { contentType: 'text/plain' })
174
+ const read = await media.get(key)
175
+ const bytes = read ? await read.bytes() : undefined
176
+ await media.delete(key)
177
+ if (!bytes || new TextDecoder().decode(bytes) !== 'ok')
178
+ return [result('Error', `A file written to ${media.description} could not be read back.`)]
179
+ return [result('Success', `Files can be written to and read from ${media.description}.`)]
180
+ } catch (error) {
181
+ return [result('Error', `${media.description} could not be used: ${failure(error)}`)]
182
+ }
183
+ }
184
+
185
+ function email(): HealthCheckResult[] {
186
+ const availability = emailAvailability(options.email)
187
+ if (availability.available)
188
+ return [result('Success', `E-mail is sent through ${availability.description}.`)]
189
+ const affects =
190
+ availability.affects.length > 0
191
+ ? ` Unavailable until it is: ${availability.affects.join(', ')}.`
192
+ : ''
193
+ return [
194
+ result(
195
+ availability.provider === 'log' ? 'Info' : 'Warning',
196
+ `${availability.reason}${affects}`,
197
+ ),
198
+ ]
199
+ }
200
+
201
+ async function nodes(): Promise<HealthCheckResult[]> {
202
+ const live = await liveNodes(db, { seenWithinMs: LIVE_WITHIN_MS })
203
+ if (live.length === 0) return [result('Warning', 'No server has reported in the last minute.')]
204
+ const at = now()
205
+ const results = live.map((node) =>
206
+ result(
207
+ 'Info',
208
+ `${node.nodeId}${node.role ? ` (${node.role})` : ''} runs Crombolo ${node.cromboloVersion ?? 'of an unknown release'} with schema ${node.version ?? '?'} revision ${node.revision ?? '?'}, last seen ${ago(node.lastSeen, at)}.`,
209
+ ),
210
+ )
211
+ const releases = new Set(live.map((node) => node.cromboloVersion ?? 'unknown'))
212
+ const schemas = new Set(live.map((node) => `${node.version}/${node.revision}`))
213
+ if (releases.size > 1)
214
+ results.unshift(
215
+ result(
216
+ 'Warning',
217
+ `The servers run different releases (${[...releases].join(', ')}). That is expected only while an upgrade rolls out.`,
218
+ ),
219
+ )
220
+ else if (schemas.size > 1)
221
+ results.unshift(
222
+ result(
223
+ 'Warning',
224
+ 'The servers deploy different schema files. That is expected only while a deploy rolls out.',
225
+ ),
226
+ )
227
+ else
228
+ results.unshift(
229
+ result('Success', `${live.length} server(s) running Crombolo ${[...releases][0]}.`),
230
+ )
231
+ return results
232
+ }
233
+
234
+ const runners: Record<string, () => HealthCheckResult[] | Promise<HealthCheckResult[]>> = {
235
+ [CHECKS.database.id]: database,
236
+ [CHECKS.migrations.id]: migrations,
237
+ [CHECKS.media.id]: mediaStorage,
238
+ [CHECKS.email.id]: email,
239
+ [CHECKS.nodes.id]: nodes,
240
+ }
241
+
242
+ return {
243
+ groups: () => GROUPS,
244
+
245
+ async run(name) {
246
+ const group = GROUPS.find((candidate) => candidate.name === name)
247
+ if (!group) return undefined
248
+ return Promise.all(
249
+ group.checks.map(async (check) => {
250
+ try {
251
+ return {
252
+ id: check.id,
253
+ results: await (runners[check.id] as () => Promise<HealthCheckResult[]>)(),
254
+ }
255
+ } catch (error) {
256
+ return {
257
+ id: check.id,
258
+ results: [result('Error', `The check failed: ${failure(error)}`)],
259
+ }
260
+ }
261
+ }),
262
+ )
263
+ },
264
+
265
+ async troubleshooting() {
266
+ const availability = emailAvailability(options.email)
267
+ const state = options.nodeState
268
+ const items: Array<[string, string | undefined]> = [
269
+ ['Crombolo version', VERSION],
270
+ ['Backoffice version', backofficeVersion()],
271
+ ['Runtime', `Bun ${Bun.version}`],
272
+ ['Operating system', `${process.platform} ${process.arch}`],
273
+ ['Mode', config.development ? 'Development' : 'Production'],
274
+ ['Server', `${config.nodeId} (${config.role})`],
275
+ ['Database', db.dialect.name === 'postgres' ? 'PostgreSQL' : 'SQLite'],
276
+ ['Database location', describeDatabase(config)],
277
+ ['Schema', state ? `${state.version} revision ${state.revision}` : undefined],
278
+ ['Upgrade policy', config.upgradePolicy],
279
+ ['Media storage', media.description],
280
+ [
281
+ 'E-mail',
282
+ availability.available ? (availability.description ?? undefined) : 'Not configured',
283
+ ],
284
+ ['Log level', config.logLevel],
285
+ ['Time zone', Intl.DateTimeFormat().resolvedOptions().timeZone],
286
+ ['Server time', now().toISOString()],
287
+ ]
288
+ return {
289
+ items: items
290
+ .filter((item): item is [string, string] => item[1] !== undefined)
291
+ .map(([name, data]) => ({ name, data })),
292
+ }
293
+ },
294
+
295
+ async upgradeCheck() {
296
+ if (config.versionCheckPeriod <= 0)
297
+ return { type: 'None', comment: 'Checking for new releases is turned off.', url: '' }
298
+ const periodMs = config.versionCheckPeriod * 86_400_000
299
+ if (checked && now().getTime() - checked.at < periodMs) return checked.answer
300
+ let answer: Awaited<ReturnType<DiagnosticsPort['upgradeCheck']>>
301
+ try {
302
+ const response = await call(
303
+ `${config.npmRegistry.replace(/\/$/, '')}/${encodeURIComponent(UPGRADE_PACKAGE)}`,
304
+ { headers: { accept: 'application/json' } },
305
+ )
306
+ if (!response.ok) throw new Error(`the registry answered ${response.status}`)
307
+ const latest = ((await response.json()) as { 'dist-tags'?: { latest?: string } })[
308
+ 'dist-tags'
309
+ ]?.latest
310
+ if (!latest) throw new Error('the registry named no latest release')
311
+ const type = upgradeType(latest, VERSION)
312
+ answer =
313
+ type === 'None'
314
+ ? { type, comment: `Crombolo ${VERSION} is the latest release.`, url: '' }
315
+ : {
316
+ type,
317
+ comment: `Crombolo ${latest} is available; this site runs ${VERSION}.`,
318
+ url: `https://www.npmjs.com/package/${UPGRADE_PACKAGE}/v/${latest}`,
319
+ }
320
+ } catch (error) {
321
+ // Not cached: an air-gapped site asks again next time rather than
322
+ // being told nothing for a week.
323
+ return {
324
+ type: 'None',
325
+ comment: `The package registry could not be reached to check for a newer release (${failure(error)}).`,
326
+ url: '',
327
+ }
328
+ }
329
+ checked = { at: now().getTime(), answer }
330
+ return answer
331
+ },
332
+ }
333
+ }
@@ -19,6 +19,7 @@ import {
19
19
  DataTypeRepository,
20
20
  type Db,
21
21
  DocumentRepository,
22
+ InvalidParentError,
22
23
  type NodeRow,
23
24
  type NodeSchemaState,
24
25
  type ValueIntake,
@@ -65,6 +66,8 @@ export function createMediaPort(
65
66
  const failure = (error: unknown) => {
66
67
  if (error instanceof WriteRejectedError)
67
68
  return { ok: false as const, reason: error.message, status: 409 }
69
+ if (error instanceof InvalidParentError)
70
+ return { ok: false as const, reason: error.message, status: 400 }
68
71
  if (error instanceof TemporaryFileMissingError)
69
72
  return { ok: false as const, reason: error.message, status: 400 }
70
73
  throw error
@@ -45,6 +45,11 @@ export interface MemberPortOptions extends DocumentRepositoryOptions {
45
45
  valueIntake?: ValueIntake
46
46
  /** How many failed sign-ins lock an account out; Umbraco's default is 5. */
47
47
  maxFailedPasswordAttempts?: number
48
+ /**
49
+ * The members the Delivery API's client credentials sign in as: Umbraco shows
50
+ * them as kind `Api`, derived from config rather than stored.
51
+ */
52
+ apiMemberUserNames?: readonly string[]
48
53
  }
49
54
 
50
55
  const notFound = () => ({
@@ -54,6 +59,9 @@ const notFound = () => ({
54
59
  })
55
60
 
56
61
  export function createMemberPort(db: Db, options: MemberPortOptions = {}): MemberPort {
62
+ const apiMembers = new Set((options.apiMemberUserNames ?? []).map((n) => n.toLowerCase()))
63
+ const kindOf = (userName: string): 'Api' | 'Default' =>
64
+ apiMembers.has(userName.toLowerCase()) ? 'Api' : 'Default'
57
65
  const members = new MemberRepository(db, options)
58
66
  const memberTypes = new ContentTypeRepository(db, { kind: 'member' })
59
67
 
@@ -110,7 +118,7 @@ export function createMemberPort(db: Db, options: MemberPortOptions = {}): Membe
110
118
  lastLockoutDate: member.lastLockoutDate?.toISOString() ?? null,
111
119
  lastPasswordChangeDate: member.lastPasswordChangeDate?.toISOString() ?? null,
112
120
  groups: await members.groupKeys(member.key),
113
- kind: 'Default',
121
+ kind: kindOf(member.username),
114
122
  profileData: null,
115
123
  flags: [],
116
124
  values: content.values
@@ -136,7 +144,7 @@ export function createMemberPort(db: Db, options: MemberPortOptions = {}): Membe
136
144
  id: member.key,
137
145
  memberType: { id: member.contentTypeKey, icon: member.contentTypeIcon ?? 'icon-user' },
138
146
  variants: [{ name: member.name, culture: null }],
139
- kind: 'Default',
147
+ kind: kindOf(member.username),
140
148
  flags: [],
141
149
  })
142
150
 
@@ -50,7 +50,20 @@ function toReference(node: ReferencingNode): unknown {
50
50
 
51
51
  export function createReferencePort(db: Db): ReferencePort {
52
52
  const references = new ReferenceRepository(db)
53
+ const binTypes = {
54
+ Document: ObjectTypes.Document,
55
+ Media: ObjectTypes.Media,
56
+ Element: ObjectTypes.Element,
57
+ } as const
53
58
  return {
59
+ async recycleBinReferencedBy(area, paging) {
60
+ const result = await references.referencingRecycleBin(
61
+ binTypes[area],
62
+ paging.skip,
63
+ paging.take,
64
+ )
65
+ return { total: result.total, items: result.items.map(toReference) }
66
+ },
54
67
  async referencedBy(key, paging) {
55
68
  const result = await references.referencedBy(key, paging.skip, paging.take)
56
69
  return { total: result.total, items: result.items.map(toReference) }
@@ -0,0 +1,48 @@
1
+ /** Relation types and their derived relations, shaped for the wire. */
2
+ import type { RelationPort, RelationTypeView } from '@crombolo/api-management'
3
+ import { ObjectTypes } from '@crombolo/core'
4
+ import { type Db, RELATION_TYPES, RelationRepository, relationTypeByKey } from '@crombolo/data'
5
+
6
+ /** Umbraco's friendly names for the object types a relation type joins. */
7
+ const OBJECT_TYPE_NAMES: Record<string, string> = {
8
+ [ObjectTypes.Document]: 'Document',
9
+ [ObjectTypes.Media]: 'Media',
10
+ [ObjectTypes.Member]: 'Member',
11
+ [ObjectTypes.Element]: 'Element',
12
+ [ObjectTypes.ElementContainer]: 'Element Container',
13
+ }
14
+
15
+ const objectType = (id: string | null) => (id ? { id, name: OBJECT_TYPE_NAMES[id] ?? id } : null)
16
+
17
+ const VIEWS: readonly RelationTypeView[] = RELATION_TYPES.map((type) => ({
18
+ id: type.key,
19
+ alias: type.alias,
20
+ name: type.name,
21
+ parentObject: objectType(type.parentObjectType),
22
+ childObject: objectType(type.childObjectType),
23
+ isBidirectional: type.isBidirectional,
24
+ isDependency: type.isDependency,
25
+ }))
26
+
27
+ export function createRelationPort(db: Db): RelationPort {
28
+ const relations = new RelationRepository(db)
29
+ return {
30
+ types: () => VIEWS,
31
+ async relations(typeKey, paging) {
32
+ const type = relationTypeByKey(typeKey)
33
+ if (!type) return undefined
34
+ const result = await relations.relations(type, paging.skip, paging.take)
35
+ return {
36
+ total: result.total,
37
+ items: result.items.map((relation) => ({
38
+ id: relation.key,
39
+ relationType: { id: relation.typeKey },
40
+ parent: { id: relation.parent.key, name: relation.parent.name },
41
+ child: { id: relation.child.key, name: relation.child.name },
42
+ createDate: relation.createDate.toISOString(),
43
+ comment: null,
44
+ })),
45
+ }
46
+ },
47
+ }
48
+ }
@@ -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,