@jarenjs/linq 0.46.5 → 0.56.0

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.
Files changed (77) hide show
  1. package/ARCHITECTURE.md +217 -0
  2. package/README.md +566 -17
  3. package/docs/APP-PEN.md +1143 -0
  4. package/docs/CONTRACT-PEN.md +1217 -0
  5. package/docs/DB-CLIENT.md +814 -0
  6. package/docs/FLOW-PEN.md +1026 -0
  7. package/docs/FORMS-PEN.md +940 -0
  8. package/docs/JSLT-PEN.md +955 -0
  9. package/docs/LINQ-FORMAT.md +774 -384
  10. package/docs/MIGRATION-PEN.md +781 -0
  11. package/docs/MODEL-PEN.md +1083 -0
  12. package/docs/QUERY-PEN.md +1636 -0
  13. package/docs/SCHEMA-PEN.md +1218 -0
  14. package/package.json +57 -4
  15. package/src/app/action.js +255 -0
  16. package/src/app/capture.js +63 -0
  17. package/src/app/define.js +260 -0
  18. package/src/app/index.js +20 -0
  19. package/src/app/patch.js +277 -0
  20. package/src/app/sub.js +106 -0
  21. package/src/async.js +329 -75
  22. package/src/capture-root.js +82 -0
  23. package/src/concurrency.js +9 -4
  24. package/src/contract/define.js +269 -0
  25. package/src/contract/http.js +247 -0
  26. package/src/contract/index.js +23 -0
  27. package/src/contract/operation.js +342 -0
  28. package/src/db/handle.js +86 -0
  29. package/src/db/include.js +316 -0
  30. package/src/db/index.js +19 -0
  31. package/src/db/live.js +43 -0
  32. package/src/db/membership.js +37 -0
  33. package/src/db/open.js +82 -0
  34. package/src/document.js +143 -13
  35. package/src/effect.js +65 -0
  36. package/src/errors.js +69 -6
  37. package/src/expression.js +532 -39
  38. package/src/flow/capture.js +33 -0
  39. package/src/flow/dag.js +302 -0
  40. package/src/flow/fsm.js +328 -0
  41. package/src/flow/index.js +22 -0
  42. package/src/forms/index.js +43 -0
  43. package/src/forms/rules.js +170 -0
  44. package/src/forms/submit.js +177 -0
  45. package/src/index.js +4 -2
  46. package/src/jslt/body.js +226 -0
  47. package/src/jslt/index.js +18 -0
  48. package/src/jslt/rules.js +207 -0
  49. package/src/json-boundary.js +90 -0
  50. package/src/migration/define.js +323 -0
  51. package/src/migration/index.js +15 -0
  52. package/src/migration/steps.js +248 -0
  53. package/src/model/collection.js +171 -0
  54. package/src/model/define.js +125 -0
  55. package/src/model/entity.js +307 -0
  56. package/src/model/index.js +47 -0
  57. package/src/model/relation.js +85 -0
  58. package/src/provider.js +137 -20
  59. package/src/schema/brand.js +31 -0
  60. package/src/schema/builders.js +526 -0
  61. package/src/schema/check.js +29 -0
  62. package/src/schema/emit.js +394 -0
  63. package/src/schema/factories.js +239 -0
  64. package/src/schema/index.js +37 -0
  65. package/src/schema-of.js +24 -0
  66. package/src/sequence.js +233 -103
  67. package/src/sources.js +10 -3
  68. package/types/app.d.ts +293 -0
  69. package/types/contract.d.ts +371 -0
  70. package/types/db.d.ts +188 -0
  71. package/types/flow.d.ts +285 -0
  72. package/types/forms.d.ts +253 -0
  73. package/types/index.d.ts +389 -41
  74. package/types/jslt.d.ts +193 -0
  75. package/types/migration.d.ts +201 -0
  76. package/types/model.d.ts +493 -0
  77. package/types/schema.d.ts +494 -0
package/src/expression.js CHANGED
@@ -15,9 +15,18 @@
15
15
  *
16
16
  * Method names shadow member access: `u.eq` is the operator, never the
17
17
  * data member — reach a colliding member with `u.get('eq')`.
18
+ *
19
+ * A root whose items are an entity's rows carries the entity's RELATION
20
+ * TABLE (QUERY-PEN §3, MODEL-FORMAT §10.1): a member access naming a
21
+ * relation records a HOP and lowers, right here, to the correlated
22
+ * phrase the engine and the store both run — `p.author.email` is
23
+ * `{ $for: { r1: '$.User[*]' }, $where: { $eq: ['$r1.id', '$it.authorId'] },
24
+ * $return: '$r1.email' }` — so the emitted document never carries a
25
+ * relation name and never needs a dialect the oracle could not prove.
18
26
  */
19
27
 
20
28
  import { LinqBuildError } from './errors.js';
29
+ import { GROUP_ITEMS } from './document.js';
21
30
 
22
31
  /** The unwrap key: proxy → its internal record. */
23
32
  const NODE = Symbol('jaren-linq-node');
@@ -26,14 +35,71 @@ const NODE = Symbol('jaren-linq-node');
26
35
  * goes through a bracketed, single-quoted selector. */
27
36
  const SHORTHAND_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
28
37
 
