@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,307 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The `x-entity` vocabulary as builder methods: one mixin,
|
|
4
|
+
* `withEntity(Base)`, applied to every schema-pen class at module
|
|
5
|
+
* scope, so `./model` is a set of NEW classes (never a patched
|
|
6
|
+
* prototype) whose every builder can be a key, an index, a column
|
|
7
|
+
* override, a store-written default or a version token. The methods
|
|
8
|
+
* write into the one `x-entity` annotation; what the store does with
|
|
9
|
+
* it is MODEL-FORMAT §9's business, and the pen refuses only what the
|
|
10
|
+
* store's own model walk would refuse and the builder can already see
|
|
11
|
+
* — `identity('auto')` off an integer, `identity('uuid')` off a string,
|
|
12
|
+
* `column('integer')` off a date, `key()`/`unique()`/`index()` off a
|
|
13
|
+
* kind that can never hold a column, `version()` off an integer, and a
|
|
14
|
+
* member outside the closed vocabulary — with `JL0102` naming the rule.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { LinqBuildError } from '../errors.js';
|
|
18
|
+
import { requireJson, requireName } from '../schema/builders.js';
|
|
19
|
+
import { captureQuery } from '../schema/check.js';
|
|
20
|
+
|
|
21
|
+
/** What a captured model default or member path evaluates over, for the
|
|
22
|
+
* `JL0104` a second callback argument raises: the document being
|
|
23
|
+
* written, which the callback's own first argument already is. The
|
|
24
|
+
* shared capture cannot say this — it holds for this pen and not for the
|
|
25
|
+
* others (`capture-root.js`). */
|
|
26
|
+
export const DOCUMENT_SCOPE = ' — it evaluates over the document being written, '
|
|
27
|
+
+ 'which its argument IS; there is nothing else to read';
|
|
28
|
+
|
|
29
|
+
/** The keyword every entity method writes. */
|
|
30
|
+
const KEYWORD = 'x-entity';
|
|
31
|
+
|
|
32
|
+
/** The two storage overrides MODEL-FORMAT §9.2 names. */
|
|
33
|
+
const COLUMNS = new Set(['integer', 'json']);
|
|
34
|
+
|
|
35
|
+
/** The closed set MODEL-FORMAT §9.2 defines. An unknown member is the
|
|
36
|
+
* store's `JD0030` — a silently ignored mapping directive loses data —
|
|
37
|
+
* so `entity()` refuses it here rather than emitting it. */
|
|
38
|
+
const BLOCK = ['key', 'unique', 'index', 'default', 'column', 'relation', 'version'];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Kinds that can NEVER be a column of their own: the store maps a
|
|
42
|
+
* top-level scalar to a typed column and keeps everything else in the
|
|
43
|
+
* JSONB document (MODEL-FORMAT §9.3), so `key`/`unique`/`index` on one
|
|
44
|
+
* of these is `JD0005` at `openStore`. `raw`, `named`, `ref`, `lazy` and
|
|
45
|
+
* `intersection` are NOT here: the store resolves `$ref` and merges
|
|
46
|
+
* `allOf` before it reads the type, so any of them may still be a
|
|
47
|
+
* scalar and the pen cannot tell.
|
|
48
|
+
*/
|
|
49
|
+
const NEVER_COLUMN = new Set([
|
|
50
|
+
'object', 'record', 'array', 'tuple', 'enum', 'literal',
|
|
51
|
+
'any', 'never', 'union', 'discriminated', 'when',
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
/** Whether a builder is a string with a date or date-time format. */
|
|
55
|
+
function isDateString(builder) {
|
|
56
|
+
const st = builder.state;
|
|
57
|
+
return st.kind === 'string'
|
|
58
|
+
&& (st.keywords.format === 'date-time' || st.keywords.format === 'date');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The mapping directives that need a column: refused on a kind the
|
|
63
|
+
* store could never give one. The position is still the store's
|
|
64
|
+
* question — a scalar nested inside an object takes no column either,
|
|
65
|
+
* and the builder cannot see where it is placed.
|
|
66
|
+
* @param {any} builder
|
|
67
|
+
* @param {string} what - the method's name
|
|
68
|
+
*/
|
|
69
|
+
function requireColumnable(builder, what) {
|
|
70
|
+
const kind = builder.state.kind;
|
|
71
|
+
if (NEVER_COLUMN.has(kind)) {
|
|
72
|
+
throw new LinqBuildError('JL0102',
|
|
73
|
+
`${what}() applies to a member with a column of its own — this one's kind is `
|
|
74
|
+
+ `'${kind}', and only a top-level scalar (string, number, integer, boolean, null) `
|
|
75
|
+
+ 'is column-mapped; everything else lives in the JSON document');
|
|
76
|
+
}
|
|
77
|
+
return builder;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The mixin: a subclass of `Base` carrying the entity methods.
|
|
82
|
+
* @template {new (state: any) => any} B
|
|
83
|
+
* @param {B} Base - a schema-pen builder class
|
|
84
|
+
* @returns {B} a new class, `Base` plus the vocabulary
|
|
85
|
+
*/
|
|
86
|
+
export function withEntity(Base) {
|
|
87
|
+
return class extends Base {
|
|
88
|
+
/**
|
|
89
|
+
* Merge into the `x-entity` block, keeping the order members were
|
|
90
|
+
* first set in. The primitive every method below writes through, and
|
|
91
|
+
* the one door to a member of the vocabulary that has no method of
|
|
92
|
+
* its own — held to the same closed set the store reads.
|
|
93
|
+
* @param {Record<string, any>} patch
|
|
94
|
+
* @returns {this}
|
|
95
|
+
*/
|
|
96
|
+
entity(patch) {
|
|
97
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
98
|
+
throw new LinqBuildError('JL0101',
|
|
99
|
+
'entity() takes a plain object of x-entity members');
|
|
100
|
+
}
|
|
101
|
+
for (const member of Object.keys(patch)) {
|
|
102
|
+
if (!BLOCK.includes(member)) {
|
|
103
|
+
throw new LinqBuildError('JL0102',
|
|
104
|
+
`entity() writes the closed x-entity vocabulary (${BLOCK.join(', ')}); `
|
|
105
|
+
+ `'${member}' is not a member of it, and the store refuses one it cannot read `
|
|
106
|
+
+ 'rather than ignoring it (a mapping directive that is silently dropped loses data)');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const current = this.annotation(KEYWORD) ?? {};
|
|
110
|
+
return this.annotate(KEYWORD, Object.freeze({ ...current, ...patch }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** `key: true` — (part of) the primary key. */
|
|
114
|
+
key() { return requireColumnable(this, 'key').entity({ key: true }); }
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `unique: true` — a unique index over the column. The ARRAY builder
|
|
118
|
+
* already owns this name (`uniqueItems`, a validation keyword), and a
|
|
119
|
+
* mixin must not change what a document asserts: where the base
|
|
120
|
+
* defines it, the base wins, and an array member takes no column of
|
|
121
|
+
* its own anyway.
|
|
122
|
+
*/
|
|
123
|
+
unique() {
|
|
124
|
+
return typeof super.unique === 'function'
|
|
125
|
+
? super.unique()
|
|
126
|
+
: requireColumnable(this, 'unique').entity({ unique: true });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `index: true` — a non-unique index over the column. */
|
|
130
|
+
index() { return requireColumnable(this, 'index').entity({ index: true }); }
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `version: true` — the optimistic-concurrency token (§11.5): one
|
|
134
|
+
* plain integer column per entity, engine-owned. Whether it is also
|
|
135
|
+
* a key, a relation or column-mapped is a COMBINATION the store
|
|
136
|
+
* judges (`JD0005`); its kind is visible here.
|
|
137
|
+
*/
|
|
138
|
+
version() {
|
|
139
|
+
if (this.state.kind !== 'integer') {
|
|
140
|
+
throw new LinqBuildError('JL0102',
|
|
141
|
+
'version() is the optimistic-concurrency token and lives in a plain integer '
|
|
142
|
+
+ `column — this member's kind is '${this.state.kind}'; spell it integer()`);
|
|
143
|
+
}
|
|
144
|
+
return this.entity({ version: true });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* `column`: `'integer'` stores a date-formatted string as epoch
|
|
149
|
+
* milliseconds; `'json'` keeps a scalar in the document.
|
|
150
|
+
* @param {'integer' | 'json'} storage
|
|
151
|
+
*/
|
|
152
|
+
column(storage) {
|
|
153
|
+
if (!COLUMNS.has(storage)) {
|
|
154
|
+
throw new LinqBuildError('JL0101',
|
|
155
|
+
`column() takes 'integer' (an epoch column for a date) or 'json' (stay in the `
|
|
156
|
+
+ `document), got ${JSON.stringify(storage)}`);
|
|
157
|
+
}
|
|
158
|
+
if (storage === 'integer' && !isDateString(this)) {
|
|
159
|
+
throw new LinqBuildError('JL0102',
|
|
160
|
+
"column('integer') applies to a date-time or date formatted string — the epoch "
|
|
161
|
+
+ 'column is derived from the RFC 3339 text; spell the member datetime() or date()');
|
|
162
|
+
}
|
|
163
|
+
return this.entity({ column: storage });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* A store-allocated key (MODEL-FORMAT §9.5): `key: true` with
|
|
168
|
+
* `default: 'uuid'` (`crypto.randomUUID()` on a single string key)
|
|
169
|
+
* or `default: 'auto'` (the database allocates a single integer key).
|
|
170
|
+
* @param {'uuid' | 'auto'} kind
|
|
171
|
+
*/
|
|
172
|
+
identity(kind) {
|
|
173
|
+
if (kind === 'uuid') {
|
|
174
|
+
if (this.state.kind !== 'string') {
|
|
175
|
+
throw new LinqBuildError('JL0102',
|
|
176
|
+
`identity('uuid') allocates a single string key — the member is a ${this.state.kind}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
else if (kind === 'auto') {
|
|
180
|
+
if (this.state.kind !== 'integer') {
|
|
181
|
+
throw new LinqBuildError('JL0102',
|
|
182
|
+
"identity('auto') is allocated by the database for a single integer key only — the "
|
|
183
|
+
+ `member is ${this.state.kind === 'number' ? 'a number; spell it integer()' : `a ${this.state.kind}`}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
else {
|
|
187
|
+
throw new LinqBuildError('JL0101',
|
|
188
|
+
`identity() takes 'uuid' or 'auto', got ${JSON.stringify(kind)}`);
|
|
189
|
+
}
|
|
190
|
+
return this.entity({ key: true, default: kind });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** `default: 'now'` — an RFC 3339 stamp on insert, when absent. */
|
|
194
|
+
now() { return this.entity({ default: 'now' }); }
|
|
195
|
+
/** `default: 'updated'` — a stamp on insert and on every update. */
|
|
196
|
+
updated() { return this.entity({ default: 'updated' }); }
|
|
197
|
+
|
|
198
|
+
/** `default: { value }` — a literal, filled when absent. @param {any} value */
|
|
199
|
+
fill(value) { return this.entity({ default: { value: requireJson(value, 'fill()') } }); }
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* `default: { query }` — evaluated over the document being written
|
|
203
|
+
* (`$` is that document; no externals): a captured callback, or a
|
|
204
|
+
* query document verbatim.
|
|
205
|
+
* @param {((doc: any, externals: any) => any) | object} rule
|
|
206
|
+
*/
|
|
207
|
+
compute(rule) {
|
|
208
|
+
const query = typeof rule === 'function'
|
|
209
|
+
? captureQuery('compute()', [], rule, { advice: () => DOCUMENT_SCOPE })
|
|
210
|
+
: requireJson(rule, 'compute()');
|
|
211
|
+
return this.entity({ default: { query } });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* `x-rename`: this entity was previously named `name` (a migration
|
|
216
|
+
* hint the planner reads; lifted onto the entity declaration by
|
|
217
|
+
* `defineModel`). @param {string} name
|
|
218
|
+
*/
|
|
219
|
+
renamedFrom(name) { return this.with({ renamedFrom: requireName(name, 'renamedFrom()') }); }
|
|
220
|
+
|
|
221
|
+
/** As in the schema pen, but `x-entity` is owned here. @param {Record<string, any>} annotations */
|
|
222
|
+
meta(annotations) {
|
|
223
|
+
if (annotations !== null && typeof annotations === 'object' && KEYWORD in annotations) {
|
|
224
|
+
throw new LinqBuildError('JL0104',
|
|
225
|
+
`meta() cannot write '${KEYWORD}' — the model pen owns that keyword; spell it `
|
|
226
|
+
+ 'through key(), identity(), unique(), index(), column(), now(), updated(), '
|
|
227
|
+
+ 'fill(), compute() or rel.*');
|
|
228
|
+
}
|
|
229
|
+
return super.meta(annotations);
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The builders one builder holds, as `[segment, child]` pairs. A
|
|
236
|
+
* `lazy()` thunk is NOT invoked: it exists to break a cycle, and calling
|
|
237
|
+
* it here would rebuild the recursion this walk is trying to finish.
|
|
238
|
+
* @param {any} state
|
|
239
|
+
* @returns {[string, any][]}
|
|
240
|
+
*/
|
|
241
|
+
function childrenOf(state) {
|
|
242
|
+
switch (state.kind) {
|
|
243
|
+
case 'object': return [
|
|
244
|
+
...state.props.map(([name, child]) => [name, child]),
|
|
245
|
+
...state.patterns.map(([pattern, child]) => [`[${pattern}]`, child]),
|
|
246
|
+
...(state.names === null ? [] : [['propertyNames', state.names]]),
|
|
247
|
+
];
|
|
248
|
+
case 'array': return [
|
|
249
|
+
['items', state.items],
|
|
250
|
+
...(state.contains === null ? [] : [['contains', state.contains]]),
|
|
251
|
+
];
|
|
252
|
+
case 'tuple': return [
|
|
253
|
+
...state.items.map((child, i) => [`[${i}]`, child]),
|
|
254
|
+
...(state.rest === null ? [] : [['rest', state.rest]]),
|
|
255
|
+
];
|
|
256
|
+
case 'record': return [['values', state.values]];
|
|
257
|
+
case 'union': case 'discriminated': case 'intersection':
|
|
258
|
+
return state.options.map((child, i) => [`[${i}]`, child]);
|
|
259
|
+
case 'when': return [
|
|
260
|
+
['if', state.cond],
|
|
261
|
+
...(state.then === null ? [] : [['then', state.then]]),
|
|
262
|
+
...(state.else === null ? [] : [['else', state.else]]),
|
|
263
|
+
];
|
|
264
|
+
case 'named': return [[state.name, state.target]];
|
|
265
|
+
default: return [];
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The path of the first DESCENDANT carrying a `renamedFrom` hint, or
|
|
271
|
+
* `null`. `$model` 0.1 puts `x-rename` on an entity or a collection
|
|
272
|
+
* declaration and nowhere else (MIGRATION-FORMAT §3), so a hint anywhere
|
|
273
|
+
* but the declaration's own builder is written by nobody — and a rename
|
|
274
|
+
* the planner never sees is a drop plus a create.
|
|
275
|
+
* @param {any} root - the declaration's builder; its OWN hint is lifted
|
|
276
|
+
* @returns {string | null}
|
|
277
|
+
*/
|
|
278
|
+
export function strandedRename(root) {
|
|
279
|
+
const seen = new Set();
|
|
280
|
+
/** @type {[string, any][]} */
|
|
281
|
+
const queue = childrenOf(root.state).map(([segment, child]) => [segment, child]);
|
|
282
|
+
while (queue.length > 0) {
|
|
283
|
+
const [path, builder] = /** @type {any} */ (queue.shift());
|
|
284
|
+
if (builder === null || typeof builder !== 'object' || seen.has(builder)) continue;
|
|
285
|
+
seen.add(builder);
|
|
286
|
+
if (builder.state?.renamedFrom !== undefined) return path;
|
|
287
|
+
for (const [segment, child] of childrenOf(builder.state)) {
|
|
288
|
+
queue.push([segment.startsWith('[') ? `${path}${segment}` : `${path}.${segment}`, child]);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The refusal `defineModel()` and `collection()` share for a stranded
|
|
296
|
+
* hint (see `strandedRename`).
|
|
297
|
+
* @param {any} builder @param {string} what @param {string} [docPath]
|
|
298
|
+
*/
|
|
299
|
+
export function refuseStrandedRename(builder, what, docPath = undefined) {
|
|
300
|
+
const path = strandedRename(builder);
|
|
301
|
+
if (path === null) return;
|
|
302
|
+
throw new LinqBuildError('JL0102',
|
|
303
|
+
`renamedFrom() on ${what}.${path} is not written — $model 0.1 carries x-rename on an `
|
|
304
|
+
+ 'entity or a collection declaration, never on a member, so the hint would be lost and '
|
|
305
|
+
+ "a rename the planner cannot see is a drop plus a create; put it on the declaration's "
|
|
306
|
+
+ 'own builder, or rename the member with a migration transform', docPath);
|
|
307
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file `@jarenjs/linq/model` — the database by code. The schema pen's
|
|
4
|
+
* every name, rebuilt from SUBCLASSES that carry the `x-entity`
|
|
5
|
+
* vocabulary (`key()`, `identity()`, `unique()`, `index()`, `column()`,
|
|
6
|
+
* `now()`, `updated()`, `fill()`, `compute()`, `version()`,
|
|
7
|
+
* `renamedFrom()`), the relation members (`rel.*`), collections and
|
|
8
|
+
* their indexes, and `defineModel()` — one `$model` 0.1 document that
|
|
9
|
+
* `openStore` accepts unchanged. Nothing here imports `@jarenjs/db`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
SchemaBuilder, StringBuilder, NumberBuilder, ArrayBuilder, TupleBuilder,
|
|
14
|
+
ObjectBuilder, WhenBuilder, NeverBuilder,
|
|
15
|
+
} from '../schema/builders.js';
|
|
16
|
+
import { createFactories } from '../schema/factories.js';
|
|
17
|
+
import { withEntity } from './entity.js';
|
|
18
|
+
import { createRelations } from './relation.js';
|
|
19
|
+
|
|
20
|
+
/** The entity-aware classes: new classes, one mixin, no patched prototype. */
|
|
21
|
+
export const EntityBuilder = withEntity(SchemaBuilder);
|
|
22
|
+
export const EntityStringBuilder = withEntity(StringBuilder);
|
|
23
|
+
export const EntityNumberBuilder = withEntity(NumberBuilder);
|
|
24
|
+
export const EntityArrayBuilder = withEntity(ArrayBuilder);
|
|
25
|
+
export const EntityTupleBuilder = withEntity(TupleBuilder);
|
|
26
|
+
export const EntityObjectBuilder = withEntity(ObjectBuilder);
|
|
27
|
+
export const EntityWhenBuilder = withEntity(WhenBuilder);
|
|
28
|
+
export const EntityNeverBuilder = withEntity(NeverBuilder);
|
|
29
|
+
|
|
30
|
+
export const {
|
|
31
|
+
string, number, integer, boolean, nil, literal, enumOf,
|
|
32
|
+
object, array, tuple, record, union, discriminated, intersection,
|
|
33
|
+
named, ref, lazy, any, never, when, from, document,
|
|
34
|
+
datetime, date, time, duration,
|
|
35
|
+
} = /** @type {any} */ (createFactories({
|
|
36
|
+
Base: EntityBuilder, String: EntityStringBuilder, Number: EntityNumberBuilder,
|
|
37
|
+
Array: EntityArrayBuilder, Tuple: EntityTupleBuilder, Object: EntityObjectBuilder,
|
|
38
|
+
When: EntityWhenBuilder, Never: EntityNeverBuilder,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
/** The relation members: `rel.hasMany`, `rel.hasOne`, `rel.belongsToMany`. */
|
|
42
|
+
export const rel = createRelations(EntityBuilder);
|
|
43
|
+
|
|
44
|
+
export { collection, index, expressionIndex } from './collection.js';
|
|
45
|
+
export { defineModel } from './define.js';
|
|
46
|
+
export { withEntity } from './entity.js';
|
|
47
|
+
export { isSchemaBuilder, schemaOf, SCHEMA_BUILDER } from '../schema/brand.js';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file Relation members: `rel.hasMany`, `rel.hasOne`, `rel.belongsToMany`
|
|
4
|
+
* — each a builder of no type (`{}`) whose `x-entity.relation` block is
|
|
5
|
+
* the whole member, spelled exactly as MODEL-FORMAT §9.4's three kinds.
|
|
6
|
+
* A relation member is a projection, never stored state, so every
|
|
7
|
+
* relation builder is `optional()` by construction. The target is an
|
|
8
|
+
* entity NAME; whether it is declared is `defineModel`'s question.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { LinqBuildError } from '../errors.js';
|
|
12
|
+
import { initial, requireName } from '../schema/builders.js';
|
|
13
|
+
|
|
14
|
+
const ON_DELETE = new Set(['cascade', 'restrict', 'setNull']);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {any} options
|
|
18
|
+
* @param {string} what
|
|
19
|
+
* @returns {{ via: string, onDelete: string }}
|
|
20
|
+
*/
|
|
21
|
+
function foreignKeyOptions(options, what) {
|
|
22
|
+
if (options === null || typeof options !== 'object') {
|
|
23
|
+
throw new LinqBuildError('JL0101', `${what} takes { via, onDelete }`);
|
|
24
|
+
}
|
|
25
|
+
const via = requireName(options.via, `${what} via`);
|
|
26
|
+
if (!ON_DELETE.has(options.onDelete)) {
|
|
27
|
+
throw new LinqBuildError('JL0101',
|
|
28
|
+
`${what} must declare onDelete: 'cascade', 'restrict' or 'setNull' — a foreign key is `
|
|
29
|
+
+ `never defaulted silently; got ${JSON.stringify(options.onDelete)}`);
|
|
30
|
+
}
|
|
31
|
+
for (const key of Object.keys(options)) {
|
|
32
|
+
if (key !== 'via' && key !== 'onDelete') {
|
|
33
|
+
throw new LinqBuildError('JL0101', `${what} does not take '${key}'`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { via, onDelete: options.onDelete };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The relation factories for one builder class.
|
|
41
|
+
* @param {any} Base - the class a relation member is built from
|
|
42
|
+
*/
|
|
43
|
+
export function createRelations(Base) {
|
|
44
|
+
const member = (relation) => new Base(initial('any', {}))
|
|
45
|
+
.annotate('x-entity', Object.freeze({ relation: Object.freeze(relation) }))
|
|
46
|
+
.with({ optional: true });
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
/**
|
|
49
|
+
* One-to-many: `{ to, many: true, via, onDelete }` — `via` names the
|
|
50
|
+
* foreign key on the TARGET entity.
|
|
51
|
+
* @param {string} to @param {{ via: string, onDelete: 'cascade' | 'restrict' | 'setNull' }} options
|
|
52
|
+
*/
|
|
53
|
+
hasMany(to, options) {
|
|
54
|
+
const { via, onDelete } = foreignKeyOptions(options, 'rel.hasMany()');
|
|
55
|
+
return member({ to: requireName(to, 'rel.hasMany()'), many: true, via, onDelete });
|
|
56
|
+
},
|
|
57
|
+
/**
|
|
58
|
+
* One-to-one (and the many-to-one side): `{ to, via, onDelete }` —
|
|
59
|
+
* `via` names the foreign key on the DECLARING entity.
|
|
60
|
+
* @param {string} to @param {{ via: string, onDelete: 'cascade' | 'restrict' | 'setNull' }} options
|
|
61
|
+
*/
|
|
62
|
+
hasOne(to, options) {
|
|
63
|
+
const { via, onDelete } = foreignKeyOptions(options, 'rel.hasOne()');
|
|
64
|
+
return member({ to: requireName(to, 'rel.hasOne()'), via, onDelete });
|
|
65
|
+
},
|
|
66
|
+
/**
|
|
67
|
+
* Many-to-many: `{ to, many: true, through? }` — a join table, named
|
|
68
|
+
* `through` or the sorted `<A>_<B>`.
|
|
69
|
+
* @param {string} to @param {{ through?: string }} [options]
|
|
70
|
+
*/
|
|
71
|
+
belongsToMany(to, options = {}) {
|
|
72
|
+
if (options === null || typeof options !== 'object') {
|
|
73
|
+
throw new LinqBuildError('JL0101', 'rel.belongsToMany() takes { through? }');
|
|
74
|
+
}
|
|
75
|
+
for (const key of Object.keys(options)) {
|
|
76
|
+
if (key !== 'through') {
|
|
77
|
+
throw new LinqBuildError('JL0101', `rel.belongsToMany() does not take '${key}'`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const relation = { to: requireName(to, 'rel.belongsToMany()'), many: true };
|
|
81
|
+
if (options.through !== undefined) relation.through = requireName(options.through, 'rel.belongsToMany() through');
|
|
82
|
+
return member(relation);
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
}
|
package/src/provider.js
CHANGED
|
@@ -1,31 +1,70 @@
|
|
|
1
1
|
//@ts-check
|
|
2
2
|
/**
|
|
3
3
|
* @file The provider seam (D2): a provider is any object exposing
|
|
4
|
-
* `execute(queryDocument, options)` — contract-level coupling
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* `execute(queryDocument, options)` — contract-level coupling: the
|
|
5
|
+
* chain imports no provider. `@jarenjs/db` implements this interface;
|
|
6
|
+
* the package's one runtime edge — the client subpath `./db` — runs
|
|
7
|
+
* the other way, toward the store, as declared optional peers, and the
|
|
8
|
+
* store never imports this package. The in-memory runner implements
|
|
7
9
|
* the SAME interface over an iterable, so it is both the reference
|
|
8
10
|
* semantics every other provider must match and the proof the seam is
|
|
9
11
|
* real.
|
|
10
12
|
*
|
|
11
13
|
* Compiled documents are shared through a bounded LRU keyed by the
|
|
12
|
-
* document's COLLISION-FREE
|
|
13
|
-
*
|
|
14
|
-
* must not share compiled
|
|
15
|
-
* A fingerprint would not do: a 32-bit content hash collides
|
|
16
|
-
* of thousands of documents, and a collision here runs one
|
|
17
|
-
* compiled program for another query's document — silently
|
|
14
|
+
* document's COLLISION-FREE, ORDER-SENSITIVE identity — its exact JSON
|
|
15
|
+
* text — one cache per REGISTRY identity, because the hooks change what
|
|
16
|
+
* compiles, so two different registries must not share compiled
|
|
17
|
+
* programs. A fingerprint would not do: a 32-bit content hash collides
|
|
18
|
+
* after tens of thousands of documents, and a collision here runs one
|
|
19
|
+
* query's compiled program for another query's document — silently
|
|
20
|
+
* wrong rows. Nor would an order-insensitive identity: a constructor's
|
|
21
|
+
* member order is part of a document's meaning (`{ id, name }` and
|
|
22
|
+
* `{ name, id }` project different objects), and a cache that keyed them
|
|
23
|
+
* as one answered the second projection in the first one's order.
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
import { compileJsonQuery, JsonQueryCompileError } from '@jarenjs/json/query';
|
|
21
|
-
import {
|
|
27
|
+
import { createBoundedCache, createWeakCache } from '@jarenjs/core/cache';
|
|
22
28
|
import { LinqBuildError } from './errors.js';
|
|
23
29
|
|
|
24
30
|
/** Stands in for an absent hook while walking the identity chain. */
|
|
25
31
|
const NO_HOOK = Object.freeze({});
|
|
26
32
|
|
|
27
33
|
const CACHES = createWeakCache();
|
|
28
|
-
const cacheFor = () =>
|
|
34
|
+
const cacheFor = () => createBoundedCache(512);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The cache identity of one compilation: the document's exact JSON text
|
|
38
|
+
* beside the declared externals and the limits. `null` when the value
|
|
39
|
+
* cannot be keyed injectively — a function, an `undefined`, a symbol, a
|
|
40
|
+
* bigint, a non-finite number, `-0`, or a class instance whose `toJSON`
|
|
41
|
+
* would otherwise stand in for it — which is a permanent miss, never
|
|
42
|
+
* someone else's entry.
|
|
43
|
+
* @param {any} document
|
|
44
|
+
* @param {readonly string[]} externals
|
|
45
|
+
* @param {any} limits
|
|
46
|
+
* @returns {string | null}
|
|
47
|
+
*/
|
|
48
|
+
function compilationKey(document, externals, limits) {
|
|
49
|
+
try {
|
|
50
|
+
return JSON.stringify([document, externals, limits ?? null], function (key, value) {
|
|
51
|
+
const raw = this[key];
|
|
52
|
+
const type = typeof raw;
|
|
53
|
+
if (type === 'function' || type === 'undefined' || type === 'symbol' || type === 'bigint'
|
|
54
|
+
|| (type === 'number' && (!Number.isFinite(raw) || Object.is(raw, -0)))) {
|
|
55
|
+
throw new TypeError('unkeyable');
|
|
56
|
+
}
|
|
57
|
+
if (raw !== null && type === 'object' && !Array.isArray(raw)) {
|
|
58
|
+
const proto = Object.getPrototypeOf(raw);
|
|
59
|
+
if (proto !== Object.prototype && proto !== null) throw new TypeError('unkeyable');
|
|
60
|
+
}
|
|
61
|
+
return value;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
29
68
|
|
|
30
69
|
/** The root of the hook-identity chain. */
|
|
31
70
|
const REGISTRY_IDS = createWeakCache();
|
|
@@ -103,10 +142,7 @@ function registryIdentity(options) {
|
|
|
103
142
|
*/
|
|
104
143
|
export function compileDocument(document, options) {
|
|
105
144
|
const cache = /** @type {any} */ (CACHES.getOrCreate(registryIdentity(options), cacheFor));
|
|
106
|
-
|
|
107
|
-
// document compiles differently against a different set of declared
|
|
108
|
-
// names, and differently again under a step or result bound
|
|
109
|
-
return cache.getOrCreate([document, options.externals, options.limits ?? null], () => {
|
|
145
|
+
const compile = () => {
|
|
110
146
|
try {
|
|
111
147
|
return compileJsonQuery(document, {
|
|
112
148
|
...compileOptionsOf(options),
|
|
@@ -123,7 +159,91 @@ export function compileDocument(document, options) {
|
|
|
123
159
|
}
|
|
124
160
|
throw err;
|
|
125
161
|
}
|
|
126
|
-
}
|
|
162
|
+
};
|
|
163
|
+
// the externals and the limits are part of the identity: the same
|
|
164
|
+
// document compiles differently against a different set of declared
|
|
165
|
+
// names, and differently again under a step or result bound
|
|
166
|
+
const key = compilationKey(document, options.externals, options.limits);
|
|
167
|
+
return key === null ? compile() : cache.getOrCreate(key, compile);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether a source is a provider: an `execute` duck. The one dispatch
|
|
172
|
+
* both surfaces share — on `fromAsync` it is asked BEFORE the iterable
|
|
173
|
+
* shapes, so a provider that also happens to be iterable is still a
|
|
174
|
+
* provider.
|
|
175
|
+
* @param {any} source
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
export function isProviderSource(source) {
|
|
179
|
+
return source !== null && typeof source === 'object'
|
|
180
|
+
&& typeof (/** @type {any} */ (source).execute) === 'function';
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The root expression a provider's items are bound through (QUERY-PEN
|
|
185
|
+
* §8): its `root` (`'$.Post[*]'` for an entity set), or `'$[*]'` when it
|
|
186
|
+
* names none — the whole input, a collection. A provider that serves
|
|
187
|
+
* SEVERAL roots and none of its own (a store with entities: `roots`) has
|
|
188
|
+
* nothing a chain could iterate — `$[*]` over the entity map would
|
|
189
|
+
* answer the rows of every entity, mixed, or count the SETS — so it is
|
|
190
|
+
* refused here, at `from()` time, naming the roots to chain over.
|
|
191
|
+
* @param {any} provider
|
|
192
|
+
* @returns {string}
|
|
193
|
+
*/
|
|
194
|
+
export function providerRoot(provider) {
|
|
195
|
+
const root = provider.root;
|
|
196
|
+
if (root === undefined || root === null) {
|
|
197
|
+
const roots = provider.roots;
|
|
198
|
+
if (Array.isArray(roots) && roots.length > 0) {
|
|
199
|
+
throw new LinqBuildError('JL0007',
|
|
200
|
+
`this provider serves entity roots ${roots.join(', ')} and has no root of its own — `
|
|
201
|
+
+ 'chain over one of them: from(store.entity(name)) (QUERY-PEN.md §8)');
|
|
202
|
+
}
|
|
203
|
+
return '$[*]';
|
|
204
|
+
}
|
|
205
|
+
if (typeof root !== 'string' || root === '') {
|
|
206
|
+
throw new LinqBuildError('JL0005',
|
|
207
|
+
`a provider's root is a path expression string ('$.Post[*]'), got ${typeof root}`);
|
|
208
|
+
}
|
|
209
|
+
return root;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The relation context a provider offers (QUERY-PEN §8): its own
|
|
214
|
+
* relation table — `relations`, keyed by member (MODEL-FORMAT §10.1) —
|
|
215
|
+
* and a resolver for the tables of the other roots of its scope
|
|
216
|
+
* (`scope.relations`, keyed by root name: one store's entity sets), so
|
|
217
|
+
* a hop can continue into another root. `null` when the provider
|
|
218
|
+
* carries no table: a relation name is then an ordinary member.
|
|
219
|
+
* @param {any} provider
|
|
220
|
+
* @returns {{ table: any, resolve: (name: string) => any } | null}
|
|
221
|
+
*/
|
|
222
|
+
export function providerRelations(provider) {
|
|
223
|
+
const table = provider.relations;
|
|
224
|
+
if (table === null || typeof table !== 'object') return null;
|
|
225
|
+
const scope = provider.scope;
|
|
226
|
+
const scoped = scope !== null && typeof scope === 'object' ? scope.relations : undefined;
|
|
227
|
+
return {
|
|
228
|
+
table,
|
|
229
|
+
resolve: (name) => (scoped !== null && typeof scoped === 'object'
|
|
230
|
+
&& Object.hasOwn(scoped, name) ? scoped[name] : undefined),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Whether two sources may share one document: the same object, or two
|
|
236
|
+
* providers carrying one `scope` — one store's entity sets, which are
|
|
237
|
+
* two roots of ONE multi-entity input (QUERY-PEN §8).
|
|
238
|
+
* @param {any} a
|
|
239
|
+
* @param {any} b
|
|
240
|
+
* @returns {boolean}
|
|
241
|
+
*/
|
|
242
|
+
export function sharesScope(a, b) {
|
|
243
|
+
if (a === b) return true;
|
|
244
|
+
if (!isProviderSource(a) || !isProviderSource(b)) return false;
|
|
245
|
+
const scope = a.scope;
|
|
246
|
+
return scope !== undefined && scope !== null && scope === b.scope;
|
|
127
247
|
}
|
|
128
248
|
|
|
129
249
|
/**
|
|
@@ -135,10 +255,7 @@ export function compileDocument(document, options) {
|
|
|
135
255
|
* @returns {'provider' | 'iterable'}
|
|
136
256
|
*/
|
|
137
257
|
export function classifySource(source) {
|
|
138
|
-
if (source
|
|
139
|
-
&& typeof (/** @type {any} */ (source).execute) === 'function') {
|
|
140
|
-
return 'provider';
|
|
141
|
-
}
|
|
258
|
+
if (isProviderSource(source)) return 'provider';
|
|
142
259
|
if (source != null && (typeof source === 'string'
|
|
143
260
|
|| typeof (/** @type {any} */ (source))[Symbol.iterator] === 'function')) {
|
|
144
261
|
return 'iterable';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/**
|
|
3
|
+
* @file The schema-builder brand: how anything outside the pen tells a
|
|
4
|
+
* builder from the document it writes. The brand is a registry symbol
|
|
5
|
+
* (`Symbol.for`), so the chain — which imports nothing from this
|
|
6
|
+
* directory, by the tree-shaking rule — recognises a builder handed to
|
|
7
|
+
* `ofType`/`cast` by looking the same symbol up; a data object that
|
|
8
|
+
* merely carries a `toJSON` member is a schema, never a builder.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The brand key every builder answers `true` under. */
|
|
12
|
+
export const SCHEMA_BUILDER = Symbol.for('@jarenjs/linq/schema-builder');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Whether `value` is a schema builder (carries the brand).
|
|
16
|
+
* @param {any} value
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
export function isSchemaBuilder(value) {
|
|
20
|
+
return value !== null && typeof value === 'object' && value[SCHEMA_BUILDER] === true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A builder's document, or the value as given: the one call a consumer
|
|
25
|
+
* needs to accept "a schema, by hand or by pen".
|
|
26
|
+
* @param {any} value
|
|
27
|
+
* @returns {any} the JSON Schema document
|
|
28
|
+
*/
|
|
29
|
+
export function schemaOf(value) {
|
|
30
|
+
return isSchemaBuilder(value) ? value.schema : value;
|
|
31
|
+
}
|