@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
package/src/jobs.ts CHANGED
@@ -1,16 +1,25 @@
1
1
  /**
2
2
  * Background jobs, as Umbraco runs them: scheduled publishing every minute and
3
- * version cleanup every hour when it is enabled. Each run can also be called directly, which is
3
+ * version cleanup every hour when it is enabled, plus the search indexer
4
+ * draining its queue and the audit log ageing out old entries hourly. Each run can also be called directly, which is
4
5
  * how tests drive them. On a multi-node Postgres site every node runs them;
5
- * each schedule is claimed atomically, so it runs once.
6
+ * each schedule and queued item is claimed atomically, so it runs once.
6
7
  */
7
8
 
8
9
  import type { NodeSchemaState } from '@crombolo/data'
9
- import { type Db, DocumentRepository, PublishBlockedError } from '@crombolo/data'
10
+ import {
11
+ AuditLog,
12
+ type Db,
13
+ DocumentRepository,
14
+ PublishBlockedError,
15
+ SearchIndex,
16
+ } from '@crombolo/data'
10
17
  import type { PublishedCache } from '@crombolo/render'
18
+ import { auditRetainedSince, recordAudit } from './adapters/audit.ts'
11
19
  import type { WorkflowRunner, WorkflowRunReport } from './form-workflows.ts'
12
20
  import { logger } from './logging.ts'
13
21
  import type { MediaFileStore } from './media-files.ts'
22
+ import type { WebhookService } from './webhooks.ts'
14
23
 
15
24
  export interface JobsOptions {
16
25
  db: Db
@@ -33,6 +42,13 @@ export interface JobsOptions {
33
42
  */
34
43
  formWorkflows?: WorkflowRunner
35
44
  formWorkflowsMs?: number
45
+ /** Raises the publishes scheduled publishing makes, and drains the delivery queue. */
46
+ webhooks?: Pick<WebhookService, 'raise' | 'runDue'>
47
+ webhooksMs?: number
48
+ /** How often the search indexer looks for queued items; a write here also starts it at once. */
49
+ searchIndexingMs?: number
50
+ /** Days the audit log keeps an entry; 0 or absent keeps them forever. */
51
+ auditRetentionDays?: number
36
52
  }
37
53
 
38
54
  export interface ScheduledRun {
@@ -45,6 +61,14 @@ export interface BackgroundJobs {
45
61
  runScheduledPublishing(now?: Date): Promise<ScheduledRun>
46
62
  /** Runs the form workflows that are due; a no-op when none are configured. */
47
63
  runFormWorkflows(now?: Date): Promise<WorkflowRunReport>
64
+ /** Delivers the webhook and bundle events that are due. */
65
+ runWebhooks(now?: Date): Promise<WorkflowRunReport>
66
+ /** Indexes every queued item, and returns how many. docs/search.md. */
67
+ runSearchIndexing(): Promise<number>
68
+ /** Something was queued here: index it shortly, rather than at the next tick. */
69
+ kickSearchIndexing(): void
70
+ /** Deletes audit entries older than the retention period, and returns how many. */
71
+ runAuditRetention(now?: Date): Promise<number>
48
72
  /** Runs whether or not the hourly job is enabled; the policy decides what goes. */
49
73
  runVersionCleanup(now?: Date): Promise<{ nodes: number; versionsDeleted: number }>
50
74
  start(): void
@@ -60,6 +84,9 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
60
84
  })
61
85
  const timers: Array<ReturnType<typeof setInterval>> = []
62
86
  let running = false
87
+ const search = new SearchIndex(options.db)
88
+ let indexing: Promise<number> | undefined
89
+ let kicked: ReturnType<typeof setTimeout> | undefined
63
90
 
64
91
  async function runScheduledPublishing(now = new Date()): Promise<ScheduledRun> {
65
92
  const run: ScheduledRun = { published: [], unpublished: [], failed: [] }
@@ -67,13 +94,20 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
67
94
  if (!(await documents.claimSchedule(due.id))) continue
68
95
  const cultures = due.culture ? [due.culture] : null
69
96
  try {
70
- if (due.action === 'Release') {
71
- await documents.publish(due.key, cultures)
72
- run.published.push(due.key)
73
- } else {
74
- await documents.unpublish(due.key, cultures)
75
- run.unpublished.push(due.key)
76
- }
97
+ const release = due.action === 'Release'
98
+ const changed = release
99
+ ? await documents.publish(due.key, cultures)
100
+ : await documents.unpublish(due.key, cultures)
101
+ ;(release ? run.published : run.unpublished).push(due.key)
102
+ await recordAudit(options.db, {
103
+ category: 'content',
104
+ action: release ? 'Publish' : 'Unpublish',
105
+ source: 'job',
106
+ entityType: 'Document',
107
+ entityKey: due.key,
108
+ entityName: changed?.variants[0]?.name ?? null,
109
+ detail: `Scheduled${due.culture ? ` (${due.culture})` : ''}`,
110
+ })
77
111
  options.onDocumentChanged?.(due.key)
78
112
  } catch (error) {
79
113
  if (!(error instanceof PublishBlockedError)) throw error
@@ -81,13 +115,26 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
81
115
  }
82
116
  }
83
117
  if (run.published.length + run.unpublished.length > 0) options.cache.invalidate()
118
+ for (const key of run.published)
119
+ await options.webhooks?.raise({ alias: 'Umbraco.ContentPublish', key })
120
+ for (const key of run.unpublished)
121
+ await options.webhooks?.raise({ alias: 'Umbraco.ContentUnpublish', key })
84
122
  return run
85
123
  }