29
- let activeEpoch = 0;
38
+ /** A bare binding variable (`$it`, `$it2`, `$r1`): the one subject a
39
+ * hop correlates with directly; a fanned path is bound first. */
40
+ const BARE_VAR_RE = /^\$[A-Za-z_][A-Za-z0-9_]*$/;
41
+
42
+ let epochCounter = 0;
43
+
44
+ /**
45
+ * The captures in progress, innermost last. A proxy is LIVE while its
46
+ * capture is on this stack and CURRENT only at the top: a nested
47
+ * capture (a chain built and run inside another callback) leaves the
48
+ * enclosing proxies usable once it returns, while a proxy of the
49
+ * enclosing capture used INSIDE the nested one is refused by name —
50
+ * the inner document would rebind `$it`, so the outer reference would
51
+ * silently point at the wrong item.
52
+ * @type {number[]}
53
+ */
54
+ const captureStack = [];
30
55
 
31
56
  /** @param {any} record */
32
57
  function assertLive(record) {
33
- if (record.epoch !== activeEpoch) {
58
+ if (record.epoch === captureStack[captureStack.length - 1]) return;
59
+ if (captureStack.includes(record.epoch)) {
34
60
  throw new LinqBuildError('JL0002',
35
- 'an expression proxy escaped its capture callback; expressions cannot be stored and replayed across operators');
61
+ 'an expression proxy of an enclosing capture was used inside a nested capture '
62
+ + 'a correlated subquery cannot be spelled this way (the inner document rebinds the '
63
+ + 'item); compute the inner query first and use its result');
36
64
  }
65
+ throw new LinqBuildError('JL0002',
66
+ 'an expression proxy escaped its capture callback; expressions cannot be stored and replayed across operators');
67
+ }
68
+
69
+ /**
70
+ * A member name as an RFC 9535 bracketed name selector. Backslashes and
71
+ * quotes are escaped, and so are the control characters the grammar
72
+ * forbids unescaped (U+0000–U+001F): without this a key holding a tab
73
+ * emitted a path the engine refused at RUN time (`JQ0004`), while
74
+ * `get()` is documented as the way to reach any key at all.
75
+ * @param {string} name
76
+ * @returns {string}
77
+ */
78
+ function bracketName(name) {
79
+ let escaped = '';
80
+ for (const c of name) {
81
+ const code = c.charCodeAt(0);
82
+ if (c === '\\') escaped += '\\\\';
83
+ else if (c === "'") escaped += "\\'";
84
+ else if (code >= 0x20) escaped += c;
85
+ else if (c === '\b') escaped += '\\b';
86
+ else if (c === '\f') escaped += '\\f';
87
+ else if (c === '\n') escaped += '\\n';
88
+ else if (c === '\r') escaped += '\\r';
89
+ else if (c === '\t') escaped += '\\t';
90
+ else escaped += '\\u' + code.toString(16).padStart(4, '0');
91
+ }
92
+ return `['${escaped}']`;
93
+ }
94
+
95
+ /**
96
+ * One member step on a path: the shorthand for an identifier, the
97
+ * bracketed selector for anything else.
98
+ * @param {string} name
99
+ * @returns {string}
100
+ */
101
+ function memberSegment(name) {
102
+ return SHORTHAND_RE.test(name) ? `.${name}` : bracketName(name);
37
103
  }
38
104
 
39
105
  /**
@@ -63,7 +129,7 @@ function embedString(s) {
63
129
  * where the caller can see which value it was.
64
130
  * @param {any} value
65
131
  */
