@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,116 @@
1
+ /**
2
+ * Public access: protecting a branch by member group or by named member.
3
+ *
4
+ * The stored rules are names, not keys, because that is what a signed-in member
5
+ * carries and what the render path compares against. Reading an entry back
6
+ * therefore resolves each name to the member or group the editor should show —
7
+ * and a name that no longer resolves simply drops out, which is exactly what a
8
+ * renamed group means for the rule.
9
+ */
10
+ import type {
11
+ MemberGroupItem,
12
+ MemberItem,
13
+ PublicAccessInput,
14
+ PublicAccessPort,
15
+ PublicAccessResponse,
16
+ PublicAccessStatus,
17
+ } from '@crombolo/api-management'
18
+ import { ObjectTypes } from '@crombolo/core'
19
+ import {
20
+ type Db,
21
+ MemberRepository,
22
+ NodeRepository,
23
+ type PublicAccessEntry,
24
+ PublicAccessRepository,
25
+ } from '@crombolo/data'
26
+
27
+ export function createPublicAccessPort(
28
+ db: Db,
29
+ options: { onChange?: () => void | Promise<void> } = {},
30
+ ): PublicAccessPort {
31
+ const access = new PublicAccessRepository(db)
32
+ const members = new MemberRepository(db)
33
+ const nodes = new NodeRepository(db)
34
+
35
+ const isDocument = async (key: string) => {
36
+ const node = await nodes.byKey(key)
37
+ return node?.objectType === ObjectTypes.Document
38
+ }
39
+
40
+ const groupsNamed = async (names: readonly string[]): Promise<MemberGroupItem[]> => {
41
+ const found: MemberGroupItem[] = []
42
+ for (const name of names) {
43
+ const rows = await db.query<{ unique_id: string; text: string | null }>(
44
+ 'SELECT unique_id, text FROM node WHERE node_object_type = ? AND text = ?',
45
+ [ObjectTypes.MemberGroup, name],
46
+ )
47
+ const row = rows[0]
48
+ if (row) found.push({ id: String(row.unique_id), name: String(row.text ?? ''), flags: [] })
49
+ }
50
+ return found
51
+ }
52
+
53
+ const membersNamed = async (usernames: readonly string[]): Promise<MemberItem[]> => {
54
+ const found: MemberItem[] = []
55
+ for (const username of usernames) {
56
+ const member = await members.byUsername(username)
57
+ if (member)
58
+ found.push({
59
+ id: member.key,
60
+ memberType: {
61
+ id: member.contentTypeKey,
62
+ icon: member.contentTypeIcon ?? 'icon-user',
63
+ },
64
+ variants: [{ name: member.name, culture: null }],
65
+ kind: 'Default',
66
+ flags: [],
67
+ })
68
+ }
69
+ return found
70
+ }
71
+
72
+ const toResponse = async (
73
+ entry: PublicAccessEntry,
74
+ requestedKey: string,
75
+ ): Promise<PublicAccessResponse> => ({
76
+ loginDocument: { id: entry.loginNodeKey },
77
+ errorDocument: { id: entry.errorNodeKey },
78
+ members: await membersNamed(entry.memberUserNames),
79
+ groups: await groupsNamed(entry.memberGroupNames),
80
+ isProtectedByAncestor: entry.nodeKey !== requestedKey.toLowerCase(),
81
+ })
82
+
83
+ return {
84
+ async entry(documentKey, includeAncestors) {
85
+ if (!(await isDocument(documentKey))) return { status: 'content-not-found' }
86
+ const found = includeAncestors
87
+ ? await access.entryFor(documentKey)
88
+ : await access.byNodeKey(documentKey)
89
+ if (!found) return { status: 'entry-not-found' }
90
+ return { status: 'ok', entry: await toResponse(found, documentKey) }
91
+ },
92
+
93
+ async save(documentKey, input: PublicAccessInput): Promise<PublicAccessStatus> {
94
+ if (!(await isDocument(documentKey))) return 'content-not-found'
95
+ if (!(await isDocument(input.loginDocumentKey))) return 'login-node-not-found'
96
+ if (!(await isDocument(input.errorDocumentKey))) return 'error-node-not-found'
97
+ const saved = await access.save({
98
+ nodeKey: documentKey,
99
+ loginNodeKey: input.loginDocumentKey,
100
+ errorNodeKey: input.errorDocumentKey,
101
+ memberGroupNames: input.memberGroupNames,
102
+ memberUserNames: input.memberUserNames,
103
+ })
104
+ if (!saved) return 'content-not-found'
105
+ await options.onChange?.()
106
+ return 'ok'
107
+ },
108
+
109
+ async remove(documentKey): Promise<PublicAccessStatus> {
110
+ if (!(await isDocument(documentKey))) return 'content-not-found'
111
+ if (!(await access.remove(documentKey))) return 'entry-not-found'
112
+ await options.onChange?.()
113
+ return 'ok'
114
+ },
115
+ }
116
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The redirect rows as the backoffice reads them.
3
+ *
4
+ * `destinationUrl` is resolved for display: a rule pointing at a document shows
5
+ * where that document lives now, so the dashboard never shows a stale target. A
6
+ * rule the site configured is reported as such and refuses to be deleted — the
7
+ * config file owns it, and the next boot would put it back.
8
+ */
9
+
10
+ import type { RedirectListing, RedirectPort } from '@crombolo/api-management'
11
+ import type { Page } from '@crombolo/core'
12
+ import { type Db, RedirectRepository, type RedirectRow } from '@crombolo/data'
13
+ import type { PublishedCache } from '@crombolo/render'
14
+
15
+ export function createRedirectPort(
16
+ db: Db,
17
+ cache: PublishedCache | undefined,
18
+ options: { isTracking: boolean; onChange?: () => void },
19
+ ): RedirectPort {
20
+ const redirects = new RedirectRepository(db)
21
+
22
+ /** The pattern as a visitor would type it, hostname included when one scopes it. */
23
+ const originalUrl = async (row: RedirectRow): Promise<string> => {
24
+ const suffix =
25
+ row.matchKind === 'prefix' ? `${row.pattern === '/' ? '' : row.pattern}/*` : row.pattern
26
+ if (row.rootKey === null) return suffix
27
+ const snapshot = await cache?.snapshot()
28
+ const domain = snapshot?.domains.find(
29
+ (d) => !d.isWildcard && snapshot.domainRoots.get(d.nodeId) === row.rootKey,
30
+ )
31
+ return domain ? `//${domain.domainName}${suffix === '/' ? '/' : suffix}` : suffix
32
+ }
33
+
34
+ const destinationUrl = async (row: RedirectRow): Promise<string> => {
35
+ if (row.targetKind !== 'document') return row.target
36
+ const content = await cache?.byKey(row.target, row.culture)
37
+ return content && content.url !== '#' ? content.url : ''
38
+ }
39
+
40
+ const listing = async (row: RedirectRow): Promise<RedirectListing> => ({
41
+ key: row.key,
42
+ originalUrl: await originalUrl(row),
43
+ destinationUrl: await destinationUrl(row),
44
+ culture: row.culture,
45
+ documentKey: row.targetKind === 'document' ? row.target : null,
46
+ created: row.createDate,
47
+ isConfigured: row.source === 'config',
48
+ })
49
+
50
+ const page = async (result: {
51
+ total: number
52
+ items: RedirectRow[]
53
+ }): Promise<Page<RedirectListing>> => ({
54
+ total: result.total,
55
+ items: await Promise.all(result.items.map(listing)),
56
+ })
57
+
58
+ return {
59
+ list: async (filter, paging) => page(await redirects.list({ filter, ...paging })),
60
+ byDocument: async (key, paging) => page(await redirects.byDocument(key, paging)),
61
+ async remove(key) {
62
+ const found = await redirects.byKey(key)
63
+ if (!found) return 'notFound'
64
+ if (found.source === 'config') return 'configured'
65
+ await redirects.delete(key)
66
+ // The rules live in the published cache's snapshot, so it has to be dropped
67
+ // for the redirect to stop answering.
68
+ options.onChange?.()
69
+ return 'deleted'
70
+ },
71
+ isTracking: () => options.isTracking,
72
+ }
73
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * References, shaped for the wire. The client discriminates the list by
3
+ * `$type`, so a referencing node becomes a document, a media item, or the
4
+ * default shape when it is neither.
5
+ */
6
+ import type { ReferencePort } from '@crombolo/api-management'
7
+ import { ObjectTypes } from '@crombolo/core'
8
+ import { type Db, ReferenceRepository, type ReferencingNode } from '@crombolo/data'
9
+
10
+ function toReference(node: ReferencingNode): unknown {
11
+ const type = {
12
+ id: node.contentTypeKey ?? node.key,
13
+ icon: node.contentTypeIcon,
14
+ alias: node.contentTypeAlias,
15
+ name: node.contentTypeAlias,
16
+ }
17
+ if (node.objectType === ObjectTypes.Document)
18
+ return {
19
+ $type: 'DocumentReferenceResponseModel',
20
+ id: node.key,
21
+ name: node.name,
22
+ published: node.published,
23
+ documentType: type,
24
+ // Invariant until a reference needs to name the culture it came from.
25
+ variants: [
26
+ {
27
+ id: node.key,
28
+ name: node.name ?? '',
29
+ culture: null,
30
+ state: node.published ? 'Published' : 'Draft',
31
+ flags: [],
32
+ },
33
+ ],
34
+ }
35
+ if (node.objectType === ObjectTypes.Media)
36
+ return {
37
+ $type: 'MediaReferenceResponseModel',
38
+ id: node.key,
39
+ name: node.name,
40
+ mediaType: type,
41
+ }
42
+ return {
43
+ $type: 'DefaultReferenceResponseModel',
44
+ id: node.key,
45
+ name: node.name,
46
+ type: node.contentTypeAlias,
47
+ icon: node.contentTypeIcon,
48
+ }
49
+ }
50
+
51
+ export function createReferencePort(db: Db): ReferencePort {
52
+ const references = new ReferenceRepository(db)
53
+ return {
54
+ async referencedBy(key, paging) {
55
+ const result = await references.referencedBy(key, paging.skip, paging.take)
56
+ return { total: result.total, items: result.items.map(toReference) }
57
+ },
58
+ async referencedDescendants(key, paging) {
59
+ const result = await references.referencedDescendants(key, paging.skip, paging.take)
60
+ return { total: result.total, items: result.items.map((id) => ({ id })) }
61
+ },
62
+ async areReferenced(keys, paging) {
63
+ const result = await references.areReferenced(keys, paging.skip, paging.take)
64
+ return { total: result.total, items: result.items.map((id) => ({ id })) }
65
+ },
66
+ }
67
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * The backoffice writes schema files. A document-type save lands in the
3
+ * database as before and, when the directory is writable, in
4
+ * `schema/content-types/<alias>.toml` — the file the next boot will sync.
5
+ * docs/09-schema-as-code.md, "The backoffice writes files".
6
+ */
7
+
8
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { join, relative } from 'node:path'
10
+ import type { PortFailure } from '@crombolo/api-management'
11
+ import type { ContentTypeAggregate, DataTypeModel } from '@crombolo/core'
12
+ import { currentSchemaState, type Db, updateSchemaStateHash } from '@crombolo/data'
13
+ import {
14
+ type ChangeClass,
15
+ exportSchemaSet,
16
+ fileNameFor,
17
+ hashSchemaSet,
18
+ loadSchemaDirectory,
19
+ nextSchemaVersion,
20
+ type SchemaTypeKind,
21
+ TYPE_KIND_FILES,
22
+ validateSchemaSet,
23
+ writeContentType,
24
+ writeDataType,
25
+ writeLanguages,
26
+ writeSchemaFiles,
27
+ writeSchemaVersion,
28
+ } from '@crombolo/schema'
29
+ import { logger } from '../logging.ts'
30
+
31
+ export interface SchemaFiles {
32
+ schemaDir: string
33
+ writable: boolean
34
+ componentAliases(): ReadonlySet<string>
35
+ /**
36
+ * Called after any write, so a shared schema store outlives this container.
37
+ * Absent when the schema is a directory on disk, which needs no publishing.
38
+ */
39
+ onChanged?(): Promise<void>
40
+ }
41
+
42
+ export interface SchemaFileWriter {
43
+ /** A read-only directory refuses the save before the database changes. */
44
+ refuse(aggregate: { alias: string }): PortFailure | undefined
45
+ /**
46
+ * Moves the site's schema version on by what the change costs the content,
47
+ * before the database is written and while the two can still be compared.
48
+ * `breaking` is a major: getting there converted data.
49
+ */
50
+ bumpVersion(classification: ChangeClass): Promise<void>
51
+ /** Writes the saved type canonically; an alias change removes the old file. */
52
+ write(aggregate: ContentTypeAggregate, previousAlias?: string): Promise<void>
53
+ remove(alias: string): Promise<void>
54
+ writeDataType(model: DataTypeModel): Promise<void>
55
+ removeDataType(alias: string): Promise<void>
56
+ writeLanguages(): Promise<void>
57
+ /** After a move or a folder change: every file, from the database, canonically. */
58
+ rewriteAll(): Promise<void>
59
+ }
60
+
61
+ /**
62
+ * A writer for one kind of content type: content types (the default) write
63
+ * `schema/content-types/`, media types `schema/media-types/`. Data types,
64
+ * languages and the rewrite are the same whichever kind asks.
65
+ */
66
+ export function createSchemaFileWriter(
67
+ db: Db,
68
+ files: SchemaFiles,
69
+ kind: SchemaTypeKind = 'document',
70
+ ): SchemaFileWriter {
71
+ const dir = join(files.schemaDir, TYPE_KIND_FILES[kind].dir)
72
+ const fileFor = (alias: string) => join(dir, fileNameFor(alias))
73
+ const plural = { document: 'Content types', media: 'Media types', member: 'Member types' }[kind]
74
+ const managed = () => existsSync(files.schemaDir)
75
+
76
+ /**
77
+ * The database already matches the files; record their hash so the next sync is
78
+ * a no-op, then hand the files to wherever they durably live.
79
+ */
80
+ const settle = async () => {
81
+ const state = await currentSchemaState(db)
82
+ await updateSchemaStateHash(
83
+ db,
84
+ state.id,
85
+ hashSchemaSet(loadSchemaDirectory(files.schemaDir).set),
86
+ )
87
+ await files.onChanged?.()
88
+ }
89
+
90
+ return {
91
+ refuse(aggregate) {
92
+ if (!managed() || files.writable) return undefined
93
+ const shown = relative(process.cwd(), fileFor(aggregate.alias))
94
+ return {
95
+ ok: false,
96
+ status: 409,
97
+ reason: `${plural} are defined in schema/ and this environment is read-only; change ${shown} in source control.`,
98
+ }
99
+ },
100
+
101
+ async bumpVersion(classification) {
102
+ if (!managed() || !files.writable || classification === 'none') return
103
+ const loaded = loadSchemaDirectory(files.schemaDir)
104
+ const next = nextSchemaVersion(loaded.set.version, classification)
105
+ if (next === loaded.set.version) return
106
+ writeFileSync(join(files.schemaDir, 'schema.toml'), writeSchemaVersion(next))
107
+ logger('schema').info('Schema version {from} → {to} ({classification})', {
108
+ from: loaded.set.version,
109
+ to: next,
110
+ classification,
111
+ })
112
+ },
113
+
114
+ async write(aggregate, previousAlias) {
115
+ if (!managed() || !files.writable) return
116
+ const set = await exportSchemaSet(db, loadSchemaDirectory(files.schemaDir).set.version)
117
+ const list =
118
+ kind === 'document'
119
+ ? set.contentTypes
120
+ : kind === 'media'
121
+ ? (set.mediaTypes ?? [])
122
+ : (set.memberTypes ?? [])
123
+ const type = list.find((t) => t.key === aggregate.key)
124
+ if (!type) return
125
+ const problems = validateSchemaSet(set, { componentAliases: files.componentAliases() })
126
+ if (problems.length > 0) {
127
+ const detail = problems.map((p) => `${p.file}: ${p.message}`).join('; ')
128
+ logger('schema').error('Not writing {file}: {detail}', {
129
+ file: fileNameFor(aggregate.alias),
130
+ detail,
131
+ })
132
+ return
133
+ }
134
+ mkdirSync(dir, { recursive: true })
135
+ if (previousAlias && previousAlias !== aggregate.alias)
136
+ rmSync(fileFor(previousAlias), { force: true })
137
+ writeFileSync(fileFor(aggregate.alias), writeContentType(type, kind))
138
+ await settle()
139
+ },
140
+
141
+ async remove(alias) {
142
+ if (!managed() || !files.writable) return
143
+ rmSync(fileFor(alias), { force: true })
144
+ await settle()
145
+ },
146
+
147
+ async writeDataType(model) {
148
+ if (!managed() || !files.writable || !model.alias) return
149
+ const set = await exportSchemaSet(db, loadSchemaDirectory(files.schemaDir).set.version)
150
+ const dataType = set.dataTypes.find((d) => d.key === model.key)
151
+ // An unchanged built-in has no file; a changed one does.
152
+ if (!dataType) {
153
+ rmSync(join(files.schemaDir, 'data-types', fileNameFor(model.alias)), { force: true })
154
+ await settle()
155
+ return
156
+ }
157
+ // The same guard `write` applies to a content type: a set that does not
158
+ // validate never reaches the disk, because the file is what every other
159
+ // node reads and what a deploy boots from.
160
+ const problems = validateSchemaSet(set, { componentAliases: files.componentAliases() })
161
+ if (problems.length > 0) {
162
+ const detail = problems.map((p) => `${p.file}: ${p.message}`).join('; ')
163
+ logger('schema').error('Not writing {file}: {detail}', {
164
+ file: fileNameFor(model.alias),
165
+ detail,
166
+ })
167
+ return
168
+ }
169
+ mkdirSync(join(files.schemaDir, 'data-types'), { recursive: true })
170
+ writeFileSync(
171
+ join(files.schemaDir, 'data-types', fileNameFor(model.alias)),
172
+ writeDataType(dataType),
173
+ )
174
+ await settle()
175
+ },
176
+
177
+ async removeDataType(alias) {
178
+ if (!managed() || !files.writable) return
179
+ rmSync(join(files.schemaDir, 'data-types', fileNameFor(alias)), { force: true })
180
+ await settle()
181
+ },
182
+
183
+ async writeLanguages() {
184
+ if (!managed() || !files.writable) return
185
+ const set = await exportSchemaSet(db, loadSchemaDirectory(files.schemaDir).set.version)
186
+ writeFileSync(join(files.schemaDir, 'languages.toml'), writeLanguages(set.languages))
187
+ await settle()
188
+ },
189
+
190
+ async rewriteAll() {
191
+ if (!managed() || !files.writable) return
192
+ const loaded = loadSchemaDirectory(files.schemaDir)
193
+ const set = await exportSchemaSet(db, loaded.set.version)
194
+ const problems = validateSchemaSet(set, { componentAliases: files.componentAliases() })
195
+ if (problems.length > 0) {
196
+ const detail = problems.map((p) => `${p.file}: ${p.message}`).join('; ')
197
+ logger('schema').error('Not rewriting the schema files: {detail}', { detail })
198
+ return
199
+ }
200
+ writeSchemaFiles(files.schemaDir, set)
201
+ await settle()
202
+ },
203
+ }
204
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Schema proposals as the TOML files they become.
3
+ *
4
+ * A type is a file in `schema/`, and that file is what gets committed, so a
5
+ * proposal holds the file rather than a request body full of keys. Applying one
6
+ * writes it and imports it — the same path a commit or another node's publish
7
+ * takes — instead of writing the database and deriving the file from it
8
+ * afterwards, which is the wrong way round and is why an editor's change could
9
+ * never be classified.
10
+ *
11
+ * Validation happens twice on purpose: when the proposal is made, so a reviewer
12
+ * is never shown something that cannot be applied, and again at approval, because
13
+ * the rest of the schema may have moved in between.
14
+ */
15
+
16
+ import { existsSync } from 'node:fs'
17
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
18
+ import { dirname, join } from 'node:path'
19
+ import type { SchemaProposalPort } from '@crombolo/assistant'
20
+ import type { Db } from '@crombolo/data'
21
+ import {
22
+ fileNameFor,
23
+ loadSchemaDirectory,
24
+ nextSchemaVersion,
25
+ parseContentType,
26
+ parseDataType,
27
+ type SchemaProblem,
28
+ TYPE_KIND_FILES,
29
+ validateSchemaSet,
30
+ writeContentType,
31
+ writeDataType,
32
+ writeSchemaVersion,
33
+ } from '@crombolo/schema'
34
+ import type { CromboloConfig } from '../config.ts'
35
+ import { logger } from '../logging.ts'
36
+ import { componentAliasesIn, importSchema } from '../schema.ts'
37
+
38
+ const log = logger('schema')
39
+
40
+ const isDataType = (kind: string) => kind.startsWith('data-type')
41
+
42
+ /** Where a kind's files live, relative to the schema directory. */
43
+ const directoryFor = (kind: string) =>
44
+ isDataType(kind) ? 'data-types' : TYPE_KIND_FILES.document.dir
45
+
46
+ export interface SchemaProposalOptions {
47
+ db: Db
48
+ config: CromboloConfig
49
+ /** The node's identity and state, refreshed in place by an import. */
50
+ boot: { nodeId: string; nodeState: { version: string; revision: string } }
51
+ }
52
+
53
+ export function createSchemaProposals(options: SchemaProposalOptions): SchemaProposalPort {
54
+ const { config } = options
55
+ const pathFor = (kind: string, alias: string) =>
56
+ join(config.schemaDir, directoryFor(kind), fileNameFor(alias))
57
+
58
+ /**
59
+ * The proposal's file substituted into the schema as it stands, so a property
60
+ * pointing at a data type that does not exist, or a composition that would
61
+ * cycle, is a problem now rather than a surprise at approval.
62
+ */
63
+ const validate = async (kind: string, alias: string, toml: string): Promise<SchemaProblem[]> => {
64
+ const file = `schema/${directoryFor(kind)}/${fileNameFor(alias)}`
65
+ if (isDataType(kind)) {
66
+ const parsed = parseDataType(file, toml)
67
+ const dataType = parsed.value
68
+ if (parsed.problems.length > 0 || !dataType) {
69
+ return parsed.problems.length > 0
70
+ ? parsed.problems
71
+ : [{ file, path: '', message: 'the file defines no data type' }]
72
+ }
73
+ const set = loadSchemaDirectory(config.schemaDir).set
74
+ const dataTypes = [
75
+ ...set.dataTypes.filter((existing) => existing.alias !== dataType.alias),
76
+ dataType,
77
+ ]
78
+ return validateSchemaSet({ ...set, dataTypes })
79
+ }
80
+
81
+ const parsed = parseContentType(file, toml)
82
+ const contentType = parsed.value
83
+ if (parsed.problems.length > 0 || !contentType) {
84
+ return parsed.problems.length > 0
85
+ ? parsed.problems
86
+ : [{ file, path: '', message: 'the file defines no content type' }]
87
+ }
88
+ const set = loadSchemaDirectory(config.schemaDir).set
89
+ const contentTypes = [
90
+ ...set.contentTypes.filter((existing) => existing.alias !== contentType.alias),
91
+ contentType,
92
+ ]
93
+ return validateSchemaSet(
94
+ { ...set, contentTypes },
95
+ { componentAliases: componentAliasesIn(config.componentsDir) },
96
+ )
97
+ }
98
+
99
+ return {
100
+ fileFor: (kind, alias) => `schema/${directoryFor(kind)}/${fileNameFor(alias)}`,
101
+
102
+ async read(kind, alias) {
103
+ const path = pathFor(kind, alias)
104
+ if (!existsSync(path)) return undefined
105
+ return await readFile(path, 'utf8')
106
+ },
107
+
108
+ validate,
109
+
110
+ async apply(kind, alias, toml) {
111
+ if (!config.schemaWritable) {
112
+ return {
113
+ ok: false,
114
+ error:
115
+ 'Types are defined in schema/ and this environment is read-only; change the file in source control, or configure a schema store.',
116
+ }
117
+ }
118
+ const problems = await validate(kind, alias, toml)
119
+ if (problems.length > 0) {
120
+ return { ok: false, error: problems.map((problem) => problem.message).join('; ') }
121
+ }
122
+
123
+ const path = pathFor(kind, alias)
124
+ const versionFile = join(config.schemaDir, 'schema.toml')
125
+ const previous = existsSync(path) ? await readFile(path, 'utf8') : undefined
126
+ const previousVersion = existsSync(versionFile)
127
+ ? await readFile(versionFile, 'utf8')
128
+ : undefined
129
+ await mkdir(dirname(path), { recursive: true })
130
+ await writeFile(path, canonical(kind, alias, toml), 'utf8')
131
+
132
+ const restore = async () => {
133
+ if (previous === undefined) await rm(path, { force: true })
134
+ else await writeFile(path, previous, 'utf8')
135
+ if (previousVersion !== undefined) await writeFile(versionFile, previousVersion, 'utf8')
136
+ }
137
+
138
+ // Classified while the files and the database still differ: afterwards there
139
+ // is nothing left to compare. The version moves in the same breath, or the
140
+ // import lands at a version the other nodes already have and they never
141
+ // learn they are behind.
142
+ const ahead = await importSchema(options.db, config, options.boot, { dryRun: true })
143
+ const version = loadSchemaDirectory(config.schemaDir).set.version
144
+ const next = nextSchemaVersion(version, ahead.classification)
145
+ if (next !== version) await writeFile(versionFile, writeSchemaVersion(next), 'utf8')
146
+
147
+ const result = await importSchema(options.db, config, options.boot)
148
+ if (result.report && result.report.action !== 'applied') {
149
+ // Put the files back: a refused import leaves the site with a file it will
150
+ // not accept, which is worse than the change not having happened.
151
+ await restore()
152
+ return {
153
+ ok: false,
154
+ error: result.report.reason ?? `the import was ${result.report.action}`,
155
+ }
156
+ }
157
+ log.info('{alias} applied from a proposal: {classification}, schema at {version}', {
158
+ alias,
159
+ classification: ahead.classification,
160
+ version: options.boot.nodeState.version,
161
+ })
162
+ return { ok: true, version: options.boot.nodeState.version }
163
+ },
164
+ }
165
+
166
+ /**
167
+ * Written back through the canonical writer, so what lands on disk is what the
168
+ * backoffice itself would write — one layout, one key order — rather than
169
+ * whatever the model's formatting happened to be.
170
+ */
171
+ function canonical(kind: string, alias: string, toml: string): string {
172
+ const file = `schema/${directoryFor(kind)}/${fileNameFor(alias)}`
173
+ if (isDataType(kind)) {
174
+ const parsed = parseDataType(file, toml)
175
+ return parsed.value ? writeDataType(parsed.value) : toml
176
+ }
177
+ const parsed = parseContentType(file, toml)
178
+ return parsed.value ? writeContentType(parsed.value) : toml
179
+ }
180
+ }