86
124
 
125
+ async function runAuditRetention(now = new Date()): Promise<number> {
126
+ const before = auditRetainedSince(options.auditRetentionDays ?? 0, now)
127
+ return before ? new AuditLog(options.db).prune(before) : 0
128
+ }
129
+
87
130
  async function runVersionCleanup(now = new Date()) {
88
131
  return documents.cleanupVersions(now, options.versionCleanup)
89
132
  }
90
133
 
134
+ async function runWebhooks(now = new Date()): Promise<WorkflowRunReport> {
135
+ return (await options.webhooks?.runDue(now)) ?? { claimed: 0, done: 0, retrying: 0, failed: 0 }
136
+ }
137
+
91
138
  async function runFormWorkflows(now = new Date()): Promise<WorkflowRunReport> {
92
139
  return (
93
140
  (await options.formWorkflows?.runDue(now)) ?? {
@@ -99,6 +146,20 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
99
146
  )
100
147
  }
101
148
 
149
+ // Its own guard rather than `guarded`: indexing a big rebuild must not hold up
150
+ // a scheduled publish, and a kick while it runs joins the run in progress.
151
+ function runSearchIndexing(): Promise<number> {
152
+ indexing ??= search.indexAll().finally(() => {
153
+ indexing = undefined
154
+ })
155
+ return indexing
156
+ }
157
+
158
+ const indexQuietly = () =>
159
+ runSearchIndexing().catch((error: unknown) => {
160
+ logger('jobs').error('Search indexing failed', { error })
161
+ })
162
+
102
163
  const guarded = (job: () => Promise<unknown>) => async () => {
103
164
  if (running) return
104
165
  running = true
@@ -120,18 +181,38 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
120
181
  return {
121
182
  runScheduledPublishing,
122
183
  runVersionCleanup,
184
+ runAuditRetention,
123
185
  runFormWorkflows,
186
+ runWebhooks,
187
+ runSearchIndexing,
188
+ kickSearchIndexing() {
189
+ if (timers.length === 0 || kicked) return
190
+ kicked = setTimeout(() => {
191
+ kicked = undefined
192
+ void indexQuietly()
193
+ }, 250)
194
+ kicked.unref()
195
+ },
124
196
  start() {
125
197
  every(options.scheduledPublishingMs ?? 60_000, runScheduledPublishing)
198
+ // The queue is shared, so this also picks up what other nodes wrote.
199
+ const searchTimer = setInterval(indexQuietly, options.searchIndexingMs ?? 5_000)
200
+ searchTimer.unref()
201
+ timers.push(searchTimer)
126
202
  // More often than the other jobs: somebody waiting on a form notification
127
203
  // notices a minute, and a queue row is cheap to look for.
128
204
  if (options.formWorkflows) every(options.formWorkflowsMs ?? 15_000, runFormWorkflows)
205
+ // As often, for the same reason: a receiver that is waiting notices a minute.
206
+ if (options.webhooks) every(options.webhooksMs ?? 15_000, runWebhooks)
129
207
  if (options.versionCleanup.enabled) every(60 * 60_000, runVersionCleanup)
208
+ if (options.auditRetentionDays) every(60 * 60_000, runAuditRetention)
130
209
  const files = options.mediaFiles
131
210
  if (files) every(60 * 60_000, () => files.cleanupExpired())
132
211
  },
133
212
  stop() {
134
213
  for (const timer of timers.splice(0)) clearInterval(timer)
214
+ if (kicked) clearTimeout(kicked)
215
+ kicked = undefined
135
216
  },
136
217
  started() {
137
218
  return timers.length > 0
@@ -262,12 +262,14 @@ export async function readBlobs(
262
262
  export async function placeBlobs(
263
263
  store: MediaStore,
264
264
  blobs: ReadonlyMap<string, string>,
265
+ /** Turns a carried file's bytes into the media file's: opens a sealed one. */
266
+ open: (key: string, bytes: Uint8Array) => Uint8Array = (_key, bytes) => bytes,
265
267
  ): Promise<{ placed: number; bytes: number }> {
266
268
  let placed = 0
267
269
  let bytes = 0
268
270
  for (const [key, path] of blobs) {
269
271
  const file = Bun.file(path)
270
- const content = new Uint8Array(await file.arrayBuffer())
272
+ const content = open(key, new Uint8Array(await file.arrayBuffer()))
271
273
  await store.put(key, content, { contentType: contentTypeFor(key) })
272
274
  placed += 1
273
275
  bytes += content.length
@@ -22,6 +22,7 @@
22
22
  import {
23
23
  expireCookie,
24
24
  hashPassword,
25
+ isNativeHash,
25
26
  memberCookieName,
26
27
  passwordConfigJson,
27
28
  readCookie,
@@ -69,6 +70,8 @@ export interface MemberAuth {
69
70
  decide(content: PublishedContent, member: RequestMember | undefined): Promise<AccessDecision>
70
71
  /** Handles the member endpoints, or undefined when the path is not one. */
71
72
  handle(request: Request, pathname: string): Promise<Response | undefined>
73
+ /** The `Set-Cookie` value that ends a member's site session. */
74
+ signOutCookie(): string
72
75
  }
73
76
 
74
77
  const isFormPost = (request: Request) =>
@@ -249,10 +252,22 @@ export function createMemberAuth(
249
252
  await verifyPassword(input.password ?? '', await hashPassword(crypto.randomUUID()))
250
253
  return failed()
251
254
  }
252
- if (!(await verifyPassword(input.password ?? '', member.passwordHash))) {
255
+ if (
256
+ !(await verifyPassword(input.password ?? '', member.passwordHash, {
257
+ passwordConfig: member.passwordConfig,
258
+ forMember: true,
259
+ }))
260
+ ) {
253
261
  await members.recordFailedLogin(member.key, config.maxFailedPasswordAttempts)
254
262
  return failed()
255
263
  }
264
+ // An imported Umbraco hash is replaced now the password is known to match it.
265
+ if (!isNativeHash(member.passwordHash))
266
+ await members.rehashPassword(
267
+ member.key,
268
+ await hashPassword(input.password ?? ''),
269
+ passwordConfigJson(),
270
+ )
256
271
  await members.recordLogin(member.key)
257
272
  return answer(
258
273
  request,
@@ -359,6 +374,7 @@ export function createMemberAuth(
359
374
  return {
360
375
  resolve,
361
376
  decide,
377
+ signOutCookie: () => expireCookie(cookie, config.secureCookies),
362
378
  async handle(request, pathname) {
363
379
  if (!pathname.startsWith(`${MEMBER_ROUTE_PREFIX}/`)) return undefined
364
380
  const action = pathname.slice(MEMBER_ROUTE_PREFIX.length + 1)
@@ -7,6 +7,7 @@
7
7
  * "out of date" is answered by generating the types and comparing them with
8
8
  * what is on disk, which cannot drift from the question being asked.
9
9
  */
10
+
10
11
  import { existsSync } from 'node:fs'
11
12
  import { readFile, writeFile } from 'node:fs/promises'
12
13
  import { join, relative } from 'node:path'
@@ -15,9 +16,11 @@ import type {
15
16
  ModelsBuilderPort,
16
17
  OutOfDateStatus,
17
18
  } from '@crombolo/api-management'
19
+ import type { FormTypes } from '@crombolo/core'
18
20
  import { generateTypes, loadSchemaDirectory, validateSchemaSet } from '@crombolo/schema'
19
21
  import type { CromboloConfig } from './config.ts'
20
22
  import { VERSION } from './config.ts'
23
+ import { formTypesFor } from './form-types.ts'
21
24
  import { logger } from './logging.ts'
22
25
 
23
26
  /** The file `crombolo generate` writes, inside the schema directory. */
@@ -26,10 +29,10 @@ export const GENERATED_FILE = 'content-types.d.ts'
26
29
  export type Intended = { types: string } | { problems: string[] }
27
30
 
28
31
  /** What `crombolo generate` would write now, or the problems stopping it. */
29
- export function intendedTypes(schemaDir: string): Intended {
32
+ export function intendedTypes(schemaDir: string, formTypes?: FormTypes): Intended {
30
33
  if (!existsSync(schemaDir)) return { problems: [`No schema directory at ${schemaDir}`] }
31
- const loaded = loadSchemaDirectory(schemaDir)
32
- const problems = [...loaded.problems, ...validateSchemaSet(loaded.set)]
34
+ const loaded = loadSchemaDirectory(schemaDir, { formTypes })
35
+ const problems = [...loaded.problems, ...validateSchemaSet(loaded.set, { types: formTypes })]
33
36
  if (problems.length > 0)
34
37
  return {
35
38
  problems: problems.map((p) => `${p.file}${p.path ? ` (${p.path})` : ''}: ${p.message}`),
@@ -40,8 +43,9 @@ export function intendedTypes(schemaDir: string): Intended {
40
43
  /** Undefined when the schema does not generate, so staleness is unknowable. */
41
44
  export async function compareModels(
42
45
  schemaDir: string,
46
+ formTypes?: FormTypes,
43
47
  ): Promise<{ outOfDate: boolean } | undefined> {
44
- const intended = intendedTypes(schemaDir)
48
+ const intended = intendedTypes(schemaDir, formTypes)
45
49
  if ('problems' in intended) return undefined
46
50
  const target = join(schemaDir, GENERATED_FILE)
47
51
  if (!existsSync(target)) return { outOfDate: true }
@@ -50,7 +54,7 @@ export async function compareModels(
50
54
  }
51
55
 
52
56
  export function createModelsBuilderPort(
53
- config: Pick<CromboloConfig, 'schemaDir'>,
57
+ config: Pick<CromboloConfig, 'schemaDir'> & Partial<Pick<CromboloConfig, 'bundles'>>,
54
58
  ): ModelsBuilderPort {
55
59
  // Umbraco keeps the last generation error in memory and shows it on the
56
60
  // dashboard; a build that succeeds clears it.
@@ -58,7 +62,7 @@ export function createModelsBuilderPort(
58
62
  const log = logger('models-builder')
59
63
 
60
64
  const describe = async (): Promise<ModelsBuilderInfo> => {
61
- const compared = await compareModels(config.schemaDir)
65
+ const compared = await compareModels(config.schemaDir, formTypesFor(config))
62
66
  return {
63
67
  // The models are TypeScript source, written on request: Umbraco's mode for
64
68
  // exactly that. The client only explains its own four mode strings.
@@ -78,13 +82,13 @@ export function createModelsBuilderPort(
78
82
  info: describe,
79
83
 
80
84
  async status(): Promise<OutOfDateStatus> {
81
- const compared = await compareModels(config.schemaDir)
85
+ const compared = await compareModels(config.schemaDir, formTypesFor(config))
82
86
  if (!compared) return 'Unknown'
83
87
  return compared.outOfDate ? 'OutOfDate' : 'Current'
84
88
  },
85
89
 
86
90
  async build() {
87
- const intended = intendedTypes(config.schemaDir)
91
+ const intended = intendedTypes(config.schemaDir, formTypesFor(config))
88
92
  if ('problems' in intended) {
89
93
  lastError = intended.problems.join('\n')
90
94
  log.warn('Models were not generated: {problems}', { problems: intended.problems.length })
package/src/ports.ts CHANGED
@@ -1,16 +1,19 @@
1
1
  /** Implementations of the Management API ports. */
2
+
3
+ import { resolveDeliveryApiSettings } from '@crombolo/api-delivery'
2
4
  import type {
3
5
  CurrentUserPort,
4
6
  LocalizationPort,
5
7
  ManagementApiDeps,
6
8
  ManifestPort,
7
9
  NodeLookup,
10
+ PreviewPort,
8
11
  Principal,
9
12
  ServerPort,
10
13
  TemporaryFilePort,
11
14
  UserItemPort,
12
15
  } from '@crombolo/api-management'
13
- import { AuthStore } from '@crombolo/auth'
16
+ import { AuthStore, expireCookie, serializeCookie } from '@crombolo/auth'
14
17
  import {
15
18
  ASSISTANT_MANIFEST_ID,
16
19
  type BackOfficePaths,
@@ -28,6 +31,7 @@ import {
28
31
  import {
29
32
  ContentTypeRepository,
30
33
  type Db,
34
+ DynamicRootRepository,
31
35
  LanguageRepository,
32
36
  RedirectRepository,
33
37
  TagRepository,
@@ -35,6 +39,7 @@ import {
35
39
  } from '@crombolo/data'
36
40
  import type { PublishedCache } from '@crombolo/render'
37
41
  import { createNodeLookup } from './access.ts'
42
+ import { createAuditHistory } from './adapters/audit.ts'
38
43
  import { createBlueprintPort } from './adapters/blueprints.ts'
39
44
  import { createPackagePort } from './adapters/bundles.ts'
40
45
  import {
@@ -47,24 +52,29 @@ import { createDictionaryPort } from './adapters/dictionary.ts'
47
52
  import { createDocumentPort } from './adapters/documents.ts'
48
53
  import { createElementPort } from './adapters/elements.ts'
49
54
  import { createFileSystemPort } from './adapters/file-system.ts'
55
+ import { createDiagnosticsPort } from './adapters/health.ts'
50
56
  import { createMediaPort } from './adapters/media.ts'
51
57
  import { createMemberGroupPort } from './adapters/member-groups.ts'
52
58
  import { createMemberPort } from './adapters/members.ts'
53
59
  import { createPublicAccessPort } from './adapters/public-access.ts'
54
60
  import { createRedirectPort } from './adapters/redirects.ts'
55
61
  import { createReferencePort } from './adapters/references.ts'
62
+ import { createRelationPort } from './adapters/relations.ts'
56
63
  import {
57
64
  createSchemaFileWriter,
58
65
  type SchemaFiles,
59
66
  type SchemaFileWriter,
60
67
  } from './adapters/schema-files.ts'
68
+ import { createSearchIndexPort } from './adapters/search-index.ts'
61
69
  import { createTemplateFileStore } from './adapters/template-files.ts'
62
70
  import { createTemplateQueryPort } from './adapters/template-query.ts'
63
71
  import { avatarUrls, createUserPorts } from './adapters/users.ts'
72
+ import { createWebhookPort } from './adapters/webhooks.ts'
64
73
  import { COMPONENT_SNIPPETS } from './component-snippets.ts'
65
74
  import { type CromboloConfig, type UserLinkSender, VERSION } from './config.ts'
66
75
  import { type EmailPort, resolveEmailPort } from './email.ts'
67
76
  import { createFileIntake } from './file-intake.ts'
77
+ import { formTypesFor } from './form-types.ts'
68
78
  import { createLogViewerPort } from './log-viewer.ts'
69
79
  import { logger } from './logging.ts'
70
80
  import { MediaFileStore } from './media-files.ts'
@@ -73,6 +83,7 @@ import { createOEmbedService } from './oembed.ts'
73
83
  import { createRedirectTracker } from './redirects.ts'
74
84
  import { componentAliasesIn, type SchemaBoot } from './schema.ts'
75
85
  import { publishSchema } from './schema-store.ts'
86
+ import { createWebhookService, type WebhookService } from './webhooks.ts'
76
87
 
77
88
  /** Re-exported for tests; the canonical list lives in @crombolo/core. */
78
89
  export const CORE_SECTIONS = CORE_SECTION_ALIASES
@@ -109,6 +120,18 @@ function createServerPort(config: CromboloConfig, canSendUserLinks: boolean): Se
109
120
  }
110
121
  }
111
122
 
123
+ /** Umbraco's preview cookie; with a signed-in editor it turns on draft rendering. */
124
+ export const PREVIEW_COOKIE = 'UMB_PREVIEW'
125
+
126
+ function createPreviewPort(config: CromboloConfig): PreviewPort {
127
+ return {
128
+ enter: () => [
129
+ serializeCookie({ name: PREVIEW_COOKIE, value: 'preview', secure: config.secureCookies }),
130
+ ],
131
+ exit: () => [expireCookie(PREVIEW_COOKIE, config.secureCookies)],
132
+ }
133
+ }
134
+
112
135
  function createManifestPort(
113
136
  paths: BackOfficePaths,
114
137
  extensions: ExtensionRegistry,
@@ -324,12 +347,13 @@ export function resolveUserLinkSender(
324
347
  }
325
348
 
326
349
  /** Where the backoffice writes schema files, and whether it may. */
327
- function schemaFiles(config: CromboloConfig): SchemaFiles {
350
+ export function schemaFiles(config: CromboloConfig): SchemaFiles {
328
351
  const store = config.schemaStore
329
352
  return {
330
353
  schemaDir: config.schemaDir,
331
354
  writable: config.schemaWritable,
332
355
  componentAliases: () => componentAliasesIn(config.componentsDir),
356
+ formTypes: formTypesFor(config),
333
357
  // Only when a store is configured: a schema directory in a repository is
334
358
  // already where the files durably live.
335
359
  onChanged: store
@@ -357,6 +381,8 @@ export interface DepsOptions {
357
381
  extensions: ExtensionRegistry
358
382
  /** Omitted only by tests that exercise the contract without a database. */
359
383
  db?: Db
384
+ /** The site's webhooks, shared with the delivery job and the event source. */
385
+ webhooks?: WebhookService
360
386
  /** Required alongside `db` to serve documents, which invalidate the cache. */
361
387
  cache?: PublishedCache
362
388
  schema?: SchemaBoot
@@ -374,6 +400,8 @@ export interface DepsOptions {
374
400
  * tells the other nodes to look; without it they keep the view they imported.
375
401
  */
376
402
  onViewsChange?: (alias: string) => void | Promise<void>
403
+ /** Items were queued for the search index; the indexer can start on them now. */
404
+ onSearchQueued?: () => void
377
405
  }
378
406
 
379
407
  function createTemporaryFilePort(files: MediaFileStore): TemporaryFilePort {
@@ -402,6 +430,7 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
402
430
  const sendUserLink = resolveUserLinkSender(options.config, email)
403
431
  const deps: ManagementApiDeps = {
404
432
  server: createServerPort(options.config, Boolean(sendUserLink)),
433
+ preview: createPreviewPort(options.config),
405
434
  // An unconfigured assistant contributes no extension, so the backoffice has
406
435
  // no drawer, no button and nothing to load.
407
436
  manifests: createManifestPort(
@@ -437,6 +466,16 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
437
466
  deps.stylesheets = createFileSystemPort(options.config.stylesheetsDir, { extension: '.css' })
438
467
  deps.scripts = createFileSystemPort(options.config.scriptsDir, { extension: '.js' })
439
468
  if (options.db) {
469
+ const dynamicRoots = new DynamicRootRepository(options.db)
470
+ deps.dynamicRoots = { resolve: (query) => dynamicRoots.resolve(query) }
471
+ deps.diagnostics = createDiagnosticsPort({
472
+ config: options.config,
473
+ db: options.db,
474
+ media: mediaFiles.store,
475
+ email,
476
+ nodeState: options.schema?.nodeState,
477
+ fetch: options.fetch,
478
+ })
440
479
  const tags = new TagRepository(options.db)
441
480
  deps.tags = { list: (filter) => tags.all(filter) }
442
481
  deps.users = createUserItemPort(options.db)
@@ -489,6 +528,9 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
489
528
  nodeState: options.schema?.nodeState,
490
529
  nodeId: options.schema?.nodeId,
491
530
  valueIntake,
531
+ apiMemberUserNames: resolveDeliveryApiSettings(
532
+ options.config.deliveryApi,
533
+ ).memberAuthorization.clientCredentialsFlow.associatedMembers.map((m) => m.userName),
492
534
  })
493
535
  deps.publicAccess = createPublicAccessPort(options.db, {
494
536
  onChange: () => options.cache?.invalidate(),
@@ -500,6 +542,17 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
500
542
  onChange: () => options.cache?.invalidate(),
501
543
  })
502
544
  deps.references = createReferencePort(options.db)
545
+ deps.relations = createRelationPort(options.db)
546
+ deps.webhooks = createWebhookPort(
547
+ options.webhooks ??
548
+ createWebhookService({
549
+ db: options.db,
550
+ schemaDir: options.config.schemaDir,
551
+ filesWritable: options.config.schemaWritable,
552
+ secretKey: options.config.secretKey,
553
+ bundles: options.config.bundles,
554
+ }),
555
+ )
503
556
  deps.redirects = createRedirectPort(options.db, options.cache, {
504
557
  isTracking: options.config.trackRedirects,
505
558
  onChange: () => options.cache?.invalidate(),
@@ -514,12 +567,14 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
514
567
  stylesheets: deps.stylesheets,
515
568
  scripts: deps.scripts,
516
569
  })
570
+ deps.auditHistory = createAuditHistory(options.db, options.config.auditRetentionDays)
517
571
  if (options.cache) {
518
572
  const cache = options.cache
519
573
  deps.templateQuery = createTemplateQueryPort({
520
574
  cache,
521
575
  types: new ContentTypeRepository(options.db),
522
576
  })
577
+ deps.searchIndex = createSearchIndexPort(options.db, options.onSearchQueued)
523
578
  deps.publishedCache = {
524
579
  reload: () => cache.invalidate(),
525
580
  rebuild: async () => {
package/src/schema.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { existsSync, watch } from 'node:fs'
8
8
  import {
9
+ CACHE_INSTRUCTION_CHANNEL,
9
10
  type CacheInstructionKind,
10
11
  cacheInstructionsAfter,
11
12
  compareStates,
@@ -39,7 +40,8 @@ import {
39
40
  writeKeysBack,
40
41
  } from '@crombolo/schema'
41
42
  import { listComponents } from './components.ts'
42
- import type { CromboloConfig } from './config.ts'
43
+ import { type CromboloConfig, VERSION } from './config.ts'
44
+ import { formTypesFor } from './form-types.ts'
43
45
  import { logger } from './logging.ts'
44
46
  import { materialiseSchema } from './schema-store.ts'
45
47
 
@@ -72,7 +74,7 @@ export async function checkSchemaDirectory(
72
74
  node: { nodeId: string; revision: string },
73
75
  options: { set?: Record<string, unknown> } = {},
74
76
  ): Promise<CheckReport> {
75
- const loaded = loadSchemaDirectory(config.schemaDir)
77
+ const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
76
78
  return runCheck(
77
79
  db,
78
80
  loaded,
@@ -108,6 +110,7 @@ export function syncOptionsFor(config: CromboloConfig): SyncOptions {
108
110
  return {
109
111
  mode: config.development ? 'development' : 'production',
110
112
  componentAliases: componentAliasesIn(config.componentsDir),
113
+ formTypes: formTypesFor(config),
111
114
  }
112
115
  }
113
116
 
@@ -121,7 +124,7 @@ export async function syncSchemaDirectory(
121
124
  // The files are read under the lock the backoffice's schema writes hold, so a
122
125
  // sync never applies a snapshot that a later backoffice change has overtaken.
123
126
  return db.locks.withLock(Locks.Schema, async () => {
124
- const loaded = loadSchemaDirectory(config.schemaDir)
127
+ const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
125
128
  const report = await syncSchema(
126
129
  db,
127
130
  loaded,
@@ -145,15 +148,15 @@ export async function bootSchema(db: Db, config: CromboloConfig): Promise<Schema
145
148
  const nodeId = config.nodeId
146
149
  if (!existsSync(config.schemaDir)) {
147
150
  const nodeState = { version: '0', revision: config.schemaRevision }
148
- await touchServer(db, { nodeId, ...nodeState, role: config.role })
151
+ await touchServer(db, { nodeId, ...nodeState, role: config.role, cromboloVersion: VERSION })
149
152
  await ensureSystemMediaTypes(db, { linkFolderChildren: true })
150
153
  await ensureSystemMemberTypes(db)
151
154
  await ensureSystemContentTypes(db)
152
155
  return { nodeState, nodeId, report: undefined, compatibilityMode: false }
153
156
  }
154
- const loaded = loadSchemaDirectory(config.schemaDir)
157
+ const loaded = loadSchemaDirectory(config.schemaDir, { formTypes: formTypesFor(config) })
155
158
  const nodeState = { version: loaded.set.version, revision: config.schemaRevision }
156
- await touchServer(db, { nodeId, ...nodeState, role: config.role })
159
+ await touchServer(db, { nodeId, ...nodeState, role: config.role, cromboloVersion: VERSION })
157
160
  if (!config.syncSchemaAtBoot)
158
161
  return { nodeState, nodeId, report: undefined, compatibilityMode: false }
159
162
 
@@ -246,11 +249,18 @@ export interface CachePollerOptions {
246
249
  onInstruction(kind: CacheInstructionKind, payload: Record<string, unknown>): void
247
250
  }
248
251
 
249
- /** Polls `cache_instruction` from where this node came in; own instructions are skipped. */
252
+ /**
253
+ * Polls `cache_instruction` from where this node came in; own instructions are
254
+ * skipped. Where the database can notify, a write wakes it at once and the
255
+ * timer is only the fallback.
256
+ */
250
257
  export class CacheInstructionPoller {
251
258
  #options: CachePollerOptions
252
259
  #cursor = 0
253
260
  #timer: ReturnType<typeof setInterval> | undefined
261
+ #unlisten: (() => Promise<void>) | undefined
262
+ #running: Promise<number> | undefined
263
+ #queued: Promise<number> | undefined
254
264
  #behind = false
255
265
  #bootLedgerId = 0
256
266
  #readsUnsafe = false
@@ -287,11 +297,39 @@ export class CacheInstructionPoller {
287
297
  if (intervalMs > 0) {
288
298
  this.#timer = setInterval(() => void this.poll().catch(() => {}), intervalMs)
289
299
  this.#timer.unref()
300
+ // Without it the timer still delivers everything, only later.
301
+ this.#unlisten = await this.#options.db
302
+ .listen?.(CACHE_INSTRUCTION_CHANNEL, () => void this.poll().catch(() => {}))
303
+ .catch(() => undefined)
290
304
  }
291
305
  }
292
306
 
293
- /** Returns how many foreign instructions were applied. */
294
- async poll(): Promise<number> {
307
+ /**
308
+ * Returns how many foreign instructions were applied. One poll at a time: a
309
+ * call while one runs waits for a single follow-up, so a burst of wake-ups
310
+ * reads the table twice, not once each.
311
+ */
312
+ poll(): Promise<number> {
313
+ if (this.#queued) return this.#queued
314
+ if (!this.#running) return this.#start()
315
+ this.#queued = this.#running
316
+ .catch(() => 0)
317
+ .then(() => {
318
+ this.#queued = undefined
319
+ return this.#start()
320
+ })
321
+ return this.#queued
322
+ }
323
+
324
+ #start(): Promise<number> {
325
+ const run = this.#pollOnce().finally(() => {
326
+ if (this.#running === run) this.#running = undefined
327
+ })
328
+ this.#running = run
329
+ return run
330
+ }
331
+
332
+ async #pollOnce(): Promise<number> {
295
333
  const { db, nodeId, nodeState, onInstruction } = this.#options
296
334
  const instructions = await cacheInstructionsAfter(db, this.#cursor)
297
335
  let applied = 0
@@ -301,7 +339,12 @@ export class CacheInstructionPoller {
301
339
  onInstruction(instruction.kind, instruction.payload)
302
340
  applied += 1
303
341
  }
304
- await touchServer(db, { nodeId, ...nodeState, role: this.#options.role })
342
+ await touchServer(db, {
343
+ nodeId,
344
+ ...nodeState,
345
+ role: this.#options.role,
346
+ cromboloVersion: VERSION,
347
+ })
305
348
  const current = await currentSchemaState(db)
306
349
  const behind = compareStates(nodeState, current) < 0
307
350
  if (behind && !this.#behind) onInstruction('schema', { stateId: current.id })
@@ -317,6 +360,8 @@ export class CacheInstructionPoller {
317
360
  stop(): void {
318
361
  if (this.#timer) clearInterval(this.#timer)
319
362
  this.#timer = undefined
363
+ void this.#unlisten?.().catch(() => {})
364
+ this.#unlisten = undefined
320
365
  }
321
366
  }
322
367
 
@@ -376,7 +421,9 @@ export async function importSchema(
376
421
 
377
422
  const report = await syncSchemaDirectory(db, config, node)
378
423
  if (report.action === 'applied') {
379
- boot.nodeState.version = loadSchemaDirectory(config.schemaDir).set.version
424
+ boot.nodeState.version = loadSchemaDirectory(config.schemaDir, {
425
+ formTypes: formTypesFor(config),
426
+ }).set.version
380
427
  boot.nodeState.revision = config.schemaRevision
381
428
  }
382
429
  return {