@fougere/schema 0.8.2-alpha.0 → 0.8.4-alpha.0

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 (219) hide show
  1. package/dist/Schema.d.ts +3 -6
  2. package/dist/Schema.d.ts.map +1 -1
  3. package/dist/Schema.js +9 -17
  4. package/dist/Schema.js.map +1 -1
  5. package/dist/SchemaDefinition.d.ts +24 -6
  6. package/dist/SchemaDefinition.d.ts.map +1 -1
  7. package/dist/SchemaDefinition.js +76 -44
  8. package/dist/SchemaDefinition.js.map +1 -1
  9. package/dist/SchemaView.d.ts +1 -1
  10. package/dist/SchemaView.d.ts.map +1 -1
  11. package/dist/axis/Axis.d.ts +1 -1
  12. package/dist/axis/Axis.d.ts.map +1 -1
  13. package/dist/axis/boundary/Boundaries.d.ts +5 -0
  14. package/dist/axis/boundary/Boundaries.d.ts.map +1 -1
  15. package/dist/axis/boundary/Boundaries.js.map +1 -1
  16. package/dist/axis/boundary/Boundary.d.ts +5 -4
  17. package/dist/axis/boundary/Boundary.d.ts.map +1 -1
  18. package/dist/axis/boundary/Boundary.js +4 -5
  19. package/dist/axis/boundary/Boundary.js.map +1 -1
  20. package/dist/axis/boundary/BoundaryAxis.js +2 -2
  21. package/dist/axis/boundary/BoundaryAxis.js.map +1 -1
  22. package/dist/axis/lifecycle/Lifecycle.d.ts +5 -3
  23. package/dist/axis/lifecycle/Lifecycle.d.ts.map +1 -1
  24. package/dist/axis/lifecycle/Lifecycle.js +5 -1
  25. package/dist/axis/lifecycle/Lifecycle.js.map +1 -1
  26. package/dist/axis/lifecycle/LifecycleAxis.js +6 -6
  27. package/dist/axis/lifecycle/LifecycleAxis.js.map +1 -1
  28. package/dist/axis/lifecycle/apply.d.ts +6 -6
  29. package/dist/axis/lifecycle/apply.d.ts.map +1 -1
  30. package/dist/axis/lifecycle/apply.js +10 -9
  31. package/dist/axis/lifecycle/apply.js.map +1 -1
  32. package/dist/axis/role/Role.d.ts +8 -5
  33. package/dist/axis/role/Role.d.ts.map +1 -1
  34. package/dist/axis/role/Role.js +7 -0
  35. package/dist/axis/role/Role.js.map +1 -1
  36. package/dist/axis/role/RoleAxis.js +9 -9
  37. package/dist/axis/role/RoleAxis.js.map +1 -1
  38. package/dist/axis/shape/Shape.d.ts +14 -0
  39. package/dist/axis/shape/Shape.d.ts.map +1 -1
  40. package/dist/axis/shape/Shape.js +43 -34
  41. package/dist/axis/shape/Shape.js.map +1 -1
  42. package/dist/entity/EntityAdapterSet.d.ts +2 -2
  43. package/dist/entity/EntityAdapterSet.d.ts.map +1 -1
  44. package/dist/entity/EntityAdapterSet.js +2 -2
  45. package/dist/entity/EntityAdapterSet.js.map +1 -1
  46. package/dist/entity.d.ts +2 -2
  47. package/dist/entity.js +2 -2
  48. package/dist/field/Field.d.ts.map +1 -1
  49. package/dist/field/Field.js +2 -1
  50. package/dist/field/Field.js.map +1 -1
  51. package/dist/field/FieldSet.d.ts +4 -7
  52. package/dist/field/FieldSet.d.ts.map +1 -1
  53. package/dist/field/FieldSet.js +4 -15
  54. package/dist/field/FieldSet.js.map +1 -1
  55. package/dist/index.d.ts +2 -2
  56. package/dist/index.d.ts.map +1 -1
  57. package/dist/index.js +1 -0
  58. package/dist/index.js.map +1 -1
  59. package/dist/lib/validation.d.ts +32 -0
  60. package/dist/lib/validation.d.ts.map +1 -0
  61. package/dist/lib/validation.js +6 -0
  62. package/dist/lib/validation.js.map +1 -0
  63. package/dist/projection/Cases.d.ts +1 -1
  64. package/dist/projection/Cases.d.ts.map +1 -1
  65. package/dist/projection/Cases.js +11 -7
  66. package/dist/projection/Cases.js.map +1 -1
  67. package/dist/projection/Visibility.js +1 -1
  68. package/dist/projection/Visibility.js.map +1 -1
  69. package/dist/projection/card/Bundle.d.ts.map +1 -1
  70. package/dist/projection/card/Bundle.js +1 -4
  71. package/dist/projection/card/Bundle.js.map +1 -1
  72. package/dist/projection/card/Card.d.ts +7 -1
  73. package/dist/projection/card/Card.d.ts.map +1 -1
  74. package/dist/projection/card/Card.js +25 -133
  75. package/dist/projection/card/Card.js.map +1 -1
  76. package/dist/projection/card/Descriptor.d.ts +2 -2
  77. package/dist/projection/card/Descriptor.d.ts.map +1 -1
  78. package/dist/projection/card/admission.d.ts +12 -11
  79. package/dist/projection/card/admission.d.ts.map +1 -1
  80. package/dist/projection/card/admission.js +13 -11
  81. package/dist/projection/card/admission.js.map +1 -1
  82. package/dist/projection/card/diff.d.ts +7 -1
  83. package/dist/projection/card/diff.d.ts.map +1 -1
  84. package/dist/projection/card/diff.js +111 -1
  85. package/dist/projection/card/diff.js.map +1 -1
  86. package/dist/validator/FieldDeclarationValidator.d.ts +1 -1
  87. package/dist/validator/FieldDeclarationValidator.d.ts.map +1 -1
  88. package/dist/validator/FieldDeclarationValidator.js +4 -4
  89. package/dist/validator/FieldDeclarationValidator.js.map +1 -1
  90. package/dist/validator/FieldValueValidator.d.ts +6 -1
  91. package/dist/validator/FieldValueValidator.d.ts.map +1 -1
  92. package/dist/validator/FieldValueValidator.js +36 -7
  93. package/dist/validator/FieldValueValidator.js.map +1 -1
  94. package/dist/validator/InputValidator.d.ts +1 -1
  95. package/dist/validator/InputValidator.d.ts.map +1 -1
  96. package/dist/validator/InputValidator.js +8 -18
  97. package/dist/validator/InputValidator.js.map +1 -1
  98. package/dist/vocabulary/preset/created.d.ts +0 -5
  99. package/dist/vocabulary/preset/created.d.ts.map +1 -1
  100. package/dist/vocabulary/preset/created.js +0 -5
  101. package/dist/vocabulary/preset/created.js.map +1 -1
  102. package/dist/vocabulary/preset/email.d.ts +0 -5
  103. package/dist/vocabulary/preset/email.d.ts.map +1 -1
  104. package/dist/vocabulary/preset/email.js +0 -5
  105. package/dist/vocabulary/preset/email.js.map +1 -1
  106. package/dist/vocabulary/preset/updated.d.ts +0 -5
  107. package/dist/vocabulary/preset/updated.d.ts.map +1 -1
  108. package/dist/vocabulary/preset/updated.js +0 -5
  109. package/dist/vocabulary/preset/updated.js.map +1 -1
  110. package/dist/vocabulary/preset/url.d.ts +0 -5
  111. package/dist/vocabulary/preset/url.d.ts.map +1 -1
  112. package/dist/vocabulary/preset/url.js +0 -5
  113. package/dist/vocabulary/preset/url.js.map +1 -1
  114. package/dist/vocabulary/primitive/bool.d.ts +0 -5
  115. package/dist/vocabulary/primitive/bool.d.ts.map +1 -1
  116. package/dist/vocabulary/primitive/bool.js +0 -5
  117. package/dist/vocabulary/primitive/bool.js.map +1 -1
  118. package/dist/vocabulary/primitive/date.d.ts +3 -3
  119. package/dist/vocabulary/primitive/date.js +3 -3
  120. package/dist/vocabulary/primitive/json.js +3 -3
  121. package/dist/vocabulary/primitive/list.d.ts +3 -3
  122. package/dist/vocabulary/primitive/list.js +3 -3
  123. package/dist/vocabulary/primitive/many.d.ts +3 -3
  124. package/dist/vocabulary/primitive/many.js +3 -3
  125. package/dist/vocabulary/primitive/number.d.ts +0 -5
  126. package/dist/vocabulary/primitive/number.d.ts.map +1 -1
  127. package/dist/vocabulary/primitive/number.js +0 -5
  128. package/dist/vocabulary/primitive/number.js.map +1 -1
  129. package/dist/vocabulary/primitive/oneOf.js +3 -3
  130. package/dist/vocabulary/primitive/primary.js +3 -3
  131. package/dist/vocabulary/primitive/ref.d.ts +0 -5
  132. package/dist/vocabulary/primitive/ref.d.ts.map +1 -1
  133. package/dist/vocabulary/primitive/ref.js +0 -5
  134. package/dist/vocabulary/primitive/ref.js.map +1 -1
  135. package/dist/vocabulary/primitive/text.d.ts +0 -5
  136. package/dist/vocabulary/primitive/text.d.ts.map +1 -1
  137. package/dist/vocabulary/primitive/text.js +0 -5
  138. package/dist/vocabulary/primitive/text.js.map +1 -1
  139. package/dist/vocabulary/rule/immutable.d.ts +0 -5
  140. package/dist/vocabulary/rule/immutable.d.ts.map +1 -1
  141. package/dist/vocabulary/rule/immutable.js +0 -5
  142. package/dist/vocabulary/rule/immutable.js.map +1 -1
  143. package/dist/vocabulary/rule/indexed.d.ts +0 -5
  144. package/dist/vocabulary/rule/indexed.d.ts.map +1 -1
  145. package/dist/vocabulary/rule/indexed.js +0 -5
  146. package/dist/vocabulary/rule/indexed.js.map +1 -1
  147. package/dist/vocabulary/rule/nullable.d.ts +3 -3
  148. package/dist/vocabulary/rule/nullable.js +3 -3
  149. package/dist/vocabulary/rule/optional.d.ts +3 -3
  150. package/dist/vocabulary/rule/optional.js +3 -3
  151. package/dist/vocabulary/rule/readOnly.d.ts +3 -3
  152. package/dist/vocabulary/rule/readOnly.js +4 -4
  153. package/dist/vocabulary/rule/readOnly.js.map +1 -1
  154. package/dist/vocabulary/rule/unique.d.ts +0 -5
  155. package/dist/vocabulary/rule/unique.d.ts.map +1 -1
  156. package/dist/vocabulary/rule/unique.js +0 -5
  157. package/dist/vocabulary/rule/unique.js.map +1 -1
  158. package/dist/vocabulary/rule/writeOnly.d.ts +2 -2
  159. package/dist/vocabulary/rule/writeOnly.js +3 -3
  160. package/dist/vocabulary/rule/writeOnly.js.map +1 -1
  161. package/dist/vocabulary/vocabulary.d.ts +2 -2
  162. package/dist/vocabulary/vocabulary.js +5 -5
  163. package/package.json +1 -1
  164. package/src/Schema.ts +14 -20
  165. package/src/SchemaDefinition.ts +74 -48
  166. package/src/SchemaView.ts +1 -1
  167. package/src/axis/Axis.ts +1 -1
  168. package/src/axis/boundary/Boundaries.ts +5 -0
  169. package/src/axis/boundary/Boundary.ts +7 -9
  170. package/src/axis/boundary/BoundaryAxis.ts +2 -2
  171. package/src/axis/lifecycle/Lifecycle.ts +9 -4
  172. package/src/axis/lifecycle/LifecycleAxis.ts +7 -7
  173. package/src/axis/lifecycle/apply.ts +10 -9
  174. package/src/axis/role/Role.ts +14 -5
  175. package/src/axis/role/RoleAxis.ts +10 -10
  176. package/src/axis/shape/Shape.ts +61 -37
  177. package/src/entity/EntityAdapterSet.ts +2 -2
  178. package/src/entity.ts +2 -2
  179. package/src/field/Field.ts +2 -1
  180. package/src/field/FieldSet.ts +6 -20
  181. package/src/index.ts +2 -2
  182. package/src/lib/validation.ts +25 -0
  183. package/src/projection/Cases.ts +13 -8
  184. package/src/projection/Visibility.ts +1 -1
  185. package/src/projection/card/Bundle.ts +1 -4
  186. package/src/projection/card/Card.ts +26 -159
  187. package/src/projection/card/Descriptor.ts +2 -10
  188. package/src/projection/card/admission.ts +14 -12
  189. package/src/projection/card/diff.ts +142 -1
  190. package/src/validator/FieldDeclarationValidator.ts +5 -5
  191. package/src/validator/FieldValueValidator.ts +45 -12
  192. package/src/validator/InputValidator.ts +8 -18
  193. package/src/vocabulary/preset/created.ts +0 -5
  194. package/src/vocabulary/preset/email.ts +0 -5
  195. package/src/vocabulary/preset/updated.ts +0 -5
  196. package/src/vocabulary/preset/url.ts +0 -5
  197. package/src/vocabulary/primitive/bool.ts +0 -5
  198. package/src/vocabulary/primitive/date.ts +3 -3
  199. package/src/vocabulary/primitive/json.ts +3 -3
  200. package/src/vocabulary/primitive/list.ts +3 -3
  201. package/src/vocabulary/primitive/many.ts +3 -3
  202. package/src/vocabulary/primitive/number.ts +0 -5
  203. package/src/vocabulary/primitive/oneOf.ts +3 -3
  204. package/src/vocabulary/primitive/primary.ts +3 -3
  205. package/src/vocabulary/primitive/ref.ts +0 -5
  206. package/src/vocabulary/primitive/text.ts +0 -5
  207. package/src/vocabulary/rule/immutable.ts +0 -5
  208. package/src/vocabulary/rule/indexed.ts +0 -5
  209. package/src/vocabulary/rule/nullable.ts +3 -3
  210. package/src/vocabulary/rule/optional.ts +3 -3
  211. package/src/vocabulary/rule/readOnly.ts +4 -4
  212. package/src/vocabulary/rule/unique.ts +0 -5
  213. package/src/vocabulary/rule/writeOnly.ts +3 -3
  214. package/src/vocabulary/vocabulary.ts +5 -5
  215. package/dist/validation.d.ts +0 -17
  216. package/dist/validation.d.ts.map +0 -1
  217. package/dist/validation.js +0 -2
  218. package/dist/validation.js.map +0 -1
  219. package/src/validation.ts +0 -10
