@jarenjs/linq 0.49.2 → 0.66.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/ARCHITECTURE.md +227 -0
  2. package/README.md +650 -17
  3. package/docs/APP-PEN.md +1143 -0
  4. package/docs/CONTRACT-PEN.md +1221 -0
  5. package/docs/DB-CLIENT.md +882 -0
  6. package/docs/FLOW-PEN.md +1033 -0
  7. package/docs/FORMS-PEN.md +940 -0
  8. package/docs/JSLT-PEN.md +955 -0
  9. package/docs/LINQ-FORMAT.md +778 -383
  10. package/docs/MIGRATION-PEN.md +781 -0
  11. package/docs/MODEL-PEN.md +1092 -0
  12. package/docs/QUERY-PEN.md +1724 -0
  13. package/docs/SCHEMA-PEN.md +1218 -0
  14. package/package.json +57 -4
  15. package/src/app/action.js +251 -0
  16. package/src/app/capture.js +63 -0
  17. package/src/app/define.js +255 -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 +377 -75
  22. package/src/capture-root.js +82 -0
  23. package/src/concurrency.js +48 -11
  24. package/src/contract/define.js +282 -0
  25. package/src/contract/http.js +247 -0
  26. package/src/contract/index.js +23 -0
  27. package/src/contract/operation.js +338 -0
  28. package/src/db/handle.js +89 -0
  29. package/src/db/include.js +351 -0
  30. package/src/db/index.js +24 -0
  31. package/src/db/ledger.js +195 -0
  32. package/src/db/live.js +43 -0
  33. package/src/db/membership.js +37 -0
  34. package/src/db/open.js +130 -0
  35. package/src/document.js +143 -13
  36. package/src/effect.js +65 -0
  37. package/src/errors.js +78 -6
  38. package/src/expression.js +463 -36
  39. package/src/federate.js +531 -0
  40. package/src/flow/capture.js +33 -0
  41. package/src/flow/dag.js +316 -0
  42. package/src/flow/fsm.js +323 -0
  43. package/src/flow/index.js +22 -0
  44. package/src/forms/index.js +43 -0
  45. package/src/forms/rules.js +170 -0
  46. package/src/forms/submit.js +177 -0
  47. package/src/index.js +5 -2
  48. package/src/jslt/body.js +226 -0
  49. package/src/jslt/index.js +18 -0
  50. package/src/jslt/rules.js +202 -0
  51. package/src/json-boundary.js +90 -0
  52. package/src/migration/define.js +318 -0
  53. package/src/migration/index.js +15 -0
  54. package/src/migration/steps.js +244 -0
  55. package/src/model/collection.js +273 -0
  56. package/src/model/define.js +125 -0
  57. package/src/model/entity.js +307 -0
  58. package/src/model/index.js +47 -0
  59. package/src/model/relation.js +85 -0
  60. package/src/provider.js +137 -20
  61. package/src/schema/brand.js +31 -0
  62. package/src/schema/builders.js +526 -0
  63. package/src/schema/check.js +29 -0
  64. package/src/schema/emit.js +394 -0
  65. package/src/schema/factories.js +239 -0
  66. package/src/schema/index.js +37 -0
  67. package/src/schema-of.js +24 -0
  68. package/src/sequence.js +233 -103
  69. package/src/sources.js +10 -3
  70. package/types/app.d.ts +293 -0
  71. package/types/contract.d.ts +468 -0
  72. package/types/db.d.ts +359 -0
  73. package/types/flow.d.ts +285 -0
  74. package/types/forms.d.ts +253 -0
  75. package/types/index.d.ts +296 -26
  76. package/types/jslt.d.ts +193 -0
  77. package/types/migration.d.ts +201 -0
  78. package/types/model.d.ts +526 -0
  79. 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,6 +264,18 @@ 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
+
174
279
  /**
175
280
  * `$date-add` / `$date-sub`: `[date, duration]` or `[date, amount, unit]`.
176
281
  * @param {any} record
@@ -210,7 +315,7 @@ function literalSpec(spec, method) {
210
315
 
211
316
  /**
212
317
  * The operator methods, name → builder(record, ...args). One table so
213
- * 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.
214
319
  * Null prototype: `constructor`/`toString` must read as member access,
215
320
  * never as inherited "methods".
216
321
  */
