@fougere/schema 0.11.0-alpha.0 → 0.12.0-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 (156) hide show
  1. package/dist/Schema.d.ts +1 -1
  2. package/dist/Schema.d.ts.map +1 -1
  3. package/dist/Schema.js +4 -3
  4. package/dist/Schema.js.map +1 -1
  5. package/dist/SchemaConstructor.d.ts +3 -4
  6. package/dist/SchemaConstructor.d.ts.map +1 -1
  7. package/dist/SchemaDefinition.d.ts.map +1 -1
  8. package/dist/SchemaDefinition.js +2 -1
  9. package/dist/SchemaDefinition.js.map +1 -1
  10. package/dist/SchemaError.d.ts +4 -0
  11. package/dist/SchemaError.d.ts.map +1 -0
  12. package/dist/SchemaError.js +7 -0
  13. package/dist/SchemaError.js.map +1 -0
  14. package/dist/SchemaView.d.ts +1 -1
  15. package/dist/SchemaView.d.ts.map +1 -1
  16. package/dist/axis/boundary/Boundary.d.ts.map +1 -1
  17. package/dist/axis/boundary/Boundary.js +3 -2
  18. package/dist/axis/boundary/Boundary.js.map +1 -1
  19. package/dist/axis/boundary/Decoder.d.ts +2 -5
  20. package/dist/axis/boundary/Decoder.d.ts.map +1 -1
  21. package/dist/axis/boundary/Decoder.js +2 -2
  22. package/dist/axis/boundary/Decoder.js.map +1 -1
  23. package/dist/axis/lifecycle/Clock.d.ts +4 -12
  24. package/dist/axis/lifecycle/Clock.d.ts.map +1 -1
  25. package/dist/axis/lifecycle/Clock.js +7 -13
  26. package/dist/axis/lifecycle/Clock.js.map +1 -1
  27. package/dist/axis/lifecycle/Generators.d.ts +5 -5
  28. package/dist/axis/lifecycle/Generators.d.ts.map +1 -1
  29. package/dist/axis/lifecycle/Generators.js +5 -15
  30. package/dist/axis/lifecycle/Generators.js.map +1 -1
  31. package/dist/axis/lifecycle/Lifecycle.d.ts +3 -3
  32. package/dist/axis/lifecycle/Lifecycle.d.ts.map +1 -1
  33. package/dist/axis/lifecycle/Lifecycle.js +11 -7
  34. package/dist/axis/lifecycle/Lifecycle.js.map +1 -1
  35. package/dist/axis/lifecycle/apply.d.ts.map +1 -1
  36. package/dist/axis/lifecycle/apply.js +3 -19
  37. package/dist/axis/lifecycle/apply.js.map +1 -1
  38. package/dist/axis/shape/Shape.d.ts +2 -2
  39. package/dist/axis/shape/Shape.d.ts.map +1 -1
  40. package/dist/axis/shape/Shape.js +2 -2
  41. package/dist/axis/shape/Shape.js.map +1 -1
  42. package/dist/entity/EntityAdapterSet.d.ts.map +1 -1
  43. package/dist/entity/EntityAdapterSet.js +3 -2
  44. package/dist/entity/EntityAdapterSet.js.map +1 -1
  45. package/dist/entity.d.ts +0 -1
  46. package/dist/entity.d.ts.map +1 -1
  47. package/dist/entity.js +0 -1
  48. package/dist/entity.js.map +1 -1
  49. package/dist/field/Field.d.ts.map +1 -1
  50. package/dist/field/Field.js +5 -4
  51. package/dist/field/Field.js.map +1 -1
  52. package/dist/field/FieldSet.d.ts.map +1 -1
  53. package/dist/field/FieldSet.js +5 -3
  54. package/dist/field/FieldSet.js.map +1 -1
  55. package/dist/field/Values.d.ts +6 -0
  56. package/dist/field/Values.d.ts.map +1 -0
  57. package/dist/field/Values.js.map +1 -0
  58. package/dist/index.d.ts +2 -0
  59. package/dist/index.d.ts.map +1 -1
  60. package/dist/index.js +1 -0
  61. package/dist/index.js.map +1 -1
  62. package/dist/lib/Registry.d.ts +1 -2
  63. package/dist/lib/Registry.d.ts.map +1 -1
  64. package/dist/lib/Registry.js +4 -4
  65. package/dist/lib/Registry.js.map +1 -1
  66. package/dist/lib/Verdict.d.ts +12 -0
  67. package/dist/lib/Verdict.d.ts.map +1 -0
  68. package/dist/lib/Verdict.js +2 -0
  69. package/dist/lib/Verdict.js.map +1 -0
  70. package/dist/projection/Cases.js +52 -54
  71. package/dist/projection/Cases.js.map +1 -1
  72. package/dist/projection/card/Bundle.d.ts.map +1 -1
  73. package/dist/projection/card/Bundle.js +2 -1
  74. package/dist/projection/card/Bundle.js.map +1 -1
  75. package/dist/projection/card/Card.d.ts +1 -1
  76. package/dist/projection/card/Card.d.ts.map +1 -1
  77. package/dist/projection/card/Card.js +7 -2
  78. package/dist/projection/card/Card.js.map +1 -1
  79. package/dist/projection/card/Diff.d.ts.map +1 -1
  80. package/dist/projection/card/Diff.js +34 -37
  81. package/dist/projection/card/Diff.js.map +1 -1
  82. package/dist/projection/card/admission.d.ts.map +1 -1
  83. package/dist/projection/card/admission.js +2 -1
  84. package/dist/projection/card/admission.js.map +1 -1
  85. package/dist/validator/AdapterFieldValidator.d.ts.map +1 -1
  86. package/dist/validator/AdapterFieldValidator.js +3 -2
  87. package/dist/validator/AdapterFieldValidator.js.map +1 -1
  88. package/dist/validator/FieldValueValidator.d.ts +3 -3
  89. package/dist/validator/FieldValueValidator.d.ts.map +1 -1
  90. package/dist/validator/FieldValueValidator.js +12 -11
  91. package/dist/validator/FieldValueValidator.js.map +1 -1
  92. package/dist/validator/InputValidator.d.ts +3 -0
  93. package/dist/validator/InputValidator.d.ts.map +1 -1
  94. package/dist/validator/InputValidator.js +28 -31
  95. package/dist/validator/InputValidator.js.map +1 -1
  96. package/dist/vocabulary/primitive/list.d.ts.map +1 -1
  97. package/dist/vocabulary/primitive/list.js +2 -1
  98. package/dist/vocabulary/primitive/list.js.map +1 -1
  99. package/dist/vocabulary/vocabulary.d.ts.map +1 -1
  100. package/dist/vocabulary/vocabulary.js +2 -1
  101. package/dist/vocabulary/vocabulary.js.map +1 -1
  102. package/package.json +1 -1
  103. package/src/Schema.ts +6 -4
  104. package/src/SchemaConstructor.ts +4 -4
  105. package/src/SchemaDefinition.ts +2 -1
  106. package/src/SchemaError.ts +7 -0
  107. package/src/SchemaView.ts +1 -1
  108. package/src/axis/boundary/Boundary.ts +3 -2
  109. package/src/axis/boundary/Decoder.ts +4 -3
  110. package/src/axis/lifecycle/Clock.ts +9 -13
  111. package/src/axis/lifecycle/Generators.ts +6 -18
  112. package/src/axis/lifecycle/Lifecycle.ts +14 -9
  113. package/src/axis/lifecycle/apply.ts +2 -16
  114. package/src/axis/shape/Shape.ts +3 -3
  115. package/src/entity/EntityAdapterSet.ts +3 -2
  116. package/src/entity.ts +0 -1
  117. package/src/field/Field.ts +5 -4
  118. package/src/field/FieldSet.ts +5 -3
  119. package/src/{Values.ts → field/Values.ts} +2 -2
  120. package/src/index.ts +2 -0
  121. package/src/lib/Registry.ts +5 -4
  122. package/src/lib/Verdict.ts +10 -0
  123. package/src/projection/Cases.ts +61 -57
  124. package/src/projection/card/Bundle.ts +2 -1
  125. package/src/projection/card/Card.ts +12 -4
  126. package/src/projection/card/Diff.ts +42 -42
  127. package/src/projection/card/admission.ts +2 -1
  128. package/src/validator/AdapterFieldValidator.ts +3 -2
  129. package/src/validator/FieldValueValidator.ts +15 -14
  130. package/src/validator/InputValidator.ts +30 -32
  131. package/src/vocabulary/primitive/list.ts +2 -1
  132. package/src/vocabulary/vocabulary.ts +2 -1
  133. package/dist/PartialValues.d.ts +0 -4
  134. package/dist/PartialValues.d.ts.map +0 -1
  135. package/dist/PartialValues.js +0 -2
  136. package/dist/PartialValues.js.map +0 -1
  137. package/dist/Values.d.ts +0 -6
  138. package/dist/Values.d.ts.map +0 -1
  139. package/dist/Values.js.map +0 -1
  140. package/dist/axis/boundary/Boundaries.d.ts +0 -17
  141. package/dist/axis/boundary/Boundaries.d.ts.map +0 -1
  142. package/dist/axis/boundary/Boundaries.js +0 -25
  143. package/dist/axis/boundary/Boundaries.js.map +0 -1
  144. package/dist/lib/Checked.d.ts +0 -11
  145. package/dist/lib/Checked.d.ts.map +0 -1
  146. package/dist/lib/Checked.js +0 -2
  147. package/dist/lib/Checked.js.map +0 -1
  148. package/dist/projection/standard.d.ts +0 -14
  149. package/dist/projection/standard.d.ts.map +0 -1
  150. package/dist/projection/standard.js +0 -2
  151. package/dist/projection/standard.js.map +0 -1
  152. package/src/PartialValues.ts +0 -4
  153. package/src/axis/boundary/Boundaries.ts +0 -36
  154. package/src/lib/Checked.ts +0 -5
  155. package/src/projection/standard.ts +0 -13
  156. /package/dist/{Values.js → field/Values.js} +0 -0
