@jarenjs/linq 0.73.0 → 0.83.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/ARCHITECTURE.md +8 -10
- package/README.md +15 -1
- package/docs/AI-PEN.md +1 -1
- package/docs/APP-PEN.md +3 -3
- package/docs/CHARTS-PEN.md +1 -1
- package/docs/CONTRACT-PEN.md +4 -4
- package/docs/DB-CLIENT.md +200 -8
- package/docs/FLOW-PEN.md +3 -3
- package/docs/FORMS-PEN.md +2 -2
- package/docs/FORMULA-PEN.md +41 -0
- package/docs/JSLT-PEN.md +4 -4
- package/docs/JTLT-PEN.md +1 -1
- package/docs/LINQ-FORMAT.md +60 -43
- package/docs/MESSAGES-PEN.md +1 -1
- package/docs/MIGRATION-PEN.md +3 -3
- package/docs/MODEL-PEN.md +27 -3
- package/docs/PROJECT-PEN.md +10 -4
- package/docs/QUERY-PEN.md +78 -51
- package/docs/SCHEMA-PEN.md +2 -2
- package/package.json +10 -6
- package/src/db/effects.js +148 -0
- package/src/db/handle.js +2 -0
- package/src/db/index.js +6 -0
- package/src/db/ingest.js +153 -0
- package/src/db/open.js +3 -0
- package/src/db/range.js +261 -0
- package/src/db/receipts.js +151 -0
- package/src/db/records.js +61 -0
- package/src/db/runs.js +164 -0
- package/src/db/search.js +86 -0
- package/src/errors.js +3 -1
- package/src/expression.js +5 -2
- package/src/federate.js +157 -139
- package/src/formula/index.js +15 -0
- package/src/messages/vocabulary.js +2 -1
- package/src/model/define.js +2 -0
- package/src/model/entity.js +15 -0
- package/src/project/index.js +6 -5
- package/types/db.d.ts +101 -0
- package/types/formula.d.ts +9 -0
- package/types/index.d.ts +14 -2
- package/types/message-vocabulary.d.ts +2 -1
- package/types/model.d.ts +18 -0
- package/types/project.d.ts +9 -3
package/src/federate.js
CHANGED
|
@@ -1,51 +1,26 @@
|
|
|
1
1
|
//@ts-check
|
|
2
2
|
/**
|
|
3
|
-
* @file
|
|
4
|
-
*
|
|
3
|
+
* @file Explicit bounded federation (QUERY-PEN §12.1). Source-local
|
|
4
|
+
* documents execute at their providers; a connected mandatory equality
|
|
5
|
+
* graph chooses the fetch order by declared estimates, with binding order
|
|
6
|
+
* breaking ties. Hash membership only removes impossible candidates: the
|
|
7
|
+
* query engine decides the resident result and its original tuple order.
|
|
5
8
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* would turn a one-line chain into an unbounded fetch of two
|
|
13
|
-
* databases.
|
|
9
|
+
* Packed joins emitted by successive LINQ joins execute inside out. Every
|
|
10
|
+
* source and intermediate has a per-side budget, and one shared admission
|
|
11
|
+
* counter covers the entire call. Intermediate phrase materialization is
|
|
12
|
+
* capped through the query engine's own limits. Buffered providers and
|
|
13
|
+
* intermediate byte sizes can only be checked after they produce an array;
|
|
14
|
+
* cursor providers admit each retained row before holding it.
|
|
14
15
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* one provider-compatible source per name, all sharing one scope — so
|
|
18
|
-
* the join the chain already knows how to build is admitted, and the
|
|
19
|
-
* federation is what executes it:
|
|
20
|
-
*
|
|
21
|
-
* 1. each binding's own document — the filters and the projection the
|
|
22
|
-
* chain already packed per side — runs against ITS source;
|
|
23
|
-
* 2. the smaller side (declared estimate, else the first named) is
|
|
24
|
-
* streamed into a hash table keyed by the join key, counting rows
|
|
25
|
-
* and serialized bytes against the budget as it fills;
|
|
26
|
-
* 3. the other side is streamed and PROBED: a row whose key no build
|
|
27
|
-
* row carries cannot join, so it is dropped before it costs
|
|
28
|
-
* anything;
|
|
29
|
-
* 4. the caller's own document runs in the engine over the two
|
|
30
|
-
* reduced sets — the resident join, which is what decides.
|
|
31
|
-
*
|
|
32
|
-
* Step 4 is why this file spells no join semantics of its own. The
|
|
33
|
-
* engine's `$eq` decides which rows pair, its ordering orders them and
|
|
34
|
-
* its projection shapes them; the hash table exists to bound the FETCH,
|
|
35
|
-
* never to answer the query. A reduction that dropped a row the engine
|
|
36
|
-
* would have joined would be a wrong answer, so the probe keeps
|
|
37
|
-
* anything it cannot key (a compound key value) rather than guessing.
|
|
38
|
-
*
|
|
39
|
-
* Non-goals, named rather than discovered: no spill (a budget is a
|
|
40
|
-
* refusal, not a disk), no distributed transaction, no cross-source
|
|
41
|
-
* write, and no non-equality join — without an equality key the fetch
|
|
42
|
-
* is the cross product, which is exactly what the budget exists to
|
|
43
|
-
* refuse.
|
|
16
|
+
* No spill, distributed transaction, cross-source writes, or disconnected
|
|
17
|
+
* cartesian products. Ordinary unrelated-source join() still refuses JL0005.
|
|
44
18
|
*/
|
|
45
19
|
|
|
46
20
|
import { LinqBuildError, LinqRuntimeError } from './errors.js';
|
|
47
21
|
import { compileDocument, isProviderSource, providerRoot } from './provider.js';
|
|
48
22
|
import { memberSegment } from './expression.js';
|
|
23
|
+
import { parseJSONPath } from '@jarenjs/json/path';
|
|
49
24
|
|
|
50
25
|
/** The strategies this boundary knows. One, for now, and it says so. */
|
|
51
26
|
const STRATEGIES = new Set(['hash']);
|
|
@@ -57,7 +32,7 @@ const STRATEGIES = new Set(['hash']);
|
|
|
57
32
|
* @returns {number}
|
|
58
33
|
*/
|
|
59
34
|
function budgetOf(value, what) {
|
|
60
|
-
if (!Number.
|
|
35
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
61
36
|
throw new LinqBuildError('JL0005',
|
|
62
37
|
`federate() needs a positive integer ${what} — a federated fetch holds rows in memory, `
|
|
63
38
|
+ 'and a bound is what makes that finite');
|
|
@@ -69,15 +44,22 @@ function budgetOf(value, what) {
|
|
|
69
44
|
* The one member path a `'$it.a.b'` operand names, or `null` for
|
|
70
45
|
* anything else (an operator call, a literal, the binding itself).
|
|
71
46
|
* @param {any} node
|
|
72
|
-
* @returns {{ binding: string, path: string[] } | null}
|
|
47
|
+
* @returns {{ binding: string, path: (string | number)[] } | null}
|
|
73
48
|
*/
|
|
74
49
|
function memberPathOf(node) {
|
|
75
50
|
if (typeof node !== 'string' || !node.startsWith('$')) return null;
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
51
|
+
const match = /^\$([^.[\]]+)([.[])/.exec(node);
|
|
52
|
+
if (match === null) return null;
|
|
53
|
+
try {
|
|
54
|
+
const { segments } = parseJSONPath(`$${node.slice(match[1].length + 1)}`);
|
|
55
|
+
if (!segments.every((segment) => !segment.descendant && segment.selectors.length === 1
|
|
56
|
+
&& ['name', 'index'].includes(segment.selectors[0].kind))) return null;
|
|
57
|
+
return { binding: match[1], path: segments.map((segment) => {
|
|
58
|
+
const selector = segment.selectors[0];
|
|
59
|
+
return selector.kind === 'name' ? selector.name : selector.index;
|
|
60
|
+
}) };
|
|
61
|
+
}
|
|
62
|
+
catch { return null; }
|
|
81
63
|
}
|
|
82
64
|
|
|
83
65
|
/** Read one member path out of a row; `undefined` where it is absent. */
|
|
@@ -85,7 +67,14 @@ function valueAt(row, path) {
|
|
|
85
67
|
let value = row;
|
|
86
68
|
for (const name of path) {
|
|
87
69
|
if (value === null || typeof value !== 'object') return undefined;
|
|
88
|
-
|
|
70
|
+
if (typeof name === 'number') {
|
|
71
|
+
if (!Array.isArray(value)) return undefined;
|
|
72
|
+
value = value[name < 0 ? value.length + name : name];
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
if (Array.isArray(value) || !Object.hasOwn(value, name)) return undefined;
|
|
76
|
+
value = value[name];
|
|
77
|
+
}
|
|
89
78
|
}
|
|
90
79
|
return value;
|
|
91
80
|
}
|
|
@@ -169,7 +158,7 @@ function locateFlwor(document) {
|
|
|
169
158
|
* @param {Map<string, any>} members - federated name → member record
|
|
170
159
|
* @returns {any}
|
|
171
160
|
*/
|
|
172
|
-
function planFederation(document, members) {
|
|
161
|
+
function planFederation(document, members, nested = false) {
|
|
173
162
|
const located = locateFlwor(document);
|
|
174
163
|
const query = located === null ? document : located.flwor;
|
|
175
164
|
const bindings = query?.$for;
|
|
@@ -178,10 +167,8 @@ function planFederation(document, members) {
|
|
|
178
167
|
'a federated document ranges over its sources with $for — this one has no bindings');
|
|
179
168
|
}
|
|
180
169
|
const names = Object.keys(bindings);
|
|
181
|
-
if (names.length
|
|
182
|
-
throw new LinqBuildError('JL0005',
|
|
183
|
-
`a federated fetch joins exactly two sources, not ${names.length} — `
|
|
184
|
-
+ 'federate one pair at a time, or load the third side yourself');
|
|
170
|
+
if (names.length < (nested ? 1 : 2)) {
|
|
171
|
+
throw new LinqBuildError('JL0005', 'a federated fetch needs at least two source bindings');
|
|
185
172
|
}
|
|
186
173
|
|
|
187
174
|
/** @type {any[]} */
|
|
@@ -191,15 +178,20 @@ function planFederation(document, members) {
|
|
|
191
178
|
// the two spellings the chain builds: a bare root, or the side's
|
|
192
179
|
// own packed document (its `$where`, its `$orderby`, its `$return`)
|
|
193
180
|
const packed = Array.isArray(value) && value.length === 1 ? value[0] : null;
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
// plan and will not guess at
|
|
181
|
+
// A source-local phrase has one bare root; a packed join is planned
|
|
182
|
+
// recursively and becomes a bounded intermediate side.
|
|
197
183
|
const inner = packed === null ? [] : Object.keys(packed.$for ?? {});
|
|
198
184
|
const root = packed === null ? value
|
|
199
185
|
: (inner.length === 1 ? packed.$for[inner[0]] : null);
|
|
200
186
|
if (typeof root !== 'string') {
|
|
201
|
-
|
|
202
|
-
|
|
187
|
+
if (packed === null || inner.length === 0) {
|
|
188
|
+
throw new LinqBuildError('JL0005',
|
|
189
|
+
`the binding '${binding}' does not range over a federated source`);
|
|
190
|
+
}
|
|
191
|
+
const child = planFederation(packed, members, true);
|
|
192
|
+
sides.push({ binding, nested: child, packed, root: null,
|
|
193
|
+
member: { name: binding, estimatedRows: undefined } });
|
|
194
|
+
continue;
|
|
203
195
|
}
|
|
204
196
|
const member = members.get(root);
|
|
205
197
|
if (member === undefined) {
|
|
@@ -210,52 +202,57 @@ function planFederation(document, members) {
|
|
|
210
202
|
sides.push({ binding, member, packed, root });
|
|
211
203
|
}
|
|
212
204
|
|
|
213
|
-
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const probe = build === first ? second : first;
|
|
222
|
-
return {
|
|
223
|
-
strategy: 'hash',
|
|
224
|
-
build: { ...build, key: key[build.binding] },
|
|
225
|
-
probe: { ...probe, key: key[probe.binding] },
|
|
226
|
-
// the resident document is the caller's own with each side reduced
|
|
227
|
-
// to its root: the packed work has already run at the source, and
|
|
228
|
-
// whatever the terminal wrapped around the query is put back
|
|
229
|
-
resident: located.rewrap({ ...query,
|
|
230
|
-
$for: Object.fromEntries(sides.map((side) => [side.binding, side.root])) }),
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
/**
|
|
235
|
-
* The equality key that links the two sides, or the refusal.
|
|
236
|
-
* @param {any} where
|
|
237
|
-
* @param {any[]} sides
|
|
238
|
-
* @returns {Record<string, string[]>}
|
|
239
|
-
*/
|
|
240
|
-
function joinKey(where, sides) {
|
|
241
|
-
const conjuncts = where === undefined || where === null ? []
|
|
242
|
-
: (Array.isArray(where?.$and) ? where.$and : [where]);
|
|
243
|
-
const [a, b] = sides;
|
|
244
|
-
for (const conjunct of conjuncts) {
|
|
245
|
-
const operands = conjunct?.$eq;
|
|
246
|
-
if (!Array.isArray(operands) || operands.length !== 2) continue;
|
|
205
|
+
// A mandatory equality graph supplies the fetch order. OR branches
|
|
206
|
+
// never prove an edge. Estimates choose among connected candidates;
|
|
207
|
+
// equal or absent estimates retain binding declaration order.
|
|
208
|
+
const edges = [];
|
|
209
|
+
const visit = (where) => {
|
|
210
|
+
if (Array.isArray(where?.$and)) { where.$and.forEach(visit); return; }
|
|
211
|
+
const operands = where?.$eq;
|
|
212
|
+
if (!Array.isArray(operands) || operands.length !== 2) return;
|
|
247
213
|
const left = memberPathOf(operands[0]);
|
|
248
214
|
const right = memberPathOf(operands[1]);
|
|
249
|
-
if (left
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
215
|
+
if (left && right && left.binding !== right.binding
|
|
216
|
+
&& names.includes(left.binding) && names.includes(right.binding)) edges.push({ left, right });
|
|
217
|
+
};
|
|
218
|
+
visit(query.$where);
|
|
219
|
+
const remaining = [...sides];
|
|
220
|
+
const order = [];
|
|
221
|
+
const selected = new Set();
|
|
222
|
+
while (remaining.length > 0) {
|
|
223
|
+
const candidates = remaining.filter((side) => selected.size === 0 || edges.some(({ left, right }) =>
|
|
224
|
+
(left.binding === side.binding && selected.has(right.binding))
|
|
225
|
+
|| (right.binding === side.binding && selected.has(left.binding))));
|
|
226
|
+
if (candidates.length === 0) throw new LinqBuildError('JL0005',
|
|
227
|
+
'a federated join needs an equality between one member of each side in a connected binding graph');
|
|
228
|
+
candidates.sort((a, b) => (a.member.estimatedRows ?? Infinity) - (b.member.estimatedRows ?? Infinity));
|
|
229
|
+
const side = candidates[0];
|
|
230
|
+
side.links = edges.flatMap(({ left, right }) => {
|
|
231
|
+
const [own, other] = left.binding === side.binding ? [left, right] : [right, left];
|
|
232
|
+
return own.binding === side.binding && selected.has(other.binding)
|
|
233
|
+
? [{ key: own.path, binding: other.binding, otherKey: other.path }] : [];
|
|
234
|
+
});
|
|
235
|
+
const edge = edges.find(({ left, right }) => left.binding === side.binding || right.binding === side.binding);
|
|
236
|
+
side.key = edge === undefined ? [] : (edge.left.binding === side.binding ? edge.left.path : edge.right.path);
|
|
237
|
+
order.push(side); selected.add(side.binding); remaining.splice(remaining.indexOf(side), 1);
|
|
238
|
+
}
|
|
239
|
+
// Bindings may independently project or alias the same source. Give
|
|
240
|
+
// those sets separate input members so one cannot overwrite another.
|
|
241
|
+
const used = new Set(sides.filter((side) => side.root !== null).map((side) => side.member.name));
|
|
242
|
+
const roots = new Set();
|
|
243
|
+
for (const side of sides) {
|
|
244
|
+
side.inputName = side.member.name;
|
|
245
|
+
if (side.root === null || roots.has(side.root)) {
|
|
246
|
+
let name = `_federated${sides.indexOf(side)}`;
|
|
247
|
+
while (used.has(name)) name += '_';
|
|
248
|
+
used.add(name); side.inputName = name; side.root = `$${memberSegment(name)}[*]`;
|
|
249
|
+
}
|
|
250
|
+
roots.add(side.root);
|
|
254
251
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
252
|
+
return { strategy: 'hash', sides: order, build: order[0], probe: order[1],
|
|
253
|
+
resident: located.rewrap({ ...query,
|
|
254
|
+
$for: Object.fromEntries(sides.map((side) => [side.binding,
|
|
255
|
+
side.packed === null ? side.root : [side.root]])) }) };
|
|
259
256
|
}
|
|
260
257
|
|
|
261
258
|
/**
|
|
@@ -274,7 +271,7 @@ function joinKey(where, sides) {
|
|
|
274
271
|
* @param {any[]} open - cursors to close, in order
|
|
275
272
|
* @returns {Promise<{ rows: any[], read: number, bytes: number, streamed: boolean }>}
|
|
276
273
|
*/
|
|
277
|
-
async function fetchSide(member, document, options, budget, keep, open) {
|
|
274
|
+
async function fetchSide(member, document, options, budget, keep, open, combined) {
|
|
278
275
|
const kept = [];
|
|
279
276
|
let read = 0;
|
|
280
277
|
let bytes = 0;
|
|
@@ -292,6 +289,11 @@ async function fetchSide(member, document, options, budget, keep, open) {
|
|
|
292
289
|
`the federated fetch of '${member.name}' reached its ${budget.maxBytes}-byte budget `
|
|
293
290
|
+ `at row ${kept.length + 1} — narrow the sides, or raise maxBytes`);
|
|
294
291
|
}
|
|
292
|
+
if (combined.rows + 1 > combined.maxRows || combined.bytes + size > combined.maxBytes) {
|
|
293
|
+
throw new LinqRuntimeError('JL2008',
|
|
294
|
+
'the federated fetch reached its combined row or byte budget — narrow the sides, or raise maxTotalRows/maxTotalBytes');
|
|
295
|
+
}
|
|
296
|
+
combined.rows++; combined.bytes += size;
|
|
295
297
|
bytes += size;
|
|
296
298
|
kept.push(row);
|
|
297
299
|
};
|
|
@@ -321,19 +323,18 @@ async function fetchSide(member, document, options, budget, keep, open) {
|
|
|
321
323
|
return { rows: kept, read, bytes, streamed: true };
|
|
322
324
|
}
|
|
323
325
|
stopIfAborted();
|
|
324
|
-
|
|
325
|
-
|
|
326
|
+
// Frame the sequence as one array so an array-valued row is still
|
|
327
|
+
// one row. Cursor sources already supply that item boundary.
|
|
328
|
+
const answer = await member.provider.execute([document], options);
|
|
329
|
+
if (!Array.isArray(answer)) throw new LinqRuntimeError('JL2008',
|
|
330
|
+
`the federated source '${member.name}' did not answer the requested array frame`);
|
|
331
|
+
for (const row of answer) { stopIfAborted(); admit(row); }
|
|
326
332
|
return { rows: kept, read, bytes, streamed: false };
|
|
327
333
|
}
|
|
328
334
|
|
|
329
|
-
/** The engine's answer shape as a row list: none, one, or many. */
|
|
330
|
-
function itemsOf(answer) {
|
|
331
|
-
if (answer === undefined) return [];
|
|
332
|
-
return Array.isArray(answer) ? answer : [answer];
|
|
333
|
-
}
|
|
334
|
-
|
|
335
335
|
/** One side's document, with the federated root rewritten to the source's own. */
|
|
336
336
|
function childDocument(side) {
|
|
337
|
+
if (side.nested) return side.packed;
|
|
337
338
|
const own = providerRoot(side.member.provider);
|
|
338
339
|
if (side.packed === null) return { $for: { it: own }, $return: '$it' };
|
|
339
340
|
const binding = Object.keys(side.packed.$for)[0];
|
|
@@ -344,7 +345,7 @@ function childDocument(side) {
|
|
|
344
345
|
* An explicit federation boundary over two or more provider sources.
|
|
345
346
|
*
|
|
346
347
|
* @param {{ sources: Record<string, any>, maxRows: number, maxBytes: number,
|
|
347
|
-
* strategy?: string }} spec
|
|
348
|
+
* maxTotalRows?: number, maxTotalBytes?: number, strategy?: string }} spec
|
|
348
349
|
* @returns {{ source: (name: string) => any, names: readonly string[] }}
|
|
349
350
|
* @example
|
|
350
351
|
* const fed = federate({
|
|
@@ -378,6 +379,11 @@ export function federate(spec) {
|
|
|
378
379
|
maxBytes: budgetOf(spec.maxBytes, 'maxBytes'),
|
|
379
380
|
};
|
|
380
381
|
|
|
382
|
+
const totals = {
|
|
383
|
+
maxRows: budgetOf(spec.maxTotalRows ?? budget.maxRows * 2, 'maxTotalRows'),
|
|
384
|
+
maxBytes: budgetOf(spec.maxTotalBytes ?? budget.maxBytes * 2, 'maxTotalBytes'),
|
|
385
|
+
};
|
|
386
|
+
|
|
381
387
|
/** The scope every member shares: what admits the join, and nothing else. */
|
|
382
388
|
const scope = Object.freeze({ federation: true });
|
|
383
389
|
/** @type {Map<string, any>} federated root → member */
|
|
@@ -415,34 +421,43 @@ export function federate(spec) {
|
|
|
415
421
|
const plan = planFederation(document, members);
|
|
416
422
|
/** @type {any[]} */
|
|
417
423
|
const open = [];
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
424
|
+
const combined = { ...totals, rows: 0, bytes: 0 };
|
|
425
|
+
const run = async (current, intermediate = false) => {
|
|
426
|
+
const fetched = new Map();
|
|
427
|
+
for (const side of current.sides) {
|
|
428
|
+
const tables = side.links.map((link) => {
|
|
429
|
+
const keys = new Set();
|
|
430
|
+
let unkeyed = false;
|
|
431
|
+
for (const row of fetched.get(link.binding)) {
|
|
432
|
+
const key = hashKey(valueAt(row, link.otherKey));
|
|
433
|
+
if (key === null) unkeyed = true; else keys.add(key);
|
|
434
|
+
}
|
|
435
|
+
return { ...link, keys, unkeyed };
|
|
436
|
+
});
|
|
437
|
+
const keep = (row) => tables.every((table) => {
|
|
438
|
+
const key = hashKey(valueAt(row, table.key));
|
|
439
|
+
return table.unkeyed || key === null || table.keys.has(key);
|
|
440
|
+
});
|
|
441
|
+
const member = side.nested ? { ...side.member,
|
|
442
|
+
provider: { execute: () => run(side.nested, true) } } : side.member;
|
|
443
|
+
const found = await fetchSide(member, childDocument(side), options, budget, keep, open, combined);
|
|
444
|
+
fetched.set(side.binding, found.rows);
|
|
430
445
|
}
|
|
431
|
-
const
|
|
432
|
-
const
|
|
433
|
-
(
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const answer =
|
|
446
|
+
const input = Object.fromEntries(current.sides.map((side) => [side.inputName, fetched.get(side.binding)]));
|
|
447
|
+
const limits = intermediate ? { ...options?.limits,
|
|
448
|
+
sequenceItems: Math.min(options?.limits?.sequenceItems ?? Infinity, budget.maxRows),
|
|
449
|
+
resultItems: Math.min(options?.limits?.resultItems ?? Infinity, budget.maxRows) } : options?.limits;
|
|
450
|
+
const compiled = compileDocument(intermediate ? [current.resident] : current.resident,
|
|
451
|
+
{ ...options, limits, externals: options?.externalNames ?? [] });
|
|
452
|
+
try { return compiled(input, options?.externals ?? {}); }
|
|
453
|
+
catch (error) {
|
|
454
|
+
if (intermediate && error.code === 'JQ2009' && /sequenceItems|resultItems/.test(error.message)) throw new LinqRuntimeError('JL2008',
|
|
455
|
+
'a federated intermediate join reached its row budget');
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
try {
|
|
460
|
+
const answer = await run(plan);
|
|
446
461
|
// the fetch answered, so a cursor that will not close IS this
|
|
447
462
|
// call's failure rather than something to swallow
|
|
448
463
|
await closeAll(open);
|
|
@@ -484,13 +499,16 @@ export function federate(spec) {
|
|
|
484
499
|
source: side.member.name,
|
|
485
500
|
root: side.root,
|
|
486
501
|
estimatedRows: side.member.estimatedRows ?? null,
|
|
487
|
-
key: `$${side.binding}
|
|
502
|
+
key: `$${side.binding}${side.key.map((part) => typeof part === 'number' ? `[${part}]` : memberSegment(part)).join('')}`,
|
|
488
503
|
document: childDocument(side),
|
|
489
|
-
streaming: typeof side.member.provider
|
|
504
|
+
streaming: typeof side.member.provider?.cursor === 'function' ? 'row' : 'buffered',
|
|
505
|
+
...(side.nested ? { children: side.nested.sides.map(describe) } : {}),
|
|
490
506
|
});
|
|
491
507
|
return {
|
|
492
508
|
strategy: plan.strategy,
|
|
493
509
|
budget: { ...budget },
|
|
510
|
+
combinedBudget: { maxTotalRows: totals.maxRows, maxTotalBytes: totals.maxBytes },
|
|
511
|
+
order: plan.sides.map(describe),
|
|
494
512
|
build: describe(plan.build),
|
|
495
513
|
probe: describe(plan.probe),
|
|
496
514
|
// the join itself is the engine's, over what the two fetches
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
/** Formula authoring emits the same JSON document consumed by json/formula. */
|
|
3
|
+
import { formulaDocument } from '@jarenjs/json/formula';
|
|
4
|
+
import { requireJson } from '../json-boundary.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Author a saved Query profile; expression is an existing Query document.
|
|
8
|
+
* @param {string} id
|
|
9
|
+
* @param {any} expression
|
|
10
|
+
* @param {object} [options] - Revision, bindings, schema/helper references and result mode.
|
|
11
|
+
* @returns {any} Frozen JSON, with no executable closures in the document.
|
|
12
|
+
*/
|
|
13
|
+
export function defineFormula(id, expression, options = {}) {
|
|
14
|
+
return formulaDocument(requireJson({ $formula: '1', revision: '1', ...options, id, expression }, 'defineFormula'));
|
|
15
|
+
}
|
package/src/model/define.js
CHANGED
|
@@ -116,6 +116,8 @@ export function defineModel(spec) {
|
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
118
|
const entity = { schema: builder.schema };
|
|
119
|
+
if (builder.state.invariants !== undefined) entity.invariants = builder.state.invariants;
|
|
120
|
+
if (builder.state.physical !== undefined) entity.physical = builder.state.physical;
|
|
119
121
|
if (builder.state.renamedFrom !== undefined) entity['x-rename'] = builder.state.renamedFrom;
|
|
120
122
|
setObjectMember(emitted, name, entity);
|
|
121
123
|
}
|
package/src/model/entity.js
CHANGED
|
@@ -218,6 +218,21 @@ export function withEntity(Base) {
|
|
|
218
218
|
*/
|
|
219
219
|
renamedFrom(name) { return this.with({ renamedFrom: requireName(name, 'renamedFrom()') }); }
|
|
220
220
|
|
|
221
|
+
/** Explicit existing columns, lifted onto the entity declaration.
|
|
222
|
+
* @param {any} layout */
|
|
223
|
+
physical(layout) {
|
|
224
|
+
if (this.state.kind !== 'object') throw new LinqBuildError('JL0102', 'physical() belongs on an entity object');
|
|
225
|
+
return this.with({ physical: requireJson(layout, 'physical()') });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
/** Declare persistence predicates, with explicit writer enforcement.
|
|
230
|
+
* @param {any[]} rules */
|
|
231
|
+
invariants(rules) {
|
|
232
|
+
if (this.state.kind !== 'object') throw new LinqBuildError('JL0102', 'invariants() belongs on an entity object');
|
|
233
|
+
return this.with({ invariants: requireJson(rules, 'invariants()') });
|
|
234
|
+
}
|
|
235
|
+
|
|
221
236
|
/** As in the schema pen, but `x-entity` is owned here. @param {Record<string, any>} annotations */
|
|
222
237
|
meta(annotations) {
|
|
223
238
|
if (annotations !== null && typeof annotations === 'object' && KEYWORD in annotations) {
|
package/src/project/index.js
CHANGED
|
@@ -5,24 +5,25 @@ import { LinqBuildError } from '../errors.js';
|
|
|
5
5
|
|
|
6
6
|
/** The public project file vocabulary, held equal to Studio's schema by tests. */
|
|
7
7
|
export const FILE_KINDS = Object.freeze(['app', 'jslt', 'query', 'state', 'data', 'schema', 'fsm', 'dag', 'model', 'contract']);
|
|
8
|
+
const FILE_OPTIONS = ['imports', 'input', 'model', 'collection'];
|
|
8
9
|
const LAYOUT_KEYS = ['mode', 'ratio', 'autorun'];
|
|
9
10
|
|
|
10
11
|
/** One file, preserving the caller's text byte-for-byte. */
|
|
11
|
-
export function file(name, kind, text) {
|
|
12
|
+
export function file(name, kind, text, options = {}) {
|
|
12
13
|
if (typeof name !== 'string' || name.length === 0 || !FILE_KINDS.includes(kind) || typeof text !== 'string')
|
|
13
14
|
throw new LinqBuildError('JL0101', 'file() requires a nonempty name, a declared kind and text');
|
|
14
|
-
return snapshot({ name, kind, text });
|
|
15
|
+
return snapshot({ name, kind, text, ...optionsOf(options, FILE_OPTIONS, 'file()') });
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
/** One file holding a public JSON value; pass another pen's `.schema` explicitly. */
|
|
18
|
-
export function jsonFile(name, kind, document) { return file(name, kind, JSON.stringify(snapshot(document))); }
|
|
19
|
+
export function jsonFile(name, kind, document, options = {}) { return file(name, kind, JSON.stringify(snapshot(document)), options); }
|
|
19
20
|
|
|
20
21
|
/** Validate the authoring shape, preserving duplicate names for Studio to judge. */
|
|
21
22
|
function filesOf(files) {
|
|
22
23
|
if (!Array.isArray(files)) throw new LinqBuildError('JL0101', 'files() takes an array of project files');
|
|
23
24
|
return files.map((value) => {
|
|
24
|
-
const
|
|
25
|
-
return file(
|
|
25
|
+
const { name, kind, text, ...options } = optionsOf(value, ['name', 'kind', 'text', ...FILE_OPTIONS], 'file');
|
|
26
|
+
return file(name, kind, text, options);
|
|
26
27
|
});
|
|
27
28
|
}
|
|
28
29
|
|