@fougere/schema 0.12.1-alpha.0 → 0.12.2-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 (246) hide show
  1. package/dist/FougereFieldAxes.d.ts.map +1 -0
  2. package/dist/FougereFieldAxes.js.map +1 -0
  3. package/dist/Schema.d.ts +4 -3
  4. package/dist/Schema.d.ts.map +1 -1
  5. package/dist/Schema.js +3 -0
  6. package/dist/Schema.js.map +1 -1
  7. package/dist/SchemaConstraints.d.ts +13 -10
  8. package/dist/SchemaConstraints.d.ts.map +1 -1
  9. package/dist/SchemaConstraints.js +32 -20
  10. package/dist/SchemaConstraints.js.map +1 -1
  11. package/dist/SchemaDefinition.d.ts.map +1 -1
  12. package/dist/SchemaDefinition.js +6 -5
  13. package/dist/SchemaDefinition.js.map +1 -1
  14. package/dist/SchemaError.d.ts +4 -1
  15. package/dist/SchemaError.d.ts.map +1 -1
  16. package/dist/SchemaError.js +5 -2
  17. package/dist/SchemaError.js.map +1 -1
  18. package/dist/SchemaView.d.ts +3 -2
  19. package/dist/SchemaView.d.ts.map +1 -1
  20. package/dist/axis/Axes.d.ts +1 -1
  21. package/dist/axis/Axes.js +3 -3
  22. package/dist/axis/Axis.d.ts +12 -2
  23. package/dist/axis/Axis.d.ts.map +1 -1
  24. package/dist/axis/boundary/Boundary.d.ts +9 -2
  25. package/dist/axis/boundary/Boundary.d.ts.map +1 -1
  26. package/dist/axis/boundary/Boundary.js +6 -2
  27. package/dist/axis/boundary/Boundary.js.map +1 -1
  28. package/dist/axis/boundary/Decoder.js.map +1 -1
  29. package/dist/axis/lifecycle/Lifecycle.d.ts +13 -4
  30. package/dist/axis/lifecycle/Lifecycle.d.ts.map +1 -1
  31. package/dist/axis/lifecycle/Lifecycle.js +8 -4
  32. package/dist/axis/lifecycle/Lifecycle.js.map +1 -1
  33. package/dist/axis/lifecycle/apply.js +1 -1
  34. package/dist/axis/lifecycle/apply.js.map +1 -1
  35. package/dist/axis/role/Role.d.ts +17 -10
  36. package/dist/axis/role/Role.d.ts.map +1 -1
  37. package/dist/axis/role/Role.js +30 -10
  38. package/dist/axis/role/Role.js.map +1 -1
  39. package/dist/axis/shape/Shape.d.ts +32 -9
  40. package/dist/axis/shape/Shape.d.ts.map +1 -1
  41. package/dist/axis/shape/Shape.js +43 -12
  42. package/dist/axis/shape/Shape.js.map +1 -1
  43. package/dist/axis/shape/ShapeFormat.d.ts +10 -0
  44. package/dist/axis/shape/ShapeFormat.d.ts.map +1 -0
  45. package/dist/axis/shape/ShapeFormat.js +32 -0
  46. package/dist/axis/shape/ShapeFormat.js.map +1 -0
  47. package/dist/entity/EntityDeclarations.d.ts +3 -2
  48. package/dist/entity/EntityDeclarations.d.ts.map +1 -1
  49. package/dist/entity/FieldGroups.d.ts +9 -0
  50. package/dist/entity/FieldGroups.d.ts.map +1 -0
  51. package/dist/entity/FieldGroups.js +2 -0
  52. package/dist/entity/FieldGroups.js.map +1 -0
  53. package/dist/field/Field.d.ts +35 -16
  54. package/dist/field/Field.d.ts.map +1 -1
  55. package/dist/field/Field.js +53 -8
  56. package/dist/field/Field.js.map +1 -1
  57. package/dist/field/FieldFormat.d.ts +1 -1
  58. package/dist/field/FieldFormat.js +4 -4
  59. package/dist/field/FieldFormat.js.map +1 -1
  60. package/dist/field/FieldSet.d.ts +12 -3
  61. package/dist/field/FieldSet.d.ts.map +1 -1
  62. package/dist/field/FieldSet.js +22 -15
  63. package/dist/field/FieldSet.js.map +1 -1
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js.map +1 -1
  67. package/dist/lib/Format.d.ts +21 -2
  68. package/dist/lib/Format.d.ts.map +1 -1
  69. package/dist/lib/Format.js +31 -3
  70. package/dist/lib/Format.js.map +1 -1
  71. package/dist/lib/UnionToIntersection.d.ts +7 -0
  72. package/dist/lib/UnionToIntersection.d.ts.map +1 -0
  73. package/dist/lib/UnionToIntersection.js +2 -0
  74. package/dist/lib/UnionToIntersection.js.map +1 -0
  75. package/dist/lib/utils.d.ts +1 -1
  76. package/dist/lib/utils.d.ts.map +1 -1
  77. package/dist/lib/utils.js +1 -1
  78. package/dist/lib/utils.js.map +1 -1
  79. package/dist/projection/Cases.d.ts +1 -1
  80. package/dist/projection/Cases.d.ts.map +1 -1
  81. package/dist/projection/Cases.js +20 -8
  82. package/dist/projection/Cases.js.map +1 -1
  83. package/dist/projection/Visibility.d.ts.map +1 -1
  84. package/dist/projection/Visibility.js +6 -6
  85. package/dist/projection/Visibility.js.map +1 -1
  86. package/dist/projection/card/Bundle.d.ts.map +1 -1
  87. package/dist/projection/card/Bundle.js.map +1 -1
  88. package/dist/projection/card/Card.d.ts.map +1 -1
  89. package/dist/projection/card/Card.js +21 -30
  90. package/dist/projection/card/Card.js.map +1 -1
  91. package/dist/projection/card/CardForms.js +1 -1
  92. package/dist/projection/card/Diff.js.map +1 -1
  93. package/dist/projection/card/FieldExtension.d.ts +2 -2
  94. package/dist/projection/card/FieldExtension.d.ts.map +1 -1
  95. package/dist/projection/card/RoleDescriptor.d.ts +7 -2
  96. package/dist/projection/card/RoleDescriptor.d.ts.map +1 -1
  97. package/dist/projection/card/RoleDescriptor.js +2 -2
  98. package/dist/projection/card/RoleDescriptor.js.map +1 -1
  99. package/dist/projection/card/ShapeKeywords.d.ts +1 -1
  100. package/dist/projection/card/ShapeKeywords.d.ts.map +1 -1
  101. package/dist/validator/FieldDeclarationValidator.d.ts.map +1 -1
  102. package/dist/validator/FieldDeclarationValidator.js +7 -10
  103. package/dist/validator/FieldDeclarationValidator.js.map +1 -1
  104. package/dist/validator/FieldValueValidator.d.ts.map +1 -1
  105. package/dist/validator/FieldValueValidator.js +0 -2
  106. package/dist/validator/FieldValueValidator.js.map +1 -1
  107. package/dist/validator/InputValidator.d.ts +7 -1
  108. package/dist/validator/InputValidator.d.ts.map +1 -1
  109. package/dist/validator/InputValidator.js +15 -14
  110. package/dist/validator/InputValidator.js.map +1 -1
  111. package/dist/vocabulary/preset/created.d.ts +2 -2
  112. package/dist/vocabulary/preset/created.d.ts.map +1 -1
  113. package/dist/vocabulary/preset/created.js +2 -2
  114. package/dist/vocabulary/preset/created.js.map +1 -1
  115. package/dist/vocabulary/preset/updated.d.ts +2 -2
  116. package/dist/vocabulary/preset/updated.d.ts.map +1 -1
  117. package/dist/vocabulary/preset/updated.js +2 -2
  118. package/dist/vocabulary/preset/updated.js.map +1 -1
  119. package/dist/vocabulary/primitive/bool.d.ts +2 -5
  120. package/dist/vocabulary/primitive/bool.d.ts.map +1 -1
  121. package/dist/vocabulary/primitive/bool.js +1 -5
  122. package/dist/vocabulary/primitive/bool.js.map +1 -1
  123. package/dist/vocabulary/primitive/date.d.ts +2 -3
  124. package/dist/vocabulary/primitive/date.d.ts.map +1 -1
  125. package/dist/vocabulary/primitive/date.js +2 -3
  126. package/dist/vocabulary/primitive/date.js.map +1 -1
  127. package/dist/vocabulary/primitive/json.d.ts +3 -3
  128. package/dist/vocabulary/primitive/json.d.ts.map +1 -1
  129. package/dist/vocabulary/primitive/json.js +11 -11
  130. package/dist/vocabulary/primitive/json.js.map +1 -1
  131. package/dist/vocabulary/primitive/list.d.ts +6 -5
  132. package/dist/vocabulary/primitive/list.d.ts.map +1 -1
  133. package/dist/vocabulary/primitive/list.js +13 -7
  134. package/dist/vocabulary/primitive/list.js.map +1 -1
  135. package/dist/vocabulary/primitive/many.d.ts +2 -4
  136. package/dist/vocabulary/primitive/many.d.ts.map +1 -1
  137. package/dist/vocabulary/primitive/many.js +2 -4
  138. package/dist/vocabulary/primitive/many.js.map +1 -1
  139. package/dist/vocabulary/primitive/number.d.ts +2 -4
  140. package/dist/vocabulary/primitive/number.d.ts.map +1 -1
  141. package/dist/vocabulary/primitive/number.js +1 -3
  142. package/dist/vocabulary/primitive/number.js.map +1 -1
  143. package/dist/vocabulary/primitive/oneOf.d.ts +4 -7
  144. package/dist/vocabulary/primitive/oneOf.d.ts.map +1 -1
  145. package/dist/vocabulary/primitive/oneOf.js +19 -11
  146. package/dist/vocabulary/primitive/oneOf.js.map +1 -1
  147. package/dist/vocabulary/primitive/primary.d.ts +2 -2
  148. package/dist/vocabulary/primitive/primary.d.ts.map +1 -1
  149. package/dist/vocabulary/primitive/primary.js +2 -3
  150. package/dist/vocabulary/primitive/primary.js.map +1 -1
  151. package/dist/vocabulary/primitive/ref.d.ts +2 -6
  152. package/dist/vocabulary/primitive/ref.d.ts.map +1 -1
  153. package/dist/vocabulary/primitive/ref.js +1 -1
  154. package/dist/vocabulary/primitive/ref.js.map +1 -1
  155. package/dist/vocabulary/primitive/text.d.ts +2 -4
  156. package/dist/vocabulary/primitive/text.d.ts.map +1 -1
  157. package/dist/vocabulary/primitive/text.js +1 -3
  158. package/dist/vocabulary/primitive/text.js.map +1 -1
  159. package/dist/vocabulary/rule/nullable.d.ts +0 -1
  160. package/dist/vocabulary/rule/nullable.d.ts.map +1 -1
  161. package/dist/vocabulary/rule/nullable.js +0 -1
  162. package/dist/vocabulary/rule/nullable.js.map +1 -1
  163. package/dist/vocabulary/rule/optional.d.ts +0 -1
  164. package/dist/vocabulary/rule/optional.d.ts.map +1 -1
  165. package/dist/vocabulary/rule/optional.js +0 -1
  166. package/dist/vocabulary/rule/optional.js.map +1 -1
  167. package/dist/vocabulary/rule/readOnly.d.ts +0 -2
  168. package/dist/vocabulary/rule/readOnly.d.ts.map +1 -1
  169. package/dist/vocabulary/rule/readOnly.js +0 -2
  170. package/dist/vocabulary/rule/readOnly.js.map +1 -1
  171. package/dist/vocabulary/rule/writeOnly.d.ts +0 -2
  172. package/dist/vocabulary/rule/writeOnly.d.ts.map +1 -1
  173. package/dist/vocabulary/rule/writeOnly.js +0 -2
  174. package/dist/vocabulary/rule/writeOnly.js.map +1 -1
  175. package/dist/vocabulary/vocabulary.d.ts +2 -3
  176. package/dist/vocabulary/vocabulary.d.ts.map +1 -1
  177. package/dist/vocabulary/vocabulary.js +17 -21
  178. package/dist/vocabulary/vocabulary.js.map +1 -1
  179. package/package.json +1 -1
  180. package/src/Schema.ts +11 -8
  181. package/src/SchemaConstraints.ts +40 -22
  182. package/src/SchemaDefinition.ts +7 -5
  183. package/src/SchemaError.ts +8 -2
  184. package/src/SchemaView.ts +3 -2
  185. package/src/axis/Axes.ts +3 -3
  186. package/src/axis/Axis.ts +13 -2
  187. package/src/axis/boundary/Boundary.ts +16 -2
  188. package/src/axis/boundary/Decoder.ts +2 -0
  189. package/src/axis/lifecycle/Lifecycle.ts +15 -4
  190. package/src/axis/lifecycle/apply.ts +1 -1
  191. package/src/axis/role/Role.ts +41 -9
  192. package/src/axis/shape/Shape.ts +89 -23
  193. package/src/axis/shape/ShapeFormat.ts +37 -0
  194. package/src/entity/EntityDeclarations.ts +3 -2
  195. package/src/entity/FieldGroups.ts +9 -0
  196. package/src/field/Field.ts +84 -21
  197. package/src/field/FieldFormat.ts +5 -5
  198. package/src/field/FieldSet.ts +27 -19
  199. package/src/index.ts +1 -0
  200. package/src/lib/Format.ts +36 -3
  201. package/src/lib/UnionToIntersection.ts +10 -0
  202. package/src/lib/utils.ts +5 -1
  203. package/src/projection/Cases.ts +25 -8
  204. package/src/projection/Visibility.ts +9 -6
  205. package/src/projection/card/Bundle.ts +2 -0
  206. package/src/projection/card/Card.ts +26 -27
  207. package/src/projection/card/CardForms.ts +1 -1
  208. package/src/projection/card/Diff.ts +4 -0
  209. package/src/projection/card/FieldExtension.ts +2 -2
  210. package/src/projection/card/RoleDescriptor.ts +9 -4
  211. package/src/projection/card/ShapeKeywords.ts +1 -1
  212. package/src/validator/FieldDeclarationValidator.ts +7 -10
  213. package/src/validator/FieldValueValidator.ts +3 -1
  214. package/src/validator/InputValidator.ts +17 -12
  215. package/src/vocabulary/preset/created.ts +3 -3
  216. package/src/vocabulary/preset/updated.ts +4 -3
  217. package/src/vocabulary/primitive/bool.ts +3 -8
  218. package/src/vocabulary/primitive/date.ts +5 -4
  219. package/src/vocabulary/primitive/json.ts +27 -12
  220. package/src/vocabulary/primitive/list.ts +16 -9
  221. package/src/vocabulary/primitive/many.ts +3 -4
  222. package/src/vocabulary/primitive/number.ts +3 -8
  223. package/src/vocabulary/primitive/oneOf.ts +40 -24
  224. package/src/vocabulary/primitive/primary.ts +9 -5
  225. package/src/vocabulary/primitive/ref.ts +3 -7
  226. package/src/vocabulary/primitive/text.ts +3 -8
  227. package/src/vocabulary/rule/nullable.ts +0 -1
  228. package/src/vocabulary/rule/optional.ts +0 -1
  229. package/src/vocabulary/rule/readOnly.ts +0 -2
  230. package/src/vocabulary/rule/writeOnly.ts +0 -2
  231. package/src/vocabulary/vocabulary.ts +18 -22
  232. package/dist/entity/CompositeUnique.d.ts +0 -4
  233. package/dist/entity/CompositeUnique.d.ts.map +0 -1
  234. package/dist/entity/CompositeUnique.js +0 -2
  235. package/dist/entity/CompositeUnique.js.map +0 -1
  236. package/dist/field/FougereFieldAxes.d.ts.map +0 -1
  237. package/dist/field/FougereFieldAxes.js.map +0 -1
  238. package/dist/field/Meta.d.ts +0 -6
  239. package/dist/field/Meta.d.ts.map +0 -1
  240. package/dist/field/Meta.js +0 -3
  241. package/dist/field/Meta.js.map +0 -1
  242. package/src/entity/CompositeUnique.ts +0 -6
  243. package/src/field/Meta.ts +0 -5
  244. /package/dist/{field/FougereFieldAxes.d.ts → FougereFieldAxes.d.ts} +0 -0
  245. /package/dist/{field/FougereFieldAxes.js → FougereFieldAxes.js} +0 -0
  246. /package/src/{field/FougereFieldAxes.ts → FougereFieldAxes.ts} +0 -0