@@ -9,6 +9,7 @@ import { SchemaDerivation } from './SchemaDerivation.js';
9
9
  import { type ValidateOptions } from './validator/ValidateOptions.js';
10
10
  import type { SchemaView } from './SchemaView.js';
11
11
  import { SchemaConstraints } from './SchemaConstraints.js';
12
+ import { SchemaError } from './SchemaError.js';
12
13
 
13
14
  /**
14
15
  * Everything a definition is made of, every member required.
@@ -225,7 +226,7 @@ export class SchemaDefinition {
225
226
  const strangers = keys.filter((key) => !Object.hasOwn(this.fields, key));
226
227
  if (strangers.length === 0) return;
227
228
 
228
- throw new Error(
229
+ throw new SchemaError(
229
230
  `${operation}(): unknown field ${strangers.map((s) => `\`${s}\``).join(', ')}. ` +
230
231
  `This schema carries ${Object.keys(this.fields).join(', ')}.`,
231
232
  );
@@ -0,0 +1,7 @@
1
+ export class SchemaError extends Error {
2
+ constructor(message: string) {
3
+ super(message);
4
+
5
+ this.name = new.target.name;
6
+ }
7
+ }
package/src/SchemaView.ts CHANGED
@@ -5,7 +5,7 @@ import type { EntityAdapters } from './entity/EntityAdapters.js';
5
5
  import type { ValidationResult } from './lib/ValidationResult.js';
6
6
  import type { ValidateOptions } from './validator/ValidateOptions.js';
7
7
  import type { SchemaDerivation } from './SchemaDerivation.js';
8
- import type { Values } from './Values.js';
8
+ import type { Values } from './field/Values.js';
9
9
 
10
10
  export interface SchemaView<TFields extends Fields = Fields> {
11
11
  readonly name: string;
@@ -5,6 +5,7 @@ import type { Field } from '../../field/Field.js';
5
5
  import { type Shape } from '../shape/Shape.js';
6
6
  import { Shapes } from '../shape/Shape.js';
7
7
  import type { BoundaryRules } from './BoundaryRules.js';
8
+ import { SchemaError } from '../../SchemaError.js';
8
9
 
9
10
  const identityDecoder: Decoder = (value) => ({ value });
10
11
 
@@ -33,7 +34,7 @@ export class Boundary {
33
34
  if (typeof ref !== 'string') return new Boundary(ref);
34
35
 
35
36
  const alias = Boundaries.aliases.find(ref);
36
- if (!alias) throw new Error(`Unknown boundary alias: '${ref}'`);
37
+ if (!alias) throw new SchemaError(`Unknown boundary alias: '${ref}'`);
37
38
  return new Boundary(alias);
38
39
  }
39
40
 
@@ -58,7 +59,7 @@ export class Boundary {
58
59
 
59
60
  /** `date-time` means a `Date` on both sides, without a word in the entity. */
