@owlmeans/client-planning 0.1.18-rc.8 → 0.1.18

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.
Files changed (146) hide show
  1. package/README.md +94 -11
  2. package/agent-meta/manifest.json +62 -4
  3. package/agent-meta/skills/client-planning/SKILL.md +60 -78
  4. package/agent-meta/skills/client-planning/parts/aux-mirrors.md +61 -0
  5. package/agent-meta/skills/client-planning/parts/mirror.md +67 -0
  6. package/agent-meta/skills/client-planning/parts/validation.md +60 -0
  7. package/build/client.d.ts +1 -1
  8. package/build/client.d.ts.map +1 -1
  9. package/build/client.js +66 -14
  10. package/build/client.js.map +1 -1
  11. package/build/commits.d.ts +2 -13
  12. package/build/commits.d.ts.map +1 -1
  13. package/build/commits.js +190 -132
  14. package/build/commits.js.map +1 -1
  15. package/build/consts.d.ts +4 -0
  16. package/build/consts.d.ts.map +1 -1
  17. package/build/consts.js +10 -4
  18. package/build/consts.js.map +1 -1
  19. package/build/definitions.d.ts +13 -0
  20. package/build/definitions.d.ts.map +1 -0
  21. package/build/definitions.js +70 -0
  22. package/build/definitions.js.map +1 -0
  23. package/build/events/types.d.ts +34 -0
  24. package/build/events/types.d.ts.map +1 -0
  25. package/build/events/types.js +2 -0
  26. package/build/events/types.js.map +1 -0
  27. package/build/events.d.ts +10 -21
  28. package/build/events.d.ts.map +1 -1
  29. package/build/events.js +104 -102
  30. package/build/events.js.map +1 -1
  31. package/build/facade.d.ts.map +1 -1
  32. package/build/facade.js +86 -49
  33. package/build/facade.js.map +1 -1
  34. package/build/feed.d.ts +4 -4
  35. package/build/feed.d.ts.map +1 -1
  36. package/build/feed.js +50 -27
  37. package/build/feed.js.map +1 -1
  38. package/build/helper/types.d.ts +15 -0
  39. package/build/helper/types.d.ts.map +1 -0
  40. package/build/helper/types.js +2 -0
  41. package/build/helper/types.js.map +1 -0
  42. package/build/helper.d.ts +7 -7
  43. package/build/helper.d.ts.map +1 -1
  44. package/build/helper.js +16 -8
  45. package/build/helper.js.map +1 -1
  46. package/build/index.d.ts +11 -0
  47. package/build/index.d.ts.map +1 -1
  48. package/build/index.js +6 -0
  49. package/build/index.js.map +1 -1
  50. package/build/lifecycle/types.d.ts +27 -0
  51. package/build/lifecycle/types.d.ts.map +1 -0
  52. package/build/lifecycle/types.js +2 -0
  53. package/build/lifecycle/types.js.map +1 -0
  54. package/build/lifecycle.d.ts +3 -0
  55. package/build/lifecycle.d.ts.map +1 -0
  56. package/build/lifecycle.js +157 -0
  57. package/build/lifecycle.js.map +1 -0
  58. package/build/resource-feed.d.ts +5 -0
  59. package/build/resource-feed.d.ts.map +1 -0
  60. package/build/resource-feed.js +97 -0
  61. package/build/resource-feed.js.map +1 -0
  62. package/build/resources.d.ts +6 -0
  63. package/build/resources.d.ts.map +1 -0
  64. package/build/resources.js +93 -0
  65. package/build/resources.js.map +1 -0
  66. package/build/stores.d.ts +4 -11
  67. package/build/stores.d.ts.map +1 -1
  68. package/build/stores.js +11 -35
  69. package/build/stores.js.map +1 -1
  70. package/build/sync/types.d.ts +18 -0
  71. package/build/sync/types.d.ts.map +1 -0
  72. package/build/sync/types.js +2 -0
  73. package/build/sync/types.js.map +1 -0
  74. package/build/sync.d.ts +4 -0
  75. package/build/sync.d.ts.map +1 -0
  76. package/build/sync.js +26 -0
  77. package/build/sync.js.map +1 -0
  78. package/build/types.d.ts +48 -3
  79. package/build/types.d.ts.map +1 -1
  80. package/build/types.local.d.ts +13 -0
  81. package/build/types.local.d.ts.map +1 -0
  82. package/build/types.local.js +2 -0
  83. package/build/types.local.js.map +1 -0
  84. package/build/utils/record/types.d.ts +28 -0
  85. package/build/utils/record/types.d.ts.map +1 -0
  86. package/build/utils/record/types.js +2 -0
  87. package/build/utils/record/types.js.map +1 -0
  88. package/build/utils/record.d.ts +3 -24
  89. package/build/utils/record.d.ts.map +1 -1
  90. package/build/utils/record.js +64 -70
  91. package/build/utils/record.js.map +1 -1
  92. package/build/validation/consts.d.ts +23 -0
  93. package/build/validation/consts.d.ts.map +1 -0
  94. package/build/validation/consts.js +289 -0
  95. package/build/validation/consts.js.map +1 -0
  96. package/build/validation/types.d.ts +17 -0
  97. package/build/validation/types.d.ts.map +1 -0
  98. package/build/validation/types.js +2 -0
  99. package/build/validation/types.js.map +1 -0
  100. package/build/validation.d.ts +10 -0
  101. package/build/validation.d.ts.map +1 -0
  102. package/build/validation.js +171 -0
  103. package/build/validation.js.map +1 -0
  104. package/package.json +20 -18
  105. package/src/client.ts +58 -15
  106. package/src/commits.ts +152 -184
  107. package/src/consts.ts +12 -4
  108. package/src/definitions.ts +93 -0
  109. package/src/events/types.ts +35 -0
  110. package/src/events.ts +118 -108
  111. package/src/facade.ts +87 -55
  112. package/src/feed.ts +47 -28
  113. package/src/helper/types.ts +15 -0
  114. package/src/helper.ts +27 -10
  115. package/src/index.ts +12 -0
  116. package/src/lifecycle/types.ts +28 -0
  117. package/src/lifecycle.ts +123 -0
  118. package/src/resource-feed.ts +79 -0
  119. package/src/resources.ts +84 -0
  120. package/src/stores.ts +11 -40
  121. package/src/sync/types.ts +18 -0
  122. package/src/sync.ts +40 -0
  123. package/src/types.local.ts +14 -0
  124. package/src/types.ts +53 -5
  125. package/src/utils/record/types.ts +27 -0
  126. package/src/utils/record.ts +68 -71
  127. package/src/validation/consts.ts +289 -0
  128. package/src/validation/types.ts +18 -0
  129. package/src/validation.ts +140 -0
  130. package/tests/app.spec.ts +72 -0
  131. package/tests/context.ts +46 -15
  132. package/tests/csp.spec.ts +119 -0
  133. package/tests/definitions.spec.ts +74 -0
  134. package/tests/draft7.spec.ts +13 -0
  135. package/tests/events.spec.ts +13 -9
  136. package/tests/facade.spec.ts +2 -1
  137. package/tests/lifecycle/context.ts +29 -0
  138. package/tests/lifecycle-close.spec.ts +103 -0
  139. package/tests/lifecycle-commits.spec.ts +92 -0
  140. package/tests/lifecycle-definitions.spec.ts +28 -0
  141. package/tests/lifecycle-feeds.spec.ts +92 -0
  142. package/tests/lifecycle-sockets.spec.ts +76 -0
  143. package/tests/replies.spec.ts +81 -0
  144. package/tests/resources.spec.ts +116 -0
  145. package/tests/stores.spec.ts +13 -11
  146. package/tests/validation.spec.ts +127 -0