@@ -1,8 +1,7 @@
1
- import { EXTENSION_AXES, EXTENSION_SLOTS, type Resolver } from '../../axis/Axis.js';
2
- import { dequal } from 'dequal';
1
+ import { EXTENSION_AXES, type Resolver } from '../../axis/Axis.js';
3
2
  import { clean, isObject } from '../../lib/utils.js';
4
3
  import { Field, type Fields } from '../../field/Field.js';
5
- import { deduplicated } from '../../field/FieldSet.js';
4
+ import { SchemaConstraints } from '../../SchemaDefinition.js';
6
5
  import { InputValidator } from '../../validator/InputValidator.js';
7
6
  import { Schema, type SchemaConstructor } from '../../Schema.js';
8
7
  import type { Values, SchemaView } from '../../SchemaView.js';
@@ -13,7 +12,7 @@ import type {
13
12
  FieldExtension,
14
13
  SchemaDescriptor,
15
14
  } from './Descriptor.js';
16
- import type { Change, Diff, DiffOptions, RenameCandidate, TypeSet } from './diff.js';
15
+ import { compare, type Diff, type DiffOptions } from './diff.js';
17
16
 
18
17
  type FieldsOf<T> = { [K in keyof T]-?: Field<T[K]> };
19
18
 
@@ -42,7 +41,7 @@ export class Card<T = Values<Fields>> {
42
41
  'x-fougere-version': 1,
43
42
  'x-fougere-vendor': 'fougere',
44
43
  };