60
61
  static forShape(shape: Shape | undefined): Boundary {
61
- if (Shapes.typeOf(shape) === 'date') return new Boundary(Boundaries.aliases.find('isoDate')!);
62
+ if (Shapes.typeOf(shape) === 'date') return new Boundary(Boundaries.aliases.resolve('isoDate'));
62
63
  return new Boundary();
63
64
  }
64
65
 
@@ -1,13 +1,14 @@
1
1
  import type { BoundaryRules } from './BoundaryRules.js';
2
2
  import { Registry } from '../../lib/Registry.js';
3
3
  import type { Encoder } from './Encoder.js';
4
+ import type { Verdict } from '../../lib/Verdict.js';
4
5
 
5
6
  /**
6
7
  * Wire to domain, and it must ANSWER a value it already produced: two facades decode — the
7
8
  * client one on what arrives, `StorageGuard` on what a handler writes — so a decoder that
8
9
  * halves cents halves them twice and stores a hundredth.
9
10
  */
10
- export type Decoder = (value: unknown) => { value: unknown } | { error: string };
11
+ export type Decoder = (value: unknown) => Verdict;
11
12
 
12
13
  /**
13
14
  * `decoders` for a value coming in, `encoders` for one going out, `aliases` for the word
@@ -32,9 +33,9 @@ Boundaries.decoders.register('isoDate', (value) => {
32
33
  if (value instanceof Date) return { value };
33
34
  if (typeof value === 'string') {
34
35
  const date = new Date(value);
35
- return Number.isNaN(date.getTime()) ? { error: 'Invalid date' } : { value: date };
36
+ return Number.isNaN(date.getTime()) ? { message: 'Invalid date' } : { value: date };
36
37
  }
37
- return { error: 'Expected a date' };
38
+ return { message: 'Expected a date' };
38
39
  });
39
40
  Boundaries.encoders.register('isoDate', (value) =>
40
41
  value instanceof Date ? value.toISOString() : value,
@@ -1,14 +1,3 @@
1
- /**
2
- * What "now" means, for the two functions that stamp it.
3
- *
4
- * The ONE place either of them reads the time, which is what makes it substitutable at
5
- * all: `created()`, `updated()` and `create: 'now'` are realized through this and nowhere
6
- * else, so a test that needs a stable instant sets it here instead of intercepting `Date`
7
- * globally — where Rails had to build `travel_to` over the language.
8
- *
9
- * A value rather than a mock: nothing here is a channel, and a frozen clock is a fact
10
- * about the run, not an interception of a call.
11
- */
12
1
  export class Clock {
13
2
  private static reading: () => number = Date.now;
14
3
 
@@ -16,11 +5,18 @@ export class Clock {
16
5
  return this.reading();
17
6
  }
18
7
 
19
- /** One substitution moves time for every stamp in the process at once. */
8
+ /**
9
+ *
10
+ * @returns function to restore the clock
11
+ */
20
12
  static freeze(at: number | Date): () => void {
21
13
  const previous = this.reading;
22
14
  const instant = at instanceof Date ? at.getTime() : at;
15
+
23
16
  this.reading = () => instant;
24
- return () => { this.reading = previous; };
17
+
18
+ return () => {
19
+ this.reading = previous;
20
+ };
25
21
  }
26
22
  }
