@crombolo/core 0.11.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/core",
3
- "version": "0.11.0",
3
+ "version": "0.13.1",
4
4
  "description": "Domain model, entity keys and shared contracts for crombolo. No I/O.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
package/src/api-paths.ts CHANGED
@@ -12,6 +12,12 @@ export const MANAGEMENT_API_PREFIX = '/umbraco/management/api/'
12
12
 
13
13
  export const MANAGEMENT_API_PATH = `${MANAGEMENT_API_PREFIX}v1`
14
14
 
15
+ /**
16
+ * Umbraco's Delivery API, at the path its contract and every headless client
17
+ * use, wherever the backoffice is mounted.
18
+ */
19
+ export const DELIVERY_API_PREFIX = '/umbraco/delivery/api'
20
+
15
21
  /** `server-event.context.js`: `${serverURL}/umbraco/serverEventHub`. */
16
22
  export const SERVER_EVENT_HUB_PATH = '/umbraco/serverEventHub'
17
23
 
@@ -11,8 +11,10 @@
11
11
  * is a client-side concern tied to CSS classes.
12
12
  */
13
13
  import {
14
+ choiceOptions,
14
15
  type FormCondition,
15
16
  type FormConditionRule,
17
+ type FormTypes,
16
18
  formFieldType,
17
19
  type SchemaForm,
18
20
  type SchemaFormField,
@@ -30,6 +32,11 @@ export interface FormFieldError {
30
32
  export interface FormValueToStore {
31
33
  fieldAlias: string
32
34
  values: string[]
35
+ /**
36
+ * What the visitor saw for each value, for a choice drawn from a source: the
37
+ * value is a key, and the label keeps the entry readable once that key is gone.
38
+ */
39
+ labels?: string[]
33
40
  }
34
41
 
35
42
  export interface SubmissionResult {
@@ -168,6 +175,8 @@ export interface ValidateSubmissionOptions {
168
175
  * forward. Values for other pages are still carried through.
169
176
  */
170
177
  page?: number
178
+ /** The field types in play: the built-ins, and the wired bundles'. */
179
+ types?: FormTypes
171
180
  }
172
181
 
173
182
  const DATE = /^\d{4}-\d{2}-\d{2}(?:T[\d:.]+Z?)?$/
@@ -189,7 +198,7 @@ export function validateSubmission(
189
198
  errors.push({ field: field.alias, message })
190
199
 
191
200
  for (const { field, page } of visibleFields(form, submitted)) {
192
- const info = formFieldType(field.type)
201
+ const info = formFieldType(field.type, options.types)
193
202
  // A presentational field collects nothing, so there is nothing to check and
194
203
  // nothing to store — and a value posted for one is ignored, not kept.
195
204
  if (!info || info.presentational) continue
@@ -222,6 +231,7 @@ export function validateSubmission(
222
231
  }
223
232
 
224
233
  let accepted = present
234
+ const allowed = new Map(choiceOptions(field).map((option) => [option.value, option.label]))
225
235
  switch (info.stores) {
226
236
  case 'string': {
227
237
  const value = first(present)
@@ -239,7 +249,7 @@ export function validateSubmission(
239
249
  fail(field, `${label(field)} cannot be after ${field.max}`)
240
250
  }
241
251
  }
242
- if (info.choices && !field.values.includes(value))
252
+ if (info.choices && !allowed.has(value))
243
253
  fail(field, `${label(field)} is not one of the choices`)
244
254
  if (field.pattern) {
245
255
  let matches = true
@@ -256,9 +266,9 @@ export function validateSubmission(
256
266
  break
257
267
  }
258
268
  case 'strings': {
259
- const unknown = present.filter((value) => !field.values.includes(value))
269
+ const unknown = present.filter((value) => !allowed.has(value))
260
270
  if (unknown.length > 0) fail(field, `${label(field)} is not one of the choices`)
261
- accepted = present.filter((value) => field.values.includes(value))
271
+ accepted = present.filter((value) => allowed.has(value))
262
272
  break
263
273
  }
264
274
  case 'number': {
@@ -285,7 +295,26 @@ export function validateSubmission(
285
295
  break
286
296
  }
287
297
  }
288
- values.push({ fieldAlias: field.alias, values: accepted })
298
+ // A bundle's own check, once the kind's have passed: a broken one refuses
299
+ // the value rather than the whole submission.
300
+ if (info.validate && !errors.some((error) => error.field === field.alias)) {
301
+ let message: string | undefined
302
+ try {
303
+ message = info.validate(accepted, field.settings ?? {})
304
+ } catch {
305
+ message = `${label(field)} could not be checked`
306
+ }
307
+ if (message) fail(field, message)
308
+ }
309
+ values.push(
310
+ field.source
311
+ ? {
312
+ fieldAlias: field.alias,
313
+ values: accepted,
314
+ labels: accepted.map((value) => allowed.get(value) ?? value),
315
+ }
316
+ : { fieldAlias: field.alias, values: accepted },
317
+ )
289
318
  }
290
319
 
291
320
  return { ok: errors.length === 0, errors, values }
package/src/forms.ts CHANGED
@@ -5,13 +5,16 @@
5
5
  * renderer need it, and `render` depends on `core` alone. What a form *is* is
6
6
  * domain knowledge; how it is written to a file is not.
7
7
  *
8
- * Field types are an internal registry rather than an extension point: the TOML
9
- * vocabulary, the validator, the renderer and the entry value shape all have to
10
- * agree, and freezing that contract before anything has stressed it would be a
11
- * promise made too early (`docs/forms.md`).
8
+ * The built-in field and workflow types are fixed here. A server bundle adds
9
+ * its own through `FormTypes`, which is passed to whatever parses, validates or
10
+ * renders a form rather than held globally (`docs/forms.md`, "Types from a
11
+ * bundle").
12
12
  */
13
13
 
14
- export type FormFieldType =
14
+ /** A field type's name: a built-in one, or one a wired bundle provides. */
15
+ export type FormFieldType = string
16
+
17
+ export type BuiltInFormFieldType =
15
18
  | 'shortAnswer'
16
19
  | 'longAnswer'
17
20
  | 'email'
@@ -30,6 +33,23 @@ export type FormFieldType =
30
33
  /** How a submitted value is stored, which decides how an entry reads it back. */
31
34
  export type FormValueKind = 'string' | 'strings' | 'number' | 'boolean' | 'files' | 'none'
32
35
 
36
+ /** What a declared setting holds, which is also the editor the designer draws for it. */
37
+ export type FormSettingKind = 'text' | 'number' | 'boolean' | 'list'
38
+
39
+ export type FormSettingValue = string | number | boolean | string[]
40
+
41
+ /**
42
+ * One setting a type accepts. For a built-in type `key` is the field's own
43
+ * property; for a bundle's type the value lives in the field's `settings`.
44
+ */
45
+ export interface FormSettingDeclaration {
46
+ key: string
47
+ label: string
48
+ kind: FormSettingKind
49
+ required?: boolean
50
+ description?: string
51
+ }
52
+
33
53
  export interface FormFieldTypeInfo {
34
54
  type: FormFieldType
35
55
  label: string
@@ -43,6 +63,15 @@ export interface FormFieldTypeInfo {
43
63
  presentational: boolean
44
64
  /** The settings this type accepts beyond the ones every field has. */
45
65
  settings: readonly FormFieldSetting[]
66
+ /** The same, as the designer edits them; a bundle's type declares only these. */
67
+ declared: readonly FormSettingDeclaration[]
68
+ /** The wired bundle that provides it; absent for a built-in type. */
69
+ bundle?: string
70
+ /** A check of a submitted value beyond its kind's: a message when it is wrong. */
71
+ validate?(
72
+ values: readonly string[],
73
+ settings: Readonly<Record<string, FormSettingValue>>,
74
+ ): string | undefined
46
75
  }
47
76
 
48
77
  export type FormFieldSetting =
@@ -60,7 +89,24 @@ export type FormFieldSetting =
60
89
 
61
90
  const TEXTUAL = ['placeholder', 'maxlength', 'default-value'] as const
62
91
 
63
- export const FORM_FIELD_TYPES: readonly FormFieldTypeInfo[] = [
92
+ /** How the designer edits each built-in setting, by its file key. */
93
+ const SETTING_DECLARATIONS: Record<FormFieldSetting, FormSettingDeclaration> = {
94
+ placeholder: { key: 'placeholder', label: 'Placeholder', kind: 'text' },
95
+ maxlength: { key: 'maxlength', label: 'Maximum length', kind: 'number' },
96
+ rows: { key: 'rows', label: 'Rows', kind: 'number' },
97
+ min: { key: 'min', label: 'Minimum', kind: 'number' },
98
+ max: { key: 'max', label: 'Maximum', kind: 'number' },
99
+ step: { key: 'step', label: 'Step', kind: 'number' },
100
+ accept: { key: 'accept', label: 'Accepted file types', kind: 'list' },
101
+ 'max-size-kb': { key: 'maxSizeKb', label: 'Largest file (KB)', kind: 'number' },
102
+ content: { key: 'content', label: 'Content', kind: 'text' },
103
+ 'default-value': { key: 'defaultValue', label: 'Default value', kind: 'text' },
104
+ multiple: { key: 'multiple', label: 'Allow several files', kind: 'boolean' },
105
+ }
106
+
107
+ type BuiltInInfo = Omit<FormFieldTypeInfo, 'declared' | 'type'> & { type: BuiltInFormFieldType }
108
+
109
+ const BUILT_IN_INFO: readonly BuiltInInfo[] = [
64
110
  {
65
111
  type: 'shortAnswer',
66
112
  label: 'Short answer',
@@ -176,11 +222,10 @@ export const FORM_FIELD_TYPES: readonly FormFieldTypeInfo[] = [
176
222
  },
177
223
  ]
178
224
 
179
- const BY_TYPE = new Map(FORM_FIELD_TYPES.map((info) => [info.type, info]))
180
-
181
- export function formFieldType(type: string): FormFieldTypeInfo | undefined {
182
- return BY_TYPE.get(type as FormFieldType)
183
- }
225
+ export const FORM_FIELD_TYPES: readonly FormFieldTypeInfo[] = BUILT_IN_INFO.map((info) => ({
226
+ ...info,
227
+ declared: info.settings.map((setting) => SETTING_DECLARATIONS[setting]),
228
+ }))
184
229
 
185
230
  export const FORM_FIELD_TYPE_NAMES: readonly string[] = FORM_FIELD_TYPES.map((info) => info.type)
186
231
 
@@ -230,6 +275,8 @@ export interface SchemaFormField {
230
275
  key?: string
231
276
  alias: string
232
277
  type: FormFieldType
278
+ /** A bundle type's own settings, as it declares them; never set on a built-in type. */
279
+ settings?: Record<string, FormSettingValue>
233
280
  caption: string
234
281
  helpText?: string
235
282
  mandatory: boolean
@@ -243,6 +290,13 @@ export interface SchemaFormField {
243
290
  autocomplete?: string
244
291
  /** A choice list, for the types that take one. */
245
292
  values: string[]
293
+ /** Where the choice list comes from instead, kept once rather than in every form. */
294
+ source?: ChoiceSource
295
+ /**
296
+ * The options a source resolved to, filled in at render and at submit; never
297
+ * read from or written to the file.
298
+ */
299
+ options?: ChoiceOption[]
246
300
  placeholder?: string
247
301
  defaultValue?: string
248
302
  content?: string
@@ -257,6 +311,53 @@ export interface SchemaFormField {
257
311
  condition?: FormCondition
258
312
  }
259
313
 
314
+ /** The kinds of list a choice field can draw its options from. */
315
+ export const CHOICE_SOURCE_KINDS = ['content', 'dictionary'] as const
316
+
317
+ export type ChoiceSourceKind = (typeof CHOICE_SOURCE_KINDS)[number]
318
+
319
+ /**
320
+ * A choice list kept somewhere else: the children of a content node (by key), or
321
+ * the children of a dictionary item (by name). docs/forms.md, "Choices from a source".
322
+ */
323
+ export interface ChoiceSource {
324
+ kind: ChoiceSourceKind
325
+ parent: string
326
+ }
327
+
328
+ /** One option: what is stored when it is chosen, and what a visitor sees. */
329
+ export interface ChoiceOption {
330
+ value: string
331
+ label: string
332
+ }
333
+
334
+ /** The options a choice field offers: its source's, once resolved, or its own values. */
335
+ export function choiceOptions(field: SchemaFormField): ChoiceOption[] {
336
+ if (field.options) return field.options
337
+ if (field.source) return []
338
+ return field.values.map((value) => ({ value, label: value }))
339
+ }
340
+
341
+ /** The form with every source-backed field's options filled in by `resolve`. */
342
+ export function withResolvedChoices(
343
+ form: SchemaForm,
344
+ resolve: (source: ChoiceSource) => ChoiceOption[],
345
+ ): SchemaForm {
346
+ if (!allFormFields(form).some((field) => field.source)) return form
347
+ return {
348
+ ...form,
349
+ pages: form.pages.map((page) => ({
350
+ ...page,
351
+ groups: page.groups.map((group) => ({
352
+ ...group,
353
+ fields: group.fields.map((field) =>
354
+ field.source ? { ...field, options: resolve(field.source) } : field,
355
+ ),
356
+ })),
357
+ })),
358
+ }
359
+ }
360
+
260
361
  export interface SchemaFormGroup {
261
362
  caption?: string
262
363
  /** A simple column count; the renderer lays the fields out across it. */
@@ -271,14 +372,165 @@ export interface SchemaFormPage {
271
372
  condition?: FormCondition
272
373
  }
273
374
 
274
- export type FormWorkflowType = 'sendEmail' | 'saveAsContent' | 'sendToUrl'
375
+ /** A workflow type's name: a built-in one, or one a wired bundle provides. */
376
+ export type FormWorkflowType = string
377
+
378
+ export type BuiltInFormWorkflowType = 'sendEmail' | 'saveAsContent' | 'sendToUrl'
275
379
 
276
- export const FORM_WORKFLOW_TYPES: readonly FormWorkflowType[] = [
380
+ export const FORM_WORKFLOW_TYPES: readonly BuiltInFormWorkflowType[] = [
277
381
  'sendEmail',
278
382
  'saveAsContent',
279
383
  'sendToUrl',
280
384
  ]
281
385
 
386
+ export interface FormWorkflowTypeInfo {
387
+ type: FormWorkflowType
388
+ label: string
389
+ /** A bundle type's settings; the built-in three are checked by the validator's own table. */
390
+ declared: readonly FormSettingDeclaration[]
391
+ bundle?: string
392
+ /** A check across its settings beyond their declarations: a message when they are wrong. */
393
+ validate?(settings: Readonly<Record<string, unknown>>): string | undefined
394
+ }
395
+
396
+ /** The field and workflow types a site can use: the built-ins and its wired bundles'. */
397
+ export interface FormTypes {
398
+ fields: ReadonlyMap<string, FormFieldTypeInfo>
399
+ workflows: ReadonlyMap<string, FormWorkflowTypeInfo>
400
+ }
401
+
402
+ const BUILT_IN_WORKFLOW_LABELS: Record<BuiltInFormWorkflowType, string> = {
403
+ sendEmail: 'Send an e-mail',
404
+ saveAsContent: 'Save as content',
405
+ sendToUrl: 'Send to a URL',
406
+ }
407
+
408
+ export const BUILT_IN_FORM_TYPES: FormTypes = {
409
+ fields: new Map(FORM_FIELD_TYPES.map((info) => [info.type, info])),
410
+ workflows: new Map(
411
+ FORM_WORKFLOW_TYPES.map((type) => [
412
+ type,
413
+ { type, label: BUILT_IN_WORKFLOW_LABELS[type], declared: [] },
414
+ ]),
415
+ ),
416
+ }
417
+
418
+ export function isBuiltInFieldType(type: string): type is BuiltInFormFieldType {
419
+ return FORM_FIELD_TYPE_NAMES.includes(type)
420
+ }
421
+
422
+ export function isBuiltInWorkflowType(type: string): type is BuiltInFormWorkflowType {
423
+ return (FORM_WORKFLOW_TYPES as readonly string[]).includes(type)
424
+ }
425
+
426
+ export function formFieldType(
427
+ type: string,
428
+ types: FormTypes = BUILT_IN_FORM_TYPES,
429
+ ): FormFieldTypeInfo | undefined {
430
+ return types.fields.get(type)
431
+ }
432
+
433
+ const TYPE_NAME = /^[a-z][A-Za-z0-9]{1,31}$/
434
+
435
+ /**
436
+ * The built-ins plus a bundle's types, or why they cannot be added: a name that
437
+ * is not camelCase, one already taken, or a setting declared twice. Pure.
438
+ */
439
+ export function formTypesWith(
440
+ base: FormTypes,
441
+ bundle: string,
442
+ added: {
443
+ fields?: readonly Omit<FormFieldTypeInfo, 'bundle' | 'settings'>[]
444
+ workflows?: readonly Omit<FormWorkflowTypeInfo, 'bundle'>[]
445
+ },
446
+ ): { types: FormTypes; problems: string[] } {
447
+ const problems: string[] = []
448
+ const fields = new Map(base.fields)
449
+ const workflows = new Map(base.workflows)
450
+ const check = (
451
+ kind: 'field' | 'workflow',
452
+ type: string,
453
+ taken: ReadonlyMap<string, { bundle?: string }>,
454
+ declared: readonly FormSettingDeclaration[],
455
+ ): boolean => {
456
+ const owner = taken.get(type)
457
+ if (!TYPE_NAME.test(type)) {
458
+ problems.push(
459
+ `The bundle "${bundle}" names a ${kind} type "${type}", which is not a camelCase name of 2–32 letters and digits.`,
460
+ )
461
+ return false
462
+ }
463
+ if (owner) {
464
+ problems.push(
465
+ `The bundle "${bundle}" adds the ${kind} type "${type}", which ${owner.bundle ? `the bundle "${owner.bundle}" already provides` : 'is built in'}.`,
466
+ )
467
+ return false
468
+ }
469
+ const keys = new Set<string>()
470
+ for (const setting of declared) {
471
+ if (keys.has(setting.key))
472
+ problems.push(
473
+ `The bundle "${bundle}"'s ${kind} type "${type}" declares the setting "${setting.key}" twice.`,
474
+ )
475
+ keys.add(setting.key)
476
+ }
477
+ return true
478
+ }
479
+ for (const field of added.fields ?? [])
480
+ if (check('field', field.type, fields, field.declared))
481
+ fields.set(field.type, { ...field, settings: [], bundle })
482
+ for (const workflow of added.workflows ?? [])
483
+ if (check('workflow', workflow.type, workflows, workflow.declared))
484
+ workflows.set(workflow.type, { ...workflow, bundle })
485
+ return { types: { fields, workflows }, problems }
486
+ }
487
+
488
+ /**
489
+ * Why `settings` does not satisfy `declared`: a setting it does not declare, a
490
+ * required one missing, or a value of the wrong kind. Pure; `where` prefixes
491
+ * each message.
492
+ */
493
+ export function settingsProblems(
494
+ declared: readonly FormSettingDeclaration[],
495
+ settings: Readonly<Record<string, unknown>>,
496
+ where: string,
497
+ ): string[] {
498
+ const problems: string[] = []
499
+ const known = new Map(declared.map((setting) => [setting.key, setting]))
500
+ for (const key of Object.keys(settings))
501
+ if (!known.has(key))
502
+ problems.push(
503
+ `${where} has the setting "${key}", which it does not take${known.size > 0 ? `; it takes ${[...known.keys()].join(', ')}` : ''}.`,
504
+ )
505
+ for (const setting of declared) {
506
+ const value = settings[setting.key]
507
+ if (value === undefined || value === '') {
508
+ if (setting.required) problems.push(`${where} needs the setting "${setting.key}".`)
509
+ continue
510
+ }
511
+ const fits =
512
+ setting.kind === 'text'
513
+ ? typeof value === 'string'
514
+ : setting.kind === 'number'
515
+ ? typeof value === 'number' && Number.isFinite(value)
516
+ : setting.kind === 'boolean'
517
+ ? typeof value === 'boolean'
518
+ : Array.isArray(value) && value.every((item) => typeof item === 'string')
519
+ if (!fits)
520
+ problems.push(
521
+ `${where} has the setting "${setting.key}" as ${JSON.stringify(value)}, which is not ${KIND_WORDS[setting.kind]}.`,
522
+ )
523
+ }
524
+ return problems
525
+ }
526
+
527
+ const KIND_WORDS: Record<FormSettingKind, string> = {
528
+ text: 'text',
529
+ number: 'a number',
530
+ boolean: 'true or false',
531
+ list: 'a list of text',
532
+ }
533
+
282
534
  /** The state transition a workflow runs on. */
283
535
  export type FormWorkflowTrigger = 'submit' | 'approve'
284
536
 
@@ -300,6 +552,11 @@ export interface SchemaForm {
300
552
  storeEntries: boolean
301
553
  /** Entries wait as `submitted` until someone approves them. */
302
554
  requiresApproval: boolean
555
+ /**
556
+ * Shows one page at a time in the browser, checking each on the server before
557
+ * the next. Without JavaScript every page shows at once, as when it is off.
558
+ */
559
+ stepPages?: boolean
303
560
  submitLabel?: string
304
561
  nextLabel?: string
305
562
  previousLabel?: string
@@ -326,6 +583,9 @@ export function allFormFields(form: SchemaForm): SchemaFormField[] {
326
583
  }
327
584
 
328
585
  /** Every field that stores something, which is what an entry and an export carry. */
329
- export function storingFormFields(form: SchemaForm): SchemaFormField[] {
330
- return allFormFields(form).filter((field) => formFieldType(field.type)?.stores !== 'none')
586
+ export function storingFormFields(
587
+ form: SchemaForm,
588
+ types: FormTypes = BUILT_IN_FORM_TYPES,
589
+ ): SchemaFormField[] {
590
+ return allFormFields(form).filter((field) => formFieldType(field.type, types)?.stores !== 'none')
331
591
  }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export * from './content-types.ts'
3
3
  export * from './documents.ts'
4
4
  export * from './form-submissions.ts'
5
5
  export * from './forms.ts'
6
+ export * from './json-patch.ts'
6
7
  export * from './notifications.ts'
7
8
  export * from './object-types.ts'
8
9
  export * from './paging.ts'
@@ -13,3 +14,4 @@ export * from './return-urls.ts'
13
14
  export * from './sections.ts'
14
15
  export * from './uploads.ts'
15
16
  export * from './users.ts'
17
+ export * from './workflows.ts'
@@ -0,0 +1,144 @@
1
+ /**
2
+ * JSON Patch with Umbraco's filter segments: `replace`, `add` and `remove` over
3
+ * paths like `/values[alias=title,culture=null,segment=null]/value`. Operations
4
+ * apply in order to a copy; any failure fails the lot. docs/api-contract.md.
5
+ */
6
+
7
+ export interface PatchOperation {
8
+ op: string
9
+ path: string
10
+ value?: unknown
11
+ }
12
+
13
+ export type PatchResult = { ok: true; value: unknown } | { ok: false; reason: string }
14
+
15
+ type Step =
16
+ | { kind: 'key'; key: string }
17
+ | { kind: 'append' }
18
+ | { kind: 'filter'; conditions: Array<[string, string | null]> }
19
+
20
+ class PatchError extends Error {}
21
+
22
+ const unescapeSegment = (segment: string) => segment.replace(/~1/g, '/').replace(/~0/g, '~')
23
+
24
+ function parseFilter(body: string, path: string): Step {
25
+ const conditions = body.split(',').map((pair): [string, string | null] => {
26
+ const at = pair.indexOf('=')
27
+ if (at <= 0) throw new PatchError(`The filter "[${body}]" in "${path}" needs key=value pairs.`)
28
+ const value = unescapeSegment(pair.slice(at + 1))
29
+ return [unescapeSegment(pair.slice(0, at)), value.toLowerCase() === 'null' ? null : value]
30
+ })
31
+ return { kind: 'filter', conditions }
32
+ }
33
+
34
+ export function parsePatchPath(path: string): Step[] {
35
+ if (!path.startsWith('/') || path.length < 2)
36
+ throw new PatchError(`"${path}" is not a path: it must start with "/" and name something.`)
37
+ const steps: Step[] = []
38
+ for (const segment of path.slice(1).split('/')) {
39
+ const open = segment.indexOf('[')
40
+ if (open === -1) {
41
+ steps.push(
42
+ segment === '-' ? { kind: 'append' } : { kind: 'key', key: unescapeSegment(segment) },
43
+ )
44
+ continue
45
+ }
46
+ if (!segment.endsWith(']')) throw new PatchError(`The filter in "${path}" is not closed.`)
47
+ if (open > 0) steps.push({ kind: 'key', key: unescapeSegment(segment.slice(0, open)) })
48
+ steps.push(parseFilter(segment.slice(open + 1, -1), path))
49
+ }
50
+ steps.forEach((step, index) => {
51
+ if (step.kind === 'append' && index !== steps.length - 1)
52
+ throw new PatchError(`"-" may only end a path, in "${path}".`)
53
+ })
54
+ return steps
55
+ }
56
+
57
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
58
+ typeof value === 'object' && value !== null && !Array.isArray(value)
59
+
60
+ function matchIndex(array: unknown[], step: Extract<Step, { kind: 'filter' }>, path: string) {
61
+ const index = array.findIndex(
62
+ (element) =>
63
+ isRecord(element) &&
64
+ step.conditions.every(([key, value]) =>
65
+ value === null
66
+ ? element[key] === undefined || element[key] === null
67
+ : element[key] === value,
68
+ ),
69
+ )
70
+ if (index === -1) throw new PatchError(`Nothing in the list matches the filter in "${path}".`)
71
+ return index
72
+ }
73
+
74
+ function arrayIndex(array: unknown[], key: string, path: string, allowEnd: boolean): number {
75
+ if (!/^(0|[1-9]\d*)$/.test(key))
76
+ throw new PatchError(`"${key}" is not a list position, in "${path}".`)
77
+ const index = Number(key)
78
+ if (index > array.length || (!allowEnd && index === array.length))
79
+ throw new PatchError(`Position ${index} is past the end of the list, in "${path}".`)
80
+ return index
81
+ }
82
+
83
+ function child(container: unknown, step: Step, path: string): unknown {
84
+ if (Array.isArray(container)) {
85
+ if (step.kind === 'filter') return container[matchIndex(container, step, path)]
86
+ if (step.kind === 'key') return container[arrayIndex(container, step.key, path, false)]
87
+ } else if (isRecord(container) && step.kind === 'key') {
88
+ if (!(step.key in container)) throw new PatchError(`There is no "${step.key}" in "${path}".`)
89
+ return container[step.key]
90
+ }
91
+ throw new PatchError(`"${path}" does not lead anywhere in the document.`)
92
+ }
93
+
94
+ function applyOne(root: unknown, operation: PatchOperation): unknown {
95
+ const { op, path } = operation
96
+ if (op !== 'replace' && op !== 'add' && op !== 'remove')
97
+ throw new PatchError(
98
+ `"${op}" is not an operation this endpoint supports: use replace, add or remove.`,
99
+ )
100
+ if (op !== 'remove' && !('value' in operation))
101
+ throw new PatchError(`"${op}" at "${path}" needs a value.`)
102
+ const steps = parsePatchPath(path)
103
+ const last = steps[steps.length - 1] as Step
104
+ if (last.kind === 'append' && op !== 'add')
105
+ throw new PatchError(`"-" appends, so only add may use it, in "${path}".`)
106
+ let parent = root
107
+ for (const step of steps.slice(0, -1)) parent = child(parent, step, path)
108
+
109
+ if (Array.isArray(parent)) {
110
+ const index =
111
+ last.kind === 'append'
112
+ ? parent.length
113
+ : last.kind === 'filter'
114
+ ? matchIndex(parent, last, path)
115
+ : arrayIndex(parent, last.key, path, op === 'add')
116
+ if (op === 'remove') parent.splice(index, 1)
117
+ else if (op === 'add' && last.kind !== 'filter') parent.splice(index, 0, operation.value)
118
+ else parent[index] = operation.value
119
+ return root
120
+ }
121
+ if (isRecord(parent) && last.kind === 'key') {
122
+ if (op === 'remove') {
123
+ if (!(last.key in parent))
124
+ throw new PatchError(`There is no "${last.key}" to remove, in "${path}".`)
125
+ delete parent[last.key]
126
+ } else parent[last.key] = operation.value
127
+ return root
128
+ }
129
+ throw new PatchError(`"${path}" does not lead anywhere in the document.`)
130
+ }
131
+
132
+ export function applyJsonPatch(
133
+ document: unknown,
134
+ operations: readonly PatchOperation[],
135
+ ): PatchResult {
136
+ try {
137
+ let value = structuredClone(document)
138
+ for (const operation of operations) value = applyOne(value, operation)
139
+ return { ok: true, value }
140
+ } catch (error) {
141
+ if (error instanceof PatchError) return { ok: false, reason: error.message }
142
+ throw error
143
+ }
144
+ }
@@ -31,6 +31,10 @@ export function problemResponse(problem: ProblemDetails, headers?: HeadersInit):
31
31
  })
32
32
  }
33
33
 
34
+ /** Contention that outlasted the wait: the caller may simply try again. */
35
+ export const serviceBusy = (detail: string) =>
36
+ problemDetails({ title: 'Service Unavailable', status: 503, detail, operationStatus: 'Busy' })
37
+
34
38
  export const notFound = (title = 'Not Found', detail?: string) =>
35
39
  problemDetails({ title, status: 404, detail, operationStatus: 'NotFound' })
36
40
 
package/src/sections.ts CHANGED
@@ -25,6 +25,8 @@ export const SECTION_ALIASES = {
25
25
  // commercial add-on there. Form building is in core here, so the section is
26
26
  // real and the alias resolves (`docs/forms.md`).
27
27
  forms: 'Crombolo.Section.Forms',
28
+ // Approval workflows: Umbraco Workflow's section, in core here (story 028).
29
+ workflow: 'Crombolo.Section.Workflow',
28
30
  } as const
29
31
 
30
32
  export type AppAlias = keyof typeof SECTION_ALIASES
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Approval workflows (story 028): a workflow is an acyclic graph of stages, each
3
+ * approved by named user groups. Arrows out of a stage are followed in
4
+ * parallel; a stage with several arrows in waits for all of them; when every
5
+ * last stage has approved, the page is published. `docs/workflows.md`.
6
+ *
7
+ * The model and the graph rules are here, pure, so the schema check, the
8
+ * engine and the designer agree on what a workflow means.
9
+ */
10
+
11
+ /** How many of a stage's approvers must approve: one, a majority, all, or a number. */
12
+ export type StageRequirement = 'one' | 'majority' | 'all' | number
13
+
14
+ /** What saving a page does while it waits for approval. */
15
+ export type WorkflowOnEdit = 'withdraw' | 'approve-latest' | 'lock' | 'lock-after-first-approval'
16
+
17
+ /** Whether the person who made the change may approve it. */
18
+ export type WorkflowSelfApproval = 'refused' | 'allowed' | 'automatic'
19
+
20
+ /** When a decision needs a comment. */
21
+ export type WorkflowComments = 'optional' | 'on-send-back' | 'always'
22
+
23
+ /** What a request in flight does when its workflow changes. */
24
+ export type WorkflowOnChange = 'keep' | 'restart' | 'withdraw'
25
+
26
+ export const WORKFLOW_ON_EDIT: readonly WorkflowOnEdit[] = [
27
+ 'withdraw',
28
+ 'approve-latest',
29
+ 'lock',
30
+ 'lock-after-first-approval',
31
+ ]
32
+ export const WORKFLOW_SELF_APPROVAL: readonly WorkflowSelfApproval[] = [
33
+ 'refused',
34
+ 'allowed',
35
+ 'automatic',
36
+ ]
37
+ export const WORKFLOW_COMMENTS: readonly WorkflowComments[] = ['optional', 'on-send-back', 'always']
38
+ export const WORKFLOW_ON_CHANGE: readonly WorkflowOnChange[] = ['keep', 'restart', 'withdraw']
39
+
40
+ /** Where a send-back from a stage goes. */
41
+ export const SEND_BACK_TO_EDITOR = 'editor'
42
+
43
+ export interface WorkflowStage {
44
+ alias: string
45
+ name?: string
46
+ /** User group aliases. */
47
+ approvers: string[]
48
+ required: StageRequirement
49
+ /** Stage aliases that follow this one; empty for a last stage. */
50
+ next: string[]
51
+ /** `editor`, or the alias of an earlier stage on this one's path. */
52
+ sendBack: string
53
+ /** Where the designer drew it. */
54
+ position?: { x: number; y: number }
55
+ }
56
+
57
+ export interface SchemaWorkflow {
58
+ key?: string
59
+ alias: string
60
+ name: string
61
+ appliesTo: { branches: string[]; contentTypes: string[] }
62
+ publishersMaySkip: boolean
63
+ onEdit: WorkflowOnEdit
64
+ selfApproval: WorkflowSelfApproval
65
+ comments: WorkflowComments
66
+ unpublish: boolean
67
+ onChange: WorkflowOnChange
68
+ stages: WorkflowStage[]
69
+ }
70
+
71
+ const byAlias = (workflow: SchemaWorkflow) => new Map(workflow.stages.map((s) => [s.alias, s]))
72
+
73
+ /** The stages with no arrow in: a valid workflow has exactly one. Pure. */
74
+ export function startStages(workflow: SchemaWorkflow): string[] {
75
+ const reached = new Set(workflow.stages.flatMap((s) => s.next))
76
+ return workflow.stages.filter((s) => !reached.has(s.alias)).map((s) => s.alias)
77
+ }
78
+
79
+ /** The stages with an arrow into this one. Pure. */
80
+ export function incomingStages(workflow: SchemaWorkflow, alias: string): string[] {
81
+ return workflow.stages.filter((s) => s.next.includes(alias)).map((s) => s.alias)
82
+ }
83
+
84
+ /** Every stage this one can be reached from. Pure. */
85
+ export function ancestorStages(workflow: SchemaWorkflow, alias: string): Set<string> {
86
+ const found = new Set<string>()
87
+ const visit = (at: string) => {
88
+ for (const parent of incomingStages(workflow, at)) {
89
+ if (found.has(parent)) continue
90
+ found.add(parent)
91
+ visit(parent)
92
+ }
93
+ }
94
+ visit(alias)
95
+ return found
96
+ }
97
+
98
+ /** Every stage reachable from this one, itself excluded. Pure. */
99
+ export function descendantStages(workflow: SchemaWorkflow, alias: string): Set<string> {
100
+ const stages = byAlias(workflow)
101
+ const found = new Set<string>()
102
+ const visit = (at: string) => {
103
+ for (const child of stages.get(at)?.next ?? []) {
104
+ if (found.has(child)) continue
105
+ found.add(child)
106
+ visit(child)
107
+ }
108
+ }
109
+ visit(alias)
110
+ return found
111
+ }
112
+
113
+ /** Whether adding an arrow `from` → `to` would close a loop. Pure. */
114
+ export function wouldLoop(workflow: SchemaWorkflow, from: string, to: string): boolean {
115
+ return from === to || descendantStages(workflow, to).has(from)
116
+ }
117
+
118
+ /** What is wrong with a workflow's graph, in words; empty when it is sound. Pure. */
119
+ export function workflowGraphProblems(
120
+ workflow: SchemaWorkflow,
121
+ ): Array<{ path: string; message: string }> {
122
+ const problems: Array<{ path: string; message: string }> = []
123
+ const seen = new Set<string>()
124
+ workflow.stages.forEach((stage, i) => {
125
+ if (seen.has(stage.alias))
126
+ problems.push({ path: `stage[${i}].alias`, message: `"${stage.alias}" names two stages` })
127
+ seen.add(stage.alias)
128
+ })
129
+ if (workflow.stages.length === 0)
130
+ return [{ path: 'stage', message: 'a workflow needs at least one stage' }]
131
+ workflow.stages.forEach((stage, i) => {
132
+ for (const next of stage.next)
133
+ if (!seen.has(next))
134
+ problems.push({ path: `stage[${i}].next`, message: `there is no stage "${next}"` })
135
+ if (stage.approvers.length === 0)
136
+ problems.push({ path: `stage[${i}].approvers`, message: 'a stage needs an approver group' })
137
+ if (
138
+ typeof stage.required === 'number' &&
139
+ (!Number.isInteger(stage.required) || stage.required < 1)
140
+ )
141
+ problems.push({ path: `stage[${i}].required`, message: 'a number of approvers is 1 or more' })
142
+ })
143
+ if (problems.length > 0) return problems
144
+
145
+ // A loop first: the rest of the rules assume there is none.
146
+ const state = new Map<string, 'visiting' | 'done'>()
147
+ const stages = byAlias(workflow)
148
+ let loop: string | undefined
149
+ const visit = (alias: string): void => {
150
+ if (loop) return
151
+ state.set(alias, 'visiting')
152
+ for (const next of stages.get(alias)?.next ?? []) {
153
+ if (state.get(next) === 'visiting') loop = `${alias} → ${next}`
154
+ else if (!state.has(next)) visit(next)
155
+ }
156
+ state.set(alias, 'done')
157
+ }
158
+ for (const stage of workflow.stages) if (!state.has(stage.alias)) visit(stage.alias)
159
+ if (loop) return [{ path: 'stage', message: `the stages loop (${loop})` }]
160
+
161
+ const starts = startStages(workflow)
162
+ if (starts.length !== 1)
163
+ problems.push({
164
+ path: 'stage',
165
+ message: `a workflow starts at one stage, and this one starts at ${starts.length}: ${starts.join(', ')}`,
166
+ })
167
+ workflow.stages.forEach((stage, i) => {
168
+ if (stage.sendBack === SEND_BACK_TO_EDITOR) return
169
+ if (!ancestorStages(workflow, stage.alias).has(stage.sendBack))
170
+ problems.push({
171
+ path: `stage[${i}].send-back`,
172
+ message: `"${stage.alias}" can send back only to the editor or a stage before it, and "${stage.sendBack}" is not`,
173
+ })
174
+ })
175
+ return problems
176
+ }
177
+
178
+ /** How many approvals a stage needs, given how many people its groups hold. Pure. */
179
+ export function approvalsNeeded(required: StageRequirement, members: number): number {
180
+ const available = Math.max(members, 1)
181
+ if (required === 'one') return 1
182
+ if (required === 'all') return available
183
+ if (required === 'majority') return Math.floor(available / 2) + 1
184
+ // More than the groups hold could never be met; the groups are the ceiling.
185
+ return Math.min(required, available)
186
+ }
187
+
188
+ /**
189
+ * The stages that become waiting now that `approved` are done: every stage
190
+ * whose arrows in all come from approved stages, not already reached. Pure.
191
+ */
192
+ export function stagesNowWaiting(
193
+ workflow: SchemaWorkflow,
194
+ approved: ReadonlySet<string>,
195
+ reached: ReadonlySet<string>,
196
+ ): string[] {
197
+ return workflow.stages
198
+ .filter((stage) => !reached.has(stage.alias))
199
+ .filter((stage) => {
200
+ const from = incomingStages(workflow, stage.alias)
201
+ return from.length > 0 && from.every((alias) => approved.has(alias))
202
+ })
203
+ .map((stage) => stage.alias)
204
+ }
205
+
206
+ /** Whether every last stage has approved, which publishes the page. Pure. */
207
+ export function workflowComplete(workflow: SchemaWorkflow, approved: ReadonlySet<string>): boolean {
208
+ return workflow.stages.filter((s) => s.next.length === 0).every((s) => approved.has(s.alias))
209
+ }
210
+
211
+ /**
212
+ * The workflow that covers a page: the nearest branch root among the page and
213
+ * its ancestors (nearest first), and only then one naming its content type. Pure.
214
+ */
215
+ export function workflowFor(
216
+ workflows: readonly SchemaWorkflow[],
217
+ chain: readonly string[],
218
+ contentTypeAlias: string | undefined,
219
+ ): SchemaWorkflow | undefined {
220
+ const lower = (value: string) => value.toLowerCase()
221
+ for (const key of [...chain].reverse()) {
222
+ const found = workflows.find((w) => w.appliesTo.branches.some((b) => lower(b) === lower(key)))
223
+ if (found) return found
224
+ }
225
+ if (!contentTypeAlias) return undefined
226
+ return workflows.find((w) => w.appliesTo.contentTypes.includes(contentTypeAlias))
227
+ }