@crombolo/core 0.11.0 → 0.12.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.12.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'
@@ -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