@@ -2,28 +2,16 @@ import { createId } from '@paralleldrive/cuid2';
2
2
 
3
3
  import { Registry } from '../../lib/Registry.js';
4
4
 
5
- export type GeneratorRef = 'cuid2' | 'uuid' | 'nanoid' | (string & {});
6
-
7
- const NANOID_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
8
-
9
- function nanoid(): string {
10
- const bytes = new Uint8Array(21);
11
- globalThis.crypto.getRandomValues(bytes);
12
- return Array.from(bytes, (byte) => NANOID_ALPHABET[byte & 63]).join('');
13
- }
5
+ export type GeneratorRef = 'cuid2' | (string & {});
14
6
 
15
7
  /**
16
- * Who answers a `generate:` name. `cuid2`, `uuid` and `nanoid` are registered like any
17
- * other, so `Generators.resolve('ulid')` is refused here and lists the three.
18
- * FR : pour qu'un `generate:` soit répondu par un registre, builtins compris.
19
- * `Generators.register('ulid', ulid)` → `create: { generate: 'ulid' }` now resolves
8
+ * Who answers a `generate:` name. `cuid2` is registered like any other, so
9
+ * `Generators.resolve('uuid')` is refused here and names what the process holds.
10
+ * `Generators.register('uuid', () => globalThis.crypto.randomUUID())` →
11
+ * `create: { generate: 'uuid' }` now resolves
20
12
  */
21
13
  export const Generators = new Registry<() => string>(
22
14
  'generator',
23
15
  'call Generators.register(name, fn)',
24
- [
25
- ['cuid2', createId],
26
- ['uuid', () => globalThis.crypto.randomUUID()],
27
- ['nanoid', nanoid],
28
- ],
16
+ [['cuid2', createId]],
29
17
  );
@@ -1,4 +1,4 @@
1
- import type { GeneratorRef } from './Generators.js';
1
+ import { Generators } from './Generators.js';
2
2
  import type { LifecycleRules } from './LifecycleRules.js';
3
3
 
4
4
  export class Lifecycle {
@@ -14,11 +14,22 @@ export class Lifecycle {
14
14
  return new Lifecycle(field.lifecycle);
15
15
  }
16
16
 
17
+ bornWith(instant: number): { value: unknown } | undefined {
18
+ const rule = this.create;
19
+
20
+ if (rule === 'now') return { value: new Date(instant) };
21
+
22
+ if (typeof rule !== 'object') return undefined;
23
+
24
+ if ('value' in rule) return { value: structuredClone(rule.value) };
25
+
26
+ return { value: Generators.resolve(rule.generate)() };
27
+ }
28
+
17
29
  get requiredAtCreate(): boolean {
18
30
  return this.create === undefined;
19
31
  }
20
32
 
21
- /** The server fills it at create, so a client form never carries it. */
22
33
  get stampedAtCreate(): boolean {
23
34
  return this.create === 'now';
24
35
  }
@@ -37,15 +48,9 @@ export class Lifecycle {
37
48
 
38
49
  get literal(): { value: unknown } | undefined {
39
50
  const rule = this.create;
51
+
40
52
  return typeof rule === 'object' && rule !== null && 'value' in rule
41
53
  ? { value: (rule as { value: unknown }).value }
42
54
  : undefined;
43
55
  }
44
-
45
- get generator(): GeneratorRef | undefined {
46
- const rule = this.create;
47
- return typeof rule === 'object' && rule !== null && 'generate' in rule
48
- ? (rule as { generate: GeneratorRef }).generate
49
- : undefined;
50
- }
51
56
  }
@@ -1,5 +1,4 @@
1
1
  import { Lifecycle } from './Lifecycle.js';
2
- import { Generators } from './Generators.js';
3
2
  import { Clock } from './Clock.js';
4
3
  import { type Field } from '../../field/Field.js';
5
4
  import { type Fields } from '../../field/Fields.js';
@@ -15,27 +14,14 @@ export function applyCreate(fields: Fields, input: Record<string, unknown>): Rec
15
14
 
16
15
  for (const [name, field] of Object.entries(fields) as [string, Field][]) {
17
16
  if (name in values) continue;
18
- const rule = Lifecycle.of(field);
17
+ const born = Lifecycle.of(field).bornWith(instant);
19
18
 
20
- if (rule.stampedAtCreate) values[name] = new Date(instant);
21
- else if (rule.literal) values[name] = freshValue(rule.literal.value);
22
- else if (rule.generator) values[name] = Generators.resolve(rule.generator)();
19
+ if (born) values[name] = born.value;
23
20
  }
24
21
 
25
22
  return values;
26
23
  }