@@ -227,7 +332,7 @@ const METHODS = {
227
332
  div: binary('$div'), idiv: binary('$idiv'), mod: binary('$mod'),
228
333
  neg: unary('$neg'),
229
334
  // §8.2 existence
230
- exists: unary('$exists'), isEmpty: unary('$empty'),
335
+ exists: fanned('$exists'), isEmpty: fanned('$empty'),
231
336
  // §8.7 strings
232
337
  startsWith: binary('$starts-with'), endsWith: binary('$ends-with'),
233
338
  contains: binary('$contains'), matches: binary('$match'),
@@ -247,8 +352,8 @@ const METHODS = {
247
352
  },
248
353
  // §8.8 aggregates as EXPRESSIONS (a group inside a projection:
249
354
  // `(u, g) => ({ n: g.count() })`)
250
- count: unary('$count'), sum: unary('$sum'), avg: unary('$avg'),
251
- min: unary('$min'), max: unary('$max'),
355
+ count: fanned('$count'), sum: fanned('$sum'), avg: fanned('$avg'),
356
+ min: fanned('$min'), max: fanned('$max'),
252
357
  // §8.13 dates — the whole family, not a corner of it. A date in this
253
358
  // suite is an RFC 3339 STRING, so every one of these is an ordinary
254
359
  // string operator with a calendar's worth of rules behind it, and
@@ -342,6 +447,11 @@ const METHODS = {
342
447
  return makeExpr({ $get: [record.doc, toExpression(index)] }, record.epoch, false);
343
448
  },
344
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 });
345
455
  if (!record.pathable) {
346
456
  throw new LinqBuildError('JL0005',
347
457
  "all() fans out a PATH ('$it.tags[*]'); it cannot follow an operator result");
@@ -349,24 +459,275 @@ const METHODS = {
349
459
  return makeExpr(`${record.doc}[*]`, record.epoch, true);
350
460
  },
351
461
  get(record, name) {
352
- if (typeof name === 'string' && record.pathable) {
353
- return makeExpr(`${record.doc}['${name.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}']`,
354
- 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));
355
469
  }
356
470
  return makeExpr({ $get: [record.doc, toExpression(name)] }, record.epoch, false);
357
471
  },
358
472
  };