66
- function isPlainJson(value) {
132
+ export function isPlainJson(value) {
67
133
  if (value === null) return true;
68
134
  const type = typeof value;
69
135
  if (type === 'string' || type === 'boolean') return true;
@@ -81,17 +147,42 @@ function isPlainJson(value) {
81
147
  return true;
82
148
  }
83
149
 
150
+ /**
151
+ * Refuse a parameter value that is not query data. A binding travels
152
+ * into a document as an external, and later into a provider as a bound
153
+ * SQL parameter, so a `Date`, `Map`, `NaN` or `-0` here would compare
154
+ * against nothing and answer `[]` with no error anywhere. One check for
155
+ * both surfaces, one message.
156
+ * @param {string} name
157
+ * @param {any} value
158
+ */
159
+ export function requireJsonBinding(name, value) {
160
+ if (isPlainJson(value)) return;
161
+ const what = value !== null && typeof value === 'object'
162
+ ? `a ${value.constructor?.name ?? 'non-plain'} instance`
163
+ : typeof value === 'number' ? (Object.is(value, -0) ? '-0' : String(value))
164
+ : `a ${typeof value}`;
165
+ throw new LinqBuildError('JL0004',
166
+ `parameter '${name}' is bound to ${what}, which is not query data — convert it `
167
+ + 'first (a Date to its ISO string or epoch number, a Map to an object, NaN or -0 to a number)');
168
+ }
169
+
84
170
  /**
85
171
  * Turn a captured callback result — a proxy, a literal, or a plain
86
172
  * object/array tree containing proxies — into a query expression.
87
173
  * Objects without `$`-prefixed keys become Rule 1 constructors; a
88
174
  * `$`-keyed data object embeds through `$map` so it stays a
89
175
  * constructor rather than colliding with the operator vocabulary;
90
- * pure data trees embed as `$const`.
176
+ * pure data trees embed as `$const` — unless `fold` is false, when
177
+ * they are spelled as constructor trees too (a stylesheet body writes
178
+ * its output the way the format's own examples do: `{ "level":
179
+ * "unknown" }`, not `{ "$const": … }` — the same value, the published
180
+ * spelling).
91
181
  * @param {any} value
182
+ * @param {boolean} [fold] - whether a pure data tree folds into one `$const`
92
183
  * @returns {any} a query expression (plain JSON)
93
184
  */
94
- export function toExpression(value) {
185
+ export function toExpression(value, fold = true) {
95
186
  if (value === null) return null;
96
187
  const t = typeof value;
97
188
  if (t === 'string') return embedString(value);
@@ -117,7 +208,7 @@ export function toExpression(value) {
117
208
  assertLive(record);
118
209
  return record.doc;
119
210
  }
120
- if (isPlainJson(value)) {
211
+ if (fold && isPlainJson(value)) {
121
212
  // verbatim data: cheaper and clearer than a constructor tree
122
213
  return { $const: value };
123
214
  }
@@ -128,7 +219,7 @@ export function toExpression(value) {
128
219
  'a captured expression cannot embed an Array subclass instance — its behaviour '
129
220
  + 'is not expressible as query data');
130
221
  }
131
- return value.map(toExpression);
222
+ return value.map((v) => toExpression(v, fold));
132
223
  }
133
224
  if (proto !== Object.prototype && proto !== null) {
134
225
  // a Date, Map, Set, RegExp or class instance: `Object.keys` reports
@@ -142,14 +233,14 @@ export function toExpression(value) {
142
233
  }
143
234
  const keys = Object.keys(value);
144
235
  if (keys.some((k) => k.charCodeAt(0) === 0x24)) {
145
- return { $map: keys.map((k) => [embedString(k), toExpression(value[k])]) };
236
+ return { $map: keys.map((k) => [embedString(k), toExpression(value[k], fold)]) };
146
237
  }
147
238
  const out = {};
148
239
  // an own `__proto__` member is DATA here; plain assignment would set
149
240
  // the builder's prototype and drop the member
150
241
  for (const key of keys) {
151
242
  Object.defineProperty(out, key, {
152
- value: toExpression(value[key]),
243
+ value: toExpression(value[key], fold),
153
244
  writable: true,
154
245
  enumerable: true,
155
246
  configurable: true,
@@ -158,7 +249,9 @@ export function toExpression(value) {
158
249
  return out;
159
250
  }
160
251
  throw new LinqBuildError('JL0005',
161
- `a captured expression cannot embed a ${t === 'undefined' ? 'undefined' : t} value`);
252
+ // `typeof undefined` IS 'undefined', so the two arms of the article
253
+ // are what differ here — not the noun
254
+ `a captured expression cannot embed ${t === 'undefined' ? 'an' : 'a'} ${t} value`);
162
255
  }
163
256
 
164
257
  /** Binary operator helper. @param {string} op */
@@ -171,9 +264,58 @@ const unary = (op) => function (/** @type {any} */ record) {
171
264
  return makeExpr({ [op]: record.doc }, record.epoch, false);
172
265
  };
173
266
 
267
+ /**
268
+ * A unary operator that ranges over ITEMS: on a root that stands for
269
+ * an array value with a fanned twin (`groupJoin`'s group is bound as an
270
+ * array so `at`/`all`/member position work, and its aggregates count
271
+ * the members, not the array), the operator applies to the fanned
272
+ * path; elsewhere it is `unary`.
273
+ * @param {string} op
274
+ */
275
+ const fanned = (op) => function (/** @type {any} */ record) {
276
+ return makeExpr({ [op]: record.seq ?? record.doc }, record.epoch, false);
277
+ };
278
+
279
+ /**
280
+ * `$date-add` / `$date-sub`: `[date, duration]` or `[date, amount, unit]`.
281
+ * @param {any} record
282
+ * @param {any} amount - an ISO 8601 duration, or a number of units
283
+ * @param {any} [unit] - the calendar unit, when `amount` is a number
284
+ * @returns {any[]}
285
+ */
286
+ function shiftArgs(record, amount, unit) {
287
+ return unit === undefined
288
+ ? [record.doc, toExpression(amount)]
289
+ : [record.doc, toExpression(amount), toExpression(unit)];
290
+ }
291
+
292
+ /**
293
+ * A §8.16 spec: captured data, embedded verbatim.
294
+ *
295
+ * These are the one place this surface hands the compiler something it
296
+ * must NOT evaluate — a width, an aggregate, a fill policy and a row
297
+ * selector are read once when the query compiles, which is what makes
298
+ * them checkable at all. `toExpression` would turn `{ every: 'PT1H' }`
299
+ * into a map constructor and `{ at: '$.on' }` into a path; the spec is
300
+ * therefore embedded as it was written, and every rule about what it may
301
+ * contain stays where it already is, in the query compiler (`JQ0003`).
302
+ *
303
+ * @param {any} spec
304
+ * @param {string} method - for the message
305
+ * @returns {any} the spec, verbatim
306
+ */
307
+ function literalSpec(spec, method) {
308
+ if (!isPlainJson(spec) || spec === null || typeof spec !== 'object' || Array.isArray(spec)) {
309
+ throw new LinqBuildError('JL0005',
310
+ `${method}() takes a plain literal spec object; it is read once when the query`
311
+ + ' compiles, so it cannot be an expression or carry a captured value');
312
+ }
313
+ return spec;
314
+ }
315
+
174
316
  /**
175
317
  * The operator methods, name → builder(record, ...args). One table so
176
- * the mapping in LINQ-FORMAT.md §4 has exactly one code counterpart.
318
+ * the mapping in QUERY-PEN.md §4 has exactly one code counterpart.
177
319
  * Null prototype: `constructor`/`toString` must read as member access,
178
320
  * never as inherited "methods".
179
321
  */
@@ -190,7 +332,7 @@ const METHODS = {
190
332
  div: binary('$div'), idiv: binary('$idiv'), mod: binary('$mod'),
191
333
  neg: unary('$neg'),
192
334
  // §8.2 existence
193
- exists: unary('$exists'), isEmpty: unary('$empty'),
335
+ exists: fanned('$exists'), isEmpty: fanned('$empty'),
194
336
  // §8.7 strings
195
337
  startsWith: binary('$starts-with'), endsWith: binary('$ends-with'),
196
338
  contains: binary('$contains'), matches: binary('$match'),
@@ -210,12 +352,67 @@ const METHODS = {
210
352
  },
211
353
  // §8.8 aggregates as EXPRESSIONS (a group inside a projection:
212
354
  // `(u, g) => ({ n: g.count() })`)
213
- count: unary('$count'), sum: unary('$sum'), avg: unary('$avg'),
214
- min: unary('$min'), max: unary('$max'),
215
- // §8.13 dates (the scalar component family; the full date surface
216
- // arrives with the relational order)
355
+ count: fanned('$count'), sum: fanned('$sum'), avg: fanned('$avg'),
356
+ min: fanned('$min'), max: fanned('$max'),
357
+ // §8.13 dates the whole family, not a corner of it. A date in this
358
+ // suite is an RFC 3339 STRING, so every one of these is an ordinary
359
+ // string operator with a calendar's worth of rules behind it, and
360
+ // every one lowers to the operator of the same name: there is no
361
+ // LINQ-only date semantics to learn and nothing here a hand-written
362
+ // document could not have said.
363
+ //
364
+ // `dateFormat` rather than `format`, and `dateAdd`/`dateSub` rather
365
+ // than `add`/`sub`, because `add` is already `$add` on this surface —
366
+ // the same reason §8.14 spells `geoArea`. Where no method name is
367
+ // taken, the operator's own name is used unprefixed (`week`,
368
+ // `quarter`, `startOf`).
217
369
  year: unary('$year'), month: unary('$month'), day: unary('$day'),
218
- epoch: unary('$epoch'),
370
+ hours: unary('$hours'), minutes: unary('$minutes'), seconds: unary('$seconds'),
371
+ offset: unary('$offset'), epoch: unary('$epoch'), datetime: unary('$datetime'),
372
+ week: unary('$week'), weekYear: unary('$week-year'),
373
+ quarter: unary('$quarter'), weekday: unary('$weekday'),
374
+ isDate: unary('$is-date'), isTime: unary('$is-time'),
375
+ isDatetime: unary('$is-datetime'), isDuration: unary('$is-duration'),
376
+ startOf: binary('$start-of'), endOf: binary('$end-of'),
377
+ dateFormat: binary('$date-format'),
378
+ dateAdd(record, amount, unit) {
379
+ return makeExpr({ '$date-add': shiftArgs(record, amount, unit) }, record.epoch, false);
380
+ },
381
+ dateSub(record, amount, unit) {
382
+ return makeExpr({ '$date-sub': shiftArgs(record, amount, unit) }, record.epoch, false);
383
+ },
384
+ dateDiff(record, to, unit) {
385
+ return makeExpr(
386
+ { '$date-diff': [record.doc, toExpression(to), toExpression(unit)] },
387
+ record.epoch, false);
388
+ },
389
+ // §8.16 time series. The three sequence-valued operators take a
390
+ // VERBATIM spec literal, so the argument is embedded with `$const`'s
391
+ // discipline - it is captured data, never an expression - and the
392
+ // compiler owns every rule about what it may say.
393
+ overlaps: binary('$overlaps'),
394
+ timeBucket(record, every, origin, context) {
395
+ const args = [record.doc, toExpression(every)];
396
+ if (origin !== undefined || context !== undefined)
397
+ args.push(origin === undefined ? null : toExpression(origin));
398
+ if (context !== undefined)
399
+ args.push(literalSpec(context, 'timeBucket'));
400
+ return makeExpr({ '$time-bucket': args }, record.epoch, false);
401
+ },
402
+ resample(record, spec) {
403
+ return makeExpr({ $resample: [record.doc, literalSpec(spec, 'resample')] },
404
+ record.epoch, false);
405
+ },
406
+ rolling(record, spec) {
407
+ return makeExpr({ $rolling: [record.doc, literalSpec(spec, 'rolling')] },
408
+ record.epoch, false);
409
+ },
410
+ asof(record, right, spec) {
411
+ const args = [record.doc, toExpression(right)];
412
+ if (spec !== undefined)
413
+ args.push(literalSpec(spec, 'asof'));
414
+ return makeExpr({ $asof: args }, record.epoch, false);
415
+ },
219
416
  // §8.14 spatial. `geoArea`/`geoLength` rather than `area`/`length`:
220
417
  // `length` is already `$string-length` on this surface and renaming a
221
418
  // shipped method for symmetry is a breaking change for a cosmetic
@@ -250,6 +447,11 @@ const METHODS = {
250
447
  return makeExpr({ $get: [record.doc, toExpression(index)] }, record.epoch, false);
251
448
  },
252
449
  all(record) {
450
+ // a hop's related rows, fanned: the phrase itself, a sequence
451
+ if (record.hop !== undefined) return makeHop({ ...record.hop, fan: true }, record.epoch, record.nav);
452
+ // a bound array's fan carries the array's navigation (a group-join's
453
+ // group holds the inner rows; a hop off them binds each row first)
454
+ if (record.seq !== undefined) return makeExpr(record.seq, record.epoch, true, { nav: record.nav });
253
455
  if (!record.pathable) {
254
456
  throw new LinqBuildError('JL0005',
255
457
  "all() fans out a PATH ('$it.tags[*]'); it cannot follow an operator result");
@@ -257,24 +459,249 @@ const METHODS = {
257
459
  return makeExpr(`${record.doc}[*]`, record.epoch, true);
258
460
  },
259
461
  get(record, name) {
260
- if (typeof name === 'string' && record.pathable) {
261
- return makeExpr(`${record.doc}['${name.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}']`,
262
- record.epoch, true);
462
+ if (typeof name === 'string') {
463
+ // a relation name hops through get() as it does through member
464
+ // access — the escape reaches a relation that collides with a
465
+ // method name (`get('count')`), not a stored member of that name
466
+ if (navigates(record, name)) return startHop(record, name);
467
+ if (record.hop !== undefined) return extendHop(record, bracketName(name));
468
+ if (record.pathable) return pathStep(record, name, bracketName(name));
263
469
  }
264
470
  return makeExpr({ $get: [record.doc, toExpression(name)] }, record.epoch, false);
265
471
  },
266
472
  };
267
473
 
474
+ //#region relation hops
475
+
476
+ /**
477
+ * The root spelling of an entity array in a multi-entity document —
478
+ * MODEL-FORMAT §10.1's `$.<Entity>[*]`, the one spelling the store's
479
+ * planner reads. Spelled here, never imported.
480
+ * @param {string} name
481
+ */
482
+ const entityRootOf = (name) => `$.${name}[*]`;
483
+
484
+ /**
485
+ * Whether a member name on this record is a relation of the entity
486
+ * whose rows the record stands for: the record carries a relation
487
+ * table (a binding root, or a hop's target at its root) and the table
488
+ * names the member. A group-join's group carries its table for its FAN
489
+ * only — the group itself is an array, not a row.
490
+ * @param {any} record
491
+ * @param {string} name
492
+ */
493
+ function navigates(record, name) {
494
+ return record.nav !== undefined && !record.navOnFan
495
+ && Object.hasOwn(record.nav.table, name);
496
+ }
497
+
498
+ /**
499
+ * The relation entry a hop may lower from, or the coded refusal: a
500
+ * many-to-many member has no queryable join-table root in this version
501
+ * (the phrase would need one — `load({ include })` reads the
502
+ * memberships), and a foreign-key relation needs its key column and the
503
+ * single key it references (a composite key would need a tuple equality
504
+ * the vocabulary does not spell).
505
+ * @param {any} relation
506
+ * @param {string} member
507
+ */
508
+ function checkRelation(relation, member) {
509
+ if (relation === null || typeof relation !== 'object' || typeof relation.to !== 'string') {
510
+ throw new LinqBuildError('JL0105',
511
+ `the relation table names '${member}' but its entry is not a relation record `
512
+ + '({ to, kind, via, fkEntity, fkTargets, targetKey } — MODEL-FORMAT §10.1)');
513
+ }
514
+ if (relation.kind === 'manyToMany') {
515
+ throw new LinqBuildError('JL0105',
516
+ `'${member}' is a many-to-many relation: the join table '${relation.joinTable}' is not a `
517
+ + 'queryable root in this version, so the hop has no phrase to lower to — read the '
518
+ + `memberships with load({ include: { ${member}: true } })`);
519
+ }
520
+ if (relation.kind !== 'oneToOne' && relation.kind !== 'oneToMany') {
521
+ throw new LinqBuildError('JL0105',
522
+ `'${member}' has relation kind '${String(relation.kind)}', which is not one this `
523
+ + 'surface lowers (oneToOne, oneToMany)');
524
+ }
525
+ if (typeof relation.via !== 'string' || typeof relation.targetKey !== 'string') {
526
+ throw new LinqBuildError('JL0105',
527
+ `'${member}' cannot lower: its foreign key or the key it references is composite or `
528
+ + 'undeclared, and the hop\'s equality would need a tuple the vocabulary does not spell');
529
+ }
530
+ }
531
+
532
+ /**
533
+ * A hop chain as one nested document: each link binds its source,
534
+ * correlates through its `$where`, and returns the next link — the
535
+ * innermost returns `ret`, the path over the last binding.
536
+ * @param {readonly { binding: string, source: any, where?: any }[]} chain
537
+ * @param {any} ret
538
+ * @returns {any}
539
+ */
540
+ function hopDocument(chain, ret) {
541
+ let doc = ret;
542
+ for (let i = chain.length - 1; i >= 0; i--) {
543
+ const link = chain[i];
544
+ /** @type {any} */
545
+ const phrase = { $for: { [link.binding]: link.source } };
546
+ if (link.where !== undefined) phrase.$where = link.where;
547
+ phrase.$return = doc;
548
+ doc = phrase;
549
+ }
550
+ return doc;
551
+ }
552
+
553
+ /**
554
+ * The proxy over a hop: as a VALUE a to-one hop is its phrase (zero or
555
+ * one row — an object member's one value, an empty operand elsewhere)
556
+ * and a to-many hop is the phrase packed into an array (`[phrase]`, the
557
+ * array of related rows a member holds); fanned (`all()`), either is the
558
+ * bare phrase, a sequence the aggregates and `exists()` range over. The
559
+ * fanned twin is the phrase in every case, so `count()`/`exists()` on
560
+ * the value count the rows, as they do on a group-join's group.
561
+ * @param {{ chain: any[], ret: string, many: boolean, fan: boolean }} hop
562
+ * @param {number} epoch
563
+ * @param {any} nav - the target entity's navigation, while `ret` is its root
564
+ */
565
+ function makeHop(hop, epoch, nav) {
566
+ const phrase = hopDocument(hop.chain, hop.ret);
567
+ return makeExpr(hop.many && !hop.fan ? [phrase] : phrase, epoch, false,
568
+ { seq: phrase, nav, hop });
569
+ }
570
+
571
+ /**
572
+ * Record one hop: `member` is a relation of the rows `target` stands
573
+ * for. The subject the new binding correlates with is a bare binding
574
+ * variable — the root (`$it`), or the previous hop's binding (`$r1`) —
575
+ * or a fanned path (`$g[*]`), which is bound to a binding of its own
576
+ * first so the equality reads one row. The equality follows the
577
+ * table's placement of the foreign key: on the declaring entity
578
+ * (`oneToOne`) the target's key equals the subject's `via`; on the
579
+ * target (`oneToMany`) the target's `via` equals the subject's key.
580
+ * @param {any} target - the record the member was read on
581
+ * @param {string} member
582
+ */
583
+ function startHop(target, member) {
584
+ const { table, resolve, sink } = target.nav;
585
+ const relation = table[member];
586
+ checkRelation(relation, member);
587
+ const chain = target.hop === undefined ? [] : [...target.hop.chain];
588
+ let subject = target.hop === undefined ? target.doc : target.hop.ret;
589
+ // a hop off a FANNED subject (a fanned to-many hop, a bound fan) is a
590
+ // sequence — one target per subject row, flattened by the outer
591
+ // phrase — never an array value; off a singular subject it is a
592
+ // value: the row (to-one) or the array of rows (to-many)
593
+ let many = target.hop !== undefined && target.hop.many;
594
+ let fan = target.hop !== undefined && target.hop.fan;
595
+ if (!BARE_VAR_RE.test(subject)) {
596
+ const binding = `r${sink.next++}`;
597
+ chain.push({ binding, source: subject });
598
+ subject = '$' + binding;
599
+ many = true;
600
+ fan = true;
601
+ }
602
+ const binding = `r${sink.next++}`;
603
+ const where = relation.kind === 'oneToMany'
604
+ ? { $eq: [`$${binding}${memberSegment(relation.via)}`, `${subject}${memberSegment(relation.targetKey)}`] }
605
+ : { $eq: [`$${binding}${memberSegment(relation.targetKey)}`, `${subject}${memberSegment(relation.via)}`] };
606
+ chain.push({ binding, source: entityRootOf(relation.to), where });
607
+ sink.hops.push({ member, kind: relation.kind, binding });
608
+ const targetTable = resolve(relation.to);
609
+ return makeHop(
610
+ { chain, ret: '$' + binding, many: many || relation.kind === 'oneToMany', fan },
611
+ target.epoch,
612
+ targetTable === undefined ? undefined : { table: targetTable, resolve, sink });
613
+ }
614
+
615
+ /**
616
+ * A member of the hop's target row: the phrase returns the path over its
617
+ * last binding, extended. A to-many hop is an ARRAY of rows until it is
618
+ * fanned — a member read off the array is refused with the fix named,
619
+ * where the same read off a stored array would answer nothing.
620
+ * @param {any} target
621
+ * @param {string} segment - the spelled path step (`.email`, `['odd key']`)
622
+ */
623
+ function extendHop(target, segment) {
624
+ const hop = target.hop;
625
+ if (hop.many && !hop.fan) {
626
+ throw new LinqBuildError('JL0005',
627
+ `${segment} is read off a to-many relation, which holds an array of related rows — `
628
+ + `fan them first (.all()${segment}), index one (.at(0)), or aggregate the array`);
629
+ }
630
+ return makeHop({ ...hop, ret: `${hop.ret}${segment}` }, target.epoch, undefined);
631
+ }
632
+
633
+ /**
634
+ * A fresh hop sink for one capture: the bindings allocated so far
635
+ * (`r1`, `r2`, … — numbered per capture, across all its roots) and the
636
+ * hops recorded, in the order the callback navigated them.
637
+ * @returns {{ hops: { member: string, kind: string, binding: string }[], next: number }}
638
+ */
639
+ export function createHopSink() {
640
+ return { hops: [], next: 1 };
641
+ }
642
+
643
+ /**
644
+ * A binding root whose items are an entity's rows: the bare name when
645
+ * there is nothing to carry, else the root record with the relation
646
+ * table, the resolver for the other roots of its scope, the capture's
647
+ * sink, and — after a `groupBy` — the member the group's rows live in.
648
+ * @param {string} name - the binding (`it`, `it2`)
649
+ * @param {{ table: any, resolve: (name: string) => any } | null} relations
650
+ * @param {ReturnType<typeof createHopSink>} sink
651
+ * @param {boolean} [grouped] - whether the items are a `{ key, items }`
652
+ * group, whose `items` aggregate as rows ({@link pathStep})
653
+ * @returns {any}
654
+ */
655
+ export function rowRoot(name, relations, sink, grouped = false) {
656
+ if (relations === null && !grouped) return name;
657
+ const root = { doc: '$' + name, pathable: true };
658
+ if (grouped) root.group = GROUP_ITEMS;
659
+ if (relations !== null) {
660
+ root.nav = { table: relations.table, resolve: relations.resolve, sink };
661
+ }
662
+ return root;
663
+ }
664
+
665
+ /**
666
+ * A group-join's group root: the `$g` array value with its `$g[*]` fan
667
+ * — and, when the inner rows have a relation table, that table on the
668
+ * FAN, so `g.all().author` hops from each row.
669
+ * @param {{ table: any, resolve: (name: string) => any } | null} relations
670
+ * @param {ReturnType<typeof createHopSink>} sink
671
+ * @returns {any}
672
+ */
673
+ export function groupRoot(relations, sink) {
674
+ const root = { doc: '$g', pathable: true, seq: '$g[*]' };
675
+ return relations === null
676
+ ? root
677
+ : { ...root, nav: { table: relations.table, resolve: relations.resolve, sink }, navOnFan: true };
678
+ }
679
+
680
+ //#endregion
681
+
268
682
  /**
269
683
  * Build one expression proxy.
270
684
  * @param {any} doc - the expression JSON so far
271
685
  * @param {number} epoch - the owning capture
272
686
  * @param {boolean} pathable - whether `doc` is a pure path string that
273
687
  * member access may extend
688
+ * @param {{ seq?: any, nav?: any, navOnFan?: boolean, hop?: any,
689
+ * group?: string }} [extra] -
690
+ * `seq`: for a value standing for an array or a hop, the fanned form
691
+ * its aggregates range over (`'$g[*]'`, a hop's phrase); `nav`: the
692
+ * relation table of the rows the value stands for, with the resolver
693
+ * for the other roots and the capture's hop sink; `navOnFan`: the
694
+ * table applies to the fan, not the value; `hop`: the hop chain;
695
+ * `group`: the member this value carries a GROUP's rows in, whose
696
+ * aggregates therefore range over the rows
274
697
  * @returns {any}
275
698
  */
276
- function makeExpr(doc, epoch, pathable) {
277
- const record = { doc, epoch, pathable };
699
+ function makeExpr(doc, epoch, pathable, extra = undefined) {
700
+ const record = {
701
+ doc, epoch, pathable,
702
+ seq: extra?.seq, nav: extra?.nav, navOnFan: extra?.navOnFan === true, hop: extra?.hop,
703
+ group: extra?.group,
704
+ };
278
705
  return new Proxy(record, {
279
706
  get(target, prop) {
280
707
  if (prop === NODE) return target;
@@ -287,17 +714,43 @@ function makeExpr(doc, epoch, pathable) {
287
714
  };
288
715
  }
289
716
  assertLive(target);
290
- if (target.pathable) {
291
- const step = SHORTHAND_RE.test(prop)
292
- ? `${target.doc}.${prop}`
293
- : `${target.doc}['${prop.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}']`;
294
- return makeExpr(step, target.epoch, true);
295
- }
296
- return makeExpr({ $get: [target.doc, prop] }, target.epoch, false);
717
+ return member(target, prop);
297
718
  },
298
719
  });
299
720
  }
300
721
 
722
+ /**
723
+ * Member access: a relation name records a hop; a hop's target extends
724
+ * the phrase's return path; a pathable path extends; anything else is
725
+ * a `$get` over the operator result.
726
+ * @param {any} target
727
+ * @param {string} prop
728
+ */
729
+ function member(target, prop) {
730
+ if (navigates(target, prop)) return startHop(target, prop);
731
+ if (target.hop !== undefined) return extendHop(target, memberSegment(prop));
732
+ if (target.pathable) return pathStep(target, prop, memberSegment(prop));
733
+ return makeExpr({ $get: [target.doc, prop] }, target.epoch, false);
734
+ }
735
+
736
+ /**
737
+ * One path step off a pathable value, member access or `get()`.
738
+ *
739
+ * A GROUP's rows aggregate as rows: after `groupBy` the item is
740
+ * `{ key, items }` and `items` holds the group, so `g.items.count()`
741
+ * ranges over the rows the way a group-join's `g.count()` already does
742
+ * ({@link groupRoot}). `g.items` itself is still the array — a member
743
+ * takes it whole (`{ matches: g.items }`), `at()` indexes it and
744
+ * `all()` fans it — so only the aggregates change, and only for the one
745
+ * member the emitter writes the group into.
746
+ * @param {any} target @param {string|number} prop @param {string} segment
747
+ */
748
+ function pathStep(target, prop, segment) {
749
+ const doc = `${target.doc}${segment}`;
750
+ return makeExpr(doc, target.epoch, true,
751
+ target.group === prop ? { seq: `${doc}[*]` } : undefined);
752
+ }
753
+
301
754
  /**
302
755
  * The parameters proxy: `p.tenantId` emits the external `$tenantId` —
303
756
  * when the name was declared via `.params({...})`. Undeclared use is
@@ -323,24 +776,64 @@ function makeParams(declared, epoch) {
323
776
  * Run one capture: `fn` receives a proxy per root (plus the parameters
324
777
  * proxy last) and its result becomes an expression via
325
778
  * {@link toExpression}. A root is a binding NAME (`'it'` → the pathable
326
- * `$it`) or a `{ doc, pathable }` record for an expression-valued root
327
- * (groupJoin's inner group). Proxies die with the capture — reuse is
328
- * `JL0002`.
779
+ * `$it`) or a `{ doc, pathable, seq?, nav?, navOnFan? }` record for a
780
+ * bound root ({@link rowRoot}: an entity's rows with their relation
781
+ * table; {@link groupRoot}: groupJoin's group, the `$g` array whose
782
+ * aggregates fan over `$g[*]`). Captures nest — a chain built and run
783
+ * inside a callback is ordinary — but a proxy used outside its capture,
784
+ * or an enclosing capture's proxy used inside a nested one, is `JL0002`.
329
785
  * @param {(...roots: any[]) => any} fn - the user callback
330
- * @param {readonly (string | { doc: any, pathable: boolean })[]} roots
786
+ * @param {readonly (string | { doc: any, pathable: boolean, seq?: string,
787
+ * nav?: any, navOnFan?: boolean })[]} roots
331
788
  * @param {Set<string>} declaredParams
789
+ * @param {boolean} [fold] - whether a pure data tree folds into one
790
+ * `$const` (the chain's spelling) or is a constructor tree (a pen's)
332
791
  * @returns {any} the captured expression (plain JSON)
333
792
  */
334
- export function captureExpression(fn, roots, declaredParams) {
335
- const epoch = ++activeEpoch;
793
+ export function captureExpression(fn, roots, declaredParams, fold = true) {
794
+ const epoch = ++epochCounter;
336
795
  const proxies = roots.map((root) => (typeof root === 'string'
337
796
  ? makeExpr('$' + root, epoch, true)
338
- : makeExpr(root.doc, epoch, root.pathable)));
797
+ : makeExpr(root.doc, epoch, root.pathable, root)));
339
798
  proxies.push(makeParams(declaredParams, epoch));
799
+ captureStack.push(epoch);
340
800
  try {
341
- return toExpression(fn(...proxies));
801
+ return toExpression(fn(...proxies), fold);
342
802
  }
343
803
  finally {
344
- activeEpoch++; // every proxy of this capture is now dead
804
+ captureStack.pop(); // every proxy of this capture is now dead
805
+ }
806
+ }
807
+
808
+ /**
809
+ * Whether `value` is an expression proxy of some capture (live or not).
810
+ * A pen walking a captured result before it lowers needs to tell a
811
+ * proxy from the plain object it would otherwise descend into.
812
+ * @param {any} value
813
+ * @returns {boolean}
814
+ */
815
+ export function isExpression(value) {
816
+ return value !== null && typeof value === 'object' && value[NODE] !== undefined;
817
+ }
818
+
819
+ /**
820
+ * Lift a hand-spelled operator expression into the capture in
821
+ * progress: the escape for an operator the method table does not name
822
+ * — a registered one (`{ $npv: [...] }`, JSLT-FORMAT §13) or a
823
+ * body-local one (`$apply`, §6). The document is taken as given — the
824
+ * engine's compiler is the judge of it (`JQ0002` for an operator it
825
+ * does not know) — and the proxy it answers is bound to the innermost
826
+ * capture, so it composes with that capture's own proxies and dies
827
+ * with them. Outside a capture there is nothing to bind it to:
828
+ * `JL0005`.
829
+ * @param {any} doc - the operator expression, plain JSON
830
+ * @returns {any} an expression proxy over `doc`
831
+ */
832
+ export function liftExpression(doc) {
833
+ if (captureStack.length === 0) {
834
+ throw new LinqBuildError('JL0005',
835
+ 'an operator expression can only be lifted inside a capture callback — no capture '
836
+ + 'is in progress to bind it to');
345
837
  }
838
+ return makeExpr(doc, captureStack[captureStack.length - 1], false);
346
839
  }