@jarenjs/linq 0.49.2 → 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 +559 -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 +771 -383
  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 +437 -36
  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 +231 -26
  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,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,249 @@ 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
+ 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
+
360
682
  /**
361
683
  * Build one expression proxy.
362
684
  * @param {any} doc - the expression JSON so far
363
685
  * @param {number} epoch - the owning capture
364
686
  * @param {boolean} pathable - whether `doc` is a pure path string that
365
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
366
697
  * @returns {any}
367
698
  */
368
- function makeExpr(doc, epoch, pathable) {
369
- 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
+ };
370
705
  return new Proxy(record, {
371
706
  get(target, prop) {
372
707
  if (prop === NODE) return target;
@@ -379,17 +714,43 @@ function makeExpr(doc, epoch, pathable) {
379
714
  };
380
715
  }
381
716
  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);
717
+ return member(target, prop);
389
718
  },
390
719
  });
391
720
  }
392
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
+
393
754
  /**
394
755
  * The parameters proxy: `p.tenantId` emits the external `$tenantId` —
395
756
  * when the name was declared via `.params({...})`. Undeclared use is
@@ -415,24 +776,64 @@ function makeParams(declared, epoch) {
415
776
  * Run one capture: `fn` receives a proxy per root (plus the parameters
416
777
  * proxy last) and its result becomes an expression via
417
778
  * {@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`.
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`.
421
785
  * @param {(...roots: any[]) => any} fn - the user callback
422
- * @param {readonly (string | { doc: any, pathable: boolean })[]} roots
786
+ * @param {readonly (string | { doc: any, pathable: boolean, seq?: string,
787
+ * nav?: any, navOnFan?: boolean })[]} roots
423
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)
424
791
  * @returns {any} the captured expression (plain JSON)
425
792
  */
426
- export function captureExpression(fn, roots, declaredParams) {
427
- const epoch = ++activeEpoch;
793
+ export function captureExpression(fn, roots, declaredParams, fold = true) {
794
+ const epoch = ++epochCounter;
428
795
  const proxies = roots.map((root) => (typeof root === 'string'
429
796
  ? makeExpr('$' + root, epoch, true)
430
- : makeExpr(root.doc, epoch, root.pathable)));
797
+ : makeExpr(root.doc, epoch, root.pathable, root)));
431
798
  proxies.push(makeParams(declaredParams, epoch));
799
+ captureStack.push(epoch);
432
800
  try {
433
- return toExpression(fn(...proxies));
801
+ return toExpression(fn(...proxies), fold);
434
802
  }
435
803
  finally {
436
- 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');
437
837
  }
838
+ return makeExpr(doc, captureStack[captureStack.length - 1], false);
438
839
  }
@@ -0,0 +1,33 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The one capture under every query-valued member of the flow
4
+ * documents: a transition guard and an effect's `with` over
5
+ * FLOW-FORMAT §3's step scope, a `query` node's document, a task's
6
+ * `with` and an edge's `select` over §6.1's input scope.
7
+ *
8
+ * The scope binds NO externals — both engines evaluate these with a
9
+ * single `$` and nothing else — so a name read off the capture's second
10
+ * argument is `JL0104` here, where the fix can be named, rather than
11
+ * `JQ2006` at step time, where a guard that cannot bind reads as a
12
+ * recorded false (FLOW-FORMAT §5.2). A returned literal is spelled as
13
+ * the format's own constructor and never folded into `$const`:
14
+ * §2's `{ "text": "retrying" }` is what a machine document carries.
15
+ */
16
+
17
+ import { cloneJson } from '@jarenjs/core/object';
18
+ import { captureQuery } from '../capture-root.js';
19
+ import { requireJson } from '../json-boundary.js';
20
+
21
+ /**
22
+ * One query-valued member: a callback captured over `$`, or a query
23
+ * document written by hand, copied.
24
+ * @param {string} what - the method, for the message
25
+ * @param {string} scope - what `$` is bound to, for the `JL0104` advice
26
+ * @param {any} value - a callback `(s) => …`, or a query document
27
+ * @returns {any} the query document (plain JSON)
28
+ */
29
+ export function queryMember(what, scope, value) {
30
+ if (typeof value !== 'function') return cloneJson(requireJson(value, what));
31
+ const advice = () => ` — ${what} evaluates over ${scope}, which its argument IS`;
32
+ return cloneJson(captureQuery(what, [], value, { advice, fold: false, noun: 'callback' }));
33
+ }