27
24
 
28
- /**
29
- * Clones a declared default, so two rows born of `create: { value: [] }` hold two arrays.
30
- * FR : clone un défaut déclaré, pour que deux lignes nées de `create: { value: [] }`
31
- * tiennent deux tableaux.
32
- * `create: { value: [] }` → each instance gets its own array
33
- */
34
- function freshValue(value: unknown): unknown {
35
- if (value === null || typeof value !== 'object') return value;
36
- return structuredClone(value);
37
- }
38
-
39
25
  /**
40
26
  * The dual of `applyCreate` on a patch: only `update: 'now'` fields are touched.
41
27
  * FR : le dual d'`applyCreate` sur une modification : seuls les champs `update: 'now'`
@@ -48,6 +48,9 @@ type _ShapeTypesAreTheStandardsLessNull = Assert<
48
48
  >;
49
49
 
50
50
  export class Shapes {
51
+ private static readonly cache = new WeakMap<object, ShapeParts>();
52
+ private static readonly none: ShapeParts = { base: undefined, nullable: false };
53
+
51
54
  static is(value: unknown): value is Shape {
52
55
  if (typeof value !== 'object' || value === null) return false;
53
56
  const type = (value as Shape).type;
@@ -96,9 +99,6 @@ export class Shapes {
96
99
  return nullable;
97
100
  }
98
101
 
99
- private static readonly cache = new WeakMap<object, ShapeParts>();
100
- private static readonly none: ShapeParts = { base: undefined, nullable: false };
101
-
102
102
  static of(shape?: Shape): ShapeParts {
103
103
  if (!shape) return this.none;
104
104
  let parts = this.cache.get(shape);
@@ -1,6 +1,7 @@
1
1
  import type { Fields } from '../field/Fields.js';
2
2
  import { isObject } from '../lib/utils.js';
3
3
  import type { EntityAdapters } from './EntityAdapters.js';
4
+ import { SchemaError } from '../SchemaError.js';
4
5
 
5
6
  type AdapterConfiguration = Record<string, unknown>;
6
7
  type AdapterConfigurations = Record<string, AdapterConfiguration>;
@@ -19,14 +20,14 @@ export class EntityAdapterSet {
19
20
  if (!adapters) return new EntityAdapterSet({});
20
21
 
21
22
  if (!isObject(adapters)) {
22
- throw new Error(
23
+ throw new SchemaError(
23
24
  `adapters: expected an object keyed by adapter name, got ${typeof adapters}.`,
24
25
  );
25
26
  }
26
27
 
27
28
  for (const [adapter, fields] of Object.entries(adapters)) {
28
29
  if (!isObject(fields)) {
29
- throw new Error(
30
+ throw new SchemaError(
30
31
  `adapters.${adapter}: expected an object keyed by field name, got ${typeof fields}. ` +
31
32
  `What an adapter is handed is addressed by the field it applies to.`,
32
33
  );
package/src/entity.ts CHANGED
@@ -5,7 +5,6 @@ import { type SchemaConstructor } from './SchemaConstructor.js';
5
5
 
6
6
  /**
7
7
  * The one call everything derives from: the fields, and what the entity states about them.
8
- * FR : l'appel dont tout dérive : les champs, et ce que l'entité en dit.
9
8
  * `class Post extends entity({ id: primary(), title: text() }, { unique: [['title']] }) {}`
10
9
  *
11
10
  * Documented: [entities](https://fougere.dev/docs/schema/entities).
@@ -7,6 +7,7 @@ import type { Axis } from '../axis/Axis.js';
7
7
  import { FieldDeclarationValidator } from '../validator/FieldDeclarationValidator.js';
8
8
  import { FieldValueValidator } from '../validator/FieldValueValidator.js';
9
9
  import { dotted } from '../lib/ValidationResult.js';
10
+ import { SchemaError } from '../SchemaError.js';
10
11
 
11
12
  type FieldDeclaration = Pick<Field, 'shape' | Axis['slot'] | 'meta'>;
12
13
 
@@ -23,7 +24,7 @@ export class Field<T = unknown> {
23
24
  const verdict = FieldDeclarationValidator.of(init).verdict;
24
25
 
25
26
  if (!verdict.success) {
26
- throw new Error(
27
+ throw new SchemaError(
27
28
  `${key ? `Field '${key}': ` : ''}` +
28
29
  verdict.errors.map((e) => `${dotted(e.path)}: ${e.message}`).join('; '),
29
30
  );
@@ -38,10 +39,10 @@ export class Field<T = unknown> {
38
39
  const create = this.lifecycle?.create;
39
40
  if (typeof create === 'object' && create !== null && 'value' in create) {
40
41
  const checked = FieldValueValidator.of(this).validate(create.value);
41
- if ('error' in checked)
42
- throw new Error(
42
+ if ('message' in checked)
43
+ throw new SchemaError(
43
44
  `${key ? `Field '${key}': ` : ''}the declared default ${JSON.stringify(create.value)} ` +
44
- `is not a legal value for it — ${checked.error}.`,
45
+ `is not a legal value for it — ${checked.message}.`,
45
46
  );
46
47
  }
47
48
  }
@@ -3,6 +3,7 @@ import type { CompositeUnique } from '../entity/CompositeUnique.js';
3
3
  import { Field } from './Field.js';
4
4
  import { type FieldName } from './FieldName.js';
5
5
  import { type Fields } from './Fields.js';
6
+ import { SchemaError } from '../SchemaError.js';
6
7
 
7
8
  export class FieldSet<TFields extends Fields = Fields> {
8
9
  private constructor(private readonly fields: TFields) {}
@@ -28,14 +29,15 @@ export class FieldSet<TFields extends Fields = Fields> {
28
29
  for (const group of unique ?? []) {
29
30
  const missing = group.filter((key) => !Object.hasOwn(fields, key));
30
31
  if (missing.length)
31
- throw new Error(
32
+ throw new SchemaError(
32
33
  `unique: [${group.join(', ')}] names ` +
33
34
  `${missing.map((key) => `'${key}'`).join(', ')}, which the entity does not declare.`,
34
35
  );
35
36
 
36
37
  if (group.length === 1) {
37
38
  const key = group[0]!;
38
- fields[key] = fields[key]!.with({ role: { ...fields[key]!.role, unique: true } });
39
+ const alone = fields[key]!;
40
+ fields[key] = alone.with({ role: { ...alone.role, unique: true } });
39
41
  continue;
40
42
  }
41
43
  composite.push([...group]);
@@ -51,7 +53,7 @@ export class FieldSet<TFields extends Fields = Fields> {
51
53
  .map(([name]) => name);
52
54
 
53
55
  if (primaries.length > 1) {
54
- throw new Error(
56
+ throw new SchemaError(
55
57
  `FieldSet.primary: ${primaries.map((name) => JSON.stringify(name)).join(', ')} all declare ` +
56
58
  '`primary`; a field set can have only one primary field.',
57
59
  );
@@ -1,5 +1,5 @@
1
- import type { Field } from './field/Field.js';
2
- import type { Fields } from './field/Fields.js';
1
+ import type { Field } from './Field.js';
2
+ import type { Fields } from './Fields.js';
3
3
 
4
4
  export type Values<TFields extends Fields> = {
5
5
  [K in keyof TFields]: TFields[K] extends Field<infer T> ? T : never;
package/src/index.ts CHANGED
@@ -18,7 +18,9 @@ export { Lifecycle } from './axis/lifecycle/Lifecycle.js';
18
18
  export { Boundary } from './axis/boundary/Boundary.js';
19
19
  export { applyCreate, applyUpdate } from './axis/lifecycle/apply.js';
20
20
  export { InputRefusal } from './validator/InputRefusal.js';
21
+ export { SchemaError } from './SchemaError.js';
21
22
  export { type ValidationError } from './lib/ValidationError.js';
23
+ export { type Verdict } from './lib/Verdict.js';
22
24
  export { dotted, type ValidationResult } from './lib/ValidationResult.js';
23
25
 
24
26
  export { Card } from './projection/card/Card.js';
@@ -1,13 +1,14 @@
1
+ import { SchemaError } from '../SchemaError.js';
2
+
1
3
  export class Registry<T> {
2
4
  private readonly entries: Map<string, T>;
3
5
 
4
- /** `Unknown boundary decoder 'celsius' — call Boundaries.decoders.register(name, fn).` */
5
6
  constructor(
6
7
  private readonly label: string,
7
8
  private readonly hint?: string,
8
- builtins?: Iterable<readonly [string, T]>,
9
+ entries?: Iterable<readonly [string, T]>,
9
10
  ) {
10
- this.entries = new Map(builtins);
11
+ this.entries = new Map(entries);
11
12
  }