@@ -4,27 +4,58 @@ import type { ShapeType } from './ShapeType.js';
4
4
 
5
5
  type Nullably<T extends string> = T | readonly [T, 'null'];
6
6
 
7
- interface StringConstraints { minLength?: number; maxLength?: number; pattern?: string; enum?: readonly (string | null)[]; format?: StringFormat }
7
+ /** The sentence a field carries, where JSON Schema already holds one. */
8
+ interface Documented {
9
+ description?: string;
10
+ }
11
+
12
+ interface StringConstraints extends Documented {
13
+ minLength?: number;
14
+ maxLength?: number;
15
+ pattern?: string;
16
+ enum?: readonly (string | null)[];
17
+ format?: StringFormat;
18
+ }
8
19
 
9
- interface NumericConstraints { minimum?: number; maximum?: number }
20
+ interface NumericConstraints extends Documented {
21
+ minimum?: number;
22
+ maximum?: number;
23
+ enum?: readonly (number | null)[];
24
+ }
10
25
 
11
- interface ArrayConstraints { items?: Shape; minItems?: number; maxItems?: number }
26
+ interface ArrayConstraints extends Documented {
27
+ items?: Shape;
28
+ minItems?: number;
29
+ maxItems?: number;
30
+ }
12
31
 
13
- interface ObjectConstraints { properties?: Record<string, unknown>; required?: readonly string[]; additionalProperties?: boolean | Shape; propertyNames?: Shape }
32
+ interface ObjectConstraints extends Documented {
33
+ properties?: Record<string, unknown>;
34
+ required?: readonly string[];
35
+ additionalProperties?: boolean | Shape;
36
+ propertyNames?: Shape;
37
+ }
14
38
 
