@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/src/federate.js CHANGED
@@ -1,51 +1,26 @@
1
1
  //@ts-check
2
2
  /**
3
- * @file The federation boundary (QUERY-PEN.md §13): an EXPLICIT opt-in
4
- * to reading two different provider sources under one query document.
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
- * The rule everywhere else in this package is that a query document
7
- * reads ONE input, and a join whose sides come from two unrelated
8
- * sources is `JL0005` at build time. That refusal is not a limitation
9
- * to route around it is what keeps a chain honest about where the
10
- * work happens. A cross-source join cannot be pushed anywhere: someone
11
- * has to hold rows in memory, and a surface that did it implicitly
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
- * So it is spelled out instead. `federate()` takes the sources by
16
- * name, takes the budgets that make the fetch finite, and hands back
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.isInteger(value) || value < 1) {
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 parts = node.slice(1).split('.');
77
- if (parts.length < 2) return null;
78
- const [binding, ...path] = parts;
79
- if (binding === '' || path.some((name) => name === '')) return null;
80
- return { binding, path };
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
- value = value[name];
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 !== 2) {
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
- // a packed side ranges over ONE root: a side that is itself a join
195
- // is a federation of a federation, which this boundary does not
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
- throw new LinqBuildError('JL0005',
202
- `the binding '${binding}' does not range over a federated source`);
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
- const key = joinKey(query.$where, sides);
214
- // the estimate decides which side fills the table: a hash join holds
215
- // the BUILD side whole, so the smaller declared side is the one to
216
- // hold. Undeclared estimates keep the caller's own order, which is
217
- // stable and says so in `explain()`
218
- const [first, second] = sides;
219
- const build = (second.member.estimatedRows ?? Infinity)
220
- < (first.member.estimatedRows ?? Infinity) ? second : first;
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 === null || right === null) continue;
250
- if (left.binding === a.binding && right.binding === b.binding)
251
- return { [a.binding]: left.path, [b.binding]: right.path };
252
- if (left.binding === b.binding && right.binding === a.binding)
253
- return { [b.binding]: left.path, [a.binding]: right.path };
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
- throw new LinqBuildError('JL0005',
256
- 'a federated join needs an equality between one member of each side — without one the '
257
- + 'fetch is the cross product of two sources, which is what the budget exists to refuse '
258
- + '(a non-equality condition still applies, but it cannot bound the fetch)');
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
- const answer = await member.provider.execute(document, options);
325
- for (const row of itemsOf(answer)) admit(row);
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
- try {
419
- const buildDoc = childDocument(plan.build);
420
- const built = await fetchSide(plan.build.member, buildDoc, options, budget,
421
- () => true, open);
422
- // the table is the REDUCTION, never the answer: it says which
423
- // keys can pair, and the engine decides which rows do
424
- const keys = new Set();
425
- let unkeyed = false;
426
- for (const row of built.rows) {
427
- const key = hashKey(valueAt(row, plan.build.key));
428
- if (key === null) unkeyed = true;
429
- else keys.add(key);
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 probeDoc = childDocument(plan.probe);
432
- const probed = await fetchSide(plan.probe.member, probeDoc, options, budget,
433
- (row) => {
434
- if (unkeyed) return true;
435
- const key = hashKey(valueAt(row, plan.probe.key));
436
- return key === null || keys.has(key);
437
- }, open);
438
-
439
- const input = {
440
- [plan.build.member.name]: built.rows,
441
- [plan.probe.member.name]: probed.rows,
442
- };
443
- const compiled = compileDocument(plan.resident,
444
- { ...options, externals: options?.externalNames ?? [] });
445
- const answer = compiled(input, options?.externals ?? {});
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}.${side.key.join('.')}`,
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.cursor === 'function' ? 'row' : 'buffered',
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
+ }
@@ -135,7 +135,8 @@ export const CATALOGS = deepFreeze({
135
135
  ],
136
136
  "x-form/assert": [],
137
137
  "form/addItem": [],
138
- "form/removeItem": []
138
+ "form/removeItem": [],
139
+ "form/jsonPlaceholder": []
139
140
  },
140
141
  "contract": {
141
142
  "contract/not-found": [],
@@ -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
  }
@@ -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) {
@@ -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 f = optionsOf(value, ['name', 'kind', 'text'], 'file');
25
- return file(f.name, f.kind, f.text);
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