@crombolo/server 0.0.0-stage → 0.9.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 (81) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +69 -0
  3. package/package.json +41 -4
  4. package/src/access.ts +114 -0
  5. package/src/adapters/assistant.ts +229 -0
  6. package/src/adapters/blueprints.ts +163 -0
  7. package/src/adapters/bundles.ts +432 -0
  8. package/src/adapters/content.ts +783 -0
  9. package/src/adapters/dictionary.ts +76 -0
  10. package/src/adapters/documents.ts +637 -0
  11. package/src/adapters/elements.ts +403 -0
  12. package/src/adapters/file-system.ts +195 -0
  13. package/src/adapters/media.ts +347 -0
  14. package/src/adapters/member-groups.ts +90 -0
  15. package/src/adapters/members.ts +261 -0
  16. package/src/adapters/public-access.ts +116 -0
  17. package/src/adapters/redirects.ts +73 -0
  18. package/src/adapters/references.ts +67 -0
  19. package/src/adapters/schema-files.ts +204 -0
  20. package/src/adapters/schema-proposals.ts +180 -0
  21. package/src/adapters/template-files.ts +58 -0
  22. package/src/adapters/template-query.ts +127 -0
  23. package/src/adapters/users.ts +963 -0
  24. package/src/assistant.ts +487 -0
  25. package/src/backup.ts +52 -0
  26. package/src/bundles.ts +508 -0
  27. package/src/component-snippets.ts +163 -0
  28. package/src/components.ts +266 -0
  29. package/src/config.ts +548 -0
  30. package/src/content-type-udt.ts +256 -0
  31. package/src/database.ts +109 -0
  32. package/src/diagnostics.ts +143 -0
  33. package/src/dictionary-transfer.ts +123 -0
  34. package/src/domains.ts +384 -0
  35. package/src/editor-types.ts +108 -0
  36. package/src/email-http.ts +79 -0
  37. package/src/email-notice.ts +36 -0
  38. package/src/email-postmark.ts +78 -0
  39. package/src/email-resend.ts +69 -0
  40. package/src/email-ses.ts +307 -0
  41. package/src/email.ts +402 -0
  42. package/src/extensions-notice.ts +35 -0
  43. package/src/file-intake.ts +84 -0
  44. package/src/form-files.ts +241 -0
  45. package/src/form-submit.ts +397 -0
  46. package/src/form-workflows.ts +397 -0
  47. package/src/forms.ts +170 -0
  48. package/src/git-github.ts +171 -0
  49. package/src/git-routes.ts +154 -0
  50. package/src/git.ts +88 -0
  51. package/src/imaging.ts +434 -0
  52. package/src/index.ts +62 -0
  53. package/src/jobs.ts +140 -0
  54. package/src/listen.ts +36 -0
  55. package/src/log-expressions.ts +524 -0
  56. package/src/log-viewer.ts +190 -0
  57. package/src/logging.ts +126 -0
  58. package/src/marketplace.ts +297 -0
  59. package/src/media-files.ts +276 -0
  60. package/src/media-store-azure.ts +218 -0
  61. package/src/media-store-s3.ts +113 -0
  62. package/src/media-store.ts +248 -0
  63. package/src/member-auth.ts +384 -0
  64. package/src/models-builder.ts +106 -0
  65. package/src/no-nodes.ts +64 -0
  66. package/src/not-implemented.ts +44 -0
  67. package/src/oembed.ts +144 -0
  68. package/src/ports.ts +553 -0
  69. package/src/redirects.ts +132 -0
  70. package/src/schema-classify.ts +82 -0
  71. package/src/schema-store.ts +145 -0
  72. package/src/schema.ts +388 -0
  73. package/src/seo.ts +371 -0
  74. package/src/server-events.ts +131 -0
  75. package/src/server.ts +1579 -0
  76. package/src/status.ts +246 -0
  77. package/src/udt.ts +168 -0
  78. package/src/value-schemas.ts +64 -0
  79. package/src/view-runtime.ts +42 -0
  80. package/src/zip.ts +152 -0
  81. package/README.md +0 -3