15
39
  export type Shape =
16
40
  | ({ type: Nullably<'string'> } & StringConstraints)
17
41
  | ({ type: Nullably<'number'> | Nullably<'integer'> } & NumericConstraints)
18
- | { type: Nullably<'boolean'> }
42
+ | ({ type: Nullably<'boolean'> } & Documented)
19
43
  | ({ type: Nullably<'array'> } & ArrayConstraints)
20
44
  | ({ type: Nullably<'object'> } & ObjectConstraints);
21
45
 
22
- const SHAPE_TYPES = ['string', 'number', 'integer', 'boolean', 'array', 'object'] as const;
46
+ export const SHAPE_TYPES = [
47
+ 'string',
48
+ 'number',
49
+ 'integer',
50
+ 'boolean',
51
+ 'array',
52
+ 'object',
53
+ ] as const;
23
54
 
24
55
  type BaseShape =
25
56
  | ({ type: 'string' } & StringConstraints)
26
57
  | ({ type: 'number' | 'integer' } & NumericConstraints)
27
- | { type: 'boolean' }
58
+ | ({ type: 'boolean' } & Documented)
28
59
  | ({ type: 'array' } & ArrayConstraints)
29
60
  | ({ type: 'object' } & ObjectConstraints);
30
61
 
@@ -42,26 +73,21 @@ type _ShapeConformsToJsonSchema = Assert<
42
73
  >;