359
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
+ // the join table is a read-only query root (MODEL-FORMAT §10.7), so
516
+ // the hop lowers through it — provided the relation record names
517
+ // the row's two columns and the key each references
518
+ if (typeof relation.joinTable !== 'string' || typeof relation.ownColumn !== 'string'
519
+ || typeof relation.ownKey !== 'string' || typeof relation.targetColumn !== 'string'
520
+ || typeof relation.targetKey !== 'string') {
521
+ throw new LinqBuildError('JL0105',
522
+ `'${member}' is a many-to-many relation whose entry does not name its join row's `
523
+ + 'columns ({ joinTable, ownColumn, ownKey, targetColumn, targetKey } — '
524
+ + 'MODEL-FORMAT §10.1), so the hop has no phrase to lower to');
525
+ }
526
+ return;
527
+ }
528
+ if (relation.kind !== 'oneToOne' && relation.kind !== 'oneToMany') {
529
+ throw new LinqBuildError('JL0105',
530
+ `'${member}' has relation kind '${String(relation.kind)}', which is not one this `
531
+ + 'surface lowers (oneToOne, oneToMany)');
532
+ }
533
+ if (typeof relation.via !== 'string' || typeof relation.targetKey !== 'string') {
534
+ throw new LinqBuildError('JL0105',
535
+ `'${member}' cannot lower: its foreign key or the key it references is composite or `
536
+ + 'undeclared, and the hop\'s equality would need a tuple the vocabulary does not spell');
537
+ }
538
+ }
539
+
540
+ /**
541
+ * A hop chain as one nested document: each link binds its source,
542
+ * correlates through its `$where`, and returns the next link — the
543
+ * innermost returns `ret`, the path over the last binding.
544
+ * @param {readonly { binding: string, source: any, where?: any }[]} chain
545
+ * @param {any} ret
546
+ * @returns {any}
547
+ */
548
+ function hopDocument(chain, ret) {
549
+ let doc = ret;
550
+ for (let i = chain.length - 1; i >= 0; i--) {
551
+ const link = chain[i];
552
+ /** @type {any} */
553
+ const phrase = { $for: { [link.binding]: link.source } };
554
+ if (link.where !== undefined) phrase.$where = link.where;
555
+ phrase.$return = doc;
556
+ doc = phrase;
557
+ }
558
+ return doc;
559
+ }
560
+
561
+ /**
562
+ * The proxy over a hop: as a VALUE a to-one hop is its phrase (zero or
563
+ * one row — an object member's one value, an empty operand elsewhere)
564
+ * and a to-many hop is the phrase packed into an array (`[phrase]`, the
565
+ * array of related rows a member holds); fanned (`all()`), either is the
566
+ * bare phrase, a sequence the aggregates and `exists()` range over. The
567
+ * fanned twin is the phrase in every case, so `count()`/`exists()` on
568
+ * the value count the rows, as they do on a group-join's group.
569
+ * @param {{ chain: any[], ret: string, many: boolean, fan: boolean }} hop
570
+ * @param {number} epoch
571
+ * @param {any} nav - the target entity's navigation, while `ret` is its root
572
+ */
573
+ function makeHop(hop, epoch, nav) {
574
+ const phrase = hopDocument(hop.chain, hop.ret);
575
+ return makeExpr(hop.many && !hop.fan ? [phrase] : phrase, epoch, false,
576
+ { seq: phrase, nav, hop });
577
+ }
578
+
579
+ /**
580
+ * Record one hop: `member` is a relation of the rows `target` stands
581
+ * for. The subject the new binding correlates with is a bare binding
582
+ * variable — the root (`$it`), or the previous hop's binding (`$r1`) —
583
+ * or a fanned path (`$g[*]`), which is bound to a binding of its own
584
+ * first so the equality reads one row. The equality follows the
585
+ * table's placement of the foreign key: on the declaring entity
586
+ * (`oneToOne`) the target's key equals the subject's `via`; on the
587
+ * target (`oneToMany`) the target's `via` equals the subject's key.
588
+ * @param {any} target - the record the member was read on
589
+ * @param {string} member
590
+ */
591
+ function startHop(target, member) {
592
+ const { table, resolve, sink } = target.nav;
593
+ const relation = table[member];
594
+ checkRelation(relation, member);
595
+ const chain = target.hop === undefined ? [] : [...target.hop.chain];
596
+ let subject = target.hop === undefined ? target.doc : target.hop.ret;
597
+ // a hop off a FANNED subject (a fanned to-many hop, a bound fan) is a
598
+ // sequence — one target per subject row, flattened by the outer
599
+ // phrase — never an array value; off a singular subject it is a
600
+ // value: the row (to-one) or the array of rows (to-many)
601
+ let many = target.hop !== undefined && target.hop.many;
602
+ let fan = target.hop !== undefined && target.hop.fan;
603
+ if (!BARE_VAR_RE.test(subject)) {
604
+ const binding = `r${sink.next++}`;
605
+ chain.push({ binding, source: subject });
606
+ subject = '$' + binding;
607
+ many = true;
608
+ fan = true;
609
+ }
610
+ if (relation.kind === 'manyToMany') {
611
+ // TWO links, numbered in chain order: the join row that names the
612
+ // membership, then the target row it names. The join table is a
613
+ // query root of its own, carrying exactly the two key columns (§10.7)
614
+ const joinBinding = `r${sink.next++}`;
615
+ const binding = `r${sink.next++}`;
616
+ chain.push({ binding: joinBinding, source: entityRootOf(relation.joinTable),
617
+ where: { $eq: [`$${joinBinding}${memberSegment(relation.ownColumn)}`,
618
+ `${subject}${memberSegment(relation.ownKey)}`] } });
619
+ chain.push({ binding, source: entityRootOf(relation.to),
620
+ where: { $eq: [`$${binding}${memberSegment(relation.targetKey)}`,
621
+ `$${joinBinding}${memberSegment(relation.targetColumn)}`] } });
622
+ sink.hops.push({ member, kind: relation.kind, binding });
623
+ const manyTarget = resolve(relation.to);
624
+ return makeHop({ chain, ret: '$' + binding, many: true, fan },
625
+ target.epoch,
626
+ manyTarget === undefined ? undefined : { table: manyTarget, resolve, sink });
627
+ }
628
+ const binding = `r${sink.next++}`;
629
+ const where = relation.kind === 'oneToMany'
630
+ ? { $eq: [`$${binding}${memberSegment(relation.via)}`, `${subject}${memberSegment(relation.targetKey)}`] }
631
+ : { $eq: [`$${binding}${memberSegment(relation.targetKey)}`, `${subject}${memberSegment(relation.via)}`] };
632
+ chain.push({ binding, source: entityRootOf(relation.to), where });
633
+ sink.hops.push({ member, kind: relation.kind, binding });
634
+ const targetTable = resolve(relation.to);
635
+ return makeHop(
636
+ { chain, ret: '$' + binding, many: many || relation.kind === 'oneToMany', fan },
637
+ target.epoch,
638
+ targetTable === undefined ? undefined : { table: targetTable, resolve, sink });
639
+ }
640
+
641
+ /**
642
+ * A member of the hop's target row: the phrase returns the path over its
643
+ * last binding, extended. A to-many hop is an ARRAY of rows until it is
644
+ * fanned — a member read off the array is refused with the fix named,
645
+ * where the same read off a stored array would answer nothing.
646
+ * @param {any} target
647
+ * @param {string} segment - the spelled path step (`.email`, `['odd key']`)
648
+ */
649
+ function extendHop(target, segment) {
650
+ const hop = target.hop;
651
+ if (hop.many && !hop.fan) {
652
+ throw new LinqBuildError('JL0005',
653
+ `${segment} is read off a to-many relation, which holds an array of related rows — `
654
+ + `fan them first (.all()${segment}), index one (.at(0)), or aggregate the array`);
655
+ }
656
+ return makeHop({ ...hop, ret: `${hop.ret}${segment}` }, target.epoch, undefined);
657
+ }
658
+
659
+ /**
660
+ * A fresh hop sink for one capture: the bindings allocated so far
661
+ * (`r1`, `r2`, … — numbered per capture, across all its roots) and the
662
+ * hops recorded, in the order the callback navigated them.
663
+ * @returns {{ hops: { member: string, kind: string, binding: string }[], next: number }}
664
+ */
665
+ export function createHopSink() {
666
+ return { hops: [], next: 1 };
667
+ }
668
+
669
+ /**
670
+ * A binding root whose items are an entity's rows: the bare name when
671
+ * there is nothing to carry, else the root record with the relation
672
+ * table, the resolver for the other roots of its scope, the capture's
673
+ * sink, and — after a `groupBy` — the member the group's rows live in.
674
+ * @param {string} name - the binding (`it`, `it2`)
675
+ * @param {{ table: any, resolve: (name: string) => any } | null} relations
676
+ * @param {ReturnType<typeof createHopSink>} sink
677
+ * @param {boolean} [grouped] - whether the items are a `{ key, items }`
678
+ * group, whose `items` aggregate as rows ({@link pathStep})
679
+ * @returns {any}
680
+ */
681
+ export function rowRoot(name, relations, sink, grouped = false) {
682
+ if (relations === null && !grouped) return name;
683
+ const root = { doc: '$' + name, pathable: true };
684
+ if (grouped) root.group = GROUP_ITEMS;
685
+ if (relations !== null) {
686
+ root.nav = { table: relations.table, resolve: relations.resolve, sink };
687
+ }
688
+ return root;
689
+ }
690
+
691
+ /**
692
+ * A group-join's group root: the `$g` array value with its `$g[*]` fan
693
+ * — and, when the inner rows have a relation table, that table on the
694
+ * FAN, so `g.all().author` hops from each row.
695
+ * @param {{ table: any, resolve: (name: string) => any } | null} relations
696
+ * @param {ReturnType<typeof createHopSink>} sink
697
+ * @returns {any}
698
+ */
699
+ export function groupRoot(relations, sink) {
700
+ const root = { doc: '$g', pathable: true, seq: '$g[*]' };
701
+ return relations === null
702
+ ? root
703
+ : { ...root, nav: { table: relations.table, resolve: relations.resolve, sink }, navOnFan: true };
704
+ }
705
+
706
+ //#endregion
707
+
360
708
  /**
361
709
  * Build one expression proxy.
362
710
  * @param {any} doc - the expression JSON so far
363
711
  * @param {number} epoch - the owning capture
364
712
  * @param {boolean} pathable - whether `doc` is a pure path string that
365
713
  * member access may extend
714
+ * @param {{ seq?: any, nav?: any, navOnFan?: boolean, hop?: any,
715
+ * group?: string }} [extra] -
716
+ * `seq`: for a value standing for an array or a hop, the fanned form
717
+ * its aggregates range over (`'$g[*]'`, a hop's phrase); `nav`: the
718
+ * relation table of the rows the value stands for, with the resolver
719
+ * for the other roots and the capture's hop sink; `navOnFan`: the
720
+ * table applies to the fan, not the value; `hop`: the hop chain;
721
+ * `group`: the member this value carries a GROUP's rows in, whose
722
+ * aggregates therefore range over the rows
366
723
  * @returns {any}
367
724
  */