45
- const title = name ?? schema.derivation?.sourceName ?? schema.name;
44
+ const title = name ?? Card.titleOf(schema);
46
45
  if (title) descriptor.title = title;
47
46
  if (required.length) descriptor.required = required;
48
47
 
@@ -55,6 +54,14 @@ export class Card<T = Values<Fields>> {
55
54
  return new Card<T>(descriptor);
56
55
  }
57
56
 
57
+ /**
58
+ * A derivation answers the name it was cut from — `Post.pick('title')` is `Post`, since
59
+ * a derived class carries `Schema`.
60
+ */
61
+ static titleOf(schema: SchemaView): string {
62
+ return schema.derivation?.sourceName ?? schema.name;
63
+ }
64
+
58
65
  get origin(): DerivedFrom | undefined {
59
66
  return this.descriptor['x-fougere-derived'];
60
67
  }
@@ -91,79 +98,22 @@ export class Card<T = Values<Fields>> {
91
98
  for (const group of property['x-fougere']?.role?.unique ?? [])
92
99
  if (group.length > 1) groups.push([...group]);
93
100
  }
94
- const schema = Schema.of({ fields, constraints: { unique: deduplicated(groups) } });
101
+ const schema = Schema.of({ fields, constraints: SchemaConstraints.of(groups) });
95
102
  const title = name ?? descriptor.title;
