@crombolo/server 0.9.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/jobs.ts CHANGED
@@ -1,13 +1,21 @@
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'
@@ -33,6 +41,10 @@ export interface JobsOptions {
33
41
  */
34
42
  formWorkflows?: WorkflowRunner
35
43
  formWorkflowsMs?: number
44
+ /** How often the search indexer looks for queued items; a write here also starts it at once. */
45
+ searchIndexingMs?: number
46
+ /** Days the audit log keeps an entry; 0 or absent keeps them forever. */
47
+ auditRetentionDays?: number
36
48
  }
37
49
 
38
50
  export interface ScheduledRun {
@@ -45,6 +57,12 @@ export interface BackgroundJobs {
45
57
  runScheduledPublishing(now?: Date): Promise<ScheduledRun>
46
58
  /** Runs the form workflows that are due; a no-op when none are configured. */
47
59
  runFormWorkflows(now?: Date): Promise<WorkflowRunReport>
60
+ /** Indexes every queued item, and returns how many. docs/search.md. */
61
+ runSearchIndexing(): Promise<number>
62
+ /** Something was queued here: index it shortly, rather than at the next tick. */
63
+ kickSearchIndexing(): void
64
+ /** Deletes audit entries older than the retention period, and returns how many. */
65
+ runAuditRetention(now?: Date): Promise<number>
48
66
  /** Runs whether or not the hourly job is enabled; the policy decides what goes. */
49
67
  runVersionCleanup(now?: Date): Promise<{ nodes: number; versionsDeleted: number }>
50
68
  start(): void
@@ -60,6 +78,9 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
60
78
  })
61
79
  const timers: Array<ReturnType<typeof setInterval>> = []
62
80
  let running = false
81
+ const search = new SearchIndex(options.db)
82
+ let indexing: Promise<number> | undefined
83
+ let kicked: ReturnType<typeof setTimeout> | undefined
63
84
 
64
85
  async function runScheduledPublishing(now = new Date()): Promise<ScheduledRun> {
65
86
  const run: ScheduledRun = { published: [], unpublished: [], failed: [] }
@@ -67,13 +88,20 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
67
88
  if (!(await documents.claimSchedule(due.id))) continue
68
89
  const cultures = due.culture ? [due.culture] : null
69
90
  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
- }
91
+ const release = due.action === 'Release'
92
+ const changed = release
93
+ ? await documents.publish(due.key, cultures)
94
+ : await documents.unpublish(due.key, cultures)
95
+ ;(release ? run.published : run.unpublished).push(due.key)
96
+ await recordAudit(options.db, {
97
+ category: 'content',
98
+ action: release ? 'Publish' : 'Unpublish',
99
+ source: 'job',
100
+ entityType: 'Document',
101
+ entityKey: due.key,
102
+ entityName: changed?.variants[0]?.name ?? null,
103
+ detail: `Scheduled${due.culture ? ` (${due.culture})` : ''}`,
104
+ })
77
105
  options.onDocumentChanged?.(due.key)
78
106
  } catch (error) {
79
107
  if (!(error instanceof PublishBlockedError)) throw error
@@ -84,6 +112,11 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
84
112
  return run
85
113
  }
86
114
 
115
+ async function runAuditRetention(now = new Date()): Promise<number> {
116
+ const before = auditRetainedSince(options.auditRetentionDays ?? 0, now)
117
+ return before ? new AuditLog(options.db).prune(before) : 0
118
+ }
119
+
87
120
  async function runVersionCleanup(now = new Date()) {
88
121
  return documents.cleanupVersions(now, options.versionCleanup)
89
122
  }
@@ -99,6 +132,20 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
99
132
  )
100
133
  }
101
134
 
