@jarenjs/linq 0.49.2 → 0.66.1
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/ARCHITECTURE.md +227 -0
- package/README.md +650 -17
- package/docs/APP-PEN.md +1143 -0
- package/docs/CONTRACT-PEN.md +1221 -0
- package/docs/DB-CLIENT.md +882 -0
- package/docs/FLOW-PEN.md +1033 -0
- package/docs/FORMS-PEN.md +940 -0
- package/docs/JSLT-PEN.md +955 -0
- package/docs/LINQ-FORMAT.md +778 -383
- package/docs/MIGRATION-PEN.md +781 -0
- package/docs/MODEL-PEN.md +1092 -0
- package/docs/QUERY-PEN.md +1724 -0
- package/docs/SCHEMA-PEN.md +1218 -0
- package/package.json +57 -4
- package/src/app/action.js +251 -0
- package/src/app/capture.js +63 -0
- package/src/app/define.js +255 -0
- package/src/app/index.js +20 -0
- package/src/app/patch.js +277 -0
- package/src/app/sub.js +106 -0
- package/src/async.js +377 -75
- package/src/capture-root.js +82 -0
- package/src/concurrency.js +48 -11
- package/src/contract/define.js +282 -0
- package/src/contract/http.js +247 -0
- package/src/contract/index.js +23 -0
- package/src/contract/operation.js +338 -0
- package/src/db/handle.js +89 -0
- package/src/db/include.js +351 -0
- package/src/db/index.js +24 -0
- package/src/db/ledger.js +195 -0
- package/src/db/live.js +43 -0
- package/src/db/membership.js +37 -0
- package/src/db/open.js +130 -0
- package/src/document.js +143 -13
- package/src/effect.js +65 -0
- package/src/errors.js +78 -6
- package/src/expression.js +463 -36
- package/src/federate.js +531 -0
- package/src/flow/capture.js +33 -0
- package/src/flow/dag.js +316 -0
- package/src/flow/fsm.js +323 -0
- package/src/flow/index.js +22 -0
- package/src/forms/index.js +43 -0
- package/src/forms/rules.js +170 -0
- package/src/forms/submit.js +177 -0
- package/src/index.js +5 -2
- package/src/jslt/body.js +226 -0
- package/src/jslt/index.js +18 -0
- package/src/jslt/rules.js +202 -0
- package/src/json-boundary.js +90 -0
- package/src/migration/define.js +318 -0
- package/src/migration/index.js +15 -0
- package/src/migration/steps.js +244 -0
- package/src/model/collection.js +273 -0
- package/src/model/define.js +125 -0
- package/src/model/entity.js +307 -0
- package/src/model/index.js +47 -0
- package/src/model/relation.js +85 -0
- package/src/provider.js +137 -20
- package/src/schema/brand.js +31 -0
- package/src/schema/builders.js +526 -0
- package/src/schema/check.js +29 -0
- package/src/schema/emit.js +394 -0
- package/src/schema/factories.js +239 -0
- package/src/schema/index.js +37 -0
- package/src/schema-of.js +24 -0
- package/src/sequence.js +233 -103
- package/src/sources.js +10 -3
- package/types/app.d.ts +293 -0
- package/types/contract.d.ts +468 -0
- package/types/db.d.ts +359 -0
- package/types/flow.d.ts +285 -0
- package/types/forms.d.ts +253 -0
- package/types/index.d.ts +296 -26
- package/types/jslt.d.ts +193 -0
- package/types/migration.d.ts +201 -0
- package/types/model.d.ts +526 -0
- package/types/schema.d.ts +494 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Document assembly: a builder tree in, one standard JSON Schema
|
|
4
|
+
* document out — deep-frozen, `$defs` hoisted, every `$ref` resolved
|
|
5
|
+
* against the definitions reachable from the root. This module reads
|
|
6
|
+
* builder STATE and writes JSON; it imports no engine and constructs no
|
|
7
|
+
* builder, so the pen never learns what a keyword means — the
|
|
8
|
+
* validator's compiler stays the only judge of semantics. It refuses
|
|
9
|
+
* exactly two things a document cannot carry faithfully: a definition
|
|
10
|
+
* spelled twice or referenced but never defined (`JL0103`), and a
|
|
11
|
+
* construct whose emitted form would mean something else (`JL0102`) —
|
|
12
|
+
* closed objects under `allOf`, and a default or coercion in a branch
|
|
13
|
+
* the normalizer never descends.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { cloneJson, deepFreeze, setObjectMember } from '@jarenjs/core/object';
|
|
17
|
+
|
|
18
|
+
import { LinqBuildError } from '../errors.js';
|
|
19
|
+
import { isSchemaBuilder } from './brand.js';
|
|
20
|
+
|
|
21
|
+
/** The `type` keyword a kind carries, where it carries one. */
|
|
22
|
+
const TYPED = Object.freeze({
|
|
23
|
+
__proto__: null,
|
|
24
|
+
string: 'string', number: 'number', integer: 'integer', boolean: 'boolean',
|
|
25
|
+
null: 'null', object: 'object', record: 'object', array: 'array', tuple: 'array',
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
/** The three keywords `compileNormalizer` acts on. */
|
|
29
|
+
const NORMALIZER_KEYS = ['default', 'x-coerce', 'x-trim'];
|
|
30
|
+
|
|
31
|
+
/** A definition whose body is still being emitted (a cycle in progress). */
|
|
32
|
+
const PENDING = Symbol('pending');
|
|
33
|
+
|
|
34
|
+
/** @param {string} key a JSON pointer token, escaped per RFC 6901 */
|
|
35
|
+
const token = (key) => key.replaceAll('~', '~0').replaceAll('/', '~1');
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* A hoisting context one or more roots share: every `named()` builder
|
|
39
|
+
* they reach becomes one entry of a single `$defs` block, in
|
|
40
|
+
* first-reference order. A schema document has one root; a contract
|
|
41
|
+
* document's roots are its operations' `input`, `output` and error
|
|
42
|
+
* schemas, so the same walk hoists a whole contract's definitions to
|
|
43
|
+
* its own root.
|
|
44
|
+
* @returns {any}
|
|
45
|
+
*/
|
|
46
|
+
export function createHoist() {
|
|
47
|
+
return {
|
|
48
|
+
/** @type {Map<string, any>} definition name → body, in discovery order */
|
|
49
|
+
defs: new Map(),
|
|
50
|
+
/** @type {Map<string, any>} definition name → the builder that owns it */
|
|
51
|
+
owners: new Map(),
|
|
52
|
+
/** @type {Map<string, string>} names demanded by `ref()` → where */
|
|
53
|
+
demanded: new Map(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Emit one builder into a shared hoisting context (`emitInto(builder,
|
|
59
|
+
* ctx, at)`): the same walk `assemble` runs, with the definitions
|
|
60
|
+
* landing in the caller's context instead of a private one.
|
|
61
|
+
*/
|
|
62
|
+
export { emitNode as emitInto };
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The `$defs` block a context collected, or `null` when it collected
|
|
66
|
+
* none; every name a `ref()` demanded must be answered by then.
|
|
67
|
+
* @param {any} ctx
|
|
68
|
+
* @returns {any}
|
|
69
|
+
*/
|
|
70
|
+
export function hoistedDefs(ctx) {
|
|
71
|
+
for (const [name, at] of ctx.demanded) {
|
|
72
|
+
if (!ctx.defs.has(name)) {
|
|
73
|
+
throw new LinqBuildError('JL0103',
|
|
74
|
+
`ref('${name}') names no definition in this document — a name is defined by `
|
|
75
|
+
+ `named('${name}', …) somewhere the root can reach`, at);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
if (ctx.defs.size === 0) return null;
|
|
79
|
+
const defs = {};
|
|
80
|
+
for (const [name, body] of ctx.defs) setObjectMember(defs, name, body);
|
|
81
|
+
return defs;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Assemble the document of one builder.
|
|
86
|
+
* @param {any} root - the builder whose document this is
|
|
87
|
+
* @returns {any} the deep-frozen JSON Schema document
|
|
88
|
+
*/
|
|
89
|
+
export function assemble(root) {
|
|
90
|
+
const ctx = createHoist();
|
|
91
|
+
const doc = emitNode(root, ctx, '');
|
|
92
|
+
const defs = hoistedDefs(ctx);
|
|
93
|
+
if (defs === null) return deepFreeze(doc);
|
|
94
|
+
const out = {};
|
|
95
|
+
setObjectMember(out, '$defs', defs);
|
|
96
|
+
if (typeof doc === 'boolean') {
|
|
97
|
+
// a boolean root with definitions: nothing references them, but a
|
|
98
|
+
// false root cannot carry them either
|
|
99
|
+
throw new LinqBuildError('JL0102',
|
|
100
|
+
'a boolean schema cannot carry $defs; name the root instead');
|
|
101
|
+
}
|
|
102
|
+
for (const key of Object.keys(doc)) setObjectMember(out, key, doc[key]);
|
|
103
|
+
return deepFreeze(out);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether a builder, or anything it reaches, carries a keyword the
|
|
108
|
+
* normalizer acts on. Walked over the builder graph rather than over
|
|
109
|
+
* emitted documents so a cycle in progress is no obstacle.
|
|
110
|
+
* @param {any} builder
|
|
111
|
+
* @param {Set<any>} seen
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
export function reachesNormalizer(builder, seen = new Set()) {
|
|
115
|
+
if (!isSchemaBuilder(builder) || seen.has(builder)) return false;
|
|
116
|
+
seen.add(builder);
|
|
117
|
+
const st = builder.state;
|
|
118
|
+
for (const [key] of st.annotations) {
|
|
119
|
+
if (NORMALIZER_KEYS.includes(key)) return true;
|
|
120
|
+
}
|
|
121
|
+
return children(st).some((child) => reachesNormalizer(child, seen));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Every builder one state holds directly (lazy thunks resolved). */
|
|
125
|
+
function children(st) {
|
|
126
|
+
switch (st.kind) {
|
|
127
|
+
case 'object':
|
|
128
|
+
return [...st.props.map(([, b]) => b), ...st.patterns.map(([, b]) => b),
|
|
129
|
+
...(st.names === null ? [] : [st.names])];
|
|
130
|
+
case 'array':
|
|
131
|
+
return st.contains === null ? [st.items] : [st.items, st.contains];
|
|
132
|
+
case 'tuple':
|
|
133
|
+
return st.rest === null ? st.items : [...st.items, st.rest];
|
|
134
|
+
case 'record':
|
|
135
|
+
return [st.values];
|
|
136
|
+
case 'union': case 'discriminated': case 'intersection':
|
|
137
|
+
return st.options;
|
|
138
|
+
case 'named':
|
|
139
|
+
return [st.target];
|
|
140
|
+
case 'lazy':
|
|
141
|
+
return [resolveLazy(st, '')];
|
|
142
|
+
case 'when':
|
|
143
|
+
return [st.cond, st.then, st.else].filter((b) => b !== null);
|
|
144
|
+
default:
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The builder a `lazy()` stands for — always a named one, because an
|
|
151
|
+
* anonymous recursion has no `$ref` to spell.
|
|
152
|
+
* @param {any} st
|
|
153
|
+
* @param {string} at
|
|
154
|
+
*/
|
|
155
|
+
function resolveLazy(st, at) {
|
|
156
|
+
const target = st.thunk();
|
|
157
|
+
if (!isSchemaBuilder(target)) {
|
|
158
|
+
throw new LinqBuildError('JL0103',
|
|
159
|
+
'lazy() must return a builder', at);
|
|
160
|
+
}
|
|
161
|
+
if (target.state.kind !== 'named') {
|
|
162
|
+
throw new LinqBuildError('JL0103',
|
|
163
|
+
'lazy() must return a NAMED builder — a recursion is spelled as a $ref, and a '
|
|
164
|
+
+ "$ref needs a definition to point at: lazy(() => Node) where Node = named('Node', …)",
|
|
165
|
+
at);
|
|
166
|
+
}
|
|
167
|
+
return target;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* The kind a builder resolves to through `named`/`lazy` wrappers, or
|
|
172
|
+
* `null` when it cannot be known here (a `ref` by name).
|
|
173
|
+
* @param {any} builder
|
|
174
|
+
* @param {string} at
|
|
175
|
+
* @returns {any} the resolved builder, or null
|
|
176
|
+
*/
|
|
177
|
+
function resolve(builder, at) {
|
|
178
|
+
let current = builder;
|
|
179
|
+
for (let hops = 0; hops < 64; hops++) {
|
|
180
|
+
const st = current.state;
|
|
181
|
+
if (st.kind === 'named') current = st.target;
|
|
182
|
+
else if (st.kind === 'lazy') current = resolveLazy(st, at);
|
|
183
|
+
else return current;
|
|
184
|
+
}
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Refuse a normalizer keyword under a branch the normalizer never
|
|
190
|
+
* descends (`anyOf`/`oneOf` options, `if`/`then`/`else`, `contains`,
|
|
191
|
+
* `propertyNames`): a default there is never materialized and a
|
|
192
|
+
* coercion never runs, so a document carrying one would promise a
|
|
193
|
+
* normalization that does not happen.
|
|
194
|
+
* @param {any} builder
|
|
195
|
+
* @param {string} where - the keyword, for the message
|
|
196
|
+
* @param {string} at
|
|
197
|
+
*/
|
|
198
|
+
function refuseNormalizerUnder(builder, where, at) {
|
|
199
|
+
if (!reachesNormalizer(builder)) return;
|
|
200
|
+
throw new LinqBuildError('JL0102',
|
|
201
|
+
`a default(), coerce() or trim() under ${where} never runs — the normalizer does not `
|
|
202
|
+
+ 'descend that branch, so the document would promise a normalization that does not '
|
|
203
|
+
+ 'happen; move it to the member that holds the branch, or drop it', at);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Emit one builder as a schema node, registering definitions as they
|
|
208
|
+
* are discovered.
|
|
209
|
+
* @param {any} builder
|
|
210
|
+
* @param {any} ctx
|
|
211
|
+
* @param {string} at - JSON pointer of this node in the document
|
|
212
|
+
* @returns {any} a plain schema node (object or boolean)
|
|
213
|
+
*/
|
|
214
|
+
function emitNode(builder, ctx, at) {
|
|
215
|
+
const st = builder.state;
|
|
216
|
+
let node = emitCore(builder, st, ctx, at);
|
|
217
|
+
if (st.nullable) node = nullableOf(node, st);
|
|
218
|
+
if (st.checks.length > 0) {
|
|
219
|
+
node.$query = st.checks.length === 1 ? st.checks[0] : { $and: st.checks };
|
|
220
|
+
}
|
|
221
|
+
for (const [key, value] of st.annotations) setObjectMember(node, key, value);
|
|
222
|
+
return node;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Fold `null` into a typed node; wrap an untyped one in `anyOf`. */
|
|
226
|
+
function nullableOf(node, st) {
|
|
227
|
+
if (TYPED[st.kind] !== undefined) {
|
|
228
|
+
node.type = [TYPED[st.kind], 'null'];
|
|
229
|
+
// a typed enum admits null only when the enum lists it
|
|
230
|
+
if (Array.isArray(node.enum) && !node.enum.includes(null)) node.enum = [...node.enum, null];
|
|
231
|
+
return node;
|
|
232
|
+
}
|
|
233
|
+
if (st.kind === 'enum') {
|
|
234
|
+
if (!st.values.includes(null)) node.enum = [...st.values, null];
|
|
235
|
+
return node;
|
|
236
|
+
}
|
|
237
|
+
if (st.kind === 'literal') return { enum: [st.value, null] };
|
|
238
|
+
return { anyOf: [node, { type: 'null' }] };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The constraint keywords a builder collected, in the order set. */
|
|
242
|
+
function withKeywords(node, st) {
|
|
243
|
+
for (const key of Object.keys(st.keywords)) node[key] = st.keywords[key];
|
|
244
|
+
return node;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The kind-specific core of a node: `type` and the structural
|
|
249
|
+
* keywords, with the collected constraints after them.
|
|
250
|
+
* @param {any} builder
|
|
251
|
+
* @param {any} st
|
|
252
|
+
* @param {any} ctx
|
|
253
|
+
* @param {string} at
|
|
254
|
+
* @returns {any}
|
|
255
|
+
*/
|
|
256
|
+
function emitCore(builder, st, ctx, at) {
|
|
257
|
+
switch (st.kind) {
|
|
258
|
+
case 'string': case 'number': case 'integer': case 'boolean': case 'null':
|
|
259
|
+
return withKeywords({ type: st.kind }, st);
|
|
260
|
+
case 'literal':
|
|
261
|
+
return { const: st.value };
|
|
262
|
+
case 'enum':
|
|
263
|
+
return { enum: st.values };
|
|
264
|
+
case 'any':
|
|
265
|
+
return {};
|
|
266
|
+
case 'never':
|
|
267
|
+
return false;
|
|
268
|
+
case 'raw':
|
|
269
|
+
return cloneJson(st.json);
|
|
270
|
+
case 'object': {
|
|
271
|
+
const node = { type: 'object' };
|
|
272
|
+
if (st.props.length > 0) {
|
|
273
|
+
const properties = {};
|
|
274
|
+
const required = [];
|
|
275
|
+
for (const [key, member] of st.props) {
|
|
276
|
+
setObjectMember(properties, key, emitNode(member, ctx, `${at}/properties/${token(key)}`));
|
|
277
|
+
if (!member.state.optional) required.push(key);
|
|
278
|
+
}
|
|
279
|
+
node.properties = properties;
|
|
280
|
+
if (required.length > 0) node.required = required;
|
|
281
|
+
}
|
|
282
|
+
if (!st.open) node.additionalProperties = false;
|
|
283
|
+
if (st.patterns.length > 0) {
|
|
284
|
+
const patternProperties = {};
|
|
285
|
+
for (const [pattern, member] of st.patterns) {
|
|
286
|
+
setObjectMember(patternProperties, pattern,
|
|
287
|
+
emitNode(member, ctx, `${at}/patternProperties/${token(pattern)}`));
|
|
288
|
+
}
|
|
289
|
+
node.patternProperties = patternProperties;
|
|
290
|
+
}
|
|
291
|
+
if (st.names !== null) {
|
|
292
|
+
refuseNormalizerUnder(st.names, 'propertyNames()', `${at}/propertyNames`);
|
|
293
|
+
node.propertyNames = emitNode(st.names, ctx, `${at}/propertyNames`);
|
|
294
|
+
}
|
|
295
|
+
if (st.dependent !== null) node.dependentRequired = cloneJson(st.dependent);
|
|
296
|
+
return withKeywords(node, st);
|
|
297
|
+
}
|
|
298
|
+
case 'record':
|
|
299
|
+
return withKeywords({
|
|
300
|
+
type: 'object',
|
|
301
|
+
additionalProperties: emitNode(st.values, ctx, `${at}/additionalProperties`),
|
|
302
|
+
}, st);
|
|
303
|
+
case 'array': {
|
|
304
|
+
const node = { type: 'array', items: emitNode(st.items, ctx, `${at}/items`) };
|
|
305
|
+
if (st.contains !== null) {
|
|
306
|
+
refuseNormalizerUnder(st.contains, 'contains()', `${at}/contains`);
|
|
307
|
+
node.contains = emitNode(st.contains, ctx, `${at}/contains`);
|
|
308
|
+
}
|
|
309
|
+
return withKeywords(node, st);
|
|
310
|
+
}
|
|
311
|
+
case 'tuple': {
|
|
312
|
+
const node = {
|
|
313
|
+
type: 'array',
|
|
314
|
+
prefixItems: st.items.map((item, i) => emitNode(item, ctx, `${at}/prefixItems/${i}`)),
|
|
315
|
+
};
|
|
316
|
+
if (st.rest !== null) node.items = emitNode(st.rest, ctx, `${at}/items`);
|
|
317
|
+
node.minItems = st.items.length;
|
|
318
|
+
return withKeywords(node, st);
|
|
319
|
+
}
|
|
320
|
+
case 'union': case 'discriminated': {
|
|
321
|
+
const keyword = st.kind === 'union' ? 'anyOf' : 'oneOf';
|
|
322
|
+
const options = st.options.map((option, i) => {
|
|
323
|
+
refuseNormalizerUnder(option, `${st.kind}()`, `${at}/${keyword}/${i}`);
|
|
324
|
+
return emitNode(option, ctx, `${at}/${keyword}/${i}`);
|
|
325
|
+
});
|
|
326
|
+
return { [keyword]: options };
|
|
327
|
+
}
|
|
328
|
+
case 'intersection': {
|
|
329
|
+
const parts = st.options.map((part, i) => {
|
|
330
|
+
const resolved = resolve(part, `${at}/allOf/${i}`);
|
|
331
|
+
if (resolved !== null && resolved.state.kind === 'object' && !resolved.state.open) {
|
|
332
|
+
throw new LinqBuildError('JL0102',
|
|
333
|
+
'closed objects do not intersect — under allOf each part rejects the other\'s '
|
|
334
|
+
+ 'members, so the document would accept neither; open() the parts, or merge '
|
|
335
|
+
+ 'them with extend()', `${at}/allOf/${i}`);
|
|
336
|
+
}
|
|
337
|
+
return emitNode(part, ctx, `${at}/allOf/${i}`);
|
|
338
|
+
});
|
|
339
|
+
return { allOf: parts };
|
|
340
|
+
}
|
|
341
|
+
case 'when': {
|
|
342
|
+
const node = {};
|
|
343
|
+
refuseNormalizerUnder(st.cond, 'when()', `${at}/if`);
|
|
344
|
+
node.if = emitNode(st.cond, ctx, `${at}/if`);
|
|
345
|
+
if (st.then !== null) {
|
|
346
|
+
refuseNormalizerUnder(st.then, 'then()', `${at}/then`);
|
|
347
|
+
node.then = emitNode(st.then, ctx, `${at}/then`);
|
|
348
|
+
}
|
|
349
|
+
if (st.else !== null) {
|
|
350
|
+
refuseNormalizerUnder(st.else, 'else()', `${at}/else`);
|
|
351
|
+
node.else = emitNode(st.else, ctx, `${at}/else`);
|
|
352
|
+
}
|
|
353
|
+
return node;
|
|
354
|
+
}
|
|
355
|
+
case 'named':
|
|
356
|
+
return define(st.name, st.target, ctx, at);
|
|
357
|
+
case 'lazy': {
|
|
358
|
+
const named = resolveLazy(st, at);
|
|
359
|
+
return define(named.state.name, named.state.target, ctx, at);
|
|
360
|
+
}
|
|
361
|
+
case 'ref':
|
|
362
|
+
if (!ctx.demanded.has(st.name)) ctx.demanded.set(st.name, at);
|
|
363
|
+
return { $ref: `#/$defs/${st.name}` };
|
|
364
|
+
/* c8 ignore next 2 -- states are produced by the builders alone */
|
|
365
|
+
default:
|
|
366
|
+
throw new LinqBuildError('JL0102', `unknown builder kind '${st.kind}'`, at);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Register a definition on first discovery (its body emitted in place,
|
|
372
|
+
* so a cycle back to it meets the pending entry and stops) and answer
|
|
373
|
+
* the reference to it. The same target under one name is one
|
|
374
|
+
* definition, however many times it is reached; a different target is
|
|
375
|
+
* a collision.
|
|
376
|
+
* @param {string} name
|
|
377
|
+
* @param {any} target
|
|
378
|
+
* @param {any} ctx
|
|
379
|
+
* @param {string} at
|
|
380
|
+
*/
|
|
381
|
+
function define(name, target, ctx, at) {
|
|
382
|
+
const owner = ctx.owners.get(name);
|
|
383
|
+
if (owner === undefined) {
|
|
384
|
+
ctx.owners.set(name, target);
|
|
385
|
+
ctx.defs.set(name, PENDING);
|
|
386
|
+
ctx.defs.set(name, emitNode(target, ctx, `/$defs/${token(name)}`));
|
|
387
|
+
}
|
|
388
|
+
else if (owner !== target) {
|
|
389
|
+
throw new LinqBuildError('JL0103',
|
|
390
|
+
`two distinct builders are named '${name}' in one document — a $defs entry `
|
|
391
|
+
+ 'can hold one definition; rename one of them', at);
|
|
392
|
+
}
|
|
393
|
+
return { $ref: `#/$defs/${name}` };
|
|
394
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The named factory functions of a pen — `string()`, `object()`,
|
|
4
|
+
* `union()`, `document()`, … — built ONCE per set of builder classes.
|
|
5
|
+
* `@jarenjs/linq/schema` calls this with the base classes; a pen that
|
|
6
|
+
* extends the schema pen (`./model`) calls it with its subclasses, so
|
|
7
|
+
* the wiring exists exactly once and no subpath patches another's
|
|
8
|
+
* prototype. Every factory answers an instance of the class it was
|
|
9
|
+
* handed, and `with()` keeps that class through every method.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { LinqBuildError } from '../errors.js';
|
|
13
|
+
import { isSchemaBuilder } from './brand.js';
|
|
14
|
+
import {
|
|
15
|
+
initial, requireJson, requireBuilder, requireString, requireName,
|
|
16
|
+
requireBuilderMap, describeValue,
|
|
17
|
+
} from './builders.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {object} BuilderClasses
|
|
21
|
+
* @property {any} Base - the class every untyped kind is built from
|
|
22
|
+
* @property {any} String
|
|
23
|
+
* @property {any} Number
|
|
24
|
+
* @property {any} Array
|
|
25
|
+
* @property {any} Tuple
|
|
26
|
+
* @property {any} Object
|
|
27
|
+
* @property {any} When
|
|
28
|
+
* @property {any} Never
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Build the factory functions for one set of builder classes.
|
|
33
|
+
* @param {BuilderClasses} classes
|
|
34
|
+
* @returns {Record<string, Function>} the named factories, frozen
|
|
35
|
+
*/
|
|
36
|
+
export function createFactories(classes) {
|
|
37
|
+
const {
|
|
38
|
+
Base: BaseBuilder, String: StringBuilder, Number: NumberBuilder,
|
|
39
|
+
Array: ArrayBuilder, Tuple: TupleBuilder, Object: ObjectBuilder,
|
|
40
|
+
When: WhenBuilder, Never: NeverBuilder,
|
|
41
|
+
} = classes;
|
|
42
|
+
|
|
43
|
+
/** A builder, or a hand-written JSON Schema wrapped as one. @param {any} value @param {string} what */
|
|
44
|
+
function builderOrJson(value, what) {
|
|
45
|
+
if (isSchemaBuilder(value)) return value;
|
|
46
|
+
if (typeof value === 'boolean' || (value !== null && typeof value === 'object' && !Array.isArray(value))) {
|
|
47
|
+
return from(value);
|
|
48
|
+
}
|
|
49
|
+
return requireBuilder(value, what);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
/** `{ type: 'string' }`. */
|
|
54
|
+
function string() { return new StringBuilder(initial('string', {})); }
|
|
55
|
+
/** `{ type: 'number' }`. */
|
|
56
|
+
function number() { return new NumberBuilder(initial('number', {})); }
|
|
57
|
+
/** `{ type: 'integer' }`. */
|
|
58
|
+
function integer() { return new NumberBuilder(initial('integer', {})); }
|
|
59
|
+
/** `{ type: 'boolean' }`. */
|
|
60
|
+
function boolean() { return new BaseBuilder(initial('boolean', {})); }
|
|
61
|
+
/** `{ type: 'null' }`. */
|
|
62
|
+
function nil() { return new BaseBuilder(initial('null', {})); }
|
|
63
|
+
/** `{}` — anything. */
|
|
64
|
+
function any() { return new BaseBuilder(initial('any', {})); }
|
|
65
|
+
/** `false` — nothing. */
|
|
66
|
+
function never() { return new NeverBuilder(initial('never', {})); }
|
|
67
|
+
|
|
68
|
+
/** `{ const: value }`. @param {any} value */
|
|
69
|
+
function literal(value) {
|
|
70
|
+
return new BaseBuilder(initial('literal', { value: requireJson(value, 'literal()') }));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** `{ enum: values }`. @param {readonly any[]} values */
|
|
74
|
+
function enumOf(values) {
|
|
75
|
+
if (!Array.isArray(values) || values.length === 0) {
|
|
76
|
+
throw new LinqBuildError('JL0101', 'enumOf() takes a non-empty array of JSON values');
|
|
77
|
+
}
|
|
78
|
+
return new BaseBuilder(initial('enum', {
|
|
79
|
+
values: Object.freeze(values.map((value) => requireJson(value, 'enumOf()'))),
|
|
80
|
+
}));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** A closed object (`additionalProperties: false`) of named members. @param {Record<string, any>} props */
|
|
84
|
+
function object(props) {
|
|
85
|
+
return new ObjectBuilder(initial('object', {
|
|
86
|
+
props: Object.freeze(requireBuilderMap(props, 'object()')),
|
|
87
|
+
open: false,
|
|
88
|
+
patterns: Object.freeze([]),
|
|
89
|
+
names: null,
|
|
90
|
+
dependent: null,
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `{ type: 'array', items }`. @param {any} items */
|
|
95
|
+
function array(items) {
|
|
96
|
+
return new ArrayBuilder(initial('array', {
|
|
97
|
+
items: requireBuilder(items, 'array()'), contains: null,
|
|
98
|
+
}));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `{ type: 'array', prefixItems, minItems }`. @param {readonly any[]} items */
|
|
102
|
+
function tuple(items) {
|
|
103
|
+
if (!Array.isArray(items)) {
|
|
104
|
+
throw new LinqBuildError('JL0101', 'tuple() takes an array of builders');
|
|
105
|
+
}
|
|
106
|
+
return new TupleBuilder(initial('tuple', {
|
|
107
|
+
items: Object.freeze(items.map((item, i) => requireBuilder(item, `tuple()[${i}]`))),
|
|
108
|
+
rest: null,
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `{ type: 'object', additionalProperties: values }`. @param {any} values */
|
|
113
|
+
function record(values) {
|
|
114
|
+
return new BaseBuilder(initial('record', { values: requireBuilder(values, 'record()') }));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {any} options @param {string} what */
|
|
118
|
+
function requireOptions(options, what) {
|
|
119
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
120
|
+
throw new LinqBuildError('JL0101', `${what} takes a non-empty array of builders or schemas`);
|
|
121
|
+
}
|
|
122
|
+
return Object.freeze(options.map((option, i) => builderOrJson(option, `${what}[${i}]`)));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `{ anyOf: options }`. @param {readonly any[]} options */
|
|
126
|
+
function union(options) {
|
|
127
|
+
return new BaseBuilder(initial('union', { options: requireOptions(options, 'union()') }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `{ oneOf: options }` where every option is an object declaring the
|
|
132
|
+
* discriminator as a `literal()` or `enumOf()` member.
|
|
133
|
+
* @param {string} key
|
|
134
|
+
* @param {readonly any[]} options
|
|
135
|
+
*/
|
|
136
|
+
function discriminated(key, options) {
|
|
137
|
+
requireString(key, 'discriminated()');
|
|
138
|
+
const parts = requireOptions(options, 'discriminated()');
|
|
139
|
+
parts.forEach((option, i) => {
|
|
140
|
+
const st = option.state;
|
|
141
|
+
const member = st.kind === 'object' ? st.props.find(([name]) => name === key) : undefined;
|
|
142
|
+
const tag = member === undefined ? null : member[1].state.kind;
|
|
143
|
+
if (tag !== 'literal' && tag !== 'enum') {
|
|
144
|
+
throw new LinqBuildError('JL0102',
|
|
145
|
+
`discriminated('${key}') option ${i} does not declare '${key}' as a literal() or `
|
|
146
|
+
+ 'enumOf() member — without the tag on every option the oneOf is not a '
|
|
147
|
+
+ 'discriminated union; use union() for an untagged one');
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return new BaseBuilder(initial('discriminated', { key, options: parts }));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** `{ allOf: parts }` — parts that are objects must be `open()`. @param {readonly any[]} parts */
|
|
154
|
+
function intersection(parts) {
|
|
155
|
+
return new BaseBuilder(initial('intersection', { options: requireOptions(parts, 'intersection()') }));
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* A definition: hoisted to `$defs` and referenced wherever it is used.
|
|
160
|
+
* @param {string} name
|
|
161
|
+
* @param {any} builder
|
|
162
|
+
*/
|
|
163
|
+
function named(name, builder) {
|
|
164
|
+
return new BaseBuilder(initial('named', {
|
|
165
|
+
name: requireName(name, 'named()'), target: requireBuilder(builder, 'named()'),
|
|
166
|
+
}));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A reference to a definition by name. @param {string} name */
|
|
170
|
+
function ref(name) {
|
|
171
|
+
return new BaseBuilder(initial('ref', { name: requireName(name, 'ref()') }));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A deferred reference to a NAMED builder — the recursion spelling. @param {() => any} thunk */
|
|
175
|
+
function lazy(thunk) {
|
|
176
|
+
if (typeof thunk !== 'function') {
|
|
177
|
+
throw new LinqBuildError('JL0101', 'lazy() takes a function returning a named builder');
|
|
178
|
+
}
|
|
179
|
+
return new BaseBuilder(initial('lazy', { thunk }));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** `{ if: cond }`, extended by `.then()`/`.else()`. @param {any} cond */
|
|
183
|
+
function when(cond) {
|
|
184
|
+
return new WhenBuilder(initial('when', {
|
|
185
|
+
cond: requireBuilder(cond, 'when()'), then: null, else: null,
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** A hand-written JSON Schema, embedded verbatim. @param {object | boolean} json */
|
|
190
|
+
function from(json) {
|
|
191
|
+
if (typeof json !== 'boolean'
|
|
192
|
+
&& (json === null || typeof json !== 'object' || Array.isArray(json))) {
|
|
193
|
+
throw new LinqBuildError('JL0101',
|
|
194
|
+
`from() takes a JSON Schema object or boolean, got ${describeValue(json)}`);
|
|
195
|
+
}
|
|
196
|
+
return new BaseBuilder(initial('raw', { json: requireJson(json, 'from()') }));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** `{ type: 'string', format: 'date-time' }`. */
|
|
200
|
+
function datetime() { return string().format('date-time'); }
|
|
201
|
+
/** `{ type: 'string', format: 'date' }`. */
|
|
202
|
+
function date() { return string().format('date'); }
|
|
203
|
+
/** `{ type: 'string', format: 'time' }`. */
|
|
204
|
+
function time() { return string().format('time'); }
|
|
205
|
+
/** `{ type: 'string', format: 'duration' }`. */
|
|
206
|
+
function duration() { return string().format('duration'); }
|
|
207
|
+
|
|
208
|
+
/** The dialect a standalone document may declare. */
|
|
209
|
+
const DRAFTS = Object.freeze({ '2020-12': 'https://json-schema.org/draft/2020-12/schema' });
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* A standalone document: the builder's schema, with `$schema` first when
|
|
213
|
+
* a draft is named. The pen writes the 2020-12 vocabulary and no other.
|
|
214
|
+
* @param {any} root
|
|
215
|
+
* @param {{ draft?: '2020-12' }} [options]
|
|
216
|
+
* @returns {any} the deep-frozen document
|
|
217
|
+
*/
|
|
218
|
+
function document(root, options = {}) {
|
|
219
|
+
const schema = requireBuilder(root, 'document()').schema;
|
|
220
|
+
if (options.draft === undefined) return schema;
|
|
221
|
+
const uri = DRAFTS[options.draft];
|
|
222
|
+
if (uri === undefined) {
|
|
223
|
+
throw new LinqBuildError('JL0102',
|
|
224
|
+
`document() writes the 2020-12 vocabulary only; '${options.draft}' is not a draft it `
|
|
225
|
+
+ 'can declare');
|
|
226
|
+
}
|
|
227
|
+
if (typeof schema === 'boolean') {
|
|
228
|
+
throw new LinqBuildError('JL0102', 'a boolean schema cannot declare a $schema');
|
|
229
|
+
}
|
|
230
|
+
return Object.freeze({ $schema: uri, ...schema });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return Object.freeze({
|
|
234
|
+
string, number, integer, boolean, nil, literal, enumOf,
|
|
235
|
+
object, array, tuple, record, union, discriminated, intersection,
|
|
236
|
+
named, ref, lazy, any, never, when, from, document,
|
|
237
|
+
datetime, date, time, duration,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `@jarenjs/linq/schema` — JSON Schema by code. Named builders
|
|
4
|
+
* write standard 2020-12 documents (the structural keywords, the
|
|
5
|
+
* constraints and the annotations, each with a method of its own;
|
|
6
|
+
* `$query` through the chain's own capture; `$defs`/`$ref` recursion;
|
|
7
|
+
* the normalizer's predicates — SCHEMA-PEN.md §6.2 lists the
|
|
8
|
+
* twenty-five owned keywords that have no method and are written with
|
|
9
|
+
* `keyword()` or `from()` instead), and the hand-authored
|
|
10
|
+
* declarations beside them carry `Infer<>`/`Input<>`, proven against
|
|
11
|
+
* emit's generated types and the validator's verdicts over one corpus.
|
|
12
|
+
* The document is the deliverable; nothing here imports an engine.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
SchemaBuilder, StringBuilder, NumberBuilder, ArrayBuilder, TupleBuilder,
|
|
17
|
+
ObjectBuilder, WhenBuilder, NeverBuilder,
|
|
18
|
+
} from './builders.js';
|
|
19
|
+
import { createFactories } from './factories.js';
|
|
20
|
+
|
|
21
|
+
export const {
|
|
22
|
+
string, number, integer, boolean, nil, literal, enumOf,
|
|
23
|
+
object, array, tuple, record, union, discriminated, intersection,
|
|
24
|
+
named, ref, lazy, any, never, when, from, document,
|
|
25
|
+
datetime, date, time, duration,
|
|
26
|
+
} = /** @type {any} */ (createFactories({
|
|
27
|
+
Base: SchemaBuilder, String: StringBuilder, Number: NumberBuilder,
|
|
28
|
+
Array: ArrayBuilder, Tuple: TupleBuilder, Object: ObjectBuilder,
|
|
29
|
+
When: WhenBuilder, Never: NeverBuilder,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
export {
|
|
33
|
+
SchemaBuilder, StringBuilder, NumberBuilder, ArrayBuilder, TupleBuilder,
|
|
34
|
+
ObjectBuilder, WhenBuilder, NeverBuilder, requireJson,
|
|
35
|
+
} from './builders.js';
|
|
36
|
+
export { createFactories } from './factories.js';
|
|
37
|
+
export { isSchemaBuilder, schemaOf, SCHEMA_BUILDER } from './brand.js';
|
package/src/schema-of.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The chain's side of the schema-builder brand. `ofType`/`cast`
|
|
4
|
+
* accept a builder from `@jarenjs/linq/schema` where a document was,
|
|
5
|
+
* and take its document — recognised by the registry symbol the pen
|
|
6
|
+
* brands its builders with, never by `toJSON` duck-typing (a data
|
|
7
|
+
* object with a `toJSON` member is a schema, not a builder). The
|
|
8
|
+
* symbol is looked up by key so the chain imports nothing from the
|
|
9
|
+
* pen's directory and a chain-only bundle carries none of it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The brand key `@jarenjs/linq/schema` answers `true` under. */
|
|
13
|
+
const SCHEMA_BUILDER = Symbol.for('@jarenjs/linq/schema-builder');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A builder's document, or the value as given.
|
|
17
|
+
* @param {any} value
|
|
18
|
+
* @returns {any} the JSON Schema document
|
|
19
|
+
*/
|
|
20
|
+
export function schemaOf(value) {
|
|
21
|
+
return value !== null && typeof value === 'object' && value[SCHEMA_BUILDER] === true
|
|
22
|
+
? value.schema
|
|
23
|
+
: value;
|
|
24
|
+
}
|