@@ -0,0 +1,289 @@
1
+ /** Draft 7 vocabulary, plus AJV's $defs spelling. Other vocabularies fail closed. */
2
+ export const FIELD_SCHEMA_KEYWORDS = new Set([
3
+ '$id', '$schema', '$ref', '$comment', '$defs', 'definitions', 'type', 'nullable', 'const', 'enum',
4
+ 'required', 'properties', 'patternProperties', 'additionalProperties', 'propertyNames',
5
+ 'minProperties', 'maxProperties', 'dependencies', 'items', 'additionalItems', 'contains',
6
+ 'minItems', 'maxItems', 'uniqueItems', 'minimum', 'maximum', 'exclusiveMinimum',
7
+ 'exclusiveMaximum', 'multipleOf', 'minLength', 'maxLength', 'pattern', 'format',
8
+ 'allOf', 'anyOf', 'oneOf', 'not', 'if', 'then', 'else', 'title', 'description', 'default',
9
+ 'examples', 'readOnly', 'writeOnly', 'contentMediaType', 'contentEncoding',
10
+ ])
11
+ export const FIELD_SCHEMA_MAPS = new Set(['properties', 'patternProperties', 'definitions', '$defs'])
12
+ export const FIELD_SCHEMA_ARRAYS = new Set(['allOf', 'anyOf', 'oneOf'])
13
+ export const FIELD_SCHEMA_CHILDREN = new Set([
14
+ 'additionalProperties', 'propertyNames', 'additionalItems', 'contains', 'not', 'if', 'then', 'else',
15
+ ])
16
+ export const FIELD_SCHEMA_ANNOTATIONS = new Set([
17
+ '$comment', 'title', 'description', 'default', 'examples', 'readOnly', 'writeOnly',
18
+ 'contentMediaType', 'contentEncoding',
19
+ ])
20
+ export const FIELD_SCHEMA_DRAFTS = new Set([
21
+ 'http://json-schema.org/draft-07/schema', 'http://json-schema.org/draft-07/schema#',
22
+ 'https://json-schema.org/draft-07/schema', 'https://json-schema.org/draft-07/schema#',
23
+ ])
24
+ /** Reserved predicates never replace cfworker's own global format definitions. */
25
+ export const FIELD_FORMAT_PREFIX = 'owlmeans-planning:'
26
+ export const MAX_FIELD_ERRORS = 64
27
+ export const MAX_FIELD_ERROR_PATH = 512
28
+ export const REQUIRED_ERROR_PREFIX = 'Instance does not have required property "'
29
+ export const REQUIRED_ERROR_SUFFIX = '".'
30
+ export const FIELD_ERROR_WRAPPERS = new Set(['properties', 'patternProperties', 'items', '$ref', 'allOf', 'if'])
31
+ export const FIELD_ERROR_MESSAGES: Readonly<Record<string, string>> = {
32
+ required: 'A required field is missing', type: 'The field has an invalid type',
33
+ enum: 'Choose one of the allowed values', const: 'The field must match its fixed value',
34
+ format: 'The field has an invalid format', pattern: 'The field does not match its required pattern',
35
+ additionalProperties: 'This field is not allowed', additionalItems: 'This item is not allowed',
36
+ minimum: 'The value is below its minimum', maximum: 'The value exceeds its maximum',
37
+ exclusiveMinimum: 'The value must exceed its minimum', exclusiveMaximum: 'The value must be below its maximum',
38
+ multipleOf: 'The value is not an allowed multiple', minLength: 'The value is too short',
39
+ maxLength: 'The value is too long', minItems: 'There are too few items', maxItems: 'There are too many items',
40
+ uniqueItems: 'Items must be distinct', minProperties: 'There are too few fields', maxProperties: 'There are too many fields',
41
+ }
42
+
43
+ /**
44
+ * The JSON Schema draft-07 meta-schema a field schema is checked against — ajv's own
45
+ * `refs/json-schema-draft-07.json`, kept here as data. Importing that file directly needs a JSON
46
+ * import attribute in Node, and a bundler that also meets ajv's own attribute-less `require` of the
47
+ * same file refuses the two (Rollup: "import attributes for the same module are always consistent").
48
+ */
49
+ export const JSON_SCHEMA_DRAFT_07: Readonly<Record<string, unknown>> = Object.freeze({
50
+ '$schema': 'http://json-schema.org/draft-07/schema#',
51
+ '$id': 'http://json-schema.org/draft-07/schema#',
52
+ 'title': 'Core schema meta-schema',
53
+ 'definitions': {
54
+ 'schemaArray': {
55
+ 'type': 'array',
56
+ 'minItems': 1,
57
+ 'items': {
58
+ '$ref': '#'
59
+ }
60
+ },
61
+ 'nonNegativeInteger': {
62
+ 'type': 'integer',
63
+ 'minimum': 0
64
+ },
65
+ 'nonNegativeIntegerDefault0': {
66
+ 'allOf': [
67
+ {
68
+ '$ref': '#/definitions/nonNegativeInteger'
69
+ },
70
+ {
71
+ 'default': 0
72
+ }
73
+ ]
74
+ },
75
+ 'simpleTypes': {
76
+ 'enum': [
77
+ 'array',
78
+ 'boolean',
79
+ 'integer',
80
+ 'null',
81
+ 'number',
82
+ 'object',
83
+ 'string'
84
+ ]
85
+ },
86
+ 'stringArray': {
87
+ 'type': 'array',
88
+ 'items': {
89
+ 'type': 'string'
90
+ },
91
+ 'uniqueItems': true,
92
+ 'default': []
93
+ }
94
+ },
95
+ 'type': [
96
+ 'object',
97
+ 'boolean'
98
+ ],
99
+ 'properties': {
100
+ '$id': {
101
+ 'type': 'string',
102
+ 'format': 'uri-reference'
103
+ },
104
+ '$schema': {
105
+ 'type': 'string',
106
+ 'format': 'uri'
107
+ },
108
+ '$ref': {
109
+ 'type': 'string',
110
+ 'format': 'uri-reference'
111
+ },
112
+ '$comment': {
113
+ 'type': 'string'
114
+ },
115
+ 'title': {
116
+ 'type': 'string'
117
+ },
118
+ 'description': {
119
+ 'type': 'string'
120
+ },
121
+ 'default': true,
122
+ 'readOnly': {
123
+ 'type': 'boolean',
124
+ 'default': false
125
+ },
126
+ 'examples': {
127
+ 'type': 'array',
128
+ 'items': true
129
+ },
130
+ 'multipleOf': {
131
+ 'type': 'number',
132
+ 'exclusiveMinimum': 0
133
+ },
134
+ 'maximum': {
135
+ 'type': 'number'
136
+ },
137
+ 'exclusiveMaximum': {
138
+ 'type': 'number'
139
+ },
140
+ 'minimum': {
141
+ 'type': 'number'
142
+ },
143
+ 'exclusiveMinimum': {
144
+ 'type': 'number'
145
+ },
146
+ 'maxLength': {
147
+ '$ref': '#/definitions/nonNegativeInteger'
148
+ },
149
+ 'minLength': {
150
+ '$ref': '#/definitions/nonNegativeIntegerDefault0'
151
+ },
152
+ 'pattern': {
153
+ 'type': 'string',
154
+ 'format': 'regex'
155
+ },
156
+ 'additionalItems': {
157
+ '$ref': '#'
158
+ },
159
+ 'items': {
160
+ 'anyOf': [
161
+ {
162
+ '$ref': '#'
163
+ },
164
+ {
165
+ '$ref': '#/definitions/schemaArray'
166
+ }
167
+ ],
168
+ 'default': true
169
+ },
170
+ 'maxItems': {
171
+ '$ref': '#/definitions/nonNegativeInteger'
172
+ },
173
+ 'minItems': {
174
+ '$ref': '#/definitions/nonNegativeIntegerDefault0'
175
+ },
176
+ 'uniqueItems': {
177
+ 'type': 'boolean',
178
+ 'default': false
179
+ },
180
+ 'contains': {
181
+ '$ref': '#'
182
+ },
183
+ 'maxProperties': {
184
+ '$ref': '#/definitions/nonNegativeInteger'
185
+ },
186
+ 'minProperties': {
187
+ '$ref': '#/definitions/nonNegativeIntegerDefault0'
188
+ },
189
+ 'required': {
190
+ '$ref': '#/definitions/stringArray'
191
+ },
192
+ 'additionalProperties': {
193
+ '$ref': '#'
194
+ },
195
+ 'definitions': {
196
+ 'type': 'object',
197
+ 'additionalProperties': {
198
+ '$ref': '#'
199
+ },
200
+ 'default': {}
201
+ },
202
+ 'properties': {
203
+ 'type': 'object',
204
+ 'additionalProperties': {
205
+ '$ref': '#'
206
+ },
207
+ 'default': {}
208
+ },
209
+ 'patternProperties': {
210
+ 'type': 'object',
211
+ 'additionalProperties': {
212
+ '$ref': '#'
213
+ },
214
+ 'propertyNames': {
215
+ 'format': 'regex'
216
+ },
217
+ 'default': {}
218
+ },
219
+ 'dependencies': {
220
+ 'type': 'object',
221
+ 'additionalProperties': {
222
+ 'anyOf': [
223
+ {
224
+ '$ref': '#'
225
+ },
226
+ {
227
+ '$ref': '#/definitions/stringArray'
228
+ }
229
+ ]
230
+ }
231
+ },
232
+ 'propertyNames': {
233
+ '$ref': '#'
234
+ },
235
+ 'const': true,
236
+ 'enum': {
237
+ 'type': 'array',
238
+ 'items': true,
239
+ 'minItems': 1,
240
+ 'uniqueItems': true
241
+ },
242
+ 'type': {
243
+ 'anyOf': [
244
+ {
245
+ '$ref': '#/definitions/simpleTypes'
246
+ },
247
+ {
248
+ 'type': 'array',
249
+ 'items': {
250
+ '$ref': '#/definitions/simpleTypes'
251
+ },
252
+ 'minItems': 1,
253
+ 'uniqueItems': true
254
+ }
255
+ ]
256
+ },
257
+ 'format': {
258
+ 'type': 'string'
259
+ },
260
+ 'contentMediaType': {
261
+ 'type': 'string'
262
+ },
263
+ 'contentEncoding': {
264
+ 'type': 'string'
265
+ },
266
+ 'if': {
267
+ '$ref': '#'
268
+ },
269
+ 'then': {
270
+ '$ref': '#'
271
+ },
272
+ 'else': {
273
+ '$ref': '#'
274
+ },
275
+ 'allOf': {
276
+ '$ref': '#/definitions/schemaArray'
277
+ },
278
+ 'anyOf': {
279
+ '$ref': '#/definitions/schemaArray'
280
+ },
281
+ 'oneOf': {
282
+ '$ref': '#/definitions/schemaArray'
283
+ },
284
+ 'not': {
285
+ '$ref': '#'
286
+ }
287
+ },
288
+ 'default': true
289
+ })
@@ -0,0 +1,18 @@
1
+ export interface PlanningFieldError {
2
+ /** Decoded field path; a required-property error includes the missing property's name. */
3
+ path: string
4
+ keyword: string
5
+ schemaPath: string
6
+ /** A bounded explanation which does not include the submitted value. */
7
+ message: string
8
+ }
9
+
10
+ export interface PlanningFieldValidation {
11
+ valid: boolean
12
+ errors: PlanningFieldError[]
13
+ }
14
+
15
+ export interface PlanningFieldValidator {
16
+ /** Validate without coercion, defaults, removal, code generation or network reference loading. */
17
+ validateFields: (fields: Record<string, unknown>) => PlanningFieldValidation
18
+ }
@@ -0,0 +1,140 @@
1
+ import { dereference, format, validate as interpret, Validator } from '@cfworker/json-schema'
2
+ import type { OutputUnit, Schema } from '@cfworker/json-schema'
3
+ import type { AnySchema, Format } from 'ajv'
4
+ import formatsPlugin from 'ajv-formats'
5
+ import type { FormatName } from 'ajv-formats'
6
+ import { SchemaInvalid } from '@owlmeans/planning'
7
+ import type { PlanningFieldError, PlanningFieldValidation, PlanningFieldValidator } from './validation/types.js'
8
+ import {
9
+ FIELD_ERROR_MESSAGES, FIELD_ERROR_WRAPPERS, FIELD_FORMAT_PREFIX, FIELD_SCHEMA_ANNOTATIONS, FIELD_SCHEMA_ARRAYS,
10
+ FIELD_SCHEMA_CHILDREN, FIELD_SCHEMA_DRAFTS, FIELD_SCHEMA_KEYWORDS, FIELD_SCHEMA_MAPS,
11
+ JSON_SCHEMA_DRAFT_07, MAX_FIELD_ERRORS, MAX_FIELD_ERROR_PATH, REQUIRED_ERROR_PREFIX, REQUIRED_ERROR_SUFFIX,
12
+ } from './validation/consts.js'
13
+
14
+ /**
15
+ * Interpret the exact served field schema under a strict script-src policy. The vocabulary is
16
+ * Draft 7/$defs/nullable with AJV reference-sibling semantics and its full string-format predicates.
17
+ * Unsupported dialects/extensions, numeric formats and multipleOf schemas refuse
18
+ * during preparation; the server's AJV remains the authoritative validator.
19
+ */
20
+ export const makePlanningFieldValidator = (fieldsSchema: AnySchema): PlanningFieldValidator => {
21
+ const invalid = (path: string, keyword: string): never => {
22
+ throw new SchemaInvalid(`client:fields:${keyword}:${path.slice(0, MAX_FIELD_ERROR_PATH)}`)
23
+ }
24
+ const pointer = (part: string): string => part.replaceAll('~', '~0').replaceAll('/', '~1')
25
+ const nodes = new Map<Schema, string>()
26
+ const meta = new Validator(structuredClone(JSON_SCHEMA_DRAFT_07) as Schema, '7', false)
27
+
28
+ const prepare = (source: unknown, path: string): Schema | boolean => {
29
+ if (typeof source === 'boolean') return source
30
+ if (source == null || typeof source !== 'object' || Array.isArray(source)) return invalid(path, 'schema')
31
+ const checked = meta.validate(source)
32
+ if (!checked.valid) return invalid(`${path}${checked.errors[0]?.instanceLocation.slice(1) ?? ''}`, 'malformed')
33
+ const node: Schema = {}
34
+ nodes.set(node, path)
35
+ for (const [key, value] of Object.entries(source)) {
36
+ const next = `${path}/${pointer(key)}`
37
+ if (!FIELD_SCHEMA_KEYWORDS.has(key) && !key.startsWith('x-')) invalid(next, 'unsupported-keyword')
38
+ if (FIELD_SCHEMA_ANNOTATIONS.has(key) || key.startsWith('x-')) continue
39
+ if (key === 'nullable') continue
40
+ if (key === '$schema' && !FIELD_SCHEMA_DRAFTS.has(String(value))) invalid(next, 'unsupported-draft')
41
+ // The interpreters disagree on floating tolerance and large/scientific-notation quotients.
42
+ if (key === 'multipleOf') invalid(next, 'unsupported-multipleOf')
43
+ if (key === 'format') {
44
+ let definition: Format
45
+ try { definition = formatsPlugin.get(value as FormatName, 'full') }
46
+ catch { return invalid(next, 'unsupported-format') }
47
+ if (typeof definition === 'object' && !(definition instanceof RegExp) && definition.type === 'number') {
48
+ invalid(next, 'unsupported-numeric-format')
49
+ }
50
+ if (typeof definition === 'object' && !(definition instanceof RegExp) && 'async' in definition && definition.async === true) {
51
+ invalid(next, 'unsupported-async-format')
52
+ }
53
+ const guard = typeof definition === 'object' && !(definition instanceof RegExp) ? definition.validate : definition
54
+ const predicate = typeof guard === 'string' ? new RegExp(guard, 'u') : guard
55
+ const name = `${FIELD_FORMAT_PREFIX}${String(value)}`
56
+ format[name] ??= input => predicate === true || (predicate instanceof RegExp ? predicate.test(input)
57
+ : (predicate as (value: string) => boolean)(input)) === true
58
+ node.format = name
59
+ } else if (FIELD_SCHEMA_MAPS.has(key)) {
60
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) invalid(next, 'malformed')
61
+ node[key] = Object.fromEntries(Object.entries(value as Record<string, unknown>)
62
+ .map(([name, child]) => [name, prepare(child, `${next}/${pointer(name)}`)]))
63
+ } else if (FIELD_SCHEMA_ARRAYS.has(key) || (key === 'items' && Array.isArray(value))) {
64
+ node[key] = (value as unknown[]).map((child, index) => prepare(child, `${next}/${index}`))
65
+ } else if (FIELD_SCHEMA_CHILDREN.has(key) || key === 'items') {
66
+ node[key] = prepare(value, next)
67
+ } else if (key === 'dependencies') {
68
+ ;(node as Record<string, unknown>)[key] = Object.fromEntries(Object.entries(value as Record<string, unknown>)
69
+ .map(([name, child]) => [name, Array.isArray(child) ? child : prepare(child, `${next}/${pointer(name)}`)]))
70
+ } else { node[key] = structuredClone(value) }
71
+ }
72
+ if ('nullable' in source) {
73
+ const nullable = (source as Schema).nullable
74
+ if (typeof nullable !== 'boolean' || node.type == null) invalid(`${path}/nullable`, 'malformed')
75
+ const kinds = Array.isArray(node.type) ? node.type : [node.type!]
76
+ if (nullable) node.type = [...new Set([...kinds, 'null' as const])]
77
+ else if (kinds.includes('null')) invalid(`${path}/nullable`, 'malformed')
78
+ }
79
+ return node
80
+ }
81
+ const schema = prepare(fieldsSchema, '#')
82
+ let lookup: Record<string, Schema | boolean>
83
+ try { lookup = dereference(schema) }
84
+ catch { return invalid('#', 'invalid-reference') }
85
+ for (const [node, path] of nodes) {
86
+ if (node.$ref != null && lookup[node.__absolute_ref__!] === undefined) invalid(`${path}/$ref`, 'missing-reference')
87
+ }
88
+ const complete = new Set<Schema>()
89
+ const visiting = new Set<Schema>()
90
+ const checkReferences = (node: Schema | boolean): void => {
91
+ if (typeof node === 'boolean' || complete.has(node)) return
92
+ if (visiting.has(node)) invalid(nodes.get(node) ?? '#', 'cyclic-reference')
93
+ visiting.add(node)
94
+ if (node.$ref != null) checkReferences(lookup[node.__absolute_ref__!]!)
95
+ // These evaluate the same instance; properties/items make productive recursive schemas.
96
+ for (const key of ['allOf', 'anyOf', 'oneOf']) (node[key] as Array<Schema | boolean> | undefined)?.forEach(checkReferences)
97
+ for (const key of ['not', 'if', 'then', 'else']) if (node[key] != null) checkReferences(node[key])
98
+ for (const child of Object.values(node.dependencies ?? {})) if (!Array.isArray(child)) checkReferences(child)
99
+ visiting.delete(node)
100
+ complete.add(node)
101
+ }
102
+ nodes.forEach((_path, node) => checkReferences(node))
103
+
104
+ const fieldError = (error: OutputUnit): PlanningFieldError => {
105
+ const parts = error.instanceLocation.slice(1).replace(/^\//, '').split('/').filter(Boolean)
106
+ .map(part => part.replaceAll('~1', '/').replaceAll('~0', '~'))
107
+ if (error.keyword === 'required' && error.error.startsWith(REQUIRED_ERROR_PREFIX) && error.error.endsWith(REQUIRED_ERROR_SUFFIX)) {
108
+ parts.push(error.error.slice(REQUIRED_ERROR_PREFIX.length, -REQUIRED_ERROR_SUFFIX.length))
109
+ }
110
+ return { path: parts.join('.').slice(0, MAX_FIELD_ERROR_PATH), keyword: error.keyword,
111
+ schemaPath: error.keywordLocation.slice(0, MAX_FIELD_ERROR_PATH),
112
+ message: FIELD_ERROR_MESSAGES[error.keyword] ?? 'The field does not match its definition' }
113
+ }
114
+ const jsonErrors = (value: unknown, path: string, active: Set<object>): PlanningFieldError[] => {
115
+ if (value === null || typeof value === 'string' || typeof value === 'boolean'
116
+ || (typeof value === 'number' && Number.isFinite(value))) return []
117
+ if (typeof value !== 'object' || active.has(value)) return [{ path, keyword: 'json', schemaPath: '#', message: 'Use a JSON value' }]
118
+ const plain = Array.isArray(value) || Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null
119
+ if (!plain) return [{ path, keyword: 'json', schemaPath: '#', message: 'Use a JSON value' }]
120
+ active.add(value)
121
+ const errors = Object.entries(value).flatMap(([key, child]) => [
122
+ ...(!Array.isArray(value) && (key.includes('.') || key.includes('$'))
123
+ ? [{ path: [path, key].filter(Boolean).join('.'), keyword: 'field-key', schemaPath: '#', message: 'This field name is not allowed' }] : []),
124
+ ...jsonErrors(child, [path, key].filter(Boolean).join('.'), active),
125
+ ])
126
+ active.delete(value)
127
+ return errors
128
+ }
129
+ const validateFields = (fields: Record<string, unknown>): PlanningFieldValidation => {
130
+ const unsafe = jsonErrors(fields, '', new Set())
131
+ if (unsafe.length > 0) return { valid: false, errors: unsafe.slice(0, MAX_FIELD_ERRORS)
132
+ .map(error => ({ ...error, path: error.path.slice(0, MAX_FIELD_ERROR_PATH) })) }
133
+ // Only the guarded Draft 7 vocabulary reaches this engine. This mode, unlike its '7' mode,
134
+ // also enforces sibling keywords beside $ref, as the native planning AJV does.
135
+ const result = interpret(fields, schema, '2019-09', lookup, false)
136
+ const errors = result.errors.filter(error => !FIELD_ERROR_WRAPPERS.has(error.keyword))
137
+ return { valid: result.valid, errors: (errors.length > 0 ? errors : result.errors).slice(0, MAX_FIELD_ERRORS).map(fieldError) }
138
+ }
139
+ return { validateFields }
140
+ }
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { appSchemaHelper, FieldsInvalid, SchemaInvalid, TransitionAction, WorkcardKind } from '@owlmeans/planning'
3
+ import type { PlanningAppDeclarations, PlanningService, WorkcardTypeSchema } from '@owlmeans/planning'
4
+ import { makePlanningFieldValidator } from '../src/index.js'
5
+ import { makeSuite, STORY, STORY_FLOW } from './context.js'
6
+ import type { Suite } from './context.js'
7
+
8
+ /** A choir: every type says what it is for, every card may carry a part. */
9
+ const APP: PlanningAppDeclarations = {
10
+ type: { properties: { description: { type: 'string' } } },
11
+ card: { properties: { part: { type: 'string', enum: ['soprano', 'alto', 'tenor', 'bass'] } } },
12
+ }
13
+ /** What a service serves: the declarations merged (normalized with `type: 'object'`). */
14
+ const SERVED = appSchemaHelper.merge([APP])
15
+
16
+ const SECTIONAL: WorkcardTypeSchema = {
17
+ type: 'test:sectional', kind: WorkcardKind.Card, version: 1, flows: [STORY_FLOW], specifications: [],
18
+ fields: { type: 'object', additionalProperties: false },
19
+ }
20
+
21
+ const declared = async (opts: Parameters<typeof makeSuite>[0] = {}): Promise<Suite> => {
22
+ const suite = await makeSuite(opts)
23
+ ;(suite.server as unknown as { planning: () => PlanningService }).planning().use({ name: 'choir', schemas: { app: APP } })
24
+
25
+ return suite
26
+ }
27
+
28
+ describe('@owlmeans/client-planning — application declarations', () => {
29
+ test('the served bundle carries them: the registry validates base fields, a form validator takes the same schema', async () => {
30
+ const suite = await declared()
31
+ const registry = await suite.client.planning().loadSchemas()
32
+
33
+ expect(registry.app()).toEqual(SERVED!)
34
+ expect(registry.validator(STORY)({ area: 'stage', part: 'alto' })).toBe(true)
35
+ expect(registry.validator(STORY)({ area: 'stage', part: 'drums' })).toBe(false)
36
+ const form = makePlanningFieldValidator(registry.fieldsSchema(STORY))
37
+ expect(form.validateFields({ area: 'stage', part: 'alto' }).valid).toBe(true)
38
+ expect(form.validateFields({ area: 'stage', part: 'drums' }).valid).toBe(false)
39
+ expect(form.validateFields({ area: 'stage', colour: 'red' }).valid).toBe(false)
40
+
41
+ const project = await suite.project('Spring concert')
42
+ await expect(suite.planning.execute({
43
+ card: { kind: WorkcardKind.Card, type: STORY, parent: project.id!, title: 'Warm-up', fields: { part: 'drums' } },
44
+ action: TransitionAction.Create,
45
+ }, { wait: true })).rejects.toBeInstanceOf(FieldsInvalid)
46
+ })
47
+
48
+ test('a schemas-only client plugin merges over the served declarations; a malformed one is refused', async () => {
49
+ const suite = await declared()
50
+ const service = suite.client.planning()
51
+ await service.loadSchemas()
52
+
53
+ service.use({ name: 'stage', schemas: { app: { card: { properties: { row: { type: 'number' } } } } } })
54
+ expect(Object.keys(service.schemas.app()?.card?.properties ?? {})).toEqual(['part', 'row'])
55
+ expect(() => service.use({ name: 'broken', schemas: { app: { card: { properties: { 'a.b': {} } } } } })).toThrow(SchemaInvalid)
56
+ expect(service.plugins().map(plugin => plugin.name)).toEqual(['stage'])
57
+ service.use({ name: 'stage' })
58
+ expect(Object.keys(service.schemas.app()?.card?.properties ?? {})).toEqual(['part'])
59
+ })
60
+
61
+ test('definitions carry a type\'s app bag over the wire; a refused bag crosses as SchemaInvalid', async () => {
62
+ const suite = await declared({ definitions: true })
63
+ const definitions = suite.planning.definitions!
64
+
65
+ await definitions.define({ types: [{ ...SECTIONAL, app: { description: 'Small groups' } }] })
66
+ const layer = await definitions.registry()
67
+ expect((layer.type(SECTIONAL.type) as WorkcardTypeSchema).app).toEqual({ description: 'Small groups' })
68
+ expect(layer.app()).toEqual(SERVED!)
69
+ expect(layer.validator(SECTIONAL.type)({ part: 'tenor' })).toBe(true)
70
+ await expect(definitions.putType({ ...SECTIONAL, version: 2, app: { conductor: 'me' } })).rejects.toBeInstanceOf(SchemaInvalid)
71
+ })
72
+ })