@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,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
|
+
}
|