@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.
- package/LICENSE +21 -0
- package/NOTICE +69 -0
- package/package.json +41 -4
- package/src/access.ts +114 -0
- package/src/adapters/assistant.ts +229 -0
- package/src/adapters/blueprints.ts +163 -0
- package/src/adapters/bundles.ts +432 -0
- package/src/adapters/content.ts +783 -0
- package/src/adapters/dictionary.ts +76 -0
- package/src/adapters/documents.ts +637 -0
- package/src/adapters/elements.ts +403 -0
- package/src/adapters/file-system.ts +195 -0
- package/src/adapters/media.ts +347 -0
- package/src/adapters/member-groups.ts +90 -0
- package/src/adapters/members.ts +261 -0
- package/src/adapters/public-access.ts +116 -0
- package/src/adapters/redirects.ts +73 -0
- package/src/adapters/references.ts +67 -0
- package/src/adapters/schema-files.ts +204 -0
- package/src/adapters/schema-proposals.ts +180 -0
- package/src/adapters/template-files.ts +58 -0
- package/src/adapters/template-query.ts +127 -0
- package/src/adapters/users.ts +963 -0
- package/src/assistant.ts +487 -0
- package/src/backup.ts +52 -0
- package/src/bundles.ts +508 -0
- package/src/component-snippets.ts +163 -0
- package/src/components.ts +266 -0
- package/src/config.ts +548 -0
- package/src/content-type-udt.ts +256 -0
- package/src/database.ts +109 -0
- package/src/diagnostics.ts +143 -0
- package/src/dictionary-transfer.ts +123 -0
- package/src/domains.ts +384 -0
- package/src/editor-types.ts +108 -0
- package/src/email-http.ts +79 -0
- package/src/email-notice.ts +36 -0
- package/src/email-postmark.ts +78 -0
- package/src/email-resend.ts +69 -0
- package/src/email-ses.ts +307 -0
- package/src/email.ts +402 -0
- package/src/extensions-notice.ts +35 -0
- package/src/file-intake.ts +84 -0
- package/src/form-files.ts +241 -0
- package/src/form-submit.ts +397 -0
- package/src/form-workflows.ts +397 -0
- package/src/forms.ts +170 -0
- package/src/git-github.ts +171 -0
- package/src/git-routes.ts +154 -0
- package/src/git.ts +88 -0
- package/src/imaging.ts +434 -0
- package/src/index.ts +62 -0
- package/src/jobs.ts +140 -0
- package/src/listen.ts +36 -0
- package/src/log-expressions.ts +524 -0
- package/src/log-viewer.ts +190 -0
- package/src/logging.ts +126 -0
- package/src/marketplace.ts +297 -0
- package/src/media-files.ts +276 -0
- package/src/media-store-azure.ts +218 -0
- package/src/media-store-s3.ts +113 -0
- package/src/media-store.ts +248 -0
- package/src/member-auth.ts +384 -0
- package/src/models-builder.ts +106 -0
- package/src/no-nodes.ts +64 -0
- package/src/not-implemented.ts +44 -0
- package/src/oembed.ts +144 -0
- package/src/ports.ts +553 -0
- package/src/redirects.ts +132 -0
- package/src/schema-classify.ts +82 -0
- package/src/schema-store.ts +145 -0
- package/src/schema.ts +388 -0
- package/src/seo.ts +371 -0
- package/src/server-events.ts +131 -0
- package/src/server.ts +1579 -0
- package/src/status.ts +246 -0
- package/src/udt.ts +168 -0
- package/src/value-schemas.ts +64 -0
- package/src/view-runtime.ts +42 -0
- package/src/zip.ts +152 -0
- 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
|
+
}
|