43
74
 
44
75
  type _ShapeTypesAreTheStandardsLessNull = Assert<
45
- [Exclude<Exclude<JSONSchema7TypeName, 'null'>, (typeof SHAPE_TYPES)[number]>] extends [never]
76
+ [Exclude<Exclude<JSONSchema7TypeName, 'null'>, (typeof SHAPE_TYPES)[number]>] extends [
77
+ never,
78
+ ]
46
79
  ? true
47
80
  : false
48
81
  >;
49
82
 
83
+ /** Every type name a shape states, whether it states one or a union of them. */
84
+ const typesOf = (shape: Shape): readonly string[] =>
85
+ (Array.isArray(shape.type) ? shape.type : [shape.type]) as readonly string[];
86
+
50
87
  export class Shapes {
51
88
  private static readonly cache = new WeakMap<object, ShapeParts>();
52
89
  private static readonly none: ShapeParts = { base: undefined, nullable: false };
53
90
 
54
- static is(value: unknown): value is Shape {
55
- if (typeof value !== 'object' || value === null) return false;
56
- const type = (value as Shape).type;
57
- const names = Array.isArray(type) ? type : [type];
58
-
59
- return (
60
- names.some((name) => (SHAPE_TYPES as readonly unknown[]).includes(name)) &&
61
- names.every((name) => name === 'null' || (SHAPE_TYPES as readonly unknown[]).includes(name))
62
- );
63
- }
64
-
65
91
  /**
66
92
  * Every `pattern` a shape states, the nested ones included — `items`, `properties`.
67
93
  *
@@ -89,11 +115,28 @@ export class Shapes {
89
115
  for (const member of Object.values(value)) Shapes.collectPatterns(member, found);
90
116
  }
91
117
 
118
+ /**
119
+ * `null` enters the grammar, and every constraint stays. It is added when it is ABSENT,
120
+ * read off the shape rather than assumed from its form — a shape already stating it comes
121
+ * back as itself, so `optional(nullable(text()))` does the work once.
122
+ *
123
+ * `enum` is widened TOO, because JSON Schema checks it apart from `type`: a shape saying
124
+ * `['string','null']` and `enum: ['draft','live']` calls null a legal type and not a legal
125
+ * value, and the engine answers `Instance does not match any of ["draft","live"]`.
126
+ *
127
+ * `{ type: 'string', minLength: 3 }` → `{ type: ['string','null'], minLength: 3 }`
128
+ * `{ type: 'string', enum: ['draft'] }` → `{ type: ['string','null'], enum: ['draft', null] }`
129
+ */
92
130
  static nullable(shape: Shape): Shape {
93
- if (Array.isArray(shape.type)) return shape;
94
- const nullable = { ...shape, type: [shape.type, 'null'] } as unknown as Shape;
131
+ const types = typesOf(shape);
132
+ if (types.includes('null')) return shape;
133
+
134
+ const nullable = { ...shape, type: [...types, 'null'] } as unknown as Shape;
95
135
  if ('enum' in nullable && nullable.enum && !nullable.enum.includes(null)) {
96
- (nullable as { enum: readonly (string | null)[] }).enum = [...nullable.enum, null];
136
+ (nullable as { enum: readonly (string | number | null)[] }).enum = [
137
+ ...nullable.enum,
138
+ null,
139
+ ];
97
140
  }
98
141
 
99
142
  return nullable;
@@ -107,7 +150,9 @@ export class Shapes {
107
150
  const baseType = shape.type.find((t) => t !== 'null');
108
151
  const base = { ...shape, type: baseType } as BaseShape;
109
152
  if ('enum' in base && base.enum) {
110
- (base as { enum: readonly (string | null)[] }).enum = base.enum.filter((v) => v !== null);
153
+ (base as { enum: readonly (string | number | null)[] }).enum = base.enum.filter(
154
+ (v) => v !== null,
155
+ );
111
156
  }
112
157
  parts = { base, nullable: true };
113
158
  } else {
@@ -115,6 +160,7 @@ export class Shapes {
115
160
  }
116
161
  this.cache.set(shape, parts);
117
162
  }
163
+
118
164
  return parts;
119
165
  }
120
166
 
@@ -122,6 +168,26 @@ export class Shapes {
122
168
  return this.of(shape).nullable;
123
169
  }
124
170
 
171
+ /**
172
+ * A value that came in as text — a form control, a command-line flag — read as the shape
173
+ * declares it. What does not read that way stays as it came, for the judge to refuse by name.
174
+ * `fromText({ type: 'integer' }, '12')` → `12`, and `fromText({ type: 'integer' }, 'abc')` → `'abc'`
175
+ */
176
+ static fromText(shape: Shape | undefined, value: unknown): unknown {
177
+ const type = this.of(shape).base?.type;
178
+
179
+ if (
180
+ (type !== 'number' && type !== 'integer') ||
181
+ typeof value !== 'string' ||
182
+ value === ''
183
+ )
184
+ return value;
185
+
186
+ const number = Number(value);
187
+
188
+ return Number.isFinite(number) ? number : value;
189
+ }
190
+
125
191
  /**
126
192
  * `shape.type` read off the base, except that a `string` answers `date` or `choice`.
127
193
  * FR : `shape.type` lu sur la base, sauf qu'une chaîne répond `date` ou `choice`.
@@ -0,0 +1,37 @@
1
+ import { Format } from '../../lib/Format.js';
2
+ import { SHAPE_TYPES, type Shape } from './Shape.js';
3
+
4
+ const SHAPE = 'field/shape';
5
+
6
+ /** A word, or that word beside `null` — the second place is what makes a shape nullable. */
7
+ const TYPE = Format.either(
8
+ Format.tokens(SHAPE_TYPES),
9
+ Format.tuple(Format.tokens(SHAPE_TYPES), Format.tokens(['null'])),
10
+ );
11
+
12
+ /**
13
+ * What a shape may state — JSON Schema's own keywords, less the ones this package has no word
14
+ * for. `shape` used to be the one key of a field declaration nothing judged, so `minLenght: 1`
15
+ * passed and was dropped where `lifecycle: { craete: 'now' }` had been refused since `Format`.
16
+ * `properties` stays open because a projection folds `meta.description` into it.
17
+ */
18
+ export const SHAPE_FORMAT = Format.of(SHAPE)
19
+ .key('type', TYPE)
20
+ .key('description', Format.text)
21
+ .key('minLength', Format.count)
22
+ .key('maxLength', Format.count)
23
+ .key('pattern', Format.text)
24
+ .key('format', Format.text)
25
+ .key('enum', Format.listOf(Format.anything))
26
+ .key('minimum', Format.number)
27
+ .key('maximum', Format.number)
28
+ .key('items', Format.ref(SHAPE))
29
+ .key('minItems', Format.count)
30
+ .key('maxItems', Format.count)
31
+ .key('properties', Format.anything)
32
+ .key('required', Format.listOf(Format.text))
33
+ .key('additionalProperties', Format.anything)
34
+ .key('propertyNames', Format.ref(SHAPE))
35
+ .needs('type')
36
+ .closed()
37
+ .as<Shape>();
@@ -1,10 +1,11 @@
1
1
  import type { Fields } from '../field/Fields.js';
2
2
  import type { EntityAdapters } from './EntityAdapters.js';
3
- import type { CompositeUnique } from './CompositeUnique.js';
3
+ import type { FieldGroups } from './FieldGroups.js';
4
4
  import type { PreviousNames } from './PreviousNames.js';
5
5
 
6
6
  export interface EntityDeclarations<TFields extends Fields> {
7
7
  adapters?: EntityAdapters<TFields>;
8
8
  previous?: PreviousNames<TFields>;
9
- unique?: CompositeUnique<TFields>;
9
+ unique?: FieldGroups<TFields>;
10
+ index?: FieldGroups<TFields>;
10
11
  }
@@ -0,0 +1,9 @@
1
+ import type { FieldName } from '../field/FieldName.js';
2
+ import type { Fields } from '../field/Fields.js';
3
+
4
+ /**
5
+ * Field names grouped, as `unique` and `index` both are — `[['listId', 'docId'], ['slug']]`.
6
+ * The ORDER inside a group counts for both: a unique constraint on `(a, b)` is not the one on
7
+ * `(b, a)`, and an index on `(a, b)` serves a filter on `a` alone and never on `b` alone.
8
+ */
9
+ export type FieldGroups<TFields extends Fields> = readonly (readonly FieldName<TFields>[])[];
@@ -1,31 +1,49 @@
1
1
  import type { Shape } from '../axis/shape/Shape.js';
2
- import type { RoleRules } from '../axis/role/Role.js';
3
- import type { LifecycleRules } from '../axis/lifecycle/Lifecycle.js';
4
- import type { BoundaryRef } from '../axis/boundary/Boundary.js';
5
- import type { Meta } from './Meta.js';
6
2
  import { Axes } from '../axis/Axes.js';
7
- import type { FougereFieldAxes } from './FougereFieldAxes.js';
3
+ import type { FougereFieldAxes } from '../FougereFieldAxes.js';
8
4
  import { FieldDeclarationValidator } from '../validator/FieldDeclarationValidator.js';
9
5
  import { FieldValueValidator } from '../validator/FieldValueValidator.js';
6
+ import { isObject } from '../lib/utils.js';
10
7
  import { dotted } from '../lib/ValidationResult.js';
11
8
  import { SchemaError } from '../SchemaError.js';
12
9
 
13
- interface FieldDeclaration extends FougereFieldAxes {
10
+ /** The sentence any word admits, whatever it shapes — a relation and a date carry one too. */
11
+ export interface Described {
12
+ description?: string;
13
+ }
14
+
15
+ /** And a value it is born with, for the words that shape one. `many()` has none to hold. */
16
+ export interface Shared<T> extends Described {
17
+ default?: T;
18
+ }
19
+
20
+ export interface FieldDeclaration extends FougereFieldAxes {
14
21
  shape: Shape;
15
- role?: RoleRules;
16
- lifecycle?: LifecycleRules;
17
- boundary?: BoundaryRef;
18
- meta?: Meta;
22
+ }
23
+
24
+ /**
25
+ * Every word ends on `setShared`, so the options are read in ONE place and refused there.
26
+ * `text(42)` used to pass for a bare `text()`, and nine of the twelve words took it.
27
+ */
28
+ const refuseLooseOptions = (opts: unknown): void => {
29
+ if (!isObject(opts))
30
+ throw new SchemaError('Options must go in an object, like { max: 200 }', { received: opts });
31
+ };
32
+
33
+ /** The registry answers strings, and a member of a declaration is what they name. */
34
+ const registered = (): (keyof FieldDeclaration)[] => Axes.names as (keyof FieldDeclaration)[];
35
+
36
+ /**
37
+ * The axes it carries are `FougereFieldAxes`, which each axis writes its own line in — so the
38
+ * three of the core are typed the way a fourth declared outside is, and the class names what
39
+ * the registry decides only once, in the constructor.
40
+ */
41
+ export interface Field<T = unknown> extends FougereFieldAxes {
42
+ readonly _type?: T;
19
43
  }
20
44
 
21
45
  export class Field<T = unknown> {
22
46
  readonly shape: Shape;
23
- readonly role?: RoleRules;
24
- readonly lifecycle?: LifecycleRules;
25
- readonly boundary?: BoundaryRef;
26
- readonly meta?: Meta;
27
-
28
- declare readonly _type?: T;
29
47
 
30
48
  constructor(init: FieldDeclaration, key?: string) {
31
49
  const verdict = FieldDeclarationValidator.of(init).verdict;
@@ -38,12 +56,11 @@ export class Field<T = unknown> {
38
56
  }
39
57
 
40
58
  this.shape = init.shape;
41
- this.meta = init.meta;
42
59
 
43
- const stated = init as unknown as Record<string, unknown>;
44
-
45
- for (const slot of Axes.names) {
46
- (this as unknown as Record<string, unknown>)[slot] = stated[slot];
60
+ for (const name of registered()) {
61
+ Object.defineProperty(this, name, {
62
+ value: init[name], writable: true, enumerable: true, configurable: true,
63
+ });
47
64
  }
48
65
 
49
66
  const create = this.lifecycle?.create;
@@ -61,7 +78,53 @@ export class Field<T = unknown> {
61
78
  return FieldDeclarationValidator.of(value).verdict.success;
62
79
  }
63
80
 
81
+ /**
82
+ * What this field declares on ONE axis, a fourth one registered outside included. An axis is
83
+ * a member, so it is read by NAME, and three callers reached for it through a cast of their
84
+ * own before this existed.
85
+ */
86
+ axis(name: keyof FieldDeclaration): unknown {
87
+ return (this satisfies FieldDeclaration)[name];
88
+ }
89
+
90
+ /** Every axis it declares, and nothing for the ones it says nothing on. */
91
+ get axes(): Record<string, unknown> {
92
+ return Object.fromEntries(
93
+ registered()
94
+ .map((name) => [name, this.axis(name)])
95
+ .filter(([, declared]) => declared !== undefined),
96
+ );
97
+ }
98
+
99
+ /** Whether it states anything at all beside its shape. */
100
+ hasAxes(): boolean {
101
+ return registered().some((name) => this.axis(name) !== undefined);
102
+ }
103
+
64
104
  with<U = T>(overrides: Partial<FieldDeclaration>): Field<U> {
65
105
  return new Field<U>({ ...this, ...overrides });
66
106
  }
107
+
108
+ /**
109
+ * What a word admits whatever its shape, and where it lands: `description` IS a JSON Schema
110
+ * keyword, `default` IS a lifecycle rule. Stated here alone, so no word writes the conversion.
111
+ * FR : ce que tout mot admet quelle que soit sa forme, et où ça atterrit.
112
+ * `text({ max: 200 }).setShared({ description: 'The title' })` → `shape.description`
113
+ */
114
+ setShared(opts?: Shared<T>): Field<T> {
115
+ if (opts === undefined) return this;
116
+
117
+ refuseLooseOptions(opts);
118
+
119
+ const overrides: Partial<FieldDeclaration> = {
120
+ ...(opts.description !== undefined
121
+ ? { shape: { ...this.shape, description: opts.description } }
122
+ : {}),
123
+ ...(opts.default !== undefined
124
+ ? { lifecycle: { ...this.lifecycle, create: { value: opts.default } } }
125
+ : {}),
126
+ };
127
+
128
+ return Object.keys(overrides).length ? this.with<T>(overrides) : this;
129
+ }
67
130
  }
@@ -1,18 +1,18 @@
1
1
  import type { Axis } from '../axis/Axis.js';
2
2
  import { Format } from '../lib/Format.js';
3
3
  import type { JsonSchema } from '../lib/JsonSchema.js';
4
- import { META_FORMAT } from './Meta.js';
4
+ import { SHAPE_FORMAT } from '../axis/shape/ShapeFormat.js';
5
5
 
6
6
  /**
7
7
  * What a field may state, composed from the keys that state a format: each is carried whole and
8
8
  * cited by its `$id`, so the document travels alone and a key keeps its own identity. `shape` is
9
- * JSON Schema itself, and `Shapes.is` is what reads it.
9
+ * cited like the rest — it is JSON Schema, and JSON Schema is what states its keywords.
10
10
  */
11
11
  export function fieldFormat(axes: readonly [string, Axis][]): JsonSchema {
12
12
  const document = axes.reduce(
13
- (held, [slot, axis]) => held.cites(slot, axis.format),
14
- Format.of('field').key('shape', Format.anything),
13
+ (held, [name, axis]) => held.cites(name, axis.format),
14
+ Format.of('field').cites('shape', SHAPE_FORMAT),
15
15
  );
16
16
 
17
- return document.cites('meta', META_FORMAT).closed().schema as JsonSchema;
17
+ return document.closed().schema as JsonSchema;
18
18
  }
@@ -1,5 +1,5 @@
1
1
  import { Role } from '../axis/role/Role.js';
2
- import type { CompositeUnique } from '../entity/CompositeUnique.js';
2
+ import type { FieldGroups } from '../entity/FieldGroups.js';
3
3
  import { Field } from './Field.js';
4
4
  import { type FieldName } from './FieldName.js';
5
5
  import { type Fields } from './Fields.js';
@@ -17,39 +17,47 @@ export class FieldSet<TFields extends Fields = Fields> {
17
17
  * FR : partage un `unique` déclaré : un groupe d'un va au champ, plusieurs restent au schéma.
18
18
  * `declaring(fields, [['email'], ['listId', 'docId']])` → `email` carries it, the pair is a group
19
19
  */
20
+ /**
21
+ * A group of ONE is the word on the field — `unique: [['slug']]` is `unique(text())` — so it
22
+ * is written there and never kept as a group, which is what lets a derivation drop it with
23
+ * its field. A group of several is what no single field can state, and it is kept whole.
24
+ */
20
25
  static declaring<TFields extends Fields>(
21
26
  declared: TFields,
22
- unique?: CompositeUnique<TFields>,
23
- ): { fields: TFields; groups: CompositeUnique<TFields> } {
27
+ groups: { unique?: FieldGroups<TFields>; index?: FieldGroups<TFields> } = {},
28
+ ): { fields: TFields; unique: FieldGroups<TFields>; index: FieldGroups<TFields> } {
24
29
  const fields: Fields = {};
25
30
  for (const [key, field] of Object.entries(declared))
26
31
  fields[key] = new Field(field, key);
27
32
 
28
- const composite: FieldName<TFields>[][] = [];
29
- for (const group of unique ?? []) {
30
- const missing = group.filter((key) => !Object.hasOwn(fields, key));
31
- if (missing.length)
32
- throw new SchemaError(
33
- `unique: [${group.join(', ')}] names ` +
34
- `${missing.map((key) => `'${key}'`).join(', ')}, which the entity does not declare.`,
35
- );
33
+ const composite = { unique: [] as FieldName<TFields>[][], index: [] as FieldName<TFields>[][] };
34
+
35
+ for (const kind of ['unique', 'index'] as const) {
36
+ for (const group of groups[kind] ?? []) {
37
+ const missing = group.filter((key) => !Object.hasOwn(fields, key));
38
+ if (missing.length)
39
+ throw new SchemaError(
40
+ `${kind}: [${group.join(', ')}] names ` +
41
+ `${missing.map((key) => `'${key}'`).join(', ')}, which the entity does not declare.`,
42
+ );
36
43
 
37
- if (group.length === 1) {
38
- const key = group[0]!;
39
- const alone = fields[key]!;
40
- fields[key] = alone.with({ role: { ...alone.role, unique: true } });
41
- continue;
44
+ if (group.length === 1) {
45
+ const key = group[0]!;
46
+ const alone = fields[key]!;
47
+ fields[key] = alone.with({ role: { ...alone.role, [kind]: true } });
48
+ continue;
49
+ }
50
+ composite[kind].push([...group]);
42
51
  }
43
- composite.push([...group]);
44
52
  }
45
53
 
46
- return { fields: fields as TFields, groups: composite };
54
+ return { fields: fields as TFields, unique: composite.unique, index: composite.index };
47
55
  }
48
56
 
49
57
  /** A second `primary` is refused, naming both, rather than the first winning silently. */
50
58
  get primary(): FieldName<TFields> | undefined {
51
59
  const primaries = Object.entries(this.fields)
52
- .filter(([, field]) => Role.of(field).isPrimary)
60
+ .filter(([, field]) => Role.of(field).isPrimary())
53
61
  .map(([name]) => name);
54
62
 
55
63
  if (primaries.length > 1) {
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ export { StringFormats } from './axis/shape/StringFormats.js';
41
41
  export { Generators } from './axis/lifecycle/Generators.js';
42
42
  export { Boundaries } from './axis/boundary/Decoder.js';
43
43
  export { type FougereEntityAdapters } from './entity/FougereEntityAdapters.js';
44
+ export { type FougereFieldAxes } from './FougereFieldAxes.js';
44
45
  export { InputValidator } from './validator/InputValidator.js';
45
46
  export { FieldValueValidator } from './validator/FieldValueValidator.js';
46
47
  export { JsonSchemaValidator } from './validator/JsonSchemaValidator.js';
package/src/lib/Format.ts CHANGED
@@ -21,6 +21,8 @@ export class Format<T = unknown> {
21
21
  static readonly anything = new Format<unknown>(true);
22
22
  static readonly text = new Format<string>({ type: 'string' });
23
23
  static readonly flag = new Format<boolean>({ type: 'boolean' });
24
+ static readonly number = new Format<number>({ type: 'number' });
25
+ static readonly count = new Format<number>({ type: 'integer', minimum: 0 });
24
26
 
25
27
  declare readonly _admits?: T;
26
28
 
@@ -34,12 +36,43 @@ export class Format<T = unknown> {
34
36
  return new Format({ enum: [...words] });
35
37
  }
36
38
 
37
- /** A word or a shape, told apart by TYPE, so a refusal names the half the value belongs to. */
38
- static either<Word, Shape>(word: Format<Word>, shape: Format<Shape>): Format<Word | Shape> {
39
+ /** `enum`, `required` — a list of one format, so `enum: 'draft'` is refused as the scalar it is. */
40
+ static listOf<Value>(format: Format<Value>): Format<Value[]> {
41
+ return new Format({ type: 'array', items: format.schema });
42
+ }
43
+
44
+ /**
45
+ * A list of a FIXED length, each place stating its own format — `['string', 'null']` and
46
+ * never `['string', 'number']`, which a list of one format cannot tell apart.
47
+ */
48
+ static tuple<const Places extends readonly Format<unknown>[]>(
49
+ ...places: Places
50
+ ): Format<{ -readonly [K in keyof Places]: Accepted<Places[K]> }> {
51
+ return new Format({
52
+ type: 'array',
53
+ prefixItems: places.map((place) => place.schema),
54
+ minItems: places.length,
55
+ maxItems: places.length,
56
+ });
57
+ }
58
+
59
+ /**
60
+ * A format naming ITSELF, which no value can do while it is being built: `items` holds a shape,
61
+ * and the shape is what declares `items`. The `$id` `of()` already posts is what it points at.
62
+ */
63
+ static ref(id: string): Format<unknown> {
64
+ return new Format({ $ref: absolute(id) as string });
65
+ }
66
+
67
+ /**
68
+ * A word or anything else, told apart by TYPE, so a refusal names the half the value belongs
69
+ * to — `create: 'nawak'` reads against the words, `type: ['string', 'null']` against the list.
70
+ */
71
+ static either<Word, Other>(word: Format<Word>, otherwise: Format<Other>): Format<Word | Other> {
39
72
  return new Format({
40
73
  if: { type: 'string' },
41
74
  then: word.schema as JsonSchema,
42
- else: shape.schema as JsonSchema,
75
+ else: otherwise.schema as JsonSchema,
43
76
  });
44
77
  }
45
78
 
@@ -0,0 +1,10 @@
1
+ /**
2
+ * What a union becomes when every member must hold at once — `A | B` → `A & B`, through the
3
+ * contravariance of a parameter position, which is the only place TypeScript folds a union.
4
+ * `Merged<[Post, Draft]>` and `ShapeKeywords` both need it, and each had written it out.
5
+ */
6
+ export type UnionToIntersection<U> = (U extends unknown ? (member: U) => void : never) extends (
7
+ member: infer Held,
8
+ ) => void
9
+ ? Held
10
+ : never;
package/src/lib/utils.ts CHANGED
@@ -15,4 +15,8 @@ export function clean<T extends Record<string, unknown>>(obj: T): T {
15
15
  export const isObject = (value: unknown): value is Record<string, unknown> =>
16
16
  typeof value === 'object' && value !== null && !Array.isArray(value);
17
17
 
18
- export const shown = (value: unknown): string => JSON.stringify(value) ?? typeof value;
18
+ export const isArrayOf = <T>(
19
+ values: readonly unknown[],
20
+ is: (value: unknown) => value is T,
21
+ ): values is readonly T[] => values.every(is);
22
+
@@ -16,6 +16,7 @@ import type { ValidationCase } from './ValidationCase.js';
16
16
  function wrongTypeFor(field: Field): unknown {
17
17
  const declared = (field.shape as { type?: string | readonly string[] }).type;
18
18
  const names = Array.isArray(declared) ? declared : [declared];
19
+
19
20
  return names.includes('string') ? 42 : 'not-a-value-of-this-shape';
20
21
  }
21
22
 
@@ -42,6 +43,11 @@ function outOfBoundsFor(field: Field): { why: string; value: unknown }[] {
42
43
  cases.push({ why: 'below minimum', value: shape.minimum - 1 });
43
44
  if (typeof shape.maximum === 'number')
44
45
  cases.push({ why: 'above maximum', value: shape.maximum + 1 });
46
+ if (shape.enum) {
47
+ const members = shape.enum.filter((value) => typeof value === 'number');
48
+
49
+ cases.push({ why: 'outside the stated set', value: Math.max(...members) + 1 });
50
+ }
45
51
  }
46
52
  if (shape?.type === 'array') {
47
53
  if (typeof shape.minItems === 'number' && shape.minItems > 0)
@@ -52,6 +58,7 @@ function outOfBoundsFor(field: Field): { why: string; value: unknown }[] {
52
58
  value: Array.from({ length: shape.maxItems + 1 }, () => null),
53
59
  });
54
60
  }
61
+
55
62
  return cases;
56
63
  }
57
64
 
@@ -64,8 +71,8 @@ function outOfBoundsFor(field: Field): { why: string; value: unknown }[] {
64
71
  export class Cases {
65
72
  private constructor(readonly all: readonly ValidationCase[]) {}
66
73
 
67
- static of(entity: SchemaView, valid: Record<string, unknown>): Cases {
68
- return new Cases(enumerate(entity, valid));
74
+ static of(schema: SchemaView, valid: Record<string, unknown>): Cases {
75
+ return new Cases(enumerate(schema, valid));
69
76
  }
70
77
 
71
78
  static holds(
@@ -74,6 +81,7 @@ export class Cases {
74
81
  ): boolean {
75
82
  if (expected === 'accept') return result.success;
76
83
  if (result.success) return false;
84
+
77
85
  return (result.errors ?? []).some((error) => rejected(error.path) === expected.reject);
78
86
  }
79
87
 
@@ -92,8 +100,8 @@ export class Cases {
92
100
  * FR : lit les quatre axes plutôt qu'une liste tenue à la main : un cas existe parce qu'un
93
101
  * champ déclare la règle qu'il casse.
94
102
  */
95
- function enumerate(entity: SchemaView, valid: Record<string, unknown>): ValidationCase[] {
96
- const fields = entity.getFields();
103
+ function enumerate(schema: SchemaView, valid: Record<string, unknown>): ValidationCase[] {
104
+ const fields = schema.getFields();
97
105
  const validator = InputValidator.of(fields);
98
106
 
99
107
  return [
@@ -124,13 +132,13 @@ function aboutField(
124
132
  const withField = (value: unknown) => ({ ...valid, [name]: value });
125
133
  const cases: ValidationCase[] = [];
126
134
 
127
- if (validator.onAbsent(field) === null && name in valid) {
135
+ if (validator.requires(field) && name in valid) {
128
136
  const input = { ...valid };
129
137
  delete input[name];
130
138
  cases.push({ why: `${name} absent`, input, patch: false, expect: { reject: name } });
131
139
  }
132
140
 
133
- if (Boundary.of(field).readOnly) {
141
+ if (Boundary.of(field).readOnly()) {
134
142
  cases.push({
135
143
  why: `${name} supplied although read-only`,
136
144
  input: withField(wrongTypeFor(field)),
@@ -139,7 +147,16 @@ function aboutField(
139
147
  });
140
148
  }
141
149
 
142
- if (Lifecycle.of(field).immutable && !Role.of(field).isPrimary) {
150
+ if (Role.of(field).isCollection()) {
151
+ cases.push({
152
+ why: `${name} supplied although the other side carries it`,
153
+ input: withField([]),
154
+ patch: false,
155
+ expect: { reject: name },
156
+ });
157
+ }
158
+
159
+ if (Lifecycle.of(field).immutable() && !Role.of(field).isPrimary()) {
143
160
  cases.push({
144
161
  why: `${name} supplied on an update`,
145
162
  input: { [name]: valid[name] ?? wrongTypeFor(field) },
@@ -150,7 +167,7 @@ function aboutField(
150
167
 
151
168
  // A reference names a row that must exist; the caller supplied its id and we do not
152
169
  // get to invent a second one, so the only case we can state about it is the bound one.
153
- if (name in valid && !Role.of(field).isReference) {
170
+ if (name in valid && !Role.of(field).isReference()) {
154
171
  cases.push({
155
172
  why: `${name} of the wrong type`,
156
173
  input: withField(wrongTypeFor(field)),