135
+ // Its own guard rather than `guarded`: indexing a big rebuild must not hold up
136
+ // a scheduled publish, and a kick while it runs joins the run in progress.
137
+ function runSearchIndexing(): Promise<number> {
138
+ indexing ??= search.indexAll().finally(() => {
139
+ indexing = undefined
140
+ })
141
+ return indexing
142
+ }
143
+
144
+ const indexQuietly = () =>
145
+ runSearchIndexing().catch((error: unknown) => {
146
+ logger('jobs').error('Search indexing failed', { error })
147
+ })
148
+
102
149
  const guarded = (job: () => Promise<unknown>) => async () => {
103
150
  if (running) return
104
151
  running = true
@@ -120,18 +167,35 @@ export function createBackgroundJobs(options: JobsOptions): BackgroundJobs {
120
167
  return {
121
168
  runScheduledPublishing,
122
169
  runVersionCleanup,
170
+ runAuditRetention,
123
171
  runFormWorkflows,
172
+ runSearchIndexing,
173
+ kickSearchIndexing() {
174
+ if (timers.length === 0 || kicked) return
175
+ kicked = setTimeout(() => {
176
+ kicked = undefined
177
+ void indexQuietly()
178
+ }, 250)
179
+ kicked.unref()
180
+ },
124
181
  start() {
125
182
  every(options.scheduledPublishingMs ?? 60_000, runScheduledPublishing)
183
+ // The queue is shared, so this also picks up what other nodes wrote.
184
+ const searchTimer = setInterval(indexQuietly, options.searchIndexingMs ?? 5_000)
185
+ searchTimer.unref()
186
+ timers.push(searchTimer)
126
187
  // More often than the other jobs: somebody waiting on a form notification
127
188
  // notices a minute, and a queue row is cheap to look for.
128
189
  if (options.formWorkflows) every(options.formWorkflowsMs ?? 15_000, runFormWorkflows)
129
190
  if (options.versionCleanup.enabled) every(60 * 60_000, runVersionCleanup)
191
+ if (options.auditRetentionDays) every(60 * 60_000, runAuditRetention)
130
192
  const files = options.mediaFiles
131
193
  if (files) every(60 * 60_000, () => files.cleanupExpired())
132
194
  },
133
195
  stop() {
134
196
  for (const timer of timers.splice(0)) clearInterval(timer)
197
+ if (kicked) clearTimeout(kicked)
198
+ kicked = undefined
135
199
  },
136
200
  started() {
137
201
  return timers.length > 0
@@ -1,19 +1,30 @@
1
1
  /**
2
- * The Bundles section's marketplace, over npm. `docs/17-bundles.md`.
2
+ * The Bundles section's marketplace, over npm. `docs/bundles.md`.
3
3
  *
4
4
  * Discovery and declaration are two different things, because npm indexes only
5
5
  * one of them: a bundle is *found* by the `crombolo-bundle` keyword, which is
6
6
  * the only thing registry search will match, and it *declares* itself with a
7
7
  * `crombolo` field in its `package.json`. Search for the keyword, then read each
8
8
  * hit's packument — custom fields survive there — and offer only the packages
9
- * that actually declare extensions. A keyword squatter never reaches the list.
9
+ * whose manifest is a valid bundle. A keyword squatter never reaches the list.
10
+ *
11
+ * An install is `bun add`, and then — unless the site or the request says not
12
+ * to — the content the bundle carries, through the same installer as
13
+ * `crombolo bundle install`. A bundle's server half is never loaded by an
14
+ * install: that still takes an edit to `crombolo.config.ts` and a deploy.
10
15
  *
11
16
  * The search is proxied rather than done in the browser: it works where the
12
17
  * browser cannot reach npm, and the backoffice keeps making same-origin
13
18
  * requests only.
14
19
  */
15
20
  import { join } from 'node:path'
16
- import { EXTENSION_KEYWORD, type ExtensionRegistry } from '@crombolo/backoffice-host'
21
+ import {
22
+ type BundlePackage,
23
+ EXTENSION_KEYWORD,
24
+ type ExtensionRegistry,
25
+ type PackageJson,
26
+ validateBundlePackage,
27
+ } from '@crombolo/backoffice-host'
17
28
  import { logger } from './logging.ts'
18
29
 
19
30
  const log = logger('packages')
@@ -27,12 +38,12 @@ export interface MarketplaceListing {
27
38
  description: string
28
39
  publisher: string | null
29
40
  links: { npm: string; homepage: string | null; repository: string | null }
30
- /** Whether the latest version declares backoffice extensions. */
31
- declaresExtensions: boolean
41
+ /** What the latest version carries; only a valid bundle is listed at all. */
42
+ carries: BundlePackage['carries']
32
43
  installedVersion: string | null
33
44
  }
34
45
 
35
- /** An installed extension as the Installed view lists it. */
46
+ /** A bundle the site depends on, as the Installed view lists it. */
36
47
  export interface InstalledListing {
37
48
  /** The npm package name, which is what an uninstall names. */
38
49
  packageName: string
@@ -41,6 +52,19 @@ export interface InstalledListing {
41
52
  id: string
42
53
  version: string
43
54
  extensionCount: number
55
+ carries: BundlePackage['carries']
56
+ /** Whether its server half is imported into `crombolo.config.ts`; only meaningful when it carries one. */
57
+ wired: boolean
58
+ /** The version whose content was applied here, or null when none has been. */
59
+ contentApplied: string | null
60
+ }
61
+
62
+ /** What became of the content half of an install. */
63
+ export interface ContentOutcome {
64
+ applied: boolean
65
+ /** Set when it was applied: the transfer run that can revert it. */
66
+ runId?: string
67
+ message: string
44
68
  }
45
69
 
46
70
  export interface MarketplaceOptions {
@@ -53,6 +77,17 @@ export interface MarketplaceOptions {
53
77
  siteDir: string
54
78
  /** Overridden by tests; the real one shells out to Bun. */
55
79
  install?: PackageInstaller
80
+ /** The ids of the bundles whose server half this node has mounted. */
81
+ wired?: () => readonly string[]
82
+ /** The version of a package whose content has been applied here. */
83
+ contentApplied?: (packageName: string) => Promise<string | null>
84
+ /**
85
+ * Installs the content half of a bundle the site now depends on. Absent where
86
+ * content cannot be applied, which declines it for every install.
87
+ */
88
+ applyContent?: (packageName: string) => Promise<ContentOutcome>
89
+ /** The site's `applyBundleContentOnInstall`. */
90
+ applyContentOnInstall?: boolean
56
91
  }
57
92
 
58
93
  export interface InstallOutcome {
@@ -64,6 +99,8 @@ export interface InstallOutcome {
64
99
  */
65
100
  dependency?: { name: string; range: string; version: string }
66
101
  message: string
102
+ /** What happened to the content the bundle carries; absent when it carries none. */
103
+ content?: ContentOutcome
67
104
  }
68
105
 
69
106
  export type PackageInstaller = (
@@ -102,8 +139,12 @@ export interface Marketplace {
102
139
  /** Where someone browsing should be sent, when they want more than this list. */
103
140
  url(): string
104
141
  search(query: string | undefined): Promise<MarketplaceListing[]>
105
- installed(): InstalledListing[]
106
- install(name: string, version: string | undefined): Promise<InstallOutcome>
142
+ installed(): Promise<InstalledListing[]>
143
+ install(
144
+ name: string,
145
+ version: string | undefined,
146
+ options?: { applyContent?: boolean },
147
+ ): Promise<InstallOutcome>
107
148
  uninstall(name: string): Promise<InstallOutcome>
108
149
  }
109
150
 
@@ -156,24 +197,35 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
156
197
  let cached: { at: number; query: string; listings: MarketplaceListing[] } | undefined
157
198
 
158
199
  const installedVersion = (name: string): string | null =>
159
- options.extensions.list().find((extension) => extension.packageName === name)?.version ?? null
200
+ options.extensions.bundles().bundles.find((bundle) => bundle.packageName === name)?.version ??
201
+ null
160
202
 
161
- /** Whether the registry says this package's latest version declares extensions. */
162
- const declares = async (name: string): Promise<boolean> => {
203
+ /** What the registry says this package's latest version carries, when it is a valid bundle. */
204
+ const declares = async (name: string): Promise<BundlePackage['carries'] | undefined> => {
163
205
  try {
164
206
  const response = await call(`${options.registry}/${encodeURIComponent(name)}`, {
165
207
  headers: { accept: 'application/json' },
166
208
  })
167
- if (!response.ok) return false
209
+ if (!response.ok) return undefined
168
210
  const packument = (await response.json()) as {
169
211
  'dist-tags'?: { latest?: string }
170
- versions?: Record<string, { crombolo?: { extensions?: unknown[] } }>
212
+ versions?: Record<string, PackageJson>
171
213
  }
172
214
  const latest = packument['dist-tags']?.latest
173
215
  const version = latest ? packument.versions?.[latest] : undefined
174
- return Array.isArray(version?.crombolo?.extensions) && version.crombolo.extensions.length > 0
216
+ if (!version || validateBundlePackage(version).length > 0) return undefined
217
+ const field = version.crombolo as {
218
+ extensions?: unknown[]
219
+ content?: string
220
+ server?: string
221
+ }
222
+ return {
223
+ extensions: Array.isArray(field.extensions) && field.extensions.length > 0,
224
+ content: field.content !== undefined,
225
+ server: field.server !== undefined,
226
+ }
175
227
  } catch {
176
- return false
228
+ return undefined
177
229
  }
178
230
  }
179
231
 
@@ -213,8 +265,10 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
213
265
  .filter((pkg): pkg is NonNullable<typeof pkg> =>
214
266
  Boolean(pkg?.name && isValidPackageName(pkg.name)),
215
267
  )
216
- .map(async (pkg): Promise<MarketplaceListing> => {
268
+ .map(async (pkg): Promise<MarketplaceListing | undefined> => {
217
269
  const name = pkg.name as string
270
+ const carries = await declares(name)
271
+ if (!carries) return undefined
218
272
  return {
219
273
  name,
220
274
  version: pkg.version ?? '0.0.0',
@@ -225,29 +279,35 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
225
279
  homepage: pkg.links?.homepage ?? null,
226
280
  repository: pkg.links?.repository ?? null,
227
281
  },
228
- declaresExtensions: await declares(name),
282
+ carries,
229
283
  installedVersion: installedVersion(name),
230
284
  }
231
285
  }),
232
286
  )
233
- // Only what really declares extensions: the keyword is a claim, the field
234
- // is the thing itself.
235
- const offered = listings.filter((listing) => listing.declaresExtensions)
287
+ // Only what really is a bundle: the keyword is a claim, the manifest is
288
+ // the thing itself.
289
+ const offered = listings.filter((listing): listing is MarketplaceListing => Boolean(listing))
236
290
  cached = { at: Date.now(), query: text, listings: offered }
237
291
  return offered
238
292
  },
239
293
 
240
- installed() {
241
- return options.extensions.list().map((extension) => ({
242
- packageName: extension.packageName,
243
- name: extension.manifest.name,
244
- id: extension.manifest.id ?? extension.packageName,
245
- version: extension.version,
246
- extensionCount: extension.manifest.extensions.length,
247
- }))
294
+ async installed() {
295
+ const wired = new Set(options.wired?.() ?? [])
296
+ return Promise.all(
297
+ options.extensions.bundles().bundles.map(async (bundle) => ({
298
+ packageName: bundle.packageName,
299
+ name: bundle.name,
300
+ id: bundle.id,
301
+ version: bundle.version,
302
+ extensionCount: bundle.field.extensions?.length ?? 0,
303
+ carries: bundle.carries,
304
+ wired: wired.has(bundle.id),
305
+ contentApplied: (await options.contentApplied?.(bundle.packageName)) ?? null,
306
+ })),
307
+ )
248
308
  },
249
309
 
250
- async install(name, version) {
310
+ async install(name, version, request = {}) {
251
311
  if (!isValidPackageName(name))
252
312
  return { ok: false, message: `'${name}' is not a package name npm would accept.` }
253
313
  if (version !== undefined && !isValidVersion(version))
@@ -263,19 +323,44 @@ export function createMarketplace(options: MarketplaceOptions): Marketplace {
263
323
  }
264
324
 
265
325
  const range = (await rangeFor(name)) ?? version ?? 'latest'
266
- const found = options.extensions.list().find((extension) => extension.packageName === name)
326
+ const found = options.extensions
327
+ .bundles()
328
+ .bundles.find((bundle) => bundle.packageName === name)
267
329
  if (!found)
268
330
  return {
269
331
  ok: true,
270
332
  dependency: { name, range, version: version ?? range },
271
- message: `${name} was installed, but it declares no backoffice extensions, so nothing was added to the backoffice.`,
333
+ message: `${name} was installed, but it is not a valid bundle, so nothing was added to the backoffice.`,
272
334
  }
335
+
336
+ // The content half lands after the dependency, so a refused check leaves
337
+ // the package installed and says why, rather than undoing an install that
338
+ // succeeded.
339
+ let content: ContentOutcome | undefined
340
+ if (found.carries.content) {
341
+ const declined = request.applyContent === false || options.applyContentOnInstall === false
342
+ content =
343
+ declined || !options.applyContent
344
+ ? {
345
+ applied: false,
346
+ message: `Its content was not applied. Run \`crombolo bundle install ${name}\` to apply it.`,
347
+ }
348
+ : await options.applyContent(name).catch((error: unknown) => ({
349
+ applied: false,
350
+ message: `Its content could not be applied: ${error instanceof Error ? error.message : String(error)}`,
351
+ }))
352
+ }
273
353
  return {
274
354
  ok: true,
275
355
  dependency: { name, range, version: found.version },
276
356
  // The install lives in this node's node_modules until the dependency is
277
357
  // committed; saying so is the difference between a surprise and a plan.
278
- message: `${name}@${found.version} is installed and active. Commit the changed package.json and bun.lock to keep it across deployments.`,
358
+ message: `${name}@${found.version} is installed. Commit the changed package.json and bun.lock to keep it across deployments.${
359
+ found.carries.server
360
+ ? ' Its server half runs only once crombolo.config.ts imports it and the site is deployed.'
361
+ : ''
362
+ }`,
363
+ ...(content ? { content } : {}),
279
364
  }
280
365
  },
281
366
 
package/src/no-nodes.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The holding page for a site with nothing published — Umbraco's "no nodes"
3
- * screen, which `docs/06-features.md` asks for.
3
+ * screen, which `docs/features.md` asks for.
4
4
  *
5
5
  * It answers 404, not 200: a brand-new site is not a working one, and anything
6
6
  * watching the URL should keep saying so until a page is published. It is only
package/src/ports.ts CHANGED
@@ -35,6 +35,7 @@ import {
35
35
  } from '@crombolo/data'
36
36
  import type { PublishedCache } from '@crombolo/render'
37
37
  import { createNodeLookup } from './access.ts'
38
+ import { createAuditHistory } from './adapters/audit.ts'
38
39
  import { createBlueprintPort } from './adapters/blueprints.ts'
39
40
  import { createPackagePort } from './adapters/bundles.ts'
40
41
  import {
@@ -58,6 +59,7 @@ import {
58
59
  type SchemaFiles,
59
60
  type SchemaFileWriter,
60
61
  } from './adapters/schema-files.ts'
62
+ import { createSearchIndexPort } from './adapters/search-index.ts'
61
63
  import { createTemplateFileStore } from './adapters/template-files.ts'
62
64
  import { createTemplateQueryPort } from './adapters/template-query.ts'
63
65
  import { avatarUrls, createUserPorts } from './adapters/users.ts'
@@ -89,7 +91,7 @@ const CULTURES = [
89
91
 
90
92
  function createServerPort(config: CromboloConfig, canSendUserLinks: boolean): ServerPort {
91
93
  return {
92
- // Phase 6 derives this from migration state; until then the site is always up.
94
+ // Story 080 derives this from migration state; until then the site is always up.
93
95
  status: () => ({ serverStatus: 'Run' }),
94
96
  configuration: () => ({
95
97
  // Reported false when no link could be delivered, so the client does not
@@ -374,6 +376,8 @@ export interface DepsOptions {
374
376
  * tells the other nodes to look; without it they keep the view they imported.
375
377
  */
376
378
  onViewsChange?: (alias: string) => void | Promise<void>
379
+ /** Items were queued for the search index; the indexer can start on them now. */
380
+ onSearchQueued?: () => void
377
381
  }
378
382
 
379
383
  function createTemporaryFilePort(files: MediaFileStore): TemporaryFilePort {
@@ -514,12 +518,14 @@ export function createDeps(options: DepsOptions): ManagementApiDeps {
514
518
  stylesheets: deps.stylesheets,
515
519
  scripts: deps.scripts,
516
520
  })
521
+ deps.auditHistory = createAuditHistory(options.db, options.config.auditRetentionDays)
517
522
  if (options.cache) {
518
523
  const cache = options.cache
519
524
  deps.templateQuery = createTemplateQueryPort({
520
525
  cache,
521
526
  types: new ContentTypeRepository(options.db),
522
527
  })
528
+ deps.searchIndex = createSearchIndexPort(options.db, options.onSearchQueued)
523
529
  deps.publishedCache = {
524
530
  reload: () => cache.invalidate(),
525
531
  rebuild: async () => {
package/src/schema.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Schema-as-code at runtime: the boot sync, the development watcher that
3
3
  * re-syncs on file changes, and the cache-instruction poll that keeps this
4
- * node's caches coherent with the others. docs/09-schema-as-code.md.
4
+ * node's caches coherent with the others. docs/schema-as-code.md.
5
5
  */
6
6
 
7
7
  import { existsSync, watch } from 'node:fs'