96
103
  if (title)
97
104
  Object.defineProperty(schema, 'name', { value: title, configurable: true });
98
105
  return schema as unknown as SchemaConstructor<FieldsOf<T>>;
99
106
  }
100
107
 
108
+ /** What changed between two descriptions of the same entity — see {@link compare}. */
101
109
  diff(other: Card, options: DiffOptions = {}): Diff {
102
- const changes: Change[] = [];
103
- const renamed = options.renamed ?? {};
104
- const before = this.descriptor.properties ?? {};
105
- const after = other.descriptor.properties ?? {};
106
- const requiredBefore = new Set(this.descriptor.required ?? []);
107
- const requiredAfter = new Set(other.descriptor.required ?? []);
108
-
109
- // Apply a declared rename first so subsequent differences use the new field name.
110
- const nameAfter = (field: string): string => renamed[field] ?? field;
111
- const removed: string[] = [];
112
- for (const [field, descriptor] of Object.entries(before)) {
113
- const now = nameAfter(field);
114
- const target = after[now];
115
- if (target === undefined) {
116
- removed.push(field);
117
- continue;
118
- }
119
-
120
- if (now !== field)
121
- changes.push({ kind: 'renamed', from: field, to: now, field: target });
122
-
123
- const wasType = typesOf(descriptor);
124
- const isType = typesOf(target);
125
- if (!dequal(wasType, isType))
126
- changes.push({ kind: 'retyped', field: now, from: wasType, to: isType });
127
- else if (!dequal(boundsOf(descriptor), boundsOf(target))) {
128
- changes.push({ kind: 'reshaped', field: now, from: descriptor, to: target });
129
- }
130
-
131
- const wasRequired = requiredBefore.has(field);
132
- const isRequired = requiredAfter.has(now);
133
- if (wasRequired !== isRequired) {
134
- changes.push({ kind: 'required', field: now, from: wasRequired, to: isRequired });
135
- }
136
- changes.push(...restated(now, descriptor['x-fougere'], target['x-fougere']));
137
- }
138
-
139
- const claimed = new Set(Object.values(renamed));
140
- const added = Object.keys(after).filter(
141
- (field) => !(field in before) && !claimed.has(field),
142
- );
143
- for (const field of removed) {
144
- changes.push({
145
- kind: 'removed',
146
- field,
147
- from: before[field],
148
- required: requiredBefore.has(field),
149
- });
150
- }
151
- for (const field of added) {
152
- changes.push({
153
- kind: 'added',
154
- field,
155
- to: after[field],
156
- required: requiredAfter.has(field),
157
- });
158
- }
159
-
160
- return { changes, ambiguous: candidates(removed, added, before, after) };
110
+ return compare(this.descriptor, other.descriptor, options);
161
111
  }
162
112
  }
163
113
 
164
114
  /**
165
- * So the three axes write themselves under `x-fougere`, each by its own hand.
166
- * FR : pour que les trois axes s'écrivent sous `x-fougere`, chacun de sa main.
115
+ * Each axis writes its own wire form under `x-fougere`; `shape` stays outside, plain.
116
+ * FR : chaque axe écrit sa forme de fil sous `x-fougere` ; `shape` reste dehors, nu.
167
117
  * `ref(User)` → `{ role: { relation: { to: 'user', kind: 'one' } } }`
168
118
  */
169
119
  function describeExtension(field: Field, key: string): FieldExtension | undefined {
@@ -178,11 +128,6 @@ function describeExtension(field: Field, key: string): FieldExtension | undefine
178
128
  return Object.keys(extension).length ? (extension as FieldExtension) : undefined;
179
129
  }
180
130
 