@@ -0,0 +1,82 @@
1
+ /**
2
+ * What a content-type change does to the content already stored.
3
+ *
4
+ * `runCheck` answers this for files against a database, which is the right shape
5
+ * for an import and the wrong one for a save: by the time a save has happened the
6
+ * database already holds the change, so there is nothing left to compare. This
7
+ * asks the same questions of the two aggregates a save has in hand — what the
8
+ * type was, and what it is about to become — so an editor's change can be
9
+ * classified before it is applied, and the schema version moved accordingly.
10
+ *
11
+ * The rules are the ones `packages/schema/src/check.ts` applies, kept in step
12
+ * with it deliberately rather than by accident:
13
+ *
14
+ * - a property's editor changed **and content exists** → breaking, because
15
+ * getting there converts data
16
+ * - a property became mandatory **and content exists** → data-requiring
17
+ * - anything else that differs → additive
18
+ *
19
+ * Removing a property is none of these: the schema system retires properties
20
+ * rather than deleting them, and their values come back if the property does.
21
+ */
22
+ import type { ContentTypeAggregate, PropertyTypeModel } from '@crombolo/core'
23
+ import { type Db, DocumentRepository } from '@crombolo/data'
24
+ import { type ChangeClass, typeNodeIdsCarrying } from '@crombolo/schema'
25
+
26
+ /** Whether anything is stored against this type, compositions included. */
27
+ async function hasContent(db: Db, typeKey: string): Promise<boolean> {
28
+ const carriers = await typeNodeIdsCarrying(db, typeKey)
29
+ if (carriers.length === 0) return false
30
+ const documents = await new DocumentRepository(db).documentsOfTypes(carriers)
31
+ return documents.length > 0
32
+ }
33
+
34
+ const byKeyOrAlias = (
35
+ properties: readonly PropertyTypeModel[],
36
+ property: PropertyTypeModel,
37
+ ): PropertyTypeModel | undefined =>
38
+ properties.find((candidate) => candidate.key === property.key) ??
39
+ properties.find((candidate) => candidate.alias === property.alias)
40
+
41
+ /**
42
+ * How `after` differs from `before`, in terms of what it costs the content.
43
+ * `before` absent means the type is new, which can only be additive.
44
+ */
45
+ export async function classifyTypeChange(
46
+ db: Db,
47
+ before: ContentTypeAggregate | undefined,
48
+ after: ContentTypeAggregate,
49
+ ): Promise<ChangeClass> {
50
+ if (!before) return 'additive'
51
+
52
+ let live: boolean | undefined
53
+ const contentExists = async () => {
54
+ live ??= await hasContent(db, before.key)
55
+ return live
56
+ }
57
+
58
+ let dataRequiring = false
59
+ for (const property of after.properties) {
60
+ const previous = byKeyOrAlias(before.properties, property)
61
+ if (!previous) continue
62
+
63
+ if (previous.dataTypeKey !== property.dataTypeKey && (await contentExists())) return 'breaking'
64
+ if (property.mandatory && !previous.mandatory && (await contentExists())) dataRequiring = true
65
+ }
66
+ if (dataRequiring) return 'data-requiring'
67
+
68
+ const same =
69
+ before.alias === after.alias &&
70
+ before.name === after.name &&
71
+ before.properties.length === after.properties.length &&
72
+ after.properties.every((property) => {
73
+ const previous = byKeyOrAlias(before.properties, property)
74
+ return (
75
+ previous !== undefined &&
76
+ previous.alias === property.alias &&
77
+ previous.dataTypeKey === property.dataTypeKey &&
78
+ previous.mandatory === property.mandatory
79
+ )
80
+ })
81
+ return same ? 'none' : 'additive'
82
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Schema files in shared storage, so a deployed site can own its own schema.
3
+ *
4
+ * Without this, `schema/` is a directory in the image: a node cannot change it
5
+ * durably — the container's disk is thrown away on redeploy — so
6
+ * `schemaWritable` is off in production and metadata is a developer's job. With a
7
+ * store configured, the schema lives in S3 or Azure instead, every node reads the
8
+ * same copy, and an editor can change a content type on a deployed site and have
9
+ * it survive and reach the other nodes.
10
+ *
11
+ * **The store is materialised into a local directory at boot**, and everything
12
+ * downstream — loading, validation, hashing, sync, the writer — carries on
13
+ * against files exactly as before. `loadSchemaDirectory` is synchronous and used
14
+ * by the boot path, the sync, the validator and the CLI; making it async to reach
15
+ * a network would have rippled through all of it for no gain. The local copy is a
16
+ * cache, the store is the truth, and "the files are the schema" stays literally
17
+ * true.
18
+ *
19
+ * A store is any `MediaStore`: the same four verbs over the same key space, so
20
+ * the S3 and Azure implementations serve this with nothing duplicated.
21
+ */
22
+ import { existsSync } from 'node:fs'
23
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
24
+ import { dirname, join, relative, sep } from 'node:path'
25
+ import type { MediaStore } from './media-store.ts'
26
+ import { type AzureMediaStoreOptions, azureMediaStore } from './media-store-azure.ts'
27
+ import { type S3MediaStoreOptions, s3MediaStore } from './media-store-s3.ts'
28
+
29
+ export interface SchemaStore {
30
+ /** Named in the boot banner and in logs, e.g. `s3://bucket/schema`. */
31
+ readonly description: string
32
+ /** Every schema file, as a path relative to the schema root. */
33
+ list(): Promise<string[]>
34
+ read(key: string): Promise<string | undefined>
35
+ write(key: string, contents: string): Promise<void>
36
+ remove(key: string): Promise<void>
37
+ }
38
+
39
+ /** Schema is TOML and nothing else; anything else in the bucket is ignored. */
40
+ const isSchemaFile = (key: string) => key.toLowerCase().endsWith('.toml')
41
+
42
+ /** Keys are always `/`-separated, whatever the host platform writes. */
43
+ const toKey = (path: string) => path.split(sep).join('/')
44
+
45
+ /** Any blob store, used for schema. */
46
+ export function schemaStoreOver(store: MediaStore): SchemaStore {
47
+ return {
48
+ description: store.description,
49
+ async list() {
50
+ return (await store.list('')).filter(isSchemaFile)
51
+ },
52
+ async read(key) {
53
+ const found = await store.get(key)
54
+ return found ? new TextDecoder().decode(await found.bytes()) : undefined
55
+ },
56
+ async write(key, contents) {
57
+ await store.put(key, new TextEncoder().encode(contents), { contentType: 'text/plain' })
58
+ },
59
+ remove: (key) => store.delete(key),
60
+ }
61
+ }
62
+
63
+ export interface S3SchemaStoreOptions extends S3MediaStoreOptions {}
64
+ export interface AzureSchemaStoreOptions extends AzureMediaStoreOptions {}
65
+
66
+ /** Schema in an S3 bucket, or anything that speaks S3. Defaults to a `schema/` folder. */
67
+ export const s3SchemaStore = (options: S3SchemaStoreOptions): SchemaStore =>
68
+ schemaStoreOver(s3MediaStore({ ...options, prefix: options.prefix ?? 'schema' }))
69
+
70
+ /** Schema in an Azure Blob container. Defaults to a `schema/` folder. */
71
+ export const azureSchemaStore = (options: AzureSchemaStoreOptions): SchemaStore =>
72
+ schemaStoreOver(azureMediaStore({ ...options, prefix: options.prefix ?? 'schema' }))
73
+
74
+ /** Every `.toml` under `dir`, as store keys. */
75
+ async function localFiles(dir: string): Promise<string[]> {
76
+ if (!existsSync(dir)) return []
77
+ const found: string[] = []
78
+ const walk = async (at: string): Promise<void> => {
79
+ for (const entry of await readdir(at, { withFileTypes: true })) {
80
+ const path = join(at, entry.name)
81
+ if (entry.isDirectory()) await walk(path)
82
+ else if (isSchemaFile(entry.name)) found.push(toKey(relative(dir, path)))
83
+ }
84
+ }
85
+ await walk(dir)
86
+ return found.sort()
87
+ }
88
+
89
+ /**
90
+ * Copies the store into `dir`, which becomes the schema directory for this boot.
91
+ *
92
+ * A mirror, not a merge: a file the store no longer has is removed locally, so a
93
+ * node that restarts with a stale cache cannot resurrect a deleted content type.
94
+ */
95
+ export async function materialiseSchema(store: SchemaStore, dir: string): Promise<string[]> {
96
+ const keys = await store.list()
97
+ await mkdir(dir, { recursive: true })
98
+
99
+ for (const key of keys) {
100
+ const contents = await store.read(key)
101
+ if (contents === undefined) continue
102
+ const path = join(dir, ...key.split('/'))
103
+ await mkdir(dirname(path), { recursive: true })
104
+ await writeFile(path, contents, 'utf8')
105
+ }
106
+
107
+ const wanted = new Set(keys)
108
+ for (const key of await localFiles(dir)) {
109
+ if (!wanted.has(key)) await rm(join(dir, ...key.split('/')), { force: true })
110
+ }
111
+ return keys
112
+ }
113
+
114
+ export interface PublishReport {
115
+ written: string[]
116
+ removed: string[]
117
+ }
118
+
119
+ /**
120
+ * Sends `dir` to the store, so a schema the backoffice just changed outlives this
121
+ * container. A mirror in the same way: a file deleted locally is deleted there.
122
+ *
123
+ * Unchanged files are skipped, which keeps a `rewriteAll` — every file, after a
124
+ * move — from rewriting the whole bucket.
125
+ */
126
+ export async function publishSchema(store: SchemaStore, dir: string): Promise<PublishReport> {
127
+ const local = await localFiles(dir)
128
+ const remote = new Set(await store.list())
129
+ const report: PublishReport = { written: [], removed: [] }
130
+
131
+ for (const key of local) {
132
+ const contents = await readFile(join(dir, ...key.split('/')), 'utf8')
133
+ if (remote.has(key) && (await store.read(key)) === contents) continue
134
+ await store.write(key, contents)
135
+ report.written.push(key)
136
+ }
137
+
138
+ const wanted = new Set(local)
139
+ for (const key of remote) {
140
+ if (wanted.has(key)) continue
141
+ await store.remove(key)
142
+ report.removed.push(key)
143
+ }
144
+ return report
145
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,388 @@
1
+ /**
2
+ * Schema-as-code at runtime: the boot sync, the development watcher that
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.
5
+ */
6
+
7
+ import { existsSync, watch } from 'node:fs'
8
+ import {
9
+ type CacheInstructionKind,
10
+ cacheInstructionsAfter,
11
+ compareStates,
12
+ contractsLedgeredAfter,
13
+ currentSchemaState,
14
+ type Db,
15
+ ensureSystemContentTypes,
16
+ ensureSystemMediaTypes,
17
+ ensureSystemMemberTypes,
18
+ type Finding,
19
+ Locks,
20
+ latestCacheInstructionId,
21
+ latestLedgerId,
22
+ type NodeSchemaState,
23
+ readWritesPaused,
24
+ touchServer,
25
+ updateSchemaStateHash,
26
+ type WritesPaused,
27
+ writeReport,
28
+ } from '@crombolo/data'
29
+ import {
30
+ type ChangeClass,
31
+ type CheckReport,
32
+ hashSchemaSet,
33
+ loadSchemaDirectory,
34
+ loadValueMigrations,
35
+ runCheck,
36
+ type SyncOptions,
37
+ type SyncReport,
38
+ syncSchema,
39
+ writeKeysBack,
40
+ } from '@crombolo/schema'
41
+ import { listComponents } from './components.ts'
42
+ import type { CromboloConfig } from './config.ts'
43
+ import { logger } from './logging.ts'
44
+ import { materialiseSchema } from './schema-store.ts'
45
+
46
+ export interface SchemaBoot {
47
+ /** The state this node runs at: the files' version, the deploy's revision. */
48
+ nodeState: NodeSchemaState
49
+ nodeId: string
50
+ /** Undefined when there is no schema directory. */
51
+ report: SyncReport | undefined
52
+ /** True when the database is ahead of the files: reads only, 409 on writes. */
53
+ compatibilityMode: boolean
54
+ /** The pre-upgrade check run at boot (development), written to `change_report`. */
55
+ check?: CheckReport
56
+ }
57
+
58
+ /** Production boot found the site diff needs data work: that is `upgrade check`'s job, not boot's. */
59
+ export class UpgradeRequiredError extends Error {
60
+ constructor(readonly check: CheckReport) {
61
+ super(
62
+ `schema/ changes need data work before they can apply (${check.outstanding.length} finding(s), ${check.classification}): ` +
63
+ 'run `crombolo upgrade check`, then `crombolo upgrade check --fix`, then `crombolo upgrade`.',
64
+ )
65
+ }
66
+ }
67
+
68
+ /** The check over the site's diff, with its value migrations loaded. */
69
+ export async function checkSchemaDirectory(
70
+ db: Db,
71
+ config: CromboloConfig,
72
+ node: { nodeId: string; revision: string },
73
+ options: { set?: Record<string, unknown> } = {},
74
+ ): Promise<CheckReport> {
75
+ const loaded = loadSchemaDirectory(config.schemaDir)
76
+ return runCheck(
77
+ db,
78
+ loaded,
79
+ { nodeId: node.nodeId, version: loaded.set.version, revision: node.revision },
80
+ {
81
+ ...syncOptionsFor(config),
82
+ set: options.set,
83
+ migrations: await loadValueMigrations(config.schemaDir),
84
+ backOfficePath: config.backOfficePath,
85
+ },
86
+ )
87
+ }
88
+
89
+ export class SchemaBootError extends Error {
90
+ constructor(readonly report: SyncReport) {
91
+ super(
92
+ report.problems.length > 0
93
+ ? `schema/ has problems:\n${report.problems.map((p) => ` ${p.file}: ${p.path ? `${p.path}: ` : ''}${p.message}`).join('\n')}`
94
+ : `schema sync refused: ${report.reason ?? 'unknown reason'}`,
95
+ )
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The components a schema may name as templates: every one of them, at any
101
+ * depth, keyed by the path that is its alias.
102
+ */
103
+ export function componentAliasesIn(componentsDir: string): Set<string> {
104
+ return new Set(listComponents(componentsDir).map((component) => component.alias))
105
+ }
106
+
107
+ export function syncOptionsFor(config: CromboloConfig): SyncOptions {
108
+ return {
109
+ mode: config.development ? 'development' : 'production',
110
+ componentAliases: componentAliasesIn(config.componentsDir),
111
+ }
112
+ }
113
+
114
+ /** One sync of the directory, with key write-back in development. */
115
+ export async function syncSchemaDirectory(
116
+ db: Db,
117
+ config: CromboloConfig,
118
+ node: { nodeId: string; version?: string; revision: string },
119
+ options: Partial<SyncOptions> = {},
120
+ ): Promise<SyncReport> {
121
+ // The files are read under the lock the backoffice's schema writes hold, so a
122
+ // sync never applies a snapshot that a later backoffice change has overtaken.
123
+ return db.locks.withLock(Locks.Schema, async () => {
124
+ const loaded = loadSchemaDirectory(config.schemaDir)
125
+ const report = await syncSchema(
126
+ db,
127
+ loaded,
128
+ { nodeId: node.nodeId, version: loaded.set.version, revision: node.revision },
129
+ { ...syncOptionsFor(config), ...options },
130
+ )
131
+ if (
132
+ report.action === 'applied' &&
133
+ config.schemaWritable &&
134
+ report.assignedKeys.length > 0 &&
135
+ report.state
136
+ ) {
137
+ writeKeysBack(loaded, report.assignedKeys)
138
+ await updateSchemaStateHash(db, report.state.id, hashSchemaSet(loaded.set))
139
+ }
140
+ return report
141
+ })
142
+ }
143
+
144
+ export async function bootSchema(db: Db, config: CromboloConfig): Promise<SchemaBoot> {
145
+ const nodeId = config.nodeId
146
+ if (!existsSync(config.schemaDir)) {
147
+ const nodeState = { version: '0', revision: config.schemaRevision }
148
+ await touchServer(db, { nodeId, ...nodeState, role: config.role })
149
+ await ensureSystemMediaTypes(db, { linkFolderChildren: true })
150
+ await ensureSystemMemberTypes(db)
151
+ await ensureSystemContentTypes(db)
152
+ return { nodeState, nodeId, report: undefined, compatibilityMode: false }
153
+ }
154
+ const loaded = loadSchemaDirectory(config.schemaDir)
155
+ const nodeState = { version: loaded.set.version, revision: config.schemaRevision }
156
+ await touchServer(db, { nodeId, ...nodeState, role: config.role })
157
+ if (!config.syncSchemaAtBoot)
158
+ return { nodeState, nodeId, report: undefined, compatibilityMode: false }
159
+
160
+ const node = { nodeId, revision: config.schemaRevision }
161
+ if (!config.development) {
162
+ // A rolling deploy expands; anything needing data refuses and names the command.
163
+ const check = await checkSchemaDirectory(db, config, node)
164
+ if (check.sync.action === 'would-apply' && check.outstanding.length > 0)
165
+ throw new UpgradeRequiredError(check)
166
+ }
167
+ const report = await syncSchemaDirectory(db, config, node)
168
+ if (report.action === 'refused') throw new SchemaBootError(report)
169
+ // Folder allows whichever built-in media types the site has — unless a file
170
+ // defines Folder, and so says itself what it allows.
171
+ const folderInFiles = (loaded.set.mediaTypes ?? []).some((t) => t.alias === 'Folder')
172
+ await ensureSystemMediaTypes(db, { linkFolderChildren: !folderInFiles })
173
+ await ensureSystemMemberTypes(db)
174
+ await ensureSystemContentTypes(db)
175
+ const boot: SchemaBoot = {
176
+ nodeState,
177
+ nodeId,
178
+ report,
179
+ compatibilityMode: report.action === 'skipped-older',
180
+ }
181
+ if (config.development) {
182
+ // pending-allowed: the findings go to the dashboard, and boot continues.
183
+ boot.check = await checkSchemaDirectory(db, config, node)
184
+ await writeReport(db, boot.check.findings, { source: 'upgrade' })
185
+ }
186
+ return boot
187
+ }
188
+
189
+ export interface SchemaWatcher {
190
+ close(): void
191
+ }
192
+
193
+ /** Re-syncs on every change under `schema/`; development only. */
194
+ export function watchSchema(
195
+ db: Db,
196
+ config: CromboloConfig,
197
+ node: { nodeId: string; revision: string },
198
+ onReport: (report: SyncReport) => void,
199
+ ): SchemaWatcher | undefined {
200
+ if (!existsSync(config.schemaDir)) return undefined
201
+ let timer: ReturnType<typeof setTimeout> | undefined
202
+ let running = false
203
+ let again = false
204
+ let closed = false
205
+ const run = async () => {
206
+ if (closed) return
207
+ if (running) {
208
+ again = true
209
+ return
210
+ }
211
+ running = true
212
+ try {
213
+ onReport(await syncSchemaDirectory(db, config, node))
214
+ } catch (error) {
215
+ if (!closed) logger('schema').error('Watching the schema files failed', { error })
216
+ } finally {
217
+ running = false
218
+ if (again) {
219
+ again = false
220
+ void run()
221
+ }
222
+ }
223
+ }
224
+ const watcher = watch(config.schemaDir, { recursive: true }, (_event, file) => {
225
+ if (file && !String(file).endsWith('.toml')) return
226
+ clearTimeout(timer)
227
+ timer = setTimeout(() => void run(), 150)
228
+ })
229
+ watcher.unref()
230
+ return {
231
+ close() {
232
+ closed = true
233
+ clearTimeout(timer)
234
+ watcher.close()
235
+ },
236
+ }
237
+ }
238
+
239
+ export interface CachePollerOptions {
240
+ db: Db
241
+ nodeId: string
242
+ nodeState: NodeSchemaState
243
+ /** Recorded in `server` on each poll, so the cluster can be read back by role. */
244
+ role?: string
245
+ /** Called for instructions other nodes appended. */
246
+ onInstruction(kind: CacheInstructionKind, payload: Record<string, unknown>): void
247
+ }
248
+
249
+ /** Polls `cache_instruction` from where this node came in; own instructions are skipped. */
250
+ export class CacheInstructionPoller {
251
+ #options: CachePollerOptions
252
+ #cursor = 0
253
+ #timer: ReturnType<typeof setInterval> | undefined
254
+ #behind = false
255
+ #bootLedgerId = 0
256
+ #readsUnsafe = false
257
+ #paused: WritesPaused | undefined
258
+
259
+ /** True once an upgrade has moved the database past this node: reads only. */
260
+ get behind(): boolean {
261
+ return this.#behind
262
+ }
263
+
264
+ /**
265
+ * True once the node is behind and a contract — a framework contract or a
266
+ * site purge — has run since it booted. Expand-only changes leave an old
267
+ * node's reads correct, as-of its own state; a contract removes something it
268
+ * may still read, so only then must it stop serving.
269
+ */
270
+ get readsUnsafe(): boolean {
271
+ return this.#readsUnsafe
272
+ }
273
+
274
+ /** Set while an operator has paused editing cluster-wide. */
275
+ get paused(): WritesPaused | undefined {
276
+ return this.#paused
277
+ }
278
+
279
+ constructor(options: CachePollerOptions) {
280
+ this.#options = options
281
+ }
282
+
283
+ async start(intervalMs: number): Promise<void> {
284
+ this.#bootLedgerId = await latestLedgerId(this.#options.db)
285
+ this.#paused = await readWritesPaused(this.#options.db)
286
+ this.#cursor = await latestCacheInstructionId(this.#options.db)
287
+ if (intervalMs > 0) {
288
+ this.#timer = setInterval(() => void this.poll().catch(() => {}), intervalMs)
289
+ this.#timer.unref()
290
+ }
291
+ }
292
+
293
+ /** Returns how many foreign instructions were applied. */
294
+ async poll(): Promise<number> {
295
+ const { db, nodeId, nodeState, onInstruction } = this.#options
296
+ const instructions = await cacheInstructionsAfter(db, this.#cursor)
297
+ let applied = 0
298
+ for (const instruction of instructions) {
299
+ this.#cursor = Math.max(this.#cursor, instruction.id)
300
+ if (instruction.createdBy === nodeId) continue
301
+ onInstruction(instruction.kind, instruction.payload)
302
+ applied += 1
303
+ }
304
+ await touchServer(db, { nodeId, ...nodeState, role: this.#options.role })
305
+ const current = await currentSchemaState(db)
306
+ const behind = compareStates(nodeState, current) < 0
307
+ if (behind && !this.#behind) onInstruction('schema', { stateId: current.id })
308
+ this.#behind = behind
309
+ // Sticky: once a contract has passed this node, no later poll makes its
310
+ // reads trustworthy again — only a replacement node does.
311
+ if (behind && !this.#readsUnsafe)
312
+ this.#readsUnsafe = (await contractsLedgeredAfter(db, this.#bootLedgerId)) > 0
313
+ this.#paused = await readWritesPaused(db)
314
+ return applied
315
+ }
316
+
317
+ stop(): void {
318
+ if (this.#timer) clearInterval(this.#timer)
319
+ this.#timer = undefined
320
+ }
321
+ }
322
+
323
+ export interface SchemaImportResult {
324
+ /** Files copied down from the store; undefined when the schema is a directory. */
325
+ materialised: number | undefined
326
+ /** How the files compare with the live database, from the same check that gates an upgrade. */
327
+ classification: ChangeClass
328
+ /** Findings a person should see before this is applied. */
329
+ findings: Finding[]
330
+ /** Undefined for a dry run, which asks what would happen without doing it. */
331
+ report: SyncReport | undefined
332
+ }
333
+
334
+ /**
335
+ * Applies the schema files to the database at runtime, rather than only at boot.
336
+ *
337
+ * This is what makes the files the source of truth rather than a projection of
338
+ * the database: a TOML edited in a commit, published by another node, or written
339
+ * by the backoffice arrives here and is imported the same way, through one path.
340
+ *
341
+ * Deliberately never automatic. It runs when someone asks, or when an operation
342
+ * needs it — committing an assistant changeset, publishing type changes — because
343
+ * importing a half-finished schema underneath an editor mid-save is worse than
344
+ * importing a minute later.
345
+ *
346
+ * `nodeState` is refreshed in place on success: the poller, `/health` and the
347
+ * published-content source all hold that one object, and without it a node that
348
+ * has just imported its own change would immediately judge itself behind and
349
+ * drain.
350
+ */
351
+ export async function importSchema(
352
+ db: Db,
353
+ config: CromboloConfig,
354
+ boot: { nodeId: string; nodeState: NodeSchemaState },
355
+ options: { dryRun?: boolean } = {},
356
+ ): Promise<SchemaImportResult> {
357
+ let materialised: number | undefined
358
+ if (config.schemaStore) {
359
+ materialised = (await materialiseSchema(config.schemaStore, config.schemaCacheDir)).length
360
+ config.schemaDir = config.schemaCacheDir
361
+ }
362
+ if (!existsSync(config.schemaDir)) {
363
+ return { materialised, classification: 'none', findings: [], report: undefined }
364
+ }
365
+
366
+ const node = { nodeId: boot.nodeId, revision: config.schemaRevision }
367
+ const check = await checkSchemaDirectory(db, config, node)
368
+ if (options.dryRun) {
369
+ return {
370
+ materialised,
371
+ classification: check.classification,
372
+ findings: check.outstanding,
373
+ report: undefined,
374
+ }
375
+ }
376
+
377
+ const report = await syncSchemaDirectory(db, config, node)
378
+ if (report.action === 'applied') {
379
+ boot.nodeState.version = loadSchemaDirectory(config.schemaDir).set.version
380
+ boot.nodeState.revision = config.schemaRevision
381
+ }
382
+ return {
383
+ materialised,
384
+ classification: check.classification,
385
+ findings: check.outstanding,
386
+ report,
387
+ }
388
+ }