@crombolo/schema 0.12.1 → 0.13.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/schema",
3
- "version": "0.12.1",
3
+ "version": "0.13.2",
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.12.1",
32
- "@crombolo/data": "0.12.1"
31
+ "@crombolo/core": "0.13.2",
32
+ "@crombolo/data": "0.13.2"
33
33
  }
34
34
  }
package/src/index.ts CHANGED
@@ -15,5 +15,6 @@ export * from './validate.ts'
15
15
  export * from './validate-forms.ts'
16
16
  export * from './value-migrations.ts'
17
17
  export * from './version.ts'
18
+ export * from './workflows.ts'
18
19
  export * from './write.ts'
19
20
  export * from './write-forms.ts'
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
 
@@ -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
+ }