368
- function makeExpr(doc, epoch, pathable) {
369
- const record = { doc, epoch, pathable };
725
+ function makeExpr(doc, epoch, pathable, extra = undefined) {
726
+ const record = {
727
+ doc, epoch, pathable,
728
+ seq: extra?.seq, nav: extra?.nav, navOnFan: extra?.navOnFan === true, hop: extra?.hop,
729
+ group: extra?.group,
730
+ };
370
731
  return new Proxy(record, {
371
732
  get(target, prop) {
372
733
  if (prop === NODE) return target;
@@ -379,17 +740,43 @@ function makeExpr(doc, epoch, pathable) {
379
740
  };
380
741
  }
381
742
  assertLive(target);
382
- if (target.pathable) {
383
- const step = SHORTHAND_RE.test(prop)
384
- ? `${target.doc}.${prop}`
385
- : `${target.doc}['${prop.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}']`;
386
- return makeExpr(step, target.epoch, true);
387
- }
388
- return makeExpr({ $get: [target.doc, prop] }, target.epoch, false);
743
+ return member(target, prop);
389
744
  },
390
745
  });
391
746
  }
392
747
 
748
+ /**
749
+ * Member access: a relation name records a hop; a hop's target extends
750
+ * the phrase's return path; a pathable path extends; anything else is
751
+ * a `$get` over the operator result.
752
+ * @param {any} target
753
+ * @param {string} prop
754
+ */
755
+ function member(target, prop) {
756
+ if (navigates(target, prop)) return startHop(target, prop);
757
+ if (target.hop !== undefined) return extendHop(target, memberSegment(prop));
758
+ if (target.pathable) return pathStep(target, prop, memberSegment(prop));
759
+ return makeExpr({ $get: [target.doc, prop] }, target.epoch, false);
760
+ }
761
+
762
+ /**
763
+ * One path step off a pathable value, member access or `get()`.
764
+ *
765
+ * A GROUP's rows aggregate as rows: after `groupBy` the item is
766
+ * `{ key, items }` and `items` holds the group, so `g.items.count()`
767
+ * ranges over the rows the way a group-join's `g.count()` already does
768
+ * ({@link groupRoot}). `g.items` itself is still the array — a member
769
+ * takes it whole (`{ matches: g.items }`), `at()` indexes it and
770
+ * `all()` fans it — so only the aggregates change, and only for the one
771
+ * member the emitter writes the group into.
772
+ * @param {any} target @param {string|number} prop @param {string} segment
773
+ */
774
+ function pathStep(target, prop, segment) {
775
+ const doc = `${target.doc}${segment}`;
776
+ return makeExpr(doc, target.epoch, true,
777
+ target.group === prop ? { seq: `${doc}[*]` } : undefined);
778
+ }
779
+
393
780
  /**
394
781
  * The parameters proxy: `p.tenantId` emits the external `$tenantId` —
395
782
  * when the name was declared via `.params({...})`. Undeclared use is
@@ -415,24 +802,64 @@ function makeParams(declared, epoch) {
415
802
  * Run one capture: `fn` receives a proxy per root (plus the parameters
416
803
  * proxy last) and its result becomes an expression via
417
804
  * {@link toExpression}. A root is a binding NAME (`'it'` → the pathable
418
- * `$it`) or a `{ doc, pathable }` record for an expression-valued root
419
- * (groupJoin's inner group). Proxies die with the capture — reuse is
420
- * `JL0002`.
805
+ * `$it`) or a `{ doc, pathable, seq?, nav?, navOnFan? }` record for a
806
+ * bound root ({@link rowRoot}: an entity's rows with their relation
807
+ * table; {@link groupRoot}: groupJoin's group, the `$g` array whose
808
+ * aggregates fan over `$g[*]`). Captures nest — a chain built and run
809
+ * inside a callback is ordinary — but a proxy used outside its capture,
810
+ * or an enclosing capture's proxy used inside a nested one, is `JL0002`.
421
811
  * @param {(...roots: any[]) => any} fn - the user callback
422
- * @param {readonly (string | { doc: any, pathable: boolean })[]} roots
812
+ * @param {readonly (string | { doc: any, pathable: boolean, seq?: string,
813
+ * nav?: any, navOnFan?: boolean })[]} roots
423
814
  * @param {Set<string>} declaredParams
815
+ * @param {boolean} [fold] - whether a pure data tree folds into one
816
+ * `$const` (the chain's spelling) or is a constructor tree (a pen's)
424
817
  * @returns {any} the captured expression (plain JSON)
425
818
  */