12
13
 
13
14
  register(name: string, value: T): T {
@@ -25,7 +26,7 @@ export class Registry<T> {
25
26
 
26
27
  if (found !== undefined) return found;
27
28
 
28
- throw new Error(
29
+ throw new SchemaError(
29
30
  `${path ? `${path}: ` : ''}Unknown ${this.label} '${name}'${this.hint ? ` — ${this.hint}` : ''}. ` +
30
31
  `This process answers ${this.names.join(', ') || 'nothing yet'}.`,
31
32
  );
@@ -0,0 +1,10 @@
1
+ import type { ValidationError } from './ValidationError.js';
2
+
3
+ /**
4
+ * A field's verdict — the value it admitted, or a refusal whose `path` says where INSIDE the
5
+ * value it happened. That path is optional here and required on a `ValidationError`: a field
6
+ * does not know its own name, so the key is prefixed by whoever iterates the fields.
7
+ */
8
+ export type Verdict =
9
+ | { value: unknown }
10
+ | (Omit<ValidationError, 'path'> & { path?: ValidationError['path'] });
@@ -95,66 +95,70 @@ export class Cases {
95
95
  function enumerate(entity: SchemaView, valid: Record<string, unknown>): ValidationCase[] {
96
96
  const fields = entity.getFields();
97
97
  const validator = InputValidator.of(fields);
98
+
99
+ return [
100
+ ...aboutTheInput(valid),
101
+ ...Object.entries(fields).flatMap(([name, field]) => aboutField(name, field as Field, valid, validator)),
102
+ ];
103
+ }
104
+
105
+ function aboutTheInput(valid: Record<string, unknown>): ValidationCase[] {
106
+ return [
107
+ { why: 'a valid input', input: valid, patch: false, expect: 'accept' },
108
+ {
109
+ why: 'a key outside the contract',
110
+ input: { ...valid, __unknown__: 'x' },
111
+ patch: false,
112
+ expect: { reject: '__unknown__' },
113
+ },
114
+ { why: 'not an object at all', input: 'a string', patch: false, expect: { reject: '.' } },
115
+ ];
116
+ }
117
+
118
+ function aboutField(
119
+ name: string,
120
+ field: Field,
121
+ valid: Record<string, unknown>,
122
+ validator: InputValidator,
123
+ ): ValidationCase[] {
124
+ const withField = (value: unknown) => ({ ...valid, [name]: value });
98
125
  const cases: ValidationCase[] = [];
99
- const withField = (name: string, value: unknown) => ({ ...valid, [name]: value });
100
-
101
- cases.push({ why: 'a valid input', input: valid, patch: false, expect: 'accept' });
102
- cases.push({
103
- why: 'a key outside the contract',
104
- input: { ...valid, __unknown__: 'x' },
105
- patch: false,
106
- expect: { reject: '__unknown__' },
107
- });
108
- cases.push({
109
- why: 'not an object at all',
110
- input: 'a string',
111
- patch: false,
112
- expect: { reject: '.' },
113
- });
114
-
115
- for (const [name, field] of Object.entries(fields) as [string, Field][]) {
116
- // A reference names a row that must exist; the caller supplied its id and we do not
117
- // get to invent a second one, so the only case we can state about it is the bound one.
118
- const isRef = Role.of(field).isReference;
119
-
120
- if (validator.onAbsent(field) === null && name in valid) {
121
- const input = { ...valid };
122
- delete input[name];
123
- cases.push({ why: `${name} absent`, input, patch: false, expect: { reject: name } });
124
- }
125
126
 
126
- if (Boundary.of(field).readOnly) {
127
- cases.push({
128
- why: `${name} supplied although read-only`,
129
- input: withField(name, wrongTypeFor(field)),
130
- patch: false,
131
- expect: { reject: name },
132
- });
133
- }
127
+ if (validator.onAbsent(field) === null && name in valid) {
128
+ const input = { ...valid };
129
+ delete input[name];
130
+ cases.push({ why: `${name} absent`, input, patch: false, expect: { reject: name } });
131
+ }
134
132
 
135
- if (Lifecycle.of(field).immutable && !Role.of(field).isPrimary) {
136
- cases.push({
137
- why: `${name} supplied on an update`,
138
- input: { [name]: valid[name] ?? wrongTypeFor(field) },
139
- patch: true,
140
- expect: { reject: name },
141
- });
142
- }
133
+ if (Boundary.of(field).readOnly) {
134
+ cases.push({
135
+ why: `${name} supplied although read-only`,
136
+ input: withField(wrongTypeFor(field)),
137
+ patch: false,
138
+ expect: { reject: name },
139
+ });
140
+ }
143
141
 
144
- if (name in valid && !isRef) {
145
- cases.push({
146
- why: `${name} of the wrong type`,
147
- input: withField(name, wrongTypeFor(field)),
148
- patch: false,
149
- expect: { reject: name },
150
- });
151
- for (const { why, value } of outOfBoundsFor(field))
152
- cases.push({
153
- why: `${name} ${why}`,
154
- input: withField(name, value),
155
- patch: false,
156
- expect: { reject: name },
157
- });
142
+ if (Lifecycle.of(field).immutable && !Role.of(field).isPrimary) {
143
+ cases.push({
144
+ why: `${name} supplied on an update`,
145
+ input: { [name]: valid[name] ?? wrongTypeFor(field) },
146
+ patch: true,
147
+ expect: { reject: name },
148
+ });
149
+ }
150
+
151
+ // A reference names a row that must exist; the caller supplied its id and we do not
152
+ // 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) {
154
+ cases.push({
155
+ why: `${name} of the wrong type`,
156
+ input: withField(wrongTypeFor(field)),
157
+ patch: false,
158
+ expect: { reject: name },
159
+ });
160
+ for (const { why, value } of outOfBoundsFor(field)) {
161
+ cases.push({ why: `${name} ${why}`, input: withField(value), patch: false, expect: { reject: name } });
158
162
  }
159
163
  }
160
164
 
@@ -162,4 +166,4 @@ function enumerate(entity: SchemaView, valid: Record<string, unknown>): Validati
162
166
  }
163
167
 
164
168
  /** What a case names: the input itself, or the field the refusal lands on. */
165
- const rejected = (path: readonly string[]): string => (path.length === 0 ? '.' : path[0]!);
169
+ const rejected = (path: readonly string[]): string => path[0] ?? '.';
@@ -8,6 +8,7 @@ import type { SchemaDescriptor } from './SchemaDescriptor.js';
8
8
  import type { Diff } from './Diff.js';
9
9
  import type { SetDiff } from './SetDiff.js';
10
10
  import type { SetDiffOptions } from './SetDiffOptions.js';
11
+ import { SchemaError } from '../../SchemaError.js';
11
12
 
12
13
  type SchemaSet = Record<string, SchemaView> | SchemaView[];
13
14
 
@@ -26,7 +27,7 @@ export class Bundle {
26
27
  const key = lowerFirst(entry.name);
27
28
  const previous = claimedBy.get(key);
28
29
  if (previous !== undefined) {
29
- throw new Error(
30
+ throw new SchemaError(
30
31
  `Schemas '${previous}' and '${entry.name}' both claim bundle key '${key}'. `
31
32
  + 'Each schema in a bundle must have a distinct registration key.',
32
33
  );
@@ -8,7 +8,7 @@ import { InputValidator } from '../../validator/InputValidator.js';
8
8
  import { Schema } from '../../Schema.js';
9
9
  import { type SchemaConstructor } from '../../SchemaConstructor.js';
10
10
  import type { SchemaView } from '../../SchemaView.js';
11
- import type { Values } from '../../Values.js';
11
+ import type { Values } from '../../field/Values.js';
12
12
  import { admitPatterns, refuse } from './admission.js';
13
13
  import type { DerivedFrom } from './DerivedFrom.js';
14
14
  import type { FieldDescriptor } from './FieldDescriptor.js';
@@ -16,6 +16,7 @@ import type { FieldExtension } from './FieldExtension.js';
16
16
  import type { SchemaDescriptor } from './SchemaDescriptor.js';
17
17
  import { compare, type Diff } from './Diff.js';
18
18
  import { type DiffOptions } from './DiffOptions.js';
19
+ import { SchemaError } from '../../SchemaError.js';
19
20
 
20
21
  type FieldsOf<T> = { [K in keyof T]-?: Field<T[K]> };
21
22
 
@@ -165,7 +166,11 @@ function originOf(schema: SchemaView): DerivedFrom | undefined {
165
166
  * `{ type: 'string', maxLength: 200 }` → the same shape `text({ max: 200 })` states
166
167
  */
167
168
  function reconstructShape(property: FieldDescriptor): Field['shape'] | undefined {
168
- const types = Array.isArray(property.type) ? property.type : property.type ? [property.type] : [];
169
+ const types = Array.isArray(property.type)
170
+ ? property.type
171
+ : property.type
172
+ ? [property.type]
173
+ : [];
169
174
  if (!types.some((type) => type !== 'null')) return undefined;
170
175
 
171
176
  // `describeField` writes the shape whole, so it is read whole: a list of keywords here
@@ -188,7 +193,7 @@ function reconstructField(
188
193
  ): Field {
189
194
  const shape = reconstructShape(property);
190
195
  if (!shape) {
191
- throw new Error(
196
+ throw new SchemaError(
192
197
  `Field '${key}': the card carries no \`type\` for it, so there is no shape to rebuild. ` +
193
198
  'A field always states one.',
194
199
  );
@@ -215,7 +220,10 @@ function reconstructField(
215
220
  * FR : écrit un groupe sur CHAQUE membre — un lecteur du fil ne voit qu'un champ.
216
221
  * `carryGroup(properties.listId, ['listId', 'docId'])` → the pair lands under its `role`
217
222
  */
218
- function carryGroup(property: FieldDescriptor | undefined, group: readonly string[]): void {
223
+ function carryGroup(
224
+ property: FieldDescriptor | undefined,
225
+ group: readonly string[],
226
+ ): void {
219
227
  if (!property) return;
220
228
  const extension = (property['x-fougere'] ??= {}) as { role?: { unique?: string[][] } };
221
229
  const role = (extension.role ??= {});