@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.
- package/dist/Schema.d.ts +1 -1
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +4 -3
- package/dist/Schema.js.map +1 -1
- package/dist/SchemaConstructor.d.ts +3 -4
- package/dist/SchemaConstructor.d.ts.map +1 -1
- package/dist/SchemaDefinition.d.ts.map +1 -1
- package/dist/SchemaDefinition.js +2 -1
- package/dist/SchemaDefinition.js.map +1 -1
- package/dist/SchemaError.d.ts +4 -0
- package/dist/SchemaError.d.ts.map +1 -0
- package/dist/SchemaError.js +7 -0
- package/dist/SchemaError.js.map +1 -0
- package/dist/SchemaView.d.ts +1 -1
- package/dist/SchemaView.d.ts.map +1 -1
- package/dist/axis/boundary/Boundary.d.ts.map +1 -1
- package/dist/axis/boundary/Boundary.js +3 -2
- package/dist/axis/boundary/Boundary.js.map +1 -1
- package/dist/axis/boundary/Decoder.d.ts +2 -5
- package/dist/axis/boundary/Decoder.d.ts.map +1 -1
- package/dist/axis/boundary/Decoder.js +2 -2
- package/dist/axis/boundary/Decoder.js.map +1 -1
- package/dist/axis/lifecycle/Clock.d.ts +4 -12
- package/dist/axis/lifecycle/Clock.d.ts.map +1 -1
- package/dist/axis/lifecycle/Clock.js +7 -13
- package/dist/axis/lifecycle/Clock.js.map +1 -1
- package/dist/axis/lifecycle/Generators.d.ts +5 -5
- package/dist/axis/lifecycle/Generators.d.ts.map +1 -1
- package/dist/axis/lifecycle/Generators.js +5 -15
- package/dist/axis/lifecycle/Generators.js.map +1 -1
- package/dist/axis/lifecycle/Lifecycle.d.ts +3 -3
- package/dist/axis/lifecycle/Lifecycle.d.ts.map +1 -1
- package/dist/axis/lifecycle/Lifecycle.js +11 -7
- package/dist/axis/lifecycle/Lifecycle.js.map +1 -1
- package/dist/axis/lifecycle/apply.d.ts.map +1 -1
- package/dist/axis/lifecycle/apply.js +3 -19
- package/dist/axis/lifecycle/apply.js.map +1 -1
- package/dist/axis/shape/Shape.d.ts +2 -2
- package/dist/axis/shape/Shape.d.ts.map +1 -1
- package/dist/axis/shape/Shape.js +2 -2
- package/dist/axis/shape/Shape.js.map +1 -1
- package/dist/entity/EntityAdapterSet.d.ts.map +1 -1
- package/dist/entity/EntityAdapterSet.js +3 -2
- package/dist/entity/EntityAdapterSet.js.map +1 -1
- package/dist/entity.d.ts +0 -1
- package/dist/entity.d.ts.map +1 -1
- package/dist/entity.js +0 -1
- package/dist/entity.js.map +1 -1
- package/dist/field/Field.d.ts.map +1 -1
- package/dist/field/Field.js +5 -4
- package/dist/field/Field.js.map +1 -1
- package/dist/field/FieldSet.d.ts.map +1 -1
- package/dist/field/FieldSet.js +5 -3
- package/dist/field/FieldSet.js.map +1 -1
- package/dist/field/Values.d.ts +6 -0
- package/dist/field/Values.d.ts.map +1 -0
- package/dist/field/Values.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/Registry.d.ts +1 -2
- package/dist/lib/Registry.d.ts.map +1 -1
- package/dist/lib/Registry.js +4 -4
- package/dist/lib/Registry.js.map +1 -1
- package/dist/lib/Verdict.d.ts +12 -0
- package/dist/lib/Verdict.d.ts.map +1 -0
- package/dist/lib/Verdict.js +2 -0
- package/dist/lib/Verdict.js.map +1 -0
- package/dist/projection/Cases.js +52 -54
- package/dist/projection/Cases.js.map +1 -1
- package/dist/projection/card/Bundle.d.ts.map +1 -1
- package/dist/projection/card/Bundle.js +2 -1
- package/dist/projection/card/Bundle.js.map +1 -1
- package/dist/projection/card/Card.d.ts +1 -1
- package/dist/projection/card/Card.d.ts.map +1 -1
- package/dist/projection/card/Card.js +7 -2
- package/dist/projection/card/Card.js.map +1 -1
- package/dist/projection/card/Diff.d.ts.map +1 -1
- package/dist/projection/card/Diff.js +34 -37
- package/dist/projection/card/Diff.js.map +1 -1
- package/dist/projection/card/admission.d.ts.map +1 -1
- package/dist/projection/card/admission.js +2 -1
- package/dist/projection/card/admission.js.map +1 -1
- package/dist/validator/AdapterFieldValidator.d.ts.map +1 -1
- package/dist/validator/AdapterFieldValidator.js +3 -2
- package/dist/validator/AdapterFieldValidator.js.map +1 -1
- package/dist/validator/FieldValueValidator.d.ts +3 -3
- package/dist/validator/FieldValueValidator.d.ts.map +1 -1
- package/dist/validator/FieldValueValidator.js +12 -11
- package/dist/validator/FieldValueValidator.js.map +1 -1
- package/dist/validator/InputValidator.d.ts +3 -0
- package/dist/validator/InputValidator.d.ts.map +1 -1
- package/dist/validator/InputValidator.js +28 -31
- package/dist/validator/InputValidator.js.map +1 -1
- package/dist/vocabulary/primitive/list.d.ts.map +1 -1
- package/dist/vocabulary/primitive/list.js +2 -1
- package/dist/vocabulary/primitive/list.js.map +1 -1
- package/dist/vocabulary/vocabulary.d.ts.map +1 -1
- package/dist/vocabulary/vocabulary.js +2 -1
- package/dist/vocabulary/vocabulary.js.map +1 -1
- package/package.json +1 -1
- package/src/Schema.ts +6 -4
- package/src/SchemaConstructor.ts +4 -4
- package/src/SchemaDefinition.ts +2 -1
- package/src/SchemaError.ts +7 -0
- package/src/SchemaView.ts +1 -1
- package/src/axis/boundary/Boundary.ts +3 -2
- package/src/axis/boundary/Decoder.ts +4 -3
- package/src/axis/lifecycle/Clock.ts +9 -13
- package/src/axis/lifecycle/Generators.ts +6 -18
- package/src/axis/lifecycle/Lifecycle.ts +14 -9
- package/src/axis/lifecycle/apply.ts +2 -16
- package/src/axis/shape/Shape.ts +3 -3
- package/src/entity/EntityAdapterSet.ts +3 -2
- package/src/entity.ts +0 -1
- package/src/field/Field.ts +5 -4
- package/src/field/FieldSet.ts +5 -3
- package/src/{Values.ts → field/Values.ts} +2 -2
- package/src/index.ts +2 -0
- package/src/lib/Registry.ts +5 -4
- package/src/lib/Verdict.ts +10 -0
- package/src/projection/Cases.ts +61 -57
- package/src/projection/card/Bundle.ts +2 -1
- package/src/projection/card/Card.ts +12 -4
- package/src/projection/card/Diff.ts +42 -42
- package/src/projection/card/admission.ts +2 -1
- package/src/validator/AdapterFieldValidator.ts +3 -2
- package/src/validator/FieldValueValidator.ts +15 -14
- package/src/validator/InputValidator.ts +30 -32
- package/src/vocabulary/primitive/list.ts +2 -1
- package/src/vocabulary/vocabulary.ts +2 -1
- package/dist/PartialValues.d.ts +0 -4
- package/dist/PartialValues.d.ts.map +0 -1
- package/dist/PartialValues.js +0 -2
- package/dist/PartialValues.js.map +0 -1
- package/dist/Values.d.ts +0 -6
- package/dist/Values.d.ts.map +0 -1
- package/dist/Values.js.map +0 -1
- package/dist/axis/boundary/Boundaries.d.ts +0 -17
- package/dist/axis/boundary/Boundaries.d.ts.map +0 -1
- package/dist/axis/boundary/Boundaries.js +0 -25
- package/dist/axis/boundary/Boundaries.js.map +0 -1
- package/dist/lib/Checked.d.ts +0 -11
- package/dist/lib/Checked.d.ts.map +0 -1
- package/dist/lib/Checked.js +0 -2
- package/dist/lib/Checked.js.map +0 -1
- package/dist/projection/standard.d.ts +0 -14
- package/dist/projection/standard.d.ts.map +0 -1
- package/dist/projection/standard.js +0 -2
- package/dist/projection/standard.js.map +0 -1
- package/src/PartialValues.ts +0 -4
- package/src/axis/boundary/Boundaries.ts +0 -36
- package/src/lib/Checked.ts +0 -5
- package/src/projection/standard.ts +0 -13
- /package/dist/{Values.js → field/Values.js} +0 -0
package/src/SchemaDefinition.ts
CHANGED
|
@@ -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
|
|
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
|
);
|
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
|
|
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.
|
|
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) =>
|
|
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()) ? {
|
|
36
|
+
return Number.isNaN(date.getTime()) ? { message: 'Invalid date' } : { value: date };
|
|
36
37
|
}
|
|
37
|
-
return {
|
|
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
|
-
/**
|
|
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
|
-
|
|
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' |
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `
|
|
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
|
|
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
|
|
17
|
+
const born = Lifecycle.of(field).bornWith(instant);
|
|
19
18
|
|
|
20
|
-
if (
|
|
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'`
|
package/src/axis/shape/Shape.ts
CHANGED
|
@@ -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
|
|
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
|
|
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).
|
package/src/field/Field.ts
CHANGED
|
@@ -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
|
|
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 ('
|
|
42
|
-
throw new
|
|
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.
|
|
45
|
+
`is not a legal value for it — ${checked.message}.`,
|
|
45
46
|
);
|
|
46
47
|
}
|
|
47
48
|
}
|
package/src/field/FieldSet.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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 './
|
|
2
|
-
import type { Fields } from './
|
|
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';
|
package/src/lib/Registry.ts
CHANGED
|
@@ -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
|
-
|
|
9
|
+
entries?: Iterable<readonly [string, T]>,
|
|
9
10
|
) {
|
|
10
|
-
this.entries = new Map(
|
|
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
|
|
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'] });
|
package/src/projection/Cases.ts
CHANGED
|
@@ -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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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 =>
|
|
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
|
|
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)
|
|
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
|
|
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(
|
|
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 ??= {});
|