181
- /**
182
- * So the shape stays plain JSON Schema, and what is ours sits under one key.
183
- * FR : pour que la forme reste du JSON Schema, ce qui est à nous tenant sous une clé.
184
- * `text({ max: 200 })` → `{ type: 'string', maxLength: 200 }`
185
- */
186
131
  function describeField(field: Field, key: string): FieldDescriptor {
187
132
  // The shape IS JSON Schema, so it lands whole rather than key by key — which is what let
188
133
  // it be copied under a computed key into a type that names its own.
@@ -194,8 +139,8 @@ function describeField(field: Field, key: string): FieldDescriptor {
194
139
  }
195
140
 
196
141
  /**
197
- * So a derived card names its root and what became of each field.
198
- * FR : pour qu'une carte dérivée nomme sa racine et le sort de chaque champ.
142
+ * What a derived card says of its root, and of every field the cut kept or dropped.
143
+ * FR : ce qu'une carte dérivée dit de sa racine, et de chaque champ gardé ou perdu.
199
144
  * `Post.pick('title')` → `{ from: 'Post', nameOf: { title: 'title' } }`
200
145
  */
201
146
  function originOf(schema: SchemaView): DerivedFrom | undefined {
@@ -208,9 +153,9 @@ function originOf(schema: SchemaView): DerivedFrom | undefined {
208
153
  }
209
154
 
210
155
  /**
211
- * So a shape read back is the shape that was written, and an unknown type is refused.
212
- * FR : pour qu'une forme relue soit celle qui a été écrite, un type inconnu étant refusé.
213
- * `{ type: 'string', maxLength: 200 }` → the same shape a `text({ max: 200 })` states
156
+ * Rebuilds the shape a card carries, refusing a type the standard does not name.
157
+ * FR : reconstruit la forme que porte une carte, refusant un type hors du standard.
158
+ * `{ type: 'string', maxLength: 200 }` → the same shape `text({ max: 200 })` states
214
159
  */
215
160
  function reconstructShape(property: FieldDescriptor): Field['shape'] | undefined {
216
161
  const types = Array.isArray(property.type) ? property.type : property.type ? [property.type] : [];
@@ -225,8 +170,8 @@ function reconstructShape(property: FieldDescriptor): Field['shape'] | undefined
225
170
  }
226
171
 
227
172
  /**
228
- * So a field read back carries its axes, rebuilt by the axes themselves.
229
- * FR : pour qu'un champ relu porte ses axes, reconstruits par les axes eux-mêmes.
173
+ * Rebuilds a field, each axis reading back what it wrote the dual of `describeField`.
174
+ * FR : reconstruit un champ, chaque axe relisant ce qu'il a écrit le dual de `describeField`.
230
175
  * `{ 'x-fougere': { lifecycle: { create: 'now' } } }` → a field stamped at create
231
176
  */
232
177
  function reconstructField(
@@ -259,86 +204,8 @@ function reconstructField(
259
204
  }
260
205
 
261
206
  /**
262
- * So an axis that changed is one named difference, not a whole field marked dirty.
263
- * FR : pour qu'un axe modifié soit une différence nommée, pas un champ entier marqué.
264
- * `{ kind: 'restated', field: 'body', axis: 'boundary', … }`
265
- */
266
- function restated(
267
- field: string,
268
- before: FieldExtension | undefined,
269
- after: FieldExtension | undefined,
270
- ): Change[] {
271
- return EXTENSION_SLOTS.filter((axis) => !dequal(before?.[axis], after?.[axis])).map(
272
- (axis) =>
273
- ({
274
- kind: 'restated',
275
- field,
276
- axis,
277
- from: before?.[axis],
278
- to: after?.[axis],
279
- }) as Change,
280
- );
281
- }
282
-
283
- /**
284
- * So two shapes are compared without what is not shape getting in the way.
285
- * FR : pour que deux formes se comparent sans que le reste s'en mêle.
286
- * `{ type: 'string', description: 'x' }` → `{ type: 'string' }`
287
- */
288
- function shapeOf(descriptor: FieldDescriptor): Record<string, unknown> {
289
- const { 'x-fougere': _extension, description: _description, ...shape } = descriptor;
290
- return shape as Record<string, unknown>;
291
- }
292
-
293
- /**
294
- * So `['string','null']` and `['null','string']` are the same type, not a change.
295
- * FR : pour que `['string','null']` et `['null','string']` soient un même type.
296
- * `typesOf({ type: ['null', 'string'] })` → `['null', 'string']`
297
- */
298
- function typesOf(descriptor: FieldDescriptor): TypeSet {
299
- const type = descriptor.type;
300
- if (type === undefined) return [];
301
- return (Array.isArray(type) ? [...type] : [type]).sort();
302
- }
303
-
304
- /**
305
- * So a bound that moved is a `reshaped`, told apart from a type that changed.
306
- * FR : pour qu'une borne déplacée soit un `reshaped`, distinct d'un type changé.
307
- * `maxLength: 200` → `maxLength: 100` → one `reshaped`, never a `retyped`
308
- */
309
- function boundsOf(descriptor: FieldDescriptor): Record<string, unknown> {
310
- const { type: _type, ...rest } = shapeOf(descriptor);
311
- return rest;
312
- }
313
-
314
- /**
315
- * So a removal plus an addition of the same shape is a question, never a guess.
316
- * FR : pour qu'une suppression plus un ajout de même forme soit une question, pas un pari.
317
- * `body` gone, `content` appeared → `ambiguous: [{ removed: 'body', added: 'content' }]`
318
- */
319
- function candidates(
320
- removed: string[],
321
- added: string[],
322
- before: Record<string, FieldDescriptor>,
323
- after: Record<string, FieldDescriptor>,
324
- ): RenameCandidate[] {
325
- const found: RenameCandidate[] = [];
326
- for (const gone of removed) {
327
- for (const appeared of added) {
328
- if (dequal(shapeOf(before[gone]), shapeOf(after[appeared])))
329
- found.push({ removed: gone, added: appeared });
330
- }
331
- }
332
- const was = Object.keys(before);
333
- const now = Object.keys(after);
334
- const apart = ({ removed: gone, added: appeared }: RenameCandidate): number =>
335
- Math.abs(now.indexOf(appeared) - was.indexOf(gone));
336
- return found.sort((a, b) => apart(a) - apart(b));
337
- }
338
-
339
- /**
340
- * So a group spanning several fields reaches a reader that only ever sees one field.
341
- * FR : pour qu'un groupe couvrant plusieurs champs atteigne un lecteur qui n'en voit qu'un.
207
+ * Writes a group onto EVERY member a wire reader sees one field at a time.
208
+ * FR : écrit un groupe sur CHAQUE membre un lecteur du fil ne voit qu'un champ.
342
209
  * `carryGroup(properties.listId, ['listId', 'docId'])` → the pair lands under its `role`
343
210
  */
344
211
  function carryGroup(property: FieldDescriptor | undefined, group: readonly string[]): void {
@@ -2,24 +2,16 @@ import type { BoundaryRef } from '../../axis/boundary/Boundary.js';
2
2
  import type { LifecycleRules } from '../../axis/lifecycle/Lifecycle.js';
3
3
  import type { Relation } from '../../axis/role/Relation.js';
4
4
  import type { RoleRules } from '../../axis/role/Role.js';
5
+ import type { JSONSchema7TypeName } from 'json-schema';
5
6
  import type { Shape } from '../../axis/shape/Shape.js';
6
7
 
7
- type JsonSchemaType =
8
- | 'string'
9
- | 'number'
10
- | 'integer'
11
- | 'boolean'
12
- | 'object'
13
- | 'array'
14
- | 'null';
15
-
16
8
  type ShapeKeywords = UnionToIntersection<KeywordsOf<Shape>>;
17
9
 
18
10
  type KeywordsOf<S> = S extends unknown ? Partial<Omit<S, 'type' | 'items' | 'properties'>> : never;
19
11
  type UnionToIntersection<U> = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
20
12
 
21
13
  export type FieldDescriptor = ShapeKeywords & {
22
- type?: JsonSchemaType | JsonSchemaType[];
14
+ type?: JSONSchema7TypeName | JSONSchema7TypeName[];
23
15
  items?: FieldDescriptor;
24
16
  properties?: Record<string, FieldDescriptor>;
25
17
  description?: string;
@@ -1,9 +1,10 @@
1
- import type { ValidationError } from '../../validation.js';
1
+ import type { ValidationError } from '../../lib/validation.js';
2
2
  import { Shapes } from '../../axis/shape/Shape.js';
3
+ import { dotted } from '../../lib/validation.js';
3
4
 
4
5
  /**
5
- * So a card that cannot be read says what is wrong and what to write instead.
6
- * FR : pour qu'une carte illisible dise ce qui cloche et quoi écrire.
6
+ * Throws with what is wrong AND what to write: a card's author is in another process.
7
+ * FR : lève avec ce qui cloche ET quoi écrire : l'auteur d'une carte est ailleurs.
7
8
  * `refuse('role.relation.kind is "maybe"', 'Expected one of one, many.')`
8
9
  */
9
10
  export function refuse(what: string, fix: string): never {
@@ -11,9 +12,10 @@ export function refuse(what: string, fix: string): never {
11
12
  }
12
13
 
13
14
  /**
14
- * So a card's axis passes the same validator as a hand-written one, and no second validator exists.
15
- * FR : pour qu'un axe d'une carte passe le juge des autres, sans second juge.
16
- * `{ update: 'maybe' }` on a card `lifecycle is malformed lifecycle.update: Expected 'now' or 'forbidden'`
15
+ * Runs a card's axis through the validator a hand-written field goes through, so there is
16
+ * one judge and not two.
17
+ * FR : passe l'axe d'une carte par le juge d'un champ écrit à la main : un juge, pas deux.
18
+ * `{ update: 'maybe' }` → `lifecycle is malformed — lifecycle.update: Expected 'now' or 'forbidden'`
17
19
  */
18
20
  export function admitWire(
19
21
  validator: (value: unknown, errors: ValidationError[]) => void,
@@ -24,15 +26,15 @@ export function admitWire(
24
26
  validator(value, errors);
25
27
  if (errors.length) {
26
28
  refuse(
27
- `${slot} is malformed — ${errors.map((e) => `${e.path}: ${e.message}`).join('; ')}`,
29
+ `${slot} is malformed — ${errors.map((e) => `${dotted(e.path)}: ${e.message}`).join('; ')}`,
28
30
  'A card states an axis the way a declaration does.',
29
31
  );
30
32
  }
31
33
  }
32
34
 
33
35
  /**
34
- * So a closed set is checked against the list that declares it, never against a copy.
35
- * FR : pour qu'un ensemble fermé soit vérifié contre la liste qui le déclare.
36
+ * Checks a token against the list that declares it, never against a copy written here.
37
+ * FR : vérifie un mot contre la liste qui le déclare, jamais contre une copie locale.
36
38
  * `oneOfTokens('many', RELATION_KINDS)` → `true`
37
39
  */
38
40
  export const oneOfTokens = <T extends readonly string[]>(
@@ -41,9 +43,9 @@ export const oneOfTokens = <T extends readonly string[]>(
41
43
  ): value is T[number] => typeof value === 'string' && (tokens as readonly string[]).includes(value);
42
44
 
43
45
  /**
44
- * So a `pattern` the engine cannot compile is refused where the card is read, not at the first row.
45
- * FR : pour qu'un `pattern` incompilable soit refusé à la lecture de la carte, pas au premier row.
46
- * `{ type: 'string', pattern: '(' }` → Field 'code' states `pattern: "("`, which is not a regular expression
46
+ * Compiles every `pattern` at the door, so a bad one is refused here and not at the first row.
47
+ * FR : compile chaque `pattern` à la porte : un mauvais est refusé ici, pas au premier row.
48
+ * `{ type: 'string', pattern: '(' }` → `Field 'code' states pattern: "(", which is not a regular expression`
47
49
  */
48
50
  export function admitPatterns(shape: unknown, subject: string): void {
49
51
  for (const pattern of Shapes.patterns(shape)) {
@@ -1,6 +1,8 @@
1
1
  import type { BoundaryRef } from '../../axis/boundary/Boundary.js';
2
2
  import type { LifecycleRules } from '../../axis/lifecycle/Lifecycle.js';
3
- import type { FieldDescriptor, RoleDescriptor } from './Descriptor.js';
3
+ import { dequal } from 'dequal';
4
+ import { EXTENSION_SLOTS } from '../../axis/Axis.js';
5
+ import type { FieldDescriptor, FieldExtension, RoleDescriptor, SchemaDescriptor } from './Descriptor.js';
4
6
 
5
7
  /** One named difference, at one place. Each kind exists because a reader asks for it. */
6
8
  export type Change =
@@ -54,3 +56,142 @@ export interface SetDiffOptions {
54
56
  /** Declared renames, per entity: `{ post: { body: 'content' } }`. */
55
57
  renamed?: Record<string, Record<string, string>>;
56
58
  }
59
+
60
+ /**
61
+ * What changed between two descriptions of the same entity, read from the wire form alone.
62
+ * FR : ce qui a changé entre deux descriptions d'une même entité, lu du seul format du fil.
63
+ * `compare(v1, v2, { renamed: { body: 'content' } })` → one `renamed`, no `ambiguous`
64
+ */
65
+ export function compare(
66
+ was: SchemaDescriptor,
67
+ is: SchemaDescriptor,
68
+ options: DiffOptions = {},
69
+ ): Diff {
70
+ const changes: Change[] = [];
71
+ const renamed = options.renamed ?? {};
72
+ const before = was.properties ?? {};
73
+ const after = is.properties ?? {};
74
+ const requiredBefore = new Set(was.required ?? []);
75
+ const requiredAfter = new Set(is.required ?? []);
76
+
77
+ // Apply a declared rename first so subsequent differences use the new field name.
78
+ const nameAfter = (field: string): string => renamed[field] ?? field;
79
+ const removed: string[] = [];
80
+ for (const [field, descriptor] of Object.entries(before)) {
81
+ const now = nameAfter(field);
82
+ const target = after[now];
83
+ if (target === undefined) {
84
+ removed.push(field);
85
+ continue;
86
+ }
87
+
88
+ if (now !== field)
89
+ changes.push({ kind: 'renamed', from: field, to: now, field: target });
90
+
91
+ const wasType = typesOf(descriptor);
92
+ const isType = typesOf(target);
93
+ if (!dequal(wasType, isType))
94
+ changes.push({ kind: 'retyped', field: now, from: wasType, to: isType });
95
+ else if (!dequal(boundsOf(descriptor), boundsOf(target))) {
96
+ changes.push({ kind: 'reshaped', field: now, from: descriptor, to: target });
97
+ }
98
+
99
+ const wasRequired = requiredBefore.has(field);
100
+ const isRequired = requiredAfter.has(now);
101
+ if (wasRequired !== isRequired) {
102
+ changes.push({ kind: 'required', field: now, from: wasRequired, to: isRequired });
103
+ }
104
+ changes.push(...restated(now, descriptor['x-fougere'], target['x-fougere']));
105
+ }
106
+
107
+ const claimed = new Set(Object.values(renamed));
108
+ const added = Object.keys(after).filter(
109
+ (field) => !(field in before) && !claimed.has(field),
110
+ );
111
+ for (const field of removed) {
112
+ changes.push({
113
+ kind: 'removed',
114
+ field,
115
+ from: before[field],
116
+ required: requiredBefore.has(field),
117
+ });
118
+ }
119
+ for (const field of added) {
120
+ changes.push({
121
+ kind: 'added',
122
+ field,
123
+ to: after[field],
124
+ required: requiredAfter.has(field),
125
+ });
126
+ }
127
+
128
+ return { changes, ambiguous: candidates(removed, added, before, after) };
129
+ }
130
+
131
+ function restated(
132
+ field: string,
133
+ before: FieldExtension | undefined,
134
+ after: FieldExtension | undefined,
135
+ ): Change[] {
136
+ return EXTENSION_SLOTS.filter((axis) => !dequal(before?.[axis], after?.[axis])).map(
137
+ (axis) =>
138
+ ({
139
+ kind: 'restated',
140
+ field,
141
+ axis,
142
+ from: before?.[axis],
143
+ to: after?.[axis],
144
+ }) as Change,
145
+ );
146
+ }
147
+
148
+ function shapeOf(descriptor: FieldDescriptor): Record<string, unknown> {
149
+ const { 'x-fougere': _extension, description: _description, ...shape } = descriptor;
150
+ return shape as Record<string, unknown>;
151
+ }
152
+
153
+ /**
154
+ * Sorted, so `['string','null']` and `['null','string']` compare equal, not as a change.
155
+ * FR : trié, pour que `['string','null']` et `['null','string']` soient égaux.
156
+ * `typesOf({ type: ['null', 'string'] })` → `['null', 'string']`
157
+ */
158
+ function typesOf(descriptor: FieldDescriptor): TypeSet {
159
+ const type = descriptor.type;
160
+ if (type === undefined) return [];
161
+ return (Array.isArray(type) ? [...type] : [type]).sort();
162
+ }
163
+
164
+ /**
165
+ * The shape without its type, which is what tells a `reshaped` from a `retyped`.
166
+ * FR : la forme sans son type, ce qui distingue un `reshaped` d'un `retyped`.
167
+ * `maxLength: 200` → `maxLength: 100` → one `reshaped`, never a `retyped`
168
+ */
169
+ function boundsOf(descriptor: FieldDescriptor): Record<string, unknown> {
170
+ const { type: _type, ...rest } = shapeOf(descriptor);
171
+ return rest;
172
+ }
173
+
174
+ /**
175
+ * A removal plus an addition of one shape is REPORTED, never resolved — only `renamed` decides.
176
+ * FR : une suppression plus un ajout de même forme est RAPPORTÉE, jamais résolue.
177
+ * `body` gone, `content` appeared → `ambiguous: [{ removed: 'body', added: 'content' }]`
178
+ */
179
+ function candidates(
180
+ removed: string[],
181
+ added: string[],
182
+ before: Record<string, FieldDescriptor>,
183
+ after: Record<string, FieldDescriptor>,
184
+ ): RenameCandidate[] {
185
+ const found: RenameCandidate[] = [];
186
+ for (const gone of removed) {
187
+ for (const appeared of added) {
188
+ if (dequal(shapeOf(before[gone]), shapeOf(after[appeared])))
189
+ found.push({ removed: gone, added: appeared });
190
+ }
191
+ }
192
+ const was = Object.keys(before);
193
+ const now = Object.keys(after);
194
+ const apart = ({ removed: gone, added: appeared }: RenameCandidate): number =>
195
+ Math.abs(now.indexOf(appeared) - was.indexOf(gone));
196
+ return found.sort((a, b) => apart(a) - apart(b));
197
+ }
@@ -2,7 +2,7 @@ import { EXTENSION_AXES } from '../axis/Axis.js';
2
2
  import { Shapes } from '../axis/shape/Shape.js';
3
3
  import type { Field } from '../field/Field.js';
4
4
  import { isObject, shown } from '../lib/utils.js';
5
- import type { ValidationError, ValidationResult } from '../validation.js';
5
+ import type { ValidationError, ValidationResult } from '../lib/validation.js';
6
6
 
7
7
  export class FieldDeclarationValidator {
8
8
  private constructor(private readonly declaration: unknown) {}
@@ -19,7 +19,7 @@ export class FieldDeclarationValidator {
19
19
  success: false,
20
20
  errors: [
21
21
  {
22
- path: '.',
22
+ path: [],
23
23
  message: `Expected an object — got ${shown(declaration)}`,
24
24
  },
25
25
  ],
@@ -30,7 +30,7 @@ export class FieldDeclarationValidator {
30
30
 
31
31
  if (!Shapes.is(declaration.shape)) {
32
32
  errors.push({
33
- path: 'shape',
33
+ path: ['shape'],
34
34
  message: `Every field states a shape — got ${shown(declaration.shape)}`,
35
35
  });
36
36
  }
@@ -43,14 +43,14 @@ export class FieldDeclarationValidator {
43
43
  if (declaration.meta !== undefined) {
44
44
  if (!isObject(declaration.meta)) {
45
45
  errors.push({
46
- path: 'meta',
46
+ path: ['meta'],
47
47
  message: `Expected an object — got ${shown(declaration.meta)}`,
48
48
  });
49
49
  } else if (
50
50
  declaration.meta.description !== undefined &&
51
51
  typeof declaration.meta.description !== 'string'
52
52
  ) {
53
- errors.push({ path: 'meta.description', message: 'Expected a string' });
53
+ errors.push({ path: ['meta', 'description'], message: 'Expected a string' });
54
54
  }
55
55
  }
56
56
 
@@ -1,8 +1,10 @@
1
1
  import { Validator, format as engineFormats } from '@cfworker/json-schema';
2
+ import { Boundary } from '../axis/boundary/Boundary.js';
2
3
  import { Formats, type FormatPredicate } from '../axis/shape/Formats.js';
3
4
  import { Shapes, type Shape } from '../axis/shape/Shape.js';
4
5
  import type { Field } from '../field/Field.js';
5
- import type { Checked } from '../validation.js';
6
+ import type { Checked } from '../lib/validation.js';
7
+ import type { OutputUnit } from '@cfworker/json-schema';
6
8
 
7
9
  interface ShapePlan {
8
10
  validator: Validator;
@@ -22,33 +24,38 @@ export class FieldValueValidator {
22
24
 
23
25
  validate(value: unknown): Checked {
24
26
  const shape = this.field.shape;
27
+ const type = Shapes.typeOf(shape);
25
28
  const base = Shapes.of(shape).base;
26
29
  if (value !== null) {
27
30
  if (base?.type === 'object' && !base.properties) return { value };
28
- if (
29
- base?.type === 'string' &&
30
- base.format === 'date-time' &&
31
- value instanceof Date
32
- ) {
31
+ if (type === 'date' && value instanceof Date) {
33
32
  return Number.isNaN(value.getTime()) ? { error: 'Invalid date' } : { value };
34
33
  }
35
- if (
36
- (base?.type === 'number' || base?.type === 'integer') &&
37
- typeof value === 'number' &&
38
- Number.isNaN(value)
39
- ) {
34
+ if ((type === 'number' || type === 'integer') && typeof value === 'number' && Number.isNaN(value)) {
40
35
  return { error: 'Expected a number' };
41
36
  }
42
37
  }
43
38
  const plan = FieldValueValidator.planFor(shape);
44
39
  const result = plan.validator.validate(value);
45
- if (!result.valid) return { error: result.errors[0]?.error ?? 'Invalid value' };
40
+ if (!result.valid) return refusalOf(result.errors);
46
41
  if (plan.custom && typeof value === 'string' && !plan.custom(value)) {
47
42
  return { error: `String does not match format "${plan.formatName}".` };
48
43
  }
49
44
  return { value };
50
45
  }
51
46
 
47
+ /**
48
+ * The value admitted, then handed back in the form the domain writes — an ISO string
49
+ * arrives as a `Date`. `null` passes untouched, and a refusal stops before the codec.
50
+ */
51
+ parse(value: unknown): Checked {
52
+ const checked = this.validate(value);
53
+ if ('error' in checked) return checked;
54
+ if (checked.value === null) return { value: null };
55
+
56
+ return Boundary.of(this.field).decode(checked.value);
57
+ }
58
+
52
59
  private static planFor(shape: Shape): ShapePlan {
53
60
  let plan = this.plans.get(shape);
54
61
  if (!plan) {
@@ -75,3 +82,29 @@ export class FieldValueValidator {
75
82
  return custom;
76
83
  }
77
84
  }
85
+
86
+ /**
87
+ * The engine states its refusals outermost first, so `errors[0]` on a nested shape is the
88
+ * parent's `Property "street" does not match schema.` — true, and never the reason. The
89
+ * DEEPEST one is the reason, and it is the one that says where.
90
+ */
91
+ function refusalOf(errors: readonly OutputUnit[]): Checked {
92
+ const deepest = errors.reduce<OutputUnit | undefined>(
93
+ (held, one) => (held && depthOf(held) >= depthOf(one) ? held : one),
94
+ undefined,
95
+ );
96
+ if (!deepest) return { error: 'Invalid value' };
97
+ const path = locationOf(deepest.instanceLocation);
98
+
99
+ return path.length > 0 ? { error: deepest.error, path } : { error: deepest.error };
100
+ }
101
+
102
+ const depthOf = (unit: OutputUnit): number => locationOf(unit.instanceLocation).length;
103
+
104
+ /** `#/city/zip` — a JSON Pointer fragment, and `~1`/`~0` are how it spells `/` and `~`. */
105
+ const locationOf = (instanceLocation: string): string[] =>
106
+ instanceLocation
107
+ .replace(/^#/, '')
108
+ .split('/')
109
+ .filter(Boolean)
110
+ .map((segment) => segment.replace(/~1/g, '/').replace(/~0/g, '~'));