426
- export function captureExpression(fn, roots, declaredParams) {
427
- const epoch = ++activeEpoch;
819
+ export function captureExpression(fn, roots, declaredParams, fold = true) {
820
+ const epoch = ++epochCounter;
428
821
  const proxies = roots.map((root) => (typeof root === 'string'
429
822
  ? makeExpr('$' + root, epoch, true)
430
- : makeExpr(root.doc, epoch, root.pathable)));
823
+ : makeExpr(root.doc, epoch, root.pathable, root)));
431
824
  proxies.push(makeParams(declaredParams, epoch));
825
+ captureStack.push(epoch);
432
826
  try {
433
- return toExpression(fn(...proxies));
827
+ return toExpression(fn(...proxies), fold);
434
828
  }
435
829
  finally {
436
- activeEpoch++; // every proxy of this capture is now dead
830
+ captureStack.pop(); // every proxy of this capture is now dead
831
+ }
832
+ }
833
+
834
+ /**
835
+ * Whether `value` is an expression proxy of some capture (live or not).
836
+ * A pen walking a captured result before it lowers needs to tell a
837
+ * proxy from the plain object it would otherwise descend into.
838
+ * @param {any} value
839
+ * @returns {boolean}
840
+ */
841
+ export function isExpression(value) {
842
+ return value !== null && typeof value === 'object' && value[NODE] !== undefined;
843
+ }
844
+
845
+ /**
846
+ * Lift a hand-spelled operator expression into the capture in
847
+ * progress: the escape for an operator the method table does not name
848
+ * — a registered one (`{ $npv: [...] }`, JSLT-FORMAT §13) or a
849
+ * body-local one (`$apply`, §6). The document is taken as given — the
850
+ * engine's compiler is the judge of it (`JQ0002` for an operator it
851
+ * does not know) — and the proxy it answers is bound to the innermost
852
+ * capture, so it composes with that capture's own proxies and dies
853
+ * with them. Outside a capture there is nothing to bind it to:
854
+ * `JL0005`.
855
+ * @param {any} doc - the operator expression, plain JSON
856
+ * @returns {any} an expression proxy over `doc`
857
+ */
858
+ export function liftExpression(doc) {
859
+ if (captureStack.length === 0) {
860
+ throw new LinqBuildError('JL0005',
861
+ 'an operator expression can only be lifted inside a capture callback — no capture '
862
+ + 'is in progress to bind it to');
437
863
  }
864
+ return makeExpr(doc, captureStack[captureStack.length - 1], false);
438
865
  }