@fougere/schema 0.2.0-alpha.1 → 0.2.0-alpha.2
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/entity.d.ts +9 -106
- package/dist/entity.d.ts.map +1 -1
- package/dist/entity.js +16 -266
- package/dist/entity.js.map +1 -1
- package/dist/field/boundary.d.ts +26 -43
- package/dist/field/boundary.d.ts.map +1 -1
- package/dist/field/boundary.js +30 -42
- package/dist/field/boundary.js.map +1 -1
- package/dist/field/field.d.ts +13 -56
- package/dist/field/field.d.ts.map +1 -1
- package/dist/field/field.js +22 -26
- package/dist/field/field.js.map +1 -1
- package/dist/field/format.d.ts +29 -0
- package/dist/field/format.d.ts.map +1 -0
- package/dist/field/format.js +24 -0
- package/dist/field/format.js.map +1 -0
- package/dist/field/index.d.ts +7 -6
- package/dist/field/index.d.ts.map +1 -1
- package/dist/field/index.js +6 -5
- package/dist/field/index.js.map +1 -1
- package/dist/field/lifecycle.d.ts +15 -24
- package/dist/field/lifecycle.d.ts.map +1 -1
- package/dist/field/lifecycle.js +10 -17
- package/dist/field/lifecycle.js.map +1 -1
- package/dist/field/meta.d.ts +1 -5
- package/dist/field/meta.d.ts.map +1 -1
- package/dist/field/meta.js +2 -4
- package/dist/field/meta.js.map +1 -1
- package/dist/field/role.d.ts +15 -22
- package/dist/field/role.d.ts.map +1 -1
- package/dist/field/role.js +11 -31
- package/dist/field/role.js.map +1 -1
- package/dist/field/shape.d.ts +24 -12
- package/dist/field/shape.d.ts.map +1 -1
- package/dist/field/shape.js +45 -27
- package/dist/field/shape.js.map +1 -1
- package/dist/hints.d.ts +10 -15
- package/dist/hints.d.ts.map +1 -1
- package/dist/hints.js +22 -1
- package/dist/hints.js.map +1 -1
- package/dist/index.d.ts +37 -36
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +37 -36
- package/dist/index.js.map +1 -1
- package/dist/name.d.ts +4 -12
- package/dist/name.d.ts.map +1 -1
- package/dist/name.js +4 -12
- package/dist/name.js.map +1 -1
- package/dist/projections/card.d.ts +39 -58
- package/dist/projections/card.d.ts.map +1 -1
- package/dist/projections/card.js.map +1 -1
- package/dist/projections/describe.d.ts +4 -4
- package/dist/projections/describe.d.ts.map +1 -1
- package/dist/projections/describe.js +4 -9
- package/dist/projections/describe.js.map +1 -1
- package/dist/projections/io.d.ts +5 -11
- package/dist/projections/io.d.ts.map +1 -1
- package/dist/projections/io.js +5 -11
- package/dist/projections/io.js.map +1 -1
- package/dist/projections/lifecycle.d.ts +5 -19
- package/dist/projections/lifecycle.d.ts.map +1 -1
- package/dist/projections/lifecycle.js +7 -29
- package/dist/projections/lifecycle.js.map +1 -1
- package/dist/projections/reconstruct.d.ts +11 -16
- package/dist/projections/reconstruct.d.ts.map +1 -1
- package/dist/projections/reconstruct.js +29 -32
- package/dist/projections/reconstruct.js.map +1 -1
- package/dist/projections/source.d.ts +11 -34
- package/dist/projections/source.d.ts.map +1 -1
- package/dist/projections/source.js +4 -23
- package/dist/projections/source.js.map +1 -1
- package/dist/projections/typescript.d.ts +6 -22
- package/dist/projections/typescript.d.ts.map +1 -1
- package/dist/projections/typescript.js +16 -43
- package/dist/projections/typescript.js.map +1 -1
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +2 -0
- package/dist/schema/index.js.map +1 -0
- package/dist/schema/schema.d.ts +74 -0
- package/dist/schema/schema.d.ts.map +1 -0
- package/dist/schema/schema.js +162 -0
- package/dist/schema/schema.js.map +1 -0
- package/dist/schema/view.d.ts +26 -0
- package/dist/schema/view.d.ts.map +1 -0
- package/dist/schema/view.js +2 -0
- package/dist/schema/view.js.map +1 -0
- package/dist/unique.d.ts +12 -37
- package/dist/unique.d.ts.map +1 -1
- package/dist/unique.js +11 -34
- package/dist/unique.js.map +1 -1
- package/dist/validation/index.d.ts +4 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +2 -0
- package/dist/validation/index.js.map +1 -0
- package/dist/validation/judge.d.ts +55 -0
- package/dist/validation/judge.d.ts.map +1 -0
- package/dist/validation/judge.js +351 -0
- package/dist/validation/judge.js.map +1 -0
- package/dist/validation/options.d.ts +6 -0
- package/dist/validation/options.d.ts.map +1 -0
- package/dist/validation/options.js +3 -0
- package/dist/validation/options.js.map +1 -0
- package/dist/validation/result.d.ts +20 -0
- package/dist/validation/result.d.ts.map +1 -0
- package/dist/validation/result.js +3 -0
- package/dist/validation/result.js.map +1 -0
- package/dist/vocabulary/bool.d.ts +1 -1
- package/dist/vocabulary/bool.d.ts.map +1 -1
- package/dist/vocabulary/bool.js +2 -2
- package/dist/vocabulary/bool.js.map +1 -1
- package/dist/vocabulary/created.d.ts +4 -10
- package/dist/vocabulary/created.d.ts.map +1 -1
- package/dist/vocabulary/created.js +5 -12
- package/dist/vocabulary/created.js.map +1 -1
- package/dist/vocabulary/date.d.ts +1 -1
- package/dist/vocabulary/date.d.ts.map +1 -1
- package/dist/vocabulary/date.js +2 -2
- package/dist/vocabulary/date.js.map +1 -1
- package/dist/vocabulary/immutable.d.ts +4 -6
- package/dist/vocabulary/immutable.d.ts.map +1 -1
- package/dist/vocabulary/immutable.js +3 -6
- package/dist/vocabulary/immutable.js.map +1 -1
- package/dist/vocabulary/indexed.d.ts +5 -16
- package/dist/vocabulary/indexed.d.ts.map +1 -1
- package/dist/vocabulary/indexed.js +4 -16
- package/dist/vocabulary/indexed.js.map +1 -1
- package/dist/vocabulary/json.d.ts +8 -13
- package/dist/vocabulary/json.d.ts.map +1 -1
- package/dist/vocabulary/json.js +3 -3
- package/dist/vocabulary/json.js.map +1 -1
- package/dist/vocabulary/list.d.ts +3 -6
- package/dist/vocabulary/list.d.ts.map +1 -1
- package/dist/vocabulary/list.js +6 -5
- package/dist/vocabulary/list.js.map +1 -1
- package/dist/vocabulary/many.d.ts +3 -4
- package/dist/vocabulary/many.d.ts.map +1 -1
- package/dist/vocabulary/many.js +5 -5
- package/dist/vocabulary/many.js.map +1 -1
- package/dist/vocabulary/nullable.d.ts +2 -4
- package/dist/vocabulary/nullable.d.ts.map +1 -1
- package/dist/vocabulary/nullable.js +4 -8
- package/dist/vocabulary/nullable.js.map +1 -1
- package/dist/vocabulary/number.d.ts +1 -1
- package/dist/vocabulary/number.d.ts.map +1 -1
- package/dist/vocabulary/number.js +2 -2
- package/dist/vocabulary/number.js.map +1 -1
- package/dist/vocabulary/oneOf.d.ts +1 -1
- package/dist/vocabulary/oneOf.d.ts.map +1 -1
- package/dist/vocabulary/oneOf.js +2 -2
- package/dist/vocabulary/oneOf.js.map +1 -1
- package/dist/vocabulary/optional.d.ts +1 -1
- package/dist/vocabulary/optional.d.ts.map +1 -1
- package/dist/vocabulary/optional.js +3 -3
- package/dist/vocabulary/optional.js.map +1 -1
- package/dist/vocabulary/primary.d.ts +5 -5
- package/dist/vocabulary/primary.d.ts.map +1 -1
- package/dist/vocabulary/primary.js +14 -11
- package/dist/vocabulary/primary.js.map +1 -1
- package/dist/vocabulary/readOnly.d.ts +4 -7
- package/dist/vocabulary/readOnly.d.ts.map +1 -1
- package/dist/vocabulary/readOnly.js +4 -7
- package/dist/vocabulary/readOnly.js.map +1 -1
- package/dist/vocabulary/ref.d.ts +1 -1
- package/dist/vocabulary/ref.d.ts.map +1 -1
- package/dist/vocabulary/ref.js +2 -2
- package/dist/vocabulary/ref.js.map +1 -1
- package/dist/vocabulary/text.d.ts +1 -1
- package/dist/vocabulary/text.d.ts.map +1 -1
- package/dist/vocabulary/text.js +2 -2
- package/dist/vocabulary/text.js.map +1 -1
- package/dist/vocabulary/unique.d.ts +8 -18
- package/dist/vocabulary/unique.d.ts.map +1 -1
- package/dist/vocabulary/unique.js +7 -18
- package/dist/vocabulary/unique.js.map +1 -1
- package/dist/vocabulary/updated.d.ts +2 -2
- package/dist/vocabulary/updated.d.ts.map +1 -1
- package/dist/vocabulary/updated.js +1 -2
- package/dist/vocabulary/updated.js.map +1 -1
- package/dist/vocabulary/writeOnly.d.ts +4 -6
- package/dist/vocabulary/writeOnly.d.ts.map +1 -1
- package/dist/vocabulary/writeOnly.js +4 -6
- package/dist/vocabulary/writeOnly.js.map +1 -1
- package/package.json +2 -2
- package/dist/projections/validation.d.ts +0 -57
- package/dist/projections/validation.d.ts.map +0 -1
- package/dist/projections/validation.js +0 -148
- package/dist/projections/validation.js.map +0 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Field, Fields } from "../field/index.js";
|
|
2
|
+
import type { Hints } from "../hints.js";
|
|
3
|
+
import type { CompositeUnique } from "../unique.js";
|
|
4
|
+
import type { ValidationResult, ValidateOptions } from '../validation/index.js';
|
|
5
|
+
/** The row an entity carries — its fields' value types, all present. */
|
|
6
|
+
export type Row<TFields extends Fields> = {
|
|
7
|
+
[K in keyof TFields]: TFields[K] extends Field<infer T> ? T : never;
|
|
8
|
+
};
|
|
9
|
+
/** A row as handed to the constructor — every key omissible. */
|
|
10
|
+
export type PartialRow<TFields extends Fields> = Partial<Row<TFields>>;
|
|
11
|
+
/**
|
|
12
|
+
* What a schema ANSWERS. No construct signature, so a class carrying a body satisfies it —
|
|
13
|
+
* and `Fields` by default, for a reader that does not care which map it holds.
|
|
14
|
+
*/
|
|
15
|
+
export interface SchemaView<TFields extends Fields = Fields> {
|
|
16
|
+
readonly name: string;
|
|
17
|
+
getFields(): TFields;
|
|
18
|
+
/** Per-consumer hints from the 2nd arg of `entity()`. Derivations carry them. */
|
|
19
|
+
getHints(): Hints<TFields> | undefined;
|
|
20
|
+
/** Field groups unique together, from the 2nd arg of `entity()`. */
|
|
21
|
+
getUnique(): CompositeUnique<TFields> | undefined;
|
|
22
|
+
/** This view's validation mode — `patch` is set by `partial()`. */
|
|
23
|
+
getOpts(): ValidateOptions;
|
|
24
|
+
validate(input: unknown): ValidationResult<Row<TFields>>;
|
|
25
|
+
}
|
|
26
|
+
//# sourceMappingURL=view.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"view.d.ts","sourceRoot":"","sources":["../../src/schema/view.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,mBAAmB,CAAC;AACvD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAGhF,wEAAwE;AACxE,MAAM,MAAM,GAAG,CAAC,OAAO,SAAS,MAAM,IAAI;KACvC,CAAC,IAAI,MAAM,OAAO,GAAG,OAAO,CAAC,CAAC,CAAC,SAAS,KAAK,CAAC,MAAM,CAAC,CAAC,GAAG,CAAC,GAAG,KAAK;CACpE,CAAC;AAEF,gEAAgE;AAChE,MAAM,MAAM,UAAU,CAAC,OAAO,SAAS,MAAM,IAAI,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;AAEvE;;;GAGG;AACH,MAAM,WAAW,UAAU,CAAC,OAAO,SAAS,MAAM,GAAG,MAAM;IACzD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,SAAS,IAAI,OAAO,CAAC;IACrB,iFAAiF;IACjF,QAAQ,IAAI,KAAK,CAAC,OAAO,CAAC,GAAG,SAAS,CAAC;IACvC,oEAAoE;IACpE,SAAS,IAAI,eAAe,CAAC,OAAO,CAAC,GAAG,SAAS,CAAC;IAClD,mEAAmE;IACnE,OAAO,IAAI,eAAe,CAAC;IAC3B,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG,gBAAgB,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC;CAC1D"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"view.js","sourceRoot":"","sources":["../../src/schema/view.ts"],"names":[],"mappings":""}
|
package/dist/unique.d.ts
CHANGED
|
@@ -1,16 +1,9 @@
|
|
|
1
1
|
import type { Fields } from './field/index.js';
|
|
2
2
|
import type { Hints } from './hints.js';
|
|
3
3
|
/**
|
|
4
|
-
* Field names that identify at most one row when taken together.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* "A book appears once in a list" is true of `(listId, docId)` and of neither
|
|
8
|
-
* alone, and there is no shape that can express it — judging one value never
|
|
9
|
-
* sees the other rows. So it is declared on the entity and realized by storage.
|
|
10
|
-
*
|
|
11
|
-
* Held here rather than in a handler because a handler cannot hold it: a check
|
|
12
|
-
* followed by a write is two round trips, and a concurrent request fits between
|
|
13
|
-
* them. The database is the only place where the promise is keepable.
|
|
4
|
+
* Field names that identify at most one row when taken together. No shape can express it —
|
|
5
|
+
* judging one value never sees the other rows — and no handler either: a check then a write
|
|
6
|
+
* is two round trips with room for a concurrent one between them. Only the database keeps it.
|
|
14
7
|
*/
|
|
15
8
|
export type CompositeUnique<TFields extends Fields> = ReadonlyArray<ReadonlyArray<Extract<keyof TFields, string>>>;
|
|
16
9
|
/**
|
|
@@ -21,45 +14,27 @@ export type CompositeUnique<TFields extends Fields> = ReadonlyArray<ReadonlyArra
|
|
|
21
14
|
* adds a key here, never a positional argument.
|
|
22
15
|
*/
|
|
23
16
|
export interface EntityDeclarations<TFields extends Fields> {
|
|
24
|
-
/** Field groups that must be unique together. */
|
|
25
17
|
unique?: CompositeUnique<TFields>;
|
|
26
18
|
/** Per-consumer hints, keyed by registered adapter. See {@link Hints}. */
|
|
27
19
|
hints?: Hints<TFields>;
|
|
28
20
|
}
|
|
29
21
|
/**
|
|
30
|
-
* Carry composite groups across a
|
|
31
|
-
* `
|
|
32
|
-
*
|
|
33
|
-
* A group whose members did not all survive is dropped rather than narrowed: the
|
|
34
|
-
* pair `(listId, docId)` says nothing about `listId` alone, and keeping the
|
|
35
|
-
* remnant would silently state a stronger fact than the author ever wrote.
|
|
22
|
+
* Carry composite groups across a key transform. A group that lost a member is DROPPED,
|
|
23
|
+
* not narrowed: `(listId, docId)` says nothing about `listId` alone.
|
|
36
24
|
*/
|
|
37
25
|
export declare function deriveUnique(groups: CompositeUnique<Fields> | undefined, mapKey: (key: string) => string | undefined): CompositeUnique<Fields> | undefined;
|
|
38
26
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* `pick('id','listId')` with a role still claiming a pair whose other member was gone.
|
|
43
|
-
*
|
|
44
|
-
* Same rule as the declaration — a group that lost a member is dropped, not narrowed.
|
|
45
|
-
* The empty self-reference needs no remapping: it names no key, which is exactly why it
|
|
46
|
-
* survives a `rename()` for free.
|
|
27
|
+
* The same, on each member's role — it must run WITH {@link deriveUnique}: once projected
|
|
28
|
+
* the group lives in two places, so remapping one alone left a role claiming a pair whose
|
|
29
|
+
* other member was gone. The empty self-reference names no key, so it needs no remapping.
|
|
47
30
|
*/
|
|
48
31
|
export declare function deriveUniqueRoles<TFields extends Fields>(fields: TFields, mapKey: (key: string) => string | undefined): TFields;
|
|
49
32
|
/**
|
|
50
|
-
* Project the entity's composite groups onto
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* The author writes each fact where it lives: `unique(slug)` on the field it is about,
|
|
54
|
-
* `entity(fields, { unique: [['listId','docId']] })` on the entity, because a fact about
|
|
55
|
-
* a pair is held by neither field alone. Both then read as ONE normal form on the role
|
|
56
|
-
* axis, so every consumer — the DDL, the card, a foreign adapter — has a single shape to
|
|
57
|
-
* handle, and a field belonging to two constraints simply carries two member lists.
|
|
33
|
+
* Project the entity's composite groups onto every member's role — the single place the
|
|
34
|
+
* two ways of stating uniqueness meet, so every consumer has one shape to handle.
|
|
58
35
|
*
|
|
59
|
-
* The entity declaration stays the source (`getUnique()`
|
|
60
|
-
*
|
|
61
|
-
* here, while a lone `unique()` keeps its `[]` self-reference — resolved by whoever reads
|
|
62
|
-
* it, which is what makes `rename()` free.
|
|
36
|
+
* The entity declaration stays the source (`getUnique()` answers it); this is a projection,
|
|
37
|
+
* never a second place to edit.
|
|
63
38
|
*/
|
|
64
39
|
export declare function projectUniqueOntoFields<TFields extends Fields>(fields: TFields, groups: CompositeUnique<TFields> | undefined): TFields;
|
|
65
40
|
//# sourceMappingURL=unique.d.ts.map
|
package/dist/unique.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unique.d.ts","sourceRoot":"","sources":["../src/unique.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAExC
|
|
1
|
+
{"version":3,"file":"unique.d.ts","sourceRoot":"","sources":["../src/unique.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC;AAExC;;;;GAIG;AACH,MAAM,MAAM,eAAe,CAAC,OAAO,SAAS,MAAM,IAAI,aAAa,CACjE,aAAa,CAAC,OAAO,CAAC,MAAM,OAAO,EAAE,MAAM,CAAC,CAAC,CAC9C,CAAC;AAEF;;;;;;GAMG;AACH,MAAM,WAAW,kBAAkB,CAAC,OAAO,SAAS,MAAM;IACxD,MAAM,CAAC,EAAE,eAAe,CAAC,OAAO,CAAC,CAAC;IAClC,0EAA0E;IAC1E,KAAK,CAAC,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;CACxB;AAED;;;GAGG;AACH,wBAAgB,YAAY,CAC1B,MAAM,EAAE,eAAe,CAAC,MAAM,CAAC,GAAG,SAAS,EAC3C,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,GAC1C,eAAe,CAAC,MAAM,CAAC,GAAG,SAAS,CAQrC;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,SAAS,MAAM,EACtD,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,MAAM,GAAG,SAAS,GAC1C,OAAO,CAeT;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CAAC,OAAO,SAAS,MAAM,EAC5D,MAAM,EAAE,OAAO,EACf,MAAM,EAAE,eAAe,CAAC,OAAO,CAAC,GAAG,SAAS,GAC3C,OAAO,CAcT"}
|
package/dist/unique.js
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Carry composite groups across a
|
|
3
|
-
* `
|
|
4
|
-
*
|
|
5
|
-
* A group whose members did not all survive is dropped rather than narrowed: the
|
|
6
|
-
* pair `(listId, docId)` says nothing about `listId` alone, and keeping the
|
|
7
|
-
* remnant would silently state a stronger fact than the author ever wrote.
|
|
2
|
+
* Carry composite groups across a key transform. A group that lost a member is DROPPED,
|
|
3
|
+
* not narrowed: `(listId, docId)` says nothing about `listId` alone.
|
|
8
4
|
*/
|
|
9
5
|
export function deriveUnique(groups, mapKey) {
|
|
10
6
|
if (!groups)
|
|
@@ -15,14 +11,9 @@ export function deriveUnique(groups, mapKey) {
|
|
|
15
11
|
return carried.length > 0 ? carried : undefined;
|
|
16
12
|
}
|
|
17
13
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* `pick('id','listId')` with a role still claiming a pair whose other member was gone.
|
|
22
|
-
*
|
|
23
|
-
* Same rule as the declaration — a group that lost a member is dropped, not narrowed.
|
|
24
|
-
* The empty self-reference needs no remapping: it names no key, which is exactly why it
|
|
25
|
-
* survives a `rename()` for free.
|
|
14
|
+
* The same, on each member's role — it must run WITH {@link deriveUnique}: once projected
|
|
15
|
+
* the group lives in two places, so remapping one alone left a role claiming a pair whose
|
|
16
|
+
* other member was gone. The empty self-reference names no key, so it needs no remapping.
|
|
26
17
|
*/
|
|
27
18
|
export function deriveUniqueRoles(fields, mapKey) {
|
|
28
19
|
const out = { ...fields };
|
|
@@ -36,27 +27,16 @@ export function deriveUniqueRoles(fields, mapKey) {
|
|
|
36
27
|
if (carried.length === groups.length)
|
|
37
28
|
continue; // nothing lost, keep the field as is
|
|
38
29
|
const { unique: _dropped, ...rest } = field.role;
|
|
39
|
-
out[key] = {
|
|
40
|
-
...field,
|
|
41
|
-
role: carried.length ? { ...rest, unique: carried } : rest,
|
|
42
|
-
};
|
|
30
|
+
out[key] = field.with({ role: carried.length ? { ...rest, unique: carried } : rest });
|
|
43
31
|
}
|
|
44
32
|
return out;
|
|
45
33
|
}
|
|
46
34
|
/**
|
|
47
|
-
* Project the entity's composite groups onto
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* The author writes each fact where it lives: `unique(slug)` on the field it is about,
|
|
51
|
-
* `entity(fields, { unique: [['listId','docId']] })` on the entity, because a fact about
|
|
52
|
-
* a pair is held by neither field alone. Both then read as ONE normal form on the role
|
|
53
|
-
* axis, so every consumer — the DDL, the card, a foreign adapter — has a single shape to
|
|
54
|
-
* handle, and a field belonging to two constraints simply carries two member lists.
|
|
35
|
+
* Project the entity's composite groups onto every member's role — the single place the
|
|
36
|
+
* two ways of stating uniqueness meet, so every consumer has one shape to handle.
|
|
55
37
|
*
|
|
56
|
-
* The entity declaration stays the source (`getUnique()`
|
|
57
|
-
*
|
|
58
|
-
* here, while a lone `unique()` keeps its `[]` self-reference — resolved by whoever reads
|
|
59
|
-
* it, which is what makes `rename()` free.
|
|
38
|
+
* The entity declaration stays the source (`getUnique()` answers it); this is a projection,
|
|
39
|
+
* never a second place to edit.
|
|
60
40
|
*/
|
|
61
41
|
export function projectUniqueOntoFields(fields, groups) {
|
|
62
42
|
if (!groups || groups.length === 0)
|
|
@@ -69,10 +49,7 @@ export function projectUniqueOntoFields(fields, groups) {
|
|
|
69
49
|
if (!field)
|
|
70
50
|
continue; // a group naming an absent field states nothing here
|
|
71
51
|
const already = field.role?.unique ?? [];
|
|
72
|
-
projected[key] = {
|
|
73
|
-
...field,
|
|
74
|
-
role: { ...field.role, unique: [...already, members] },
|
|
75
|
-
};
|
|
52
|
+
projected[key] = field.with({ role: { ...field.role, unique: [...already, members] } });
|
|
76
53
|
}
|
|
77
54
|
}
|
|
78
55
|
return projected;
|
package/dist/unique.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"unique.js","sourceRoot":"","sources":["../src/unique.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"unique.js","sourceRoot":"","sources":["../src/unique.ts"],"names":[],"mappings":"AAyBA;;;GAGG;AACH,MAAM,UAAU,YAAY,CAC1B,MAA2C,EAC3C,MAA2C;IAE3C,IAAI,CAAC,MAAM;QAAE,OAAO,SAAS,CAAC;IAE9B,MAAM,OAAO,GAAG,MAAM;SACnB,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;SACjC,MAAM,CAAC,CAAC,KAAK,EAAqB,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,KAAK,SAAS,CAAC,CAAC,CAAC;IAEjF,OAAO,OAAO,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAC;AAClD,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,iBAAiB,CAC/B,MAAe,EACf,MAA2C;IAE3C,MAAM,GAAG,GAAG,EAAE,GAAG,MAAM,EAAY,CAAC;IACpC,KAAK,MAAM,CAAC,GAAG,EAAE,KAAK,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC;QAC/C,MAAM,MAAM,GAAG,KAAK,CAAC,IAAI,EAAE,MAAM,CAAC;QAClC,IAAI,CAAC,MAAM,EAAE,MAAM;YAAE,SAAS;QAE9B,MAAM,OAAO,GAAG,MAAM;aACnB,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,KAAK,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;aAC7D,MAAM,CAAC,CAAC,KAAK,EAAqB,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,EAAE,EAAE,CAAC,MAAM,KAAK,SAAS,CAAC,CAAC,CAAC;QAEvF,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM,CAAC,MAAM;YAAE,SAAS,CAAG,qCAAqC;QACvF,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,EAAE,GAAG,KAAK,CAAC,IAAK,CAAC;QAClD,GAAG,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;IACxF,CAAC;IACD,OAAO,GAAc,CAAC;AACxB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,uBAAuB,CACrC,MAAe,EACf,MAA4C;IAE5C,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,MAAM,CAAC;IAElD,MAAM,SAAS,GAAG,EAAE,GAAG,MAAM,EAAY,CAAC;IAC1C,KAAK,MAAM,KAAK,IAAI,MAAM,EAAE,CAAC;QAC3B,MAAM,OAAO,GAAG,CAAC,GAAG,KAAK,CAAC,CAAC;QAC3B,KAAK,MAAM,GAAG,IAAI,OAAO,EAAE,CAAC;YAC1B,MAAM,KAAK,GAAG,SAAS,CAAC,GAAG,CAAC,CAAC;YAC7B,IAAI,CAAC,KAAK;gBAAE,SAAS,CAAG,qDAAqD;YAC7E,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,MAAM,IAAI,EAAE,CAAC;YACzC,SAAS,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,EAAE,GAAG,KAAK,CAAC,IAAI,EAAE,MAAM,EAAE,CAAC,GAAG,OAAO,EAAE,OAAO,CAAC,EAAE,EAAE,CAAC,CAAC;QAC1F,CAAC;IACH,CAAC;IACD,OAAO,SAAoB,CAAC;AAC9B,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/validation/index.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,eAAe,EAAE,gBAAgB,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAC9E,YAAY,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/validation/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,KAAK,EAAE,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Field, Fields } from '../field/index.js';
|
|
2
|
+
import type { Checked, ValidationResult } from './result.js';
|
|
3
|
+
import type { ValidateOptions } from './options.js';
|
|
4
|
+
/**
|
|
5
|
+
* The one judge, at the three levels anything is judged: a field's DECLARATION, one VALUE
|
|
6
|
+
* against its shape, a ROW against a field map. Same answer shape at all three.
|
|
7
|
+
*
|
|
8
|
+
* A class and not three functions because it holds state: the per-shape plan cache. The
|
|
9
|
+
* closed vocabularies belong to the axes that close them — this file imports them.
|
|
10
|
+
*/
|
|
11
|
+
export declare class Judge {
|
|
12
|
+
/**
|
|
13
|
+
* One plan per shape — the shape object IS the source JSON Schema (the closed
|
|
14
|
+
* grammar the helpers emit), handed straight to the engine. Keyed by the shape
|
|
15
|
+
* reference (stable: derivations copy field refs, `partial()` spreads the field but
|
|
16
|
+
* keeps the same `shape`), built once and reused so the parse cost is amortised —
|
|
17
|
+
* and so an unresolvable format is refused once per shape, never per value.
|
|
18
|
+
*/
|
|
19
|
+
private static readonly plans;
|
|
20
|
+
private static planFor;
|
|
21
|
+
/**
|
|
22
|
+
* Resolve a declared format, and REFUSE a name neither the engine nor the registry
|
|
23
|
+
* knows: the engine ignores an unknown format in silence, so a typo would let every
|
|
24
|
+
* value through while the card claims the field is constrained.
|
|
25
|
+
*
|
|
26
|
+
* SCOPE: the field's own shape — a format nested inside `json(Entity)` is not reached.
|
|
27
|
+
*/
|
|
28
|
+
private static customFormatOf;
|
|
29
|
+
/**
|
|
30
|
+
* Validate a present value (null included) against its field — pure, never mutates.
|
|
31
|
+
*
|
|
32
|
+
* Reads `shape` and nothing else, so it answers "is this a legal value?" without ever
|
|
33
|
+
* asking who is speaking. That is what makes it usable on the way OUT of the domain,
|
|
34
|
+
* where the client-only axes (`boundary`, `lifecycle`) do not apply.
|
|
35
|
+
*/
|
|
36
|
+
static value(field: Field, value: unknown): Checked;
|
|
37
|
+
/** `'now' | 'optional' | { value } | { generate: <name> }` — the four ways absence is answered. */
|
|
38
|
+
private static checkCreate;
|
|
39
|
+
private static checkLifecycle;
|
|
40
|
+
private static checkRelation;
|
|
41
|
+
private static checkRole;
|
|
42
|
+
/**
|
|
43
|
+
* The registry is OPEN, so a name cannot be checked here — `declaredBoundary` resolves it
|
|
44
|
+
* and throws `Unknown boundary alias` at the one place that can know. Only the FORM is
|
|
45
|
+
* judged: a name, or a pair of directional rules.
|
|
46
|
+
*/
|
|
47
|
+
private static checkBoundary;
|
|
48
|
+
/** Judge a field's declaration — the five axes against their closed vocabularies. */
|
|
49
|
+
static field(value: unknown): ValidationResult<Field>;
|
|
50
|
+
/** Judge an input object against a field map — membership, absence, and who may speak. */
|
|
51
|
+
static row(fields: Fields, input: unknown, opts?: ValidateOptions): ValidationResult<Record<string, unknown>>;
|
|
52
|
+
/** The same judgment, as a boolean. */
|
|
53
|
+
static isField(value: unknown): value is Field;
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=judge.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"judge.d.ts","sourceRoot":"","sources":["../../src/validation/judge.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,EAA0B,MAAM,mBAAmB,CAAC;AAK/E,OAAO,KAAK,EAAE,OAAO,EAAmB,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC9E,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AAgBpD;;;;;;GAMG;AACH,qBAAa,KAAK;IAChB;;;;;;OAMG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAoC;IAEjE,OAAO,CAAC,MAAM,CAAC,OAAO;IAetB;;;;;;OAMG;IACH,OAAO,CAAC,MAAM,CAAC,cAAc;IAW7B;;;;;;OAMG;IAEH,MAAM,CAAC,KAAK,CAAC,KAAK,EAAE,KAAK,EAAE,KAAK,EAAE,OAAO,GAAG,OAAO,CAiClD;IAED,mGAAmG;IACnG,OAAO,CAAC,MAAM,CAAC,WAAW;IAoB1B,OAAO,CAAC,MAAM,CAAC,cAAc;IAoB7B,OAAO,CAAC,MAAM,CAAC,aAAa;IAgC5B,OAAO,CAAC,MAAM,CAAC,SAAS;IAiCxB;;;;OAIG;IACH,OAAO,CAAC,MAAM,CAAC,aAAa;IAwB5B,qFAAqF;IAErF,MAAM,CAAC,KAAK,CAAC,KAAK,EAAE,OAAO,GAAG,gBAAgB,CAAC,KAAK,CAAC,CAsCpD;IAED,0FAA0F;IAC1F,MAAM,CAAC,GAAG,CACR,MAAM,EAAE,MAAM,EACd,KAAK,EAAE,OAAO,EACd,IAAI,GAAE,eAAoB,GACvB,gBAAgB,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CA8E7C;IAED,uCAAuC;IACvC,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,KAAK,CAE7C;CACF"}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { CREATE_TOKENS, UPDATE_TOKENS } from '../field/lifecycle.js';
|
|
2
|
+
import { ON_DELETE, RELATION_KINDS } from '../field/role.js';
|
|
3
|
+
import { Anatomy, Formats, boundaryOf, isShape, resolveBoundary, } from '../field/index.js';
|
|
4
|
+
import { Validator, format as engineFormats } from '@cfworker/json-schema';
|
|
5
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
6
|
+
const oneOfTokens = (v, tokens) => typeof v === 'string' && tokens.includes(v);
|
|
7
|
+
/**
|
|
8
|
+
* The one judge, at the three levels anything is judged: a field's DECLARATION, one VALUE
|
|
9
|
+
* against its shape, a ROW against a field map. Same answer shape at all three.
|
|
10
|
+
*
|
|
11
|
+
* A class and not three functions because it holds state: the per-shape plan cache. The
|
|
12
|
+
* closed vocabularies belong to the axes that close them — this file imports them.
|
|
13
|
+
*/
|
|
14
|
+
export class Judge {
|
|
15
|
+
/**
|
|
16
|
+
* One plan per shape — the shape object IS the source JSON Schema (the closed
|
|
17
|
+
* grammar the helpers emit), handed straight to the engine. Keyed by the shape
|
|
18
|
+
* reference (stable: derivations copy field refs, `partial()` spreads the field but
|
|
19
|
+
* keeps the same `shape`), built once and reused so the parse cost is amortised —
|
|
20
|
+
* and so an unresolvable format is refused once per shape, never per value.
|
|
21
|
+
*/
|
|
22
|
+
static plans = new WeakMap();
|
|
23
|
+
static planFor(shape) {
|
|
24
|
+
let p = this.plans.get(shape);
|
|
25
|
+
if (!p) {
|
|
26
|
+
const base = Anatomy.of(shape).base;
|
|
27
|
+
const formatName = base?.type === 'string' ? base.format : undefined;
|
|
28
|
+
p = {
|
|
29
|
+
validator: new Validator(shape, '2020-12', true),
|
|
30
|
+
custom: formatName === undefined ? undefined : this.customFormatOf(formatName),
|
|
31
|
+
formatName,
|
|
32
|
+
};
|
|
33
|
+
this.plans.set(shape, p);
|
|
34
|
+
}
|
|
35
|
+
return p;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a declared format, and REFUSE a name neither the engine nor the registry
|
|
39
|
+
* knows: the engine ignores an unknown format in silence, so a typo would let every
|
|
40
|
+
* value through while the card claims the field is constrained.
|
|
41
|
+
*
|
|
42
|
+
* SCOPE: the field's own shape — a format nested inside `json(Entity)` is not reached.
|
|
43
|
+
*/
|
|
44
|
+
static customFormatOf(name) {
|
|
45
|
+
const custom = Formats.resolve(name);
|
|
46
|
+
if (!custom && !(name in engineFormats)) {
|
|
47
|
+
throw new Error(`Unknown format: '${name}'. Register it with Formats.register('${name}', …) — ` +
|
|
48
|
+
`the engine judges ${Object.keys(engineFormats).length} formats natively and this is not one of them.`);
|
|
49
|
+
}
|
|
50
|
+
return custom;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Validate a present value (null included) against its field — pure, never mutates.
|
|
54
|
+
*
|
|
55
|
+
* Reads `shape` and nothing else, so it answers "is this a legal value?" without ever
|
|
56
|
+
* asking who is speaking. That is what makes it usable on the way OUT of the domain,
|
|
57
|
+
* where the client-only axes (`boundary`, `lifecycle`) do not apply.
|
|
58
|
+
*/
|
|
59
|
+
static value(field, value) {
|
|
60
|
+
const shape = field.shape;
|
|
61
|
+
// The pre-engine guards dispatch on the BASE type — `shape.type` itself may be
|
|
62
|
+
// the nullable union. They only short-circuit NON-null values: null always goes
|
|
63
|
+
// to the engine, whose union judges it (that is the whole nullability model).
|
|
64
|
+
const base = Anatomy.of(shape).base;
|
|
65
|
+
if (value !== null) {
|
|
66
|
+
// Opaque JSON (`json()`): no nested shape → passes through unchecked.
|
|
67
|
+
// `json(Entity)` carries `properties` and falls through to the engine,
|
|
68
|
+
// which validates the nesting natively (it's plain JSON Schema).
|
|
69
|
+
if (base?.type === 'object' && !base.properties)
|
|
70
|
+
return { value };
|
|
71
|
+
// A date-time field also accepts a live Date — the domain value, not its JSON wire
|
|
72
|
+
// string — which the boundary then passes through. The engine validates JSON, so a
|
|
73
|
+
// Date (a non-JSON value) skips the schema check here; a date STRING is validated below.
|
|
74
|
+
if (base?.type === 'string' && base.format === 'date-time' && value instanceof Date) {
|
|
75
|
+
return Number.isNaN(value.getTime()) ? { error: 'Invalid date' } : { value };
|
|
76
|
+
}
|
|
77
|
+
// NaN is `typeof number` yet has no JSON form, so the JSON-centric engine accepts it —
|
|
78
|
+
// guard it here (same nature as the Date case: a JS value JSON can't represent).
|
|
79
|
+
if ((base?.type === 'number' || base?.type === 'integer') && typeof value === 'number' && Number.isNaN(value)) {
|
|
80
|
+
return { error: 'Expected a number' };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const plan = this.planFor(shape);
|
|
84
|
+
const result = plan.validator.validate(value);
|
|
85
|
+
if (!result.valid)
|
|
86
|
+
return { error: result.errors[0]?.error ?? 'Invalid value' };
|
|
87
|
+
// A registered format judges AFTER the engine passed — so a custom rule composes
|
|
88
|
+
// with the built-ins instead of replacing them, and the message is the engine's
|
|
89
|
+
// own wording: from the outside, `siret` fails exactly like `email` does.
|
|
90
|
+
if (plan.custom && typeof value === 'string' && !plan.custom(value)) {
|
|
91
|
+
return { error: `String does not match format "${plan.formatName}".` };
|
|
92
|
+
}
|
|
93
|
+
return { value };
|
|
94
|
+
}
|
|
95
|
+
/** `'now' | 'optional' | { value } | { generate: <name> }` — the four ways absence is answered. */
|
|
96
|
+
static checkCreate(rule, errors) {
|
|
97
|
+
if (oneOfTokens(rule, CREATE_TOKENS))
|
|
98
|
+
return;
|
|
99
|
+
if (isObject(rule)) {
|
|
100
|
+
if ("value" in rule)
|
|
101
|
+
return; // any value — the shape judges it (`assertDefaultsAreValid`)
|
|
102
|
+
if ("generate" in rule) {
|
|
103
|
+
if (typeof rule.generate !== "string") {
|
|
104
|
+
errors.push({
|
|
105
|
+
path: "lifecycle.create.generate",
|
|
106
|
+
message: "Expected a generator name",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
errors.push({
|
|
113
|
+
path: "lifecycle.create",
|
|
114
|
+
message: `Expected 'now', 'optional', { value } or { generate } — got ${JSON.stringify(rule)}`,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
static checkLifecycle(lifecycle, errors) {
|
|
118
|
+
if (!isObject(lifecycle)) {
|
|
119
|
+
errors.push({
|
|
120
|
+
path: "lifecycle",
|
|
121
|
+
message: `Expected an object — got ${JSON.stringify(lifecycle)}`,
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (lifecycle.create !== undefined)
|
|
126
|
+
this.checkCreate(lifecycle.create, errors);
|
|
127
|
+
if (lifecycle.update !== undefined &&
|
|
128
|
+
!oneOfTokens(lifecycle.update, UPDATE_TOKENS)) {
|
|
129
|
+
errors.push({
|
|
130
|
+
path: "lifecycle.update",
|
|
131
|
+
message: `Expected 'now' or 'forbidden' — got ${JSON.stringify(lifecycle.update)}`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
static checkRelation(relation, errors) {
|
|
136
|
+
if (!isObject(relation)) {
|
|
137
|
+
errors.push({
|
|
138
|
+
path: "role.relation",
|
|
139
|
+
message: `Expected an object — got ${JSON.stringify(relation)}`,
|
|
140
|
+
});
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (!oneOfTokens(relation.kind, RELATION_KINDS)) {
|
|
144
|
+
errors.push({
|
|
145
|
+
path: "role.relation.kind",
|
|
146
|
+
message: `Expected 'one' or 'many' — got ${JSON.stringify(relation.kind)}`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// A thunk, never the class: it is what lets a circular relation resolve lazily.
|
|
150
|
+
if (typeof relation.to !== "function") {
|
|
151
|
+
errors.push({
|
|
152
|
+
path: "role.relation.to",
|
|
153
|
+
message: "Expected a thunk returning the target entity",
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
if (relation.onDelete !== undefined &&
|
|
157
|
+
!oneOfTokens(relation.onDelete, ON_DELETE)) {
|
|
158
|
+
errors.push({
|
|
159
|
+
path: "role.relation.onDelete",
|
|
160
|
+
message: `Expected 'cascade', 'restrict' or 'set null' — got ${JSON.stringify(relation.onDelete)}`,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
static checkRole(role, errors) {
|
|
165
|
+
if (!isObject(role)) {
|
|
166
|
+
errors.push({
|
|
167
|
+
path: "role",
|
|
168
|
+
message: `Expected an object — got ${JSON.stringify(role)}`,
|
|
169
|
+
});
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
for (const flag of ["primary", "index"]) {
|
|
173
|
+
if (role[flag] !== undefined && typeof role[flag] !== "boolean") {
|
|
174
|
+
errors.push({
|
|
175
|
+
path: `role.${flag}`,
|
|
176
|
+
message: `Expected a boolean — got ${JSON.stringify(role[flag])}`,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (role.unique !== undefined) {
|
|
181
|
+
const groups = role.unique;
|
|
182
|
+
const legal = Array.isArray(groups) &&
|
|
183
|
+
groups.every((g) => Array.isArray(g) && g.every((m) => typeof m === "string"));
|
|
184
|
+
if (!legal) {
|
|
185
|
+
errors.push({
|
|
186
|
+
path: "role.unique",
|
|
187
|
+
message: "Expected groups of field names — string[][]",
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (role.relation !== undefined)
|
|
192
|
+
this.checkRelation(role.relation, errors);
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The registry is OPEN, so a name cannot be checked here — `declaredBoundary` resolves it
|
|
196
|
+
* and throws `Unknown boundary alias` at the one place that can know. Only the FORM is
|
|
197
|
+
* judged: a name, or a pair of directional rules.
|
|
198
|
+
*/
|
|
199
|
+
static checkBoundary(boundary, errors) {
|
|
200
|
+
if (typeof boundary === "string")
|
|
201
|
+
return;
|
|
202
|
+
if (!isObject(boundary)) {
|
|
203
|
+
errors.push({
|
|
204
|
+
path: "boundary",
|
|
205
|
+
message: `Expected an alias name or { in, out } — got ${JSON.stringify(boundary)}`,
|
|
206
|
+
});
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
for (const [side, verb] of [
|
|
210
|
+
["in", "decode"],
|
|
211
|
+
["out", "encode"],
|
|
212
|
+
]) {
|
|
213
|
+
const rule = boundary[side];
|
|
214
|
+
if (rule === undefined || rule === "closed")
|
|
215
|
+
continue;
|
|
216
|
+
if (!isObject(rule) || typeof rule[verb] !== "string") {
|
|
217
|
+
errors.push({
|
|
218
|
+
path: `boundary.${side}`,
|
|
219
|
+
message: `Expected 'closed' or { ${verb}: <name> }`,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Judge a field's declaration — the five axes against their closed vocabularies. */
|
|
225
|
+
static field(value) {
|
|
226
|
+
if (!isObject(value)) {
|
|
227
|
+
return {
|
|
228
|
+
success: false,
|
|
229
|
+
errors: [
|
|
230
|
+
{
|
|
231
|
+
path: ".",
|
|
232
|
+
message: `Expected an object — got ${JSON.stringify(value)}`,
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
const errors = [];
|
|
238
|
+
if (!isShape(value.shape)) {
|
|
239
|
+
errors.push({
|
|
240
|
+
path: "shape",
|
|
241
|
+
message: `Every field states a shape — got ${JSON.stringify(value.shape)}`,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
if (value.lifecycle !== undefined)
|
|
245
|
+
this.checkLifecycle(value.lifecycle, errors);
|
|
246
|
+
if (value.role !== undefined)
|
|
247
|
+
this.checkRole(value.role, errors);
|
|
248
|
+
if (value.boundary !== undefined)
|
|
249
|
+
this.checkBoundary(value.boundary, errors);
|
|
250
|
+
if (value.meta !== undefined) {
|
|
251
|
+
if (!isObject(value.meta)) {
|
|
252
|
+
errors.push({
|
|
253
|
+
path: "meta",
|
|
254
|
+
message: `Expected an object — got ${JSON.stringify(value.meta)}`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
else if (value.meta.description !== undefined &&
|
|
258
|
+
typeof value.meta.description !== "string") {
|
|
259
|
+
errors.push({ path: "meta.description", message: "Expected a string" });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return errors.length ? { success: false, errors } : { success: true, data: value };
|
|
263
|
+
}
|
|
264
|
+
/** Judge an input object against a field map — membership, absence, and who may speak. */
|
|
265
|
+
static row(fields, input, opts = {}) {
|
|
266
|
+
if (typeof input !== 'object' || input === null) {
|
|
267
|
+
return { success: false, errors: [{ path: '.', message: 'Expected an object' }] };
|
|
268
|
+
}
|
|
269
|
+
const data = input;
|
|
270
|
+
const errors = [];
|
|
271
|
+
const out = {};
|
|
272
|
+
// Refuse, never strip: a key outside the contract is an error, not a silent
|
|
273
|
+
// drop. The client derives from the same contract (a form cannot emit a
|
|
274
|
+
// foreign key), so a stranger is a bug or an attack — both deserve the 400.
|
|
275
|
+
// Stripping would also let the envelope diverge from GraphQL, which refuses
|
|
276
|
+
// unknown input keys by construction.
|
|
277
|
+
//
|
|
278
|
+
// A FACT is judged by this same rule, deliberately — the one place it was tempting to
|
|
279
|
+
// relax it. A subscriber's copy of a fact can be older than the sender's, so tolerating
|
|
280
|
+
// a stranger key would let a rolling deployment through; it would also mean a reader
|
|
281
|
+
// silently ignoring a field it was supposed to handle. If the judge refuses, that is
|
|
282
|
+
// the end of it: the sender re-syncs its readers before it ships.
|
|
283
|
+
for (const key of Object.keys(data)) {
|
|
284
|
+
if (!(key in fields)) {
|
|
285
|
+
errors.push({ path: key, message: 'Unknown field' });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
for (const [key, field] of Object.entries(fields)) {
|
|
289
|
+
const path = key;
|
|
290
|
+
const value = data[key];
|
|
291
|
+
if (value === undefined) {
|
|
292
|
+
if (opts.patch)
|
|
293
|
+
continue; // patch: an unsent field is left untouched
|
|
294
|
+
// A read-only field is server-owned: its absence from a client input is
|
|
295
|
+
// never "Required" (same stance as OpenAPI readOnly+required).
|
|
296
|
+
if (boundaryOf(field).in === 'closed')
|
|
297
|
+
continue;
|
|
298
|
+
// Absence is answered by `lifecycle.create` — a key access on the normal
|
|
299
|
+
// form. The judge only asks "is there a rule?": any rule ('now',
|
|
300
|
+
// 'optional', { value }, { generate }) makes absence legal, and the field
|
|
301
|
+
// is omitted from the result. Realisation is the storage adapter's role.
|
|
302
|
+
if (field.lifecycle?.create !== undefined)
|
|
303
|
+
continue;
|
|
304
|
+
// No create rule: a `many` relation defaults to the empty collection
|
|
305
|
+
// (graph semantics, read on `role` — never a shared `{ value: [] }`).
|
|
306
|
+
if (field.role?.relation?.kind === 'many') {
|
|
307
|
+
out[key] = [];
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
errors.push({ path, message: 'Required' });
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
// A PRESENT value can be illegal by an axis other than shape:
|
|
314
|
+
// boundary `in: 'closed'` — a read-only field never crosses inbound;
|
|
315
|
+
// lifecycle `update: 'forbidden'` — re-supplying an immutable field in a patch.
|
|
316
|
+
if (boundaryOf(field).in === 'closed') {
|
|
317
|
+
errors.push({ path, message: 'Read-only' });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
if (opts.patch && field.lifecycle?.update === 'forbidden') {
|
|
321
|
+
errors.push({ path, message: 'Immutable' });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
// The shape predicate judges the value — null included, via the `[T,'null']`
|
|
325
|
+
// union. Then the boundary's decode converts wire→domain; null skips decode
|
|
326
|
+
// (a legal null is already the domain value, there is nothing to convert).
|
|
327
|
+
const checked = this.value(field, value);
|
|
328
|
+
if ('error' in checked) {
|
|
329
|
+
errors.push({ path, message: checked.error });
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (checked.value === null) {
|
|
333
|
+
out[key] = null;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const decoded = resolveBoundary(field).decode(checked.value);
|
|
337
|
+
if ('error' in decoded)
|
|
338
|
+
errors.push({ path, message: decoded.error });
|
|
339
|
+
else
|
|
340
|
+
out[key] = decoded.value;
|
|
341
|
+
}
|
|
342
|
+
if (errors.length > 0)
|
|
343
|
+
return { success: false, errors };
|
|
344
|
+
return { success: true, data: out };
|
|
345
|
+
}
|
|
346
|
+
/** The same judgment, as a boolean. */
|
|
347
|
+
static isField(value) {
|
|
348
|
+
return this.field(value).success;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
//# sourceMappingURL=judge.js.map
|