@crombolo/schema 0.12.1 → 0.13.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/package.json +3 -3
- package/src/index.ts +1 -0
- package/src/load.ts +14 -1
- package/src/model.ts +3 -1
- package/src/sync.ts +15 -0
- package/src/validate.ts +8 -0
- package/src/workflows.ts +229 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crombolo/schema",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.1",
|
|
4
4
|
"description": "Schema-as-code for crombolo: TOML document, media and member type definitions.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Crombolo",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"access": "public"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@crombolo/core": "0.
|
|
32
|
-
"@crombolo/data": "0.
|
|
31
|
+
"@crombolo/core": "0.13.1",
|
|
32
|
+
"@crombolo/data": "0.13.1"
|
|
33
33
|
}
|
|
34
34
|
}
|
package/src/index.ts
CHANGED
package/src/load.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { createHash } from 'node:crypto'
|
|
9
9
|
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
10
10
|
import { join } from 'node:path'
|
|
11
|
-
import type { FormTypes } from '@crombolo/core'
|
|
11
|
+
import type { FormTypes, SchemaWorkflow } from '@crombolo/core'
|
|
12
12
|
import type { SchemaForm } from './forms.ts'
|
|
13
13
|
import type {
|
|
14
14
|
SchemaContentType,
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
parseSchemaSettings,
|
|
29
29
|
} from './parse.ts'
|
|
30
30
|
import { parseForm } from './parse-forms.ts'
|
|
31
|
+
import { parseWorkflow, writeWorkflow } from './workflows.ts'
|
|
31
32
|
import {
|
|
32
33
|
writeContentType,
|
|
33
34
|
writeDataType,
|
|
@@ -124,6 +125,15 @@ export function loadSchemaDirectory(
|
|
|
124
125
|
files.set(`form:${parsed.value.alias}`, file)
|
|
125
126
|
}
|
|
126
127
|
}
|
|
128
|
+
const workflows: SchemaWorkflow[] = []
|
|
129
|
+
for (const file of tomlFiles(join(schemaDir, 'workflows'))) {
|
|
130
|
+
const parsed = parseWorkflow(rel(file), readFileSync(file, 'utf8'))
|
|
131
|
+
problems.push(...parsed.problems)
|
|
132
|
+
if (parsed.value) {
|
|
133
|
+
workflows.push(parsed.value)
|
|
134
|
+
files.set(`workflow:${parsed.value.alias}`, file)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
127
137
|
const languagesFile = join(schemaDir, 'languages.toml')
|
|
128
138
|
if (existsSync(languagesFile)) {
|
|
129
139
|
const parsed = parseLanguages(rel(languagesFile), readFileSync(languagesFile, 'utf8'))
|
|
@@ -139,6 +149,7 @@ export function loadSchemaDirectory(
|
|
|
139
149
|
dataTypes,
|
|
140
150
|
languages,
|
|
141
151
|
forms,
|
|
152
|
+
...(workflows.length > 0 ? { workflows } : {}),
|
|
142
153
|
...(urls ? { urls } : {}),
|
|
143
154
|
},
|
|
144
155
|
problems,
|
|
@@ -158,6 +169,8 @@ export function hashSchemaSet(set: SchemaSet): string {
|
|
|
158
169
|
...set.dataTypes.map((d) => writeDataType(d)).sort(),
|
|
159
170
|
writeLanguages([...set.languages].sort((a, b) => a.iso.localeCompare(b.iso))),
|
|
160
171
|
...(set.forms ?? []).map((f) => writeForm(f)).sort(),
|
|
172
|
+
// Absent from a set with none, so every hash taken before workflows stands.
|
|
173
|
+
...(set.workflows ?? []).map((w) => writeWorkflow(w)).sort(),
|
|
161
174
|
]
|
|
162
175
|
return createHash('sha256').update(parts.join('\n---\n')).digest('hex')
|
|
163
176
|
}
|
package/src/model.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SchemaForm } from '@crombolo/core'
|
|
1
|
+
import type { SchemaForm, SchemaWorkflow } from '@crombolo/core'
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* The schema-as-code model: what a site's `schema/*.toml` files describe.
|
|
@@ -114,6 +114,8 @@ export interface SchemaSet {
|
|
|
114
114
|
forms?: SchemaForm[]
|
|
115
115
|
/** `[urls]` in schema/schema.toml; Umbraco's defaults when absent. */
|
|
116
116
|
urls?: SchemaUrls
|
|
117
|
+
/** `schema/workflows/*.toml` — approval workflows; see `docs/workflows.md`. */
|
|
118
|
+
workflows?: SchemaWorkflow[]
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
/** How URLs are built, as Umbraco's `Umbraco:CMS:Global` settings set it. */
|
package/src/sync.ts
CHANGED
|
@@ -28,8 +28,10 @@ import {
|
|
|
28
28
|
SYSTEM_MEDIA_TYPES,
|
|
29
29
|
SYSTEM_MEMBER_TYPE,
|
|
30
30
|
sameUrlOptions,
|
|
31
|
+
syncWorkflows,
|
|
31
32
|
type UrlOptions,
|
|
32
33
|
updateSchemaStateHash,
|
|
34
|
+
type WorkflowSyncReport,
|
|
33
35
|
writeUrlOptions,
|
|
34
36
|
} from '@crombolo/data'
|
|
35
37
|
import { BUILTIN_DATA_TYPES, storageTypeFor, systemPropertyKeys } from './builtin.ts'
|
|
@@ -43,6 +45,7 @@ import {
|
|
|
43
45
|
typeFileKey,
|
|
44
46
|
} from './model.ts'
|
|
45
47
|
import { validateSchemaSet } from './validate.ts'
|
|
48
|
+
import { workflowVersion } from './workflows.ts'
|
|
46
49
|
|
|
47
50
|
export interface SyncNode extends NodeSchemaState {
|
|
48
51
|
nodeId: string
|
|
@@ -97,6 +100,8 @@ export interface SyncReport {
|
|
|
97
100
|
deferred: string[]
|
|
98
101
|
/** Set when the sync changed how URLs are built, so the old ones can be redirected. */
|
|
99
102
|
urls?: { before: UrlOptions; after: UrlOptions }
|
|
103
|
+
/** Set when a workflow changed, with what that did to requests in flight. */
|
|
104
|
+
workflows?: WorkflowSyncReport
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
export const emptySyncReport = (
|
|
@@ -286,6 +291,16 @@ export async function syncSchema(
|
|
|
286
291
|
const urls = { hideTopLevel: set.urls?.hideTopLevel ?? DEFAULT_URL_OPTIONS.hideTopLevel }
|
|
287
292
|
const before = await writeUrlOptions(tx, urls)
|
|
288
293
|
if (!sameUrlOptions(before, urls)) report.urls = { before, after: urls }
|
|
294
|
+
// Approval workflows likewise: only a current sync changes the rules
|
|
295
|
+
// that pages are published under.
|
|
296
|
+
const workflows = await syncWorkflows(
|
|
297
|
+
tx,
|
|
298
|
+
(set.workflows ?? []).map((workflow) => ({
|
|
299
|
+
workflow,
|
|
300
|
+
version: workflowVersion(workflow),
|
|
301
|
+
})),
|
|
302
|
+
)
|
|
303
|
+
if (workflows.changed.length > 0) report.workflows = workflows
|
|
289
304
|
}
|
|
290
305
|
|
|
291
306
|
// ---- templates referenced by types must have a row
|
package/src/validate.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Whole-set validation. Every problem is reported, not just the first, because
|
|
3
3
|
* a person fixing a schema wants the full list.
|
|
4
4
|
*/
|
|
5
|
+
|
|
5
6
|
import { SYSTEM_MEDIA_TYPES, SYSTEM_MEMBER_TYPE } from '@crombolo/data'
|
|
6
7
|
import {
|
|
7
8
|
BUILTIN_DATA_TYPE_ALIASES,
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
TYPE_KIND_FILES,
|
|
19
20
|
} from './model.ts'
|
|
20
21
|
import { type ValidateFormsOptions, validateForms } from './validate-forms.ts'
|
|
22
|
+
import { validateWorkflows } from './workflows.ts'
|
|
21
23
|
|
|
22
24
|
export interface ValidateOptions extends ValidateFormsOptions {
|
|
23
25
|
/** Template aliases that exist as view files; omit to skip template checks. */
|
|
@@ -220,6 +222,12 @@ export function validateSchemaSet(set: SchemaSet, options: ValidateOptions = {})
|
|
|
220
222
|
options.contentTypeAliases ?? new Set(set.contentTypes.map((t) => t.alias)),
|
|
221
223
|
}),
|
|
222
224
|
)
|
|
225
|
+
problems.push(
|
|
226
|
+
...validateWorkflows(
|
|
227
|
+
set.workflows ?? [],
|
|
228
|
+
options.contentTypeAliases ?? new Set(set.contentTypes.map((t) => t.alias)),
|
|
229
|
+
),
|
|
230
|
+
)
|
|
223
231
|
return problems
|
|
224
232
|
}
|
|
225
233
|
|
package/src/workflows.ts
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `schema/workflows/*.toml` — approval workflows (story 028). TOML in and out,
|
|
3
|
+
* strict as the type parser is, and canonical on the way out so the designer
|
|
4
|
+
* writes a file a person reads in a diff: `parseWorkflow(writeWorkflow(x))`
|
|
5
|
+
* equals `x`. The graph rules are `@crombolo/core`'s. `docs/workflows.md`.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
type SchemaWorkflow,
|
|
9
|
+
SEND_BACK_TO_EDITOR,
|
|
10
|
+
type StageRequirement,
|
|
11
|
+
WORKFLOW_COMMENTS,
|
|
12
|
+
WORKFLOW_ON_CHANGE,
|
|
13
|
+
WORKFLOW_ON_EDIT,
|
|
14
|
+
WORKFLOW_SELF_APPROVAL,
|
|
15
|
+
type WorkflowStage,
|
|
16
|
+
workflowGraphProblems,
|
|
17
|
+
} from '@crombolo/core'
|
|
18
|
+
import type { SchemaProblem } from './model.ts'
|
|
19
|
+
import { type ParseResult, Reader, type Toml, toml } from './reader.ts'
|
|
20
|
+
|
|
21
|
+
const WORKFLOW_KEYS = [
|
|
22
|
+
'key',
|
|
23
|
+
'alias',
|
|
24
|
+
'name',
|
|
25
|
+
'applies-to',
|
|
26
|
+
'publishers-may-skip',
|
|
27
|
+
'on-edit',
|
|
28
|
+
'self-approval',
|
|
29
|
+
'comments',
|
|
30
|
+
'unpublish',
|
|
31
|
+
'on-change',
|
|
32
|
+
] as const
|
|
33
|
+
const APPLIES_KEYS = ['branches', 'content-types'] as const
|
|
34
|
+
const STAGE_KEYS = [
|
|
35
|
+
'alias',
|
|
36
|
+
'name',
|
|
37
|
+
'approvers',
|
|
38
|
+
'required',
|
|
39
|
+
'next',
|
|
40
|
+
'send-back',
|
|
41
|
+
'position',
|
|
42
|
+
] as const
|
|
43
|
+
const POSITION_KEYS = ['x', 'y'] as const
|
|
44
|
+
|
|
45
|
+
function choice<T extends string>(
|
|
46
|
+
r: Reader,
|
|
47
|
+
t: Toml,
|
|
48
|
+
key: string,
|
|
49
|
+
path: string,
|
|
50
|
+
allowed: readonly T[],
|
|
51
|
+
fallback: T,
|
|
52
|
+
): T {
|
|
53
|
+
const value = r.str(t, key, path)
|
|
54
|
+
if (value === undefined) return fallback
|
|
55
|
+
if (!(allowed as readonly string[]).includes(value)) {
|
|
56
|
+
r.problem(`${path}.${key}`, `expected one of ${allowed.join(', ')}`)
|
|
57
|
+
return fallback
|
|
58
|
+
}
|
|
59
|
+
return value as T
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function requirement(r: Reader, t: Toml, path: string): StageRequirement {
|
|
63
|
+
const value = t.required
|
|
64
|
+
if (value === undefined) return 'one'
|
|
65
|
+
if (typeof value === 'number') return value
|
|
66
|
+
if (value === 'one' || value === 'majority' || value === 'all') return value
|
|
67
|
+
r.problem(`${path}.required`, 'expected one, majority, all, or a number')
|
|
68
|
+
return 'one'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function stage(r: Reader, t: Toml, path: string): WorkflowStage {
|
|
72
|
+
r.unknown(t, STAGE_KEYS, path)
|
|
73
|
+
let position: WorkflowStage['position']
|
|
74
|
+
if (t.position !== undefined) {
|
|
75
|
+
const p = r.table(t.position, `${path}.position`)
|
|
76
|
+
if (p) {
|
|
77
|
+
r.unknown(p, POSITION_KEYS, `${path}.position`)
|
|
78
|
+
position = {
|
|
79
|
+
x: r.num(p, 'x', `${path}.position`) ?? 0,
|
|
80
|
+
y: r.num(p, 'y', `${path}.position`) ?? 0,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
alias: r.str(t, 'alias', path, true) ?? '',
|
|
86
|
+
...(r.str(t, 'name', path) !== undefined ? { name: r.str(t, 'name', path) } : {}),
|
|
87
|
+
approvers: r.strs(t, 'approvers', path),
|
|
88
|
+
required: requirement(r, t, path),
|
|
89
|
+
next: r.strs(t, 'next', path),
|
|
90
|
+
sendBack: r.str(t, 'send-back', path) ?? SEND_BACK_TO_EDITOR,
|
|
91
|
+
...(position ? { position } : {}),
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseWorkflow(file: string, source: string): ParseResult<SchemaWorkflow> {
|
|
96
|
+
const r = new Reader(file)
|
|
97
|
+
const root = toml(source, r)
|
|
98
|
+
if (!root) return { value: undefined, problems: r.problems }
|
|
99
|
+
r.unknown(root, ['workflow', 'stage'], '')
|
|
100
|
+
const t = r.table(root.workflow, 'workflow')
|
|
101
|
+
if (!t) {
|
|
102
|
+
r.problem('workflow', 'a [workflow] table is required')
|
|
103
|
+
return { value: undefined, problems: r.problems }
|
|
104
|
+
}
|
|
105
|
+
const path = 'workflow'
|
|
106
|
+
r.unknown(t, WORKFLOW_KEYS, path)
|
|
107
|
+
let appliesTo = { branches: [] as string[], contentTypes: [] as string[] }
|
|
108
|
+
if (t['applies-to'] !== undefined) {
|
|
109
|
+
const a = r.table(t['applies-to'], `${path}.applies-to`)
|
|
110
|
+
if (a) {
|
|
111
|
+
r.unknown(a, APPLIES_KEYS, `${path}.applies-to`)
|
|
112
|
+
appliesTo = {
|
|
113
|
+
branches: r.strs(a, 'branches', `${path}.applies-to`).map((k) => k.toLowerCase()),
|
|
114
|
+
contentTypes: r.strs(a, 'content-types', `${path}.applies-to`),
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const value: SchemaWorkflow = {
|
|
119
|
+
...(r.str(t, 'key', path) !== undefined ? { key: r.str(t, 'key', path) } : {}),
|
|
120
|
+
alias: r.str(t, 'alias', path, true) ?? '',
|
|
121
|
+
name: r.str(t, 'name', path, true) ?? '',
|
|
122
|
+
appliesTo,
|
|
123
|
+
publishersMaySkip: r.bool(t, 'publishers-may-skip', path, true),
|
|
124
|
+
onEdit: choice(r, t, 'on-edit', path, WORKFLOW_ON_EDIT, 'withdraw'),
|
|
125
|
+
selfApproval: choice(r, t, 'self-approval', path, WORKFLOW_SELF_APPROVAL, 'refused'),
|
|
126
|
+
comments: choice(r, t, 'comments', path, WORKFLOW_COMMENTS, 'optional'),
|
|
127
|
+
unpublish: r.bool(t, 'unpublish', path, false),
|
|
128
|
+
onChange: choice(r, t, 'on-change', path, WORKFLOW_ON_CHANGE, 'keep'),
|
|
129
|
+
stages: r.tables(root.stage, 'stage').map((s, i) => stage(r, s, `stage[${i}]`)),
|
|
130
|
+
}
|
|
131
|
+
if (r.problems.length === 0)
|
|
132
|
+
for (const problem of workflowGraphProblems(value)) r.problem(problem.path, problem.message)
|
|
133
|
+
return { value: r.problems.length === 0 ? value : undefined, problems: r.problems }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const str = (value: string) => JSON.stringify(value)
|
|
137
|
+
const list = (values: readonly string[]) => `[${values.map(str).join(', ')}]`
|
|
138
|
+
|
|
139
|
+
/** A workflow as canonical TOML: a fixed key order, defaults written out. */
|
|
140
|
+
export function writeWorkflow(workflow: SchemaWorkflow): string {
|
|
141
|
+
const out = ['[workflow]']
|
|
142
|
+
if (workflow.key) out.push(`key = ${str(workflow.key)}`)
|
|
143
|
+
out.push(`alias = ${str(workflow.alias)}`, `name = ${str(workflow.name)}`)
|
|
144
|
+
out.push(
|
|
145
|
+
`applies-to = { branches = ${list(workflow.appliesTo.branches)}, content-types = ${list(workflow.appliesTo.contentTypes)} }`,
|
|
146
|
+
`publishers-may-skip = ${workflow.publishersMaySkip}`,
|
|
147
|
+
`on-edit = ${str(workflow.onEdit)}`,
|
|
148
|
+
`self-approval = ${str(workflow.selfApproval)}`,
|
|
149
|
+
`comments = ${str(workflow.comments)}`,
|
|
150
|
+
`unpublish = ${workflow.unpublish}`,
|
|
151
|
+
`on-change = ${str(workflow.onChange)}`,
|
|
152
|
+
)
|
|
153
|
+
for (const s of workflow.stages) {
|
|
154
|
+
out.push('', '[[stage]]', `alias = ${str(s.alias)}`)
|
|
155
|
+
if (s.name !== undefined) out.push(`name = ${str(s.name)}`)
|
|
156
|
+
out.push(
|
|
157
|
+
`approvers = ${list(s.approvers)}`,
|
|
158
|
+
`required = ${typeof s.required === 'number' ? s.required : str(s.required)}`,
|
|
159
|
+
`next = ${list(s.next)}`,
|
|
160
|
+
`send-back = ${str(s.sendBack)}`,
|
|
161
|
+
)
|
|
162
|
+
if (s.position) out.push(`position = { x = ${s.position.x}, y = ${s.position.y} }`)
|
|
163
|
+
}
|
|
164
|
+
return `${out.join('\n')}\n`
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* What the set of workflows gets wrong together: two with the same alias, or
|
|
169
|
+
* two claiming the same branch or content type, or naming a content type the
|
|
170
|
+
* schema does not have. Pure.
|
|
171
|
+
*/
|
|
172
|
+
export function validateWorkflows(
|
|
173
|
+
workflows: readonly SchemaWorkflow[],
|
|
174
|
+
contentTypeAliases: ReadonlySet<string>,
|
|
175
|
+
): SchemaProblem[] {
|
|
176
|
+
const problems: SchemaProblem[] = []
|
|
177
|
+
const file = (w: SchemaWorkflow) => `schema/workflows/${w.alias}.toml`
|
|
178
|
+
const aliases = new Map<string, string>()
|
|
179
|
+
const branches = new Map<string, string>()
|
|
180
|
+
const types = new Map<string, string>()
|
|
181
|
+
for (const w of workflows) {
|
|
182
|
+
if (aliases.has(w.alias))
|
|
183
|
+
problems.push({
|
|
184
|
+
file: file(w),
|
|
185
|
+
path: 'workflow.alias',
|
|
186
|
+
message: `two workflows are called "${w.alias}"`,
|
|
187
|
+
})
|
|
188
|
+
aliases.set(w.alias, w.alias)
|
|
189
|
+
for (const branch of w.appliesTo.branches) {
|
|
190
|
+
const other = branches.get(branch)
|
|
191
|
+
if (other)
|
|
192
|
+
problems.push({
|
|
193
|
+
file: file(w),
|
|
194
|
+
path: 'workflow.applies-to.branches',
|
|
195
|
+
message: `the branch ${branch} is covered by "${other}" already`,
|
|
196
|
+
})
|
|
197
|
+
branches.set(branch, w.alias)
|
|
198
|
+
}
|
|
199
|
+
for (const type of w.appliesTo.contentTypes) {
|
|
200
|
+
const other = types.get(type)
|
|
201
|
+
if (other)
|
|
202
|
+
problems.push({
|
|
203
|
+
file: file(w),
|
|
204
|
+
path: 'workflow.applies-to.content-types',
|
|
205
|
+
message: `the content type "${type}" is covered by "${other}" already`,
|
|
206
|
+
})
|
|
207
|
+
types.set(type, w.alias)
|
|
208
|
+
if (!contentTypeAliases.has(type))
|
|
209
|
+
problems.push({
|
|
210
|
+
file: file(w),
|
|
211
|
+
path: 'workflow.applies-to.content-types',
|
|
212
|
+
message: `there is no content type "${type}"`,
|
|
213
|
+
})
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return problems
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* A workflow's version: a hash of its canonical file, which a request remembers.
|
|
221
|
+
* Where the designer drew a stage is left out, so moving a box is not a change. Pure.
|
|
222
|
+
*/
|
|
223
|
+
export function workflowVersion(workflow: SchemaWorkflow): string {
|
|
224
|
+
const meaning = {
|
|
225
|
+
...workflow,
|
|
226
|
+
stages: workflow.stages.map(({ position: _position, ...stage }) => stage),
|
|
227
|
+
}
|
|
228
|
+
return new Bun.CryptoHasher('sha256').update(writeWorkflow(meaning)).digest('hex').slice(0, 16)
|
|
229
|
+
}
|