@omgbase/oqx 0.15.0 → 0.20.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 (76) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +308 -70
  3. package/dist/ast.d.ts +97 -27
  4. package/dist/ast.d.ts.map +1 -1
  5. package/dist/ast.js +21 -6
  6. package/dist/ast.js.map +1 -1
  7. package/dist/build.d.ts +43 -0
  8. package/dist/build.d.ts.map +1 -0
  9. package/dist/build.js +58 -0
  10. package/dist/build.js.map +1 -0
  11. package/dist/context.d.ts +30 -6
  12. package/dist/context.d.ts.map +1 -1
  13. package/dist/context.js +11 -10
  14. package/dist/context.js.map +1 -1
  15. package/dist/engine.d.ts +13 -3
  16. package/dist/engine.d.ts.map +1 -1
  17. package/dist/engine.js +281 -100
  18. package/dist/engine.js.map +1 -1
  19. package/dist/errors.d.ts +1 -1
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +2 -1
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +19 -6
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +36 -13
  26. package/dist/index.js.map +1 -1
  27. package/dist/lexer.d.ts +15 -1
  28. package/dist/lexer.d.ts.map +1 -1
  29. package/dist/lexer.js +104 -51
  30. package/dist/lexer.js.map +1 -1
  31. package/dist/optimize/analysis.d.ts +3 -2
  32. package/dist/optimize/analysis.d.ts.map +1 -1
  33. package/dist/optimize/analysis.js +19 -18
  34. package/dist/optimize/analysis.js.map +1 -1
  35. package/dist/optimize/ir.d.ts +2 -2
  36. package/dist/optimize/ir.d.ts.map +1 -1
  37. package/dist/optimize/ir.js +3 -3
  38. package/dist/optimize/ir.js.map +1 -1
  39. package/dist/parser.d.ts.map +1 -1
  40. package/dist/parser.js +782 -335
  41. package/dist/parser.js.map +1 -1
  42. package/dist/plan.d.ts.map +1 -1
  43. package/dist/plan.js +4 -2
  44. package/dist/plan.js.map +1 -1
  45. package/dist/print.d.ts +17 -0
  46. package/dist/print.d.ts.map +1 -0
  47. package/dist/print.js +266 -0
  48. package/dist/print.js.map +1 -0
  49. package/dist/resolve.d.ts +23 -0
  50. package/dist/resolve.d.ts.map +1 -0
  51. package/dist/resolve.js +333 -0
  52. package/dist/resolve.js.map +1 -0
  53. package/dist/version.d.ts +5 -0
  54. package/dist/version.d.ts.map +1 -0
  55. package/dist/version.js +5 -0
  56. package/dist/version.js.map +1 -0
  57. package/dist/walk.d.ts +56 -0
  58. package/dist/walk.d.ts.map +1 -0
  59. package/dist/walk.js +157 -0
  60. package/dist/walk.js.map +1 -0
  61. package/package.json +1 -1
  62. package/src/ast.ts +126 -40
  63. package/src/build.ts +73 -0
  64. package/src/context.ts +36 -12
  65. package/src/engine.ts +285 -110
  66. package/src/errors.ts +3 -2
  67. package/src/index.ts +40 -14
  68. package/src/lexer.ts +102 -51
  69. package/src/optimize/analysis.ts +17 -15
  70. package/src/optimize/ir.ts +4 -4
  71. package/src/parser.ts +721 -289
  72. package/src/plan.ts +3 -2
  73. package/src/print.ts +276 -0
  74. package/src/resolve.ts +309 -0
  75. package/src/version.ts +4 -0
  76. package/src/walk.ts +201 -0
package/dist/engine.js CHANGED
@@ -21,10 +21,19 @@
21
21
  import { DefaultContext } from "./context.js";
22
22
  import { OqxError } from "./errors.js";
23
23
  import { relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange, } from "./semantics.js";
24
+ import { printTemplate } from "./print.js";
24
25
  import { DEFAULT_RULES, HashIndex, intersectPositions, lookupOrder, planFor, residualWithout } from "./optimize/index.js";
25
26
  const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
26
27
  const KEY = "$key";
27
28
  const HARD_DEPTH_CAP = 8;
29
+ // The `$key: expr` item of a projection, if any — the identity the rows it
30
+ // yields carry (SEMANTICS §16, since 0.20).
31
+ function keyItem(proj) {
32
+ for (const it of proj.select)
33
+ if (it.kind === "field" && it.lift === 0 && it.name === KEY)
34
+ return it;
35
+ return null;
36
+ }
28
37
  const UNBOUNDED = { offset: 0, limit: null };
29
38
  export class InMemoryEngine {
30
39
  ctx;
@@ -45,8 +54,12 @@ export class InMemoryEngine {
45
54
  };
46
55
  if (this.trace)
47
56
  run.trace = this.trace;
48
- const root = { row: null, parent: null, bindings, run };
49
- const rows = this.reproject(this.rowsOf(this.evalRaw(query.source, root)), query.from, root);
57
+ const root = { row: this.ctx.rootObject?.(), parent: null, bindings, run };
58
+ const src = this.coerceRows(this.evalRaw(query.source, root), query.source);
59
+ const rows = this.reproject(src.rows, query.from, root);
60
+ // One object as the source yields one record (SEMANTICS §3) — unless a
61
+ // `from` re-projection or a walk fans it out.
62
+ const single = src.single && query.from.length === 0 && !query.follow;
50
63
  const bound = this.boundOf(query, root);
51
64
  if (query.follow)
52
65
  return this.runFollow(query, rows, root, bound);
@@ -86,21 +99,26 @@ export class InMemoryEngine {
86
99
  break;
87
100
  }
88
101
  }
102
+ this.applyKey(kept, query);
89
103
  this.sortScopes(kept, query.orderBy);
90
104
  if (query.distinct)
91
105
  kept = this.dedupByProjection(kept, query);
92
106
  kept = sliceBound(kept, bound);
93
- return this.shape(query.consumer, kept, query);
107
+ return this.shape(query.consumer, kept, query, single);
94
108
  }
95
109
  // Evaluate a block's `limit`/`offset`. The bound is part of the block, so it
96
110
  // is read in a row-less scope INSIDE it: a bare name is absent (there is no
97
111
  // current item yet), `^name` is the enclosing row — exactly as in the block's
98
- // body — and literals/bindings are themselves. For a top-level query `scope`
99
- // is the root. Each must be a non-negative integer.
112
+ // body — and literals/bindings are themselves. For a top-level query the
113
+ // scope is the root scope WITHOUT its row (SEMANTICS §18): bare names are
114
+ // still the named roots, but `$it` — reached through a bracket inside the
115
+ // bound — is absent, not the root object. Each must be a non-negative integer.
100
116
  boundOf(b, enclosing) {
101
117
  if (!b.limit && !b.offset)
102
118
  return UNBOUNDED;
103
- const scope = enclosing.parent === null ? enclosing : rowless(enclosing);
119
+ const scope = enclosing.parent === null
120
+ ? { row: undefined, parent: null, bindings: enclosing.bindings, run: enclosing.run }
121
+ : rowless(enclosing);
104
122
  const read = (e, word) => {
105
123
  if (!e)
106
124
  return null;
@@ -118,8 +136,60 @@ export class InMemoryEngine {
118
136
  return true;
119
137
  return this.evalWhere(where, this.enter(row, parent));
120
138
  }
121
- rowsOf(v) {
122
- return Array.from(this.ctx.toRows(v));
139
+ // The rows of a value in row position (SEMANTICS §3): absent → none; an array
140
+ // (or a host iterable) → its elements, a collection; an object → one row, and
141
+ // the value is SINGLE — a block over it yields one result rather than a list;
142
+ // a scalar (a string, a number, a boolean) or a range → an eval error. A
143
+ // context says which of its objects are single (`isSingle`); without it, any
144
+ // non-iterable object is.
145
+ coerceRows(v, what) {
146
+ if (v == null)
147
+ return { rows: [], single: false };
148
+ if (Array.isArray(v))
149
+ return { rows: v, single: false };
150
+ if (typeof v !== "object" || isRange(v)) {
151
+ const kind = isRange(v) ? "a range" : typeof v === "string" ? "a string" : typeof v === "number" ? "a number" : typeof v === "boolean" ? "a boolean" : `a ${typeof v}`;
152
+ const shown = isRange(v) ? "" : ` (${JSON.stringify(v)})`;
153
+ const subject = what ? `\`${describe(what)}\` is` : "the receiver is";
154
+ throw new OqxError(`${subject} ${kind}${shown}, not a collection or an object — rows come from a collection (a list) or one object (a record)`, "eval");
155
+ }
156
+ const rows = Array.from(this.ctx.toRows(v));
157
+ const single = this.ctx.isSingle ? this.ctx.isSingle(v) : !(Symbol.iterator in v);
158
+ return { rows, single };
159
+ }
160
+ rowsOf(v, what = null) {
161
+ return this.coerceRows(v, what).rows;
162
+ }
163
+ // The host's identity of a row (SEMANTICS §16): an entry is known by its key;
164
+ // any other row by the context's `identityOf`, else by its structural value.
165
+ hostIdentity(row) {
166
+ if (isEntry(row))
167
+ return row.key;
168
+ if (this.ctx.identityOf)
169
+ return this.ctx.identityOf(row);
170
+ if (this.ctx.identity)
171
+ return this.ctx.identity(row);
172
+ return row;
173
+ }
174
+ // The identity a scope's row is known by — `$key`: the identity the walk or a
175
+ // `$key:` projection gave it (`meta`), else the host's.
176
+ scopeIdentity(scope) {
177
+ if (scope.meta && Object.hasOwn(scope.meta, KEY))
178
+ return scope.meta[KEY];
179
+ if (scope.parent === null && scope.row === undefined)
180
+ return undefined;
181
+ return this.hostIdentity(scope.row);
182
+ }
183
+ // A projection's `$key: expr` item gives the rows it yields that identity:
184
+ // evaluate it on each kept scope and record it as the scope's `$key`.
185
+ applyKey(scopes, proj) {
186
+ const item = keyItem(proj);
187
+ if (!item)
188
+ return;
189
+ for (const s of scopes) {
190
+ const key = this.evalExpr(item.expr, s);
191
+ s.meta = { ...(s.meta ?? {}), [KEY]: key };
192
+ }
123
193
  }
124
194
  child(row, parent) {
125
195
  return this.enter(row, parent);
@@ -145,7 +215,7 @@ export class InMemoryEngine {
145
215
  runFollow(query, rows, root, bound) {
146
216
  const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
147
217
  const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
148
- const occ = this.followWalk(seeds, query.follow, root);
218
+ const occ = this.followWalk(this.keyed(seeds, query, root), query.follow, query.frontier, root);
149
219
  let scopes = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
150
220
  if (post)
151
221
  scopes = scopes.filter((s) => this.evalWhere(post, s));
@@ -153,7 +223,13 @@ export class InMemoryEngine {
153
223
  if (query.distinct)
154
224
  scopes = this.dedupByProjection(scopes, query);
155
225
  scopes = sliceBound(scopes, bound);
156
- return this.shape(query.consumer, scopes, query);
226
+ return this.shape(query.consumer, scopes, query, false);
227
+ }
228
+ // The seeds of a walk with their identities: the body's `$key: expr` item,
229
+ // read in each seed's scope, else the host's identity (SEMANTICS §20).
230
+ keyed(rows, proj, parent) {
231
+ const item = keyItem(proj);
232
+ return rows.map((row) => ({ row, key: item ? this.evalExpr(item.expr, this.enter(row, parent)) : this.hostIdentity(row) }));
157
233
  }
158
234
  // Dedup scopes by their PROJECTED value (`distinct`): keep the first scope per
159
235
  // distinct projection, preserving order. An empty projection dedups by row
@@ -165,7 +241,7 @@ export class InMemoryEngine {
165
241
  const out = [];
166
242
  for (const s of scopes) {
167
243
  const key = proj.select.length === 0
168
- ? `i:${canonicalKey(this.ctx.identity(s.row))}`
244
+ ? `i:${canonicalKey(this.scopeIdentity(s))}`
169
245
  : `p:${canonicalKey(this.projectRow(proj, s))}`;
170
246
  if (seen.has(key))
171
247
  continue;
@@ -175,81 +251,102 @@ export class InMemoryEngine {
175
251
  return out;
176
252
  }
177
253
  // A bounded, per-path recursive walk. Each occurrence carries recursion
178
- // metadata: `$depth` (seed = 1), a categorical `$stop`, and a deterministic
179
- // `$ordinal`. Semantics:
254
+ // metadata: `$depth` (seed = 1), a categorical `$stop`, a deterministic
255
+ // `$ordinal`, and `$key`, the identity it is known by. Semantics (SEMANTICS §20):
180
256
  // • per-path — a node reached by N distinct paths yields N occurrences
181
257
  // (unless `distinct`, which keeps the minimal (depth, path) per identity);
182
- // • cycles are safe — revisiting a key already on the current path admits ONE
183
- // occurrence with `$stop == "cycle"` and does not expand it (no runaway);
258
+ // • cycles are safe — revisiting an identity already on the current path
259
+ // admits ONE occurrence with `$stop == "cycle"` and does not expand it;
184
260
  // • `$stop` ∈ interior | leaf | frontier | depth | cycle, with precedence
185
261
  // cycle > frontier > depth > leaf > interior; only `interior` rows expand;
186
262
  // • `$leaf` = (stop == leaf); `$frontier` = (stop ∈ {frontier, depth}) — the
187
263
  // "there is unfollowed graph beyond me" signal;
188
- // • identity for cycle detection + `distinct` is `by <expr>` when given, else
189
- // `ctx.identity(row)` (an entry's identity is its value's); identities are
190
- // compared structurally via `canonicalKey`, and `$ordinal` paths compare
191
- // component-wise (`comparePath`), so `10` follows `9`;
264
+ // • identity is `$key` (since 0.20): a seed's is the body's `$key:` item or
265
+ // the host's identity; a successor's is its destination block's `$key:`
266
+ // item, else the host's identity of the yielded row. Identities compare
267
+ // structurally (`canonicalKey`); `$ordinal` paths compare component-wise
268
+ // (`comparePath`), so `10` follows `9`;
192
269
  // • a row's successors are the destinations' rows in source order — a plain
193
270
  // relation read in the row's scope, a destination block evaluated as a
194
271
  // select-position directive in that scope (so `^` inside it is the frontier
195
- // row and its `^people` receiver resolves one scope further out) — filtered
196
- // by the follow `where`, then unioned by identity within the step (a
197
- // recurring identity is kept once, at its first position);
198
- // • the follow `where` reads the candidate's scope hung off the FRONTIER
199
- // row's scope: a bare name is the candidate's, `^name` the row being
200
- // expanded, `^^name` the walk's enclosing scope. `frontier` and `by` read
201
- // the occurrence's own scope with `^` the enclosing scope, as the body does.
202
- followWalk(seedRows, follow, parent) {
203
- const cap = follow.depth ?? HARD_DEPTH_CAP;
204
- const scopeFor = (row) => this.enter(row, parent);
205
- const identityOf = (row) => follow.by ? this.evalExpr(follow.by, scopeFor(row)) : this.ctx.identity(isEntry(row) ? row.value : row);
206
- const succOf = (row) => {
207
- const rowScope = scopeFor(row);
272
+ // row and its `^people` receiver resolves one scope further out), shaped by
273
+ // the block's items other than `$key` — then unioned by identity within
274
+ // the step (a recurring identity is kept once, at its first position, and
275
+ // the destination that reached it first governs the occurrence);
276
+ // • a destination block's `depth n` (0.20) makes it decline to expand a
277
+ // frontier row whose `$depth >= n`; a row with no successors some
278
+ // destination declined is `depth`, with none declined `leaf`; the hard
279
+ // cap 8 is every destination's ceiling;
280
+ // • a destination block's `frontier P` marks the occurrences IT reached for
281
+ // which `P` holds (seeds are reached by none); `P` reads the occurrence's
282
+ // own scope with `^` the walk's enclosing scope, as the body does;
283
+ // • the body's own `frontier P` (`seedFrontier`, 0.20) marks the SEEDS for
284
+ // which `P` holds, read in the seed's scope exactly like the body's `where`.
285
+ followWalk(seeds, follow, seedFrontier, parent) {
286
+ const frontierScope = (k) => this.enter(k.row, parent, { meta: { [KEY]: k.key } });
287
+ const succOf = (k, depth) => {
288
+ const rowScope = frontierScope(k);
208
289
  const raw = [];
209
- for (const dest of follow.destinations) {
210
- const v = dest.kind === "op" ? this.evalCollectValue(dest, rowScope) : this.evalRaw(dest, rowScope);
211
- raw.push(...this.rowsOf(v));
212
- }
213
- const kept = follow.where ? raw.filter((x) => truthy(this.evalExpr(follow.where, this.enter(x, rowScope)))) : raw;
290
+ let declined = false;
291
+ follow.destinations.forEach((dest, via) => {
292
+ if (dest.kind === "op") {
293
+ if (dest.sub.depth !== null && depth >= dest.sub.depth) {
294
+ declined = true;
295
+ return;
296
+ }
297
+ for (const x of this.destinationRows(dest, rowScope))
298
+ raw.push({ ...x, via });
299
+ }
300
+ else {
301
+ for (const row of this.rowsOf(this.evalRaw(dest, rowScope), dest))
302
+ raw.push({ row, key: this.hostIdentity(row), via });
303
+ }
304
+ });
214
305
  const seen = new Set();
215
- const out = [];
216
- for (const x of kept) {
217
- const key = canonicalKey(identityOf(x));
306
+ const succ = [];
307
+ for (const x of raw) {
308
+ const key = canonicalKey(x.key);
218
309
  if (seen.has(key))
219
310
  continue;
220
311
  seen.add(key);
221
- out.push(x);
312
+ succ.push(x);
222
313
  }
223
- return out;
314
+ return { succ, declined };
315
+ };
316
+ const frontierHit = (k, via) => {
317
+ if (via === null)
318
+ return seedFrontier !== null && truthy(this.evalExpr(seedFrontier, frontierScope(k)));
319
+ const dest = follow.destinations[via];
320
+ if (dest.kind !== "op" || !dest.sub.frontier)
321
+ return false;
322
+ return truthy(this.evalExpr(dest.sub.frontier, frontierScope(k)));
224
323
  };
225
- const frontierHit = (row) => follow.frontier ? truthy(this.evalExpr(follow.frontier, scopeFor(row))) : false;
226
324
  const walked = [];
227
- const visit = (row, depth, ancestors, ancestorIds) => {
228
- const id = identityOf(row);
229
- const key = canonicalKey(id);
230
- const path = [...ancestorIds, id];
325
+ const visit = (k, via, depth, ancestors, ancestorIds) => {
326
+ const key = canonicalKey(k.key);
327
+ const path = [...ancestorIds, k.key];
231
328
  let stop;
232
329
  if (ancestors.includes(key))
233
330
  stop = "cycle";
234
- else if (frontierHit(row))
331
+ else if (frontierHit(k, via))
235
332
  stop = "frontier";
236
- else if (depth >= cap)
333
+ else if (depth >= HARD_DEPTH_CAP)
237
334
  stop = "depth";
238
335
  else {
239
- const succ = succOf(row);
336
+ const { succ, declined } = succOf(k, depth);
240
337
  if (succ.length === 0)
241
- stop = "leaf";
338
+ stop = declined ? "depth" : "leaf";
242
339
  else {
243
- walked.push({ row, depth, path, key, stop: "interior" });
244
- for (const s of succ)
245
- visit(s, depth + 1, [...ancestors, key], path);
340
+ walked.push({ row: k.row, depth, path, id: k.key, key, stop: "interior" });
341
+ for (const x of succ)
342
+ visit(x, x.via, depth + 1, [...ancestors, key], path);
246
343
  return;
247
344
  }
248
345
  }
249
- walked.push({ row, depth, path, key, stop });
346
+ walked.push({ row: k.row, depth, path, id: k.key, key, stop });
250
347
  };
251
- for (const r of seedRows)
252
- visit(r, 1, [], []);
348
+ for (const k of seeds)
349
+ visit(k, null, 1, [], []);
253
350
  let rows = walked;
254
351
  if (follow.distinct) {
255
352
  // keep the minimal (depth, path) occurrence per identity key.
@@ -266,6 +363,7 @@ export class InMemoryEngine {
266
363
  return rows.map((w, i) => ({
267
364
  row: w.row,
268
365
  meta: {
366
+ [KEY]: w.id,
269
367
  $depth: w.depth,
270
368
  $stop: w.stop,
271
369
  $leaf: w.stop === "leaf",
@@ -274,13 +372,30 @@ export class InMemoryEngine {
274
372
  },
275
373
  }));
276
374
  }
375
+ // A destination block's successors for one frontier row, each with its
376
+ // identity: the block's rows (its `where`, order, `distinct`, bound and
377
+ // consumer applied — `first`/`single` yield at most one), shaped by its items
378
+ // other than `$key` (the raw rows when none remain), keyed by its `$key:`
379
+ // item read in the row's scope, else by the host's identity of the row yielded.
380
+ destinationRows(dest, rowScope) {
381
+ const { scopes } = this.evalCollectScopes(dest, rowScope, this.planOf(dest, rowScope), true);
382
+ const key = keyItem(dest.sub);
383
+ const shape = key ? { select: dest.sub.select.filter((it) => it !== key), values: dest.sub.values } : dest.sub;
384
+ return scopes.map((s) => {
385
+ const row = this.projectRow(shape, s);
386
+ const id = key ? this.evalExpr(key.expr, s) : shape.select.length === 0 ? this.scopeIdentity(s) : this.hostIdentity(row);
387
+ return { row, key: id };
388
+ });
389
+ }
277
390
  // ---- consumer shaping -----------------------------------------------------
278
- shape(consumer, scopes, proj) {
391
+ // `single` — the receiver was one object: a `collect` yields its one record
392
+ // (or nothing) rather than a one-element list (SEMANTICS §3, §15).
393
+ shape(consumer, scopes, proj, single) {
279
394
  switch (consumer) {
280
395
  case "exists": return { consumer, exists: scopes.length > 0 };
281
396
  case "none": return { consumer, none: scopes.length === 0 };
282
397
  case "count": return { consumer, count: scopes.length };
283
- case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)) };
398
+ case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)), single };
284
399
  case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]) : null };
285
400
  case "single":
286
401
  if (scopes.length > 1)
@@ -370,6 +485,7 @@ export class InMemoryEngine {
370
485
  // The rows a consumer op reduces: matched → ordered → distinct → bounded.
371
486
  opRows(op, scope, bound, acc) {
372
487
  let scopes = this.matchRows(op, scope, acc);
488
+ this.applyKey(scopes, op.sub);
373
489
  this.sortScopes(scopes, op.sub.orderBy);
374
490
  if (op.distinct)
375
491
  scopes = this.dedupByProjection(scopes, op.sub);
@@ -434,7 +550,7 @@ export class InMemoryEngine {
434
550
  if (plan.receiverStable)
435
551
  run.stableCollections.set(op, (state = newCollection(value)));
436
552
  else if (plan.correlated.length === 0)
437
- return this.scan(this.rowsOf(value), op, scope);
553
+ return this.scan(this.coerceRows(value, op.receiver), op, scope);
438
554
  else if (value !== null && typeof value === "object") {
439
555
  state = run.collections.get(value);
440
556
  if (!state)
@@ -442,7 +558,7 @@ export class InMemoryEngine {
442
558
  }
443
559
  else {
444
560
  run.trace?.({ kind: "fallback", reason: "not-a-collection" });
445
- return this.scan(this.rowsOf(value), op, scope);
561
+ return this.scan(this.coerceRows(value, op.receiver), op, scope);
446
562
  }
447
563
  }
448
564
  state.seen++;
@@ -451,26 +567,27 @@ export class InMemoryEngine {
451
567
  if (direct)
452
568
  return direct;
453
569
  if (state.seen >= 2 || plan.receiverStable) {
454
- const probed = this.probe(plan, state, scope);
570
+ const probed = this.probe(plan, state, scope, op);
455
571
  if (probed)
456
- return { rows: probed, where: plan.residual };
572
+ return { rows: probed, where: plan.residual, single: false };
457
573
  }
458
574
  else
459
575
  run.trace?.({ kind: "fallback", reason: "unstable" });
460
576
  }
461
- return this.scan(this.materialize(state), op, scope);
577
+ return this.scan(this.materialize(state, op), op, scope);
462
578
  }
463
579
  // The plain scan: the rows, re-projected by `from`, with the whole `where`.
464
- scan(rows, op, scope) {
465
- return { rows: this.reproject(rows, op.sub.from, scope), where: op.sub.where };
580
+ // A `from` re-projection fans a single receiver out: the block is plural.
581
+ scan(src, op, scope) {
582
+ return { rows: this.reproject(src.rows, op.sub.from, scope), where: op.sub.where, single: src.single && op.sub.from.length === 0 };
466
583
  }
467
584
  reproject(rows, from, scope) {
468
585
  for (const proj of from)
469
- rows = rows.flatMap((r) => this.rowsOf(this.evalRaw(proj, this.child(r, scope))));
586
+ rows = rows.flatMap((r) => this.rowsOf(this.evalRaw(proj, this.child(r, scope)), proj));
470
587
  return rows;
471
588
  }
472
- materialize(state) {
473
- return (state.rows ??= this.rowsOf(state.value));
589
+ materialize(state, op) {
590
+ return (state.rows ??= this.coerceRows(state.value, op.receiver));
474
591
  }
475
592
  // A probe answered by a context index that yields rows directly, before the
476
593
  // collection is materialized: one correlation is probed (`lookupOrder`) and
@@ -485,19 +602,19 @@ export class InMemoryEngine {
485
602
  return null;
486
603
  const rows = Array.from(index.lookupRows(outer.value));
487
604
  scope.run.trace?.({ kind: "lookup", path: c.path, candidates: rows.length });
488
- return { rows, where: residualWithout(plan, c) };
605
+ return { rows, where: residualWithout(plan, c), single: false };
489
606
  }
490
607
  return null;
491
608
  }
492
609
  // The engine's probe: one bucket per correlation, intersected smallest-first;
493
610
  // the candidates come back in receiver order. `null` (an index that could
494
611
  // not be built, an outer value whose evaluation threw) leaves the scan in charge.
495
- probe(plan, state, scope) {
612
+ probe(plan, state, scope, op) {
496
613
  const run = scope.run;
497
- const rows = this.materialize(state);
614
+ const rows = this.materialize(state, op).rows;
498
615
  const buckets = [];
499
616
  for (const c of plan.correlated) {
500
- const index = this.indexFor(state, c, scope);
617
+ const index = this.indexFor(state, c, scope, op);
501
618
  if (!index) {
502
619
  run.trace?.({ kind: "fallback", reason: "index" });
503
620
  return null;
@@ -538,12 +655,12 @@ export class InMemoryEngine {
538
655
  // here by evaluating the local expression in each row's scope — the same read
539
656
  // the scan performs, so the keys are exactly what `==` would compare. A throw
540
657
  // while building (a context that rejects a read) leaves the path un-indexed.
541
- indexFor(state, c, scope) {
658
+ indexFor(state, c, scope, op) {
542
659
  const key = pathKey(c.path);
543
660
  const hit = state.indexes.get(key);
544
661
  if (hit !== undefined)
545
662
  return hit;
546
- const rows = this.materialize(state);
663
+ const rows = this.materialize(state, op).rows;
547
664
  let index = this.contextIndex(state, c);
548
665
  let source = "context";
549
666
  if (!index) {
@@ -569,30 +686,47 @@ export class InMemoryEngine {
569
686
  return this.memoized(op, scope, () => this.evalCollectValuePlanned(op, plan, scope));
570
687
  }
571
688
  evalCollectValuePlanned(op, plan, scope) {
689
+ const { scopes, single } = this.evalCollectScopes(op, scope, plan);
690
+ switch (op.op) {
691
+ // Over a single receiver (one object) a `collect` is the record or absent,
692
+ // not a one-element list (SEMANTICS §3).
693
+ case "collect": return single ? (scopes.length > 0 ? this.projectRow(op.sub, scopes[0]) : undefined) : scopes.map((s) => this.projectRow(op.sub, s));
694
+ default: return scopes.length > 0 ? this.projectRow(op.sub, scopes[0]) : null;
695
+ }
696
+ }
697
+ // The rows a select-position directive yields, as scopes, reduced by its
698
+ // consumer (`first`/`single` keep at most one; `single` raises on more), and
699
+ // whether its receiver was a single object. `destination` marks a follow
700
+ // destination block: its `frontier` is the destination's (read by the outer
701
+ // walk), never a seed frontier for a `follow` of its own.
702
+ evalCollectScopes(op, scope, plan = this.planOf(op, scope), destination = false) {
572
703
  const sub = op.sub;
573
704
  const bound = this.boundOf(sub, scope);
574
705
  let scopes;
706
+ let single = false;
575
707
  if (sub.follow) {
576
708
  // The seeds are the matched rows themselves (`followWalk` enters them); a
577
709
  // follow block is never correlated, so `where` here is the whole predicate.
578
710
  const { rows, where } = this.access(op, plan, scope);
579
711
  const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
580
- scopes = this.followWalk(seeds, sub.follow, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
712
+ scopes = this.followWalk(this.keyed(seeds, sub, scope), sub.follow, destination ? null : sub.frontier, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
581
713
  this.sortScopes(scopes, sub.orderBy);
582
714
  if (op.distinct)
583
715
  scopes = this.dedupByProjection(scopes, sub);
584
716
  scopes = sliceBound(scopes, bound);
585
717
  }
586
718
  else {
587
- scopes = this.opRows(op, scope, bound, this.access(op, plan, scope));
719
+ const acc = this.access(op, plan, scope);
720
+ single = acc.single;
721
+ scopes = this.opRows(op, scope, bound, acc);
588
722
  }
589
723
  switch (op.op) {
590
- case "collect": return scopes.map((s) => this.projectRow(sub, s));
591
- case "first": return scopes.length > 0 ? this.projectRow(sub, scopes[0]) : null;
724
+ case "collect": return { scopes, single };
725
+ case "first": return { scopes: scopes.slice(0, 1), single };
592
726
  case "single":
593
727
  if (scopes.length > 1)
594
728
  throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
595
- return scopes.length > 0 ? this.projectRow(sub, scopes[0]) : null;
729
+ return { scopes, single };
596
730
  default: throw new OqxError(`${op.op} { … } is not valid in select position`, "eval");
597
731
  }
598
732
  }
@@ -656,11 +790,6 @@ export class InMemoryEngine {
656
790
  const r = this.evalExpr(e.recv, scope);
657
791
  return r == null ? undefined : this.ctx.get(r, e.name);
658
792
  }
659
- case "index": {
660
- const r = this.evalExpr(e.recv, scope);
661
- const i = this.evalExpr(e.index, scope);
662
- return r == null ? undefined : this.ctx.get(r, String(i));
663
- }
664
793
  case "call": return this.evalCall(e, scope);
665
794
  case "unary": {
666
795
  const v = this.evalExpr(e.expr, scope);
@@ -668,9 +797,25 @@ export class InMemoryEngine {
668
797
  return !truthy(v);
669
798
  return v == null ? undefined : -v;
670
799
  }
800
+ // `x!` — the value, or an eval error naming the expression (and the row's
801
+ // identity when it has a scalar one). Never a filter, never a coercion.
802
+ case "required": {
803
+ const v = this.evalExpr(e.expr, scope);
804
+ if (v == null)
805
+ throw new OqxError(`\`${describe(e.expr)}!\` is absent${this.rowIdentity(scope)}`, "eval");
806
+ return v;
807
+ }
808
+ // A value-position directive (`jobs first { … }.pay`, the desugared `x[…]`).
809
+ case "op": return this.evalCollectValue(e, scope);
671
810
  case "binary": {
672
811
  const l = this.evalExpr(e.left, scope);
673
812
  const r = this.evalExpr(e.right, scope);
813
+ if (e.op === "is" || e.op === "is not") {
814
+ // Identity (SEMANTICS §5b): the host's identity of each side,
815
+ // compared structurally — the key `distinct` and `follow` use.
816
+ const same = canonicalKey(this.hostIdentity(l)) === canonicalKey(this.hostIdentity(r));
817
+ return e.op === "is" ? same : !same;
818
+ }
674
819
  return isRelOp(e.op) ? relate(e.op, l, r) : arith(e.op, l, r);
675
820
  }
676
821
  case "logical": {
@@ -681,15 +826,37 @@ export class InMemoryEngine {
681
826
  }
682
827
  case "in": return membership(this.evalExpr(e.left, scope), this.evalExpr(e.right, scope));
683
828
  case "range": return makeRange(e.lo == null ? null : this.evalExpr(e.lo, scope), e.hi == null ? null : this.evalExpr(e.hi, scope), e.exclusiveEnd);
829
+ // A tuple (SEMANTICS §3b): the elements in order, concatenated with ONE
830
+ // level of flattening — a collection contributes its items, anything else
831
+ // itself; an absent (`undefined`) element contributes nothing, a `null`
832
+ // one `null`; duplicates are kept.
833
+ case "tuple": {
834
+ const out = [];
835
+ for (const item of e.items) {
836
+ const v = this.evalExpr(item, scope);
837
+ if (v === undefined)
838
+ continue;
839
+ if (Array.isArray(v))
840
+ out.push(...v);
841
+ else if (v !== null && typeof v === "object" && !isEntry(v) && !isRange(v) && Symbol.iterator in v)
842
+ out.push(...v);
843
+ else
844
+ out.push(v);
845
+ }
846
+ return out;
847
+ }
684
848
  }
685
849
  }
686
850
  // Resolve a name against ONE scope — never its ancestors. A scope provides,
687
851
  // in order: `$it` (the scope's row itself — the current item, whatever its
688
- // type, so scalar collections are queryable; absent at the root, which has no
689
- // row; `$value`, its pre-0.15 spelling, is an ordinary property name now); `$key` (the property key) when it is an entry scope — see `enter`;
690
- // the recursion intrinsics (`$depth`, …) when it is a follow occurrence;
691
- // values lifted into it by `^name:` items; then either the row's own property
692
- // or, for the root scope (no row), the context's named roots.
852
+ // type, so scalar collections are queryable; at the root the host's root
853
+ // object, since 0.18; `$value`, its pre-0.15 spelling, is an ordinary
854
+ // property name now); `$key` (the property key) when it is an entry scope —
855
+ // see `enter`; the recursion intrinsics (`$depth`, …) when it is a follow
856
+ // occurrence; values lifted into it by `^name:` items; then either the row's
857
+ // own property or, for the root scope, the context's named roots (`root(name)`
858
+ // — never a property of the root object, so a host with lazy roots is
859
+ // unaffected by what its root object holds).
693
860
  //
694
861
  // The two metadata steps apply only where the scope CARRIES that metadata
695
862
  // (SEMANTICS §2, since 0.13). Anywhere else — `$key` on an ordinary row,
@@ -706,8 +873,11 @@ export class InMemoryEngine {
706
873
  // 0, "") need no special case — there is no "absent, so look outward" rule.
707
874
  resolveIn(name, scope) {
708
875
  if (name === "$it")
709
- return scope.parent === null ? undefined : scope.row;
710
- if ((name === KEY || RECUR.has(name)) && scope.meta && Object.hasOwn(scope.meta, name))
876
+ return scope.row;
877
+ // `$key` is the row's identity on every row (SEMANTICS §16, since 0.20).
878
+ if (name === KEY)
879
+ return this.scopeIdentity(scope);
880
+ if (RECUR.has(name) && scope.meta && Object.hasOwn(scope.meta, name))
711
881
  return scope.meta[name];
712
882
  if (scope.lifts && Object.hasOwn(scope.lifts, name))
713
883
  return scope.lifts[name];
@@ -715,6 +885,15 @@ export class InMemoryEngine {
715
885
  return this.ctx.root(name);
716
886
  return this.ctx.get(scope.row, name);
717
887
  }
888
+ // ` on <identity>` for a required-value error when the scope's row has a
889
+ // scalar identity (`$key`: the host's, an entry's key, a `$key:` projection);
890
+ // empty otherwise, and at the root (the root object is not a row of the data).
891
+ rowIdentity(scope) {
892
+ if (scope.parent === null || scope.row == null)
893
+ return "";
894
+ const id = this.scopeIdentity(scope);
895
+ return typeof id === "string" || typeof id === "number" ? ` on ${JSON.stringify(id)}` : "";
896
+ }
718
897
  evalCall(e, scope) {
719
898
  const args = e.args.map((a) => this.evalExpr(a, scope));
720
899
  if (e.recv === null) {
@@ -800,6 +979,12 @@ function boundedCount(n, b) {
800
979
  const rest = Math.max(0, n - b.offset);
801
980
  return b.limit == null ? rest : Math.min(rest, b.limit);
802
981
  }
982
+ // The canonical text of an expression for an error message; a binding shows as
983
+ // its `${n}` marker (the printer refuses to turn a bound value into source).
984
+ function describe(e) {
985
+ const t = printTemplate(e);
986
+ return t.strings.map((s, i) => (i < t.count ? `${s}\${${t.indices[i]}}` : s)).join("");
987
+ }
803
988
  function describeReceiver(e) {
804
989
  if (e.kind === "ident")
805
990
  return e.name;
@@ -820,7 +1005,7 @@ function partitionRecur(w) {
820
1005
  const post = [];
821
1006
  for (const p of parts)
822
1007
  (whereHasRecur(p) ? post : seed).push(p);
823
- const rebuild = (ps) => (ps.length === 0 ? null : ps.length === 1 ? ps[0] : { kind: "and", parts: ps });
1008
+ const rebuild = (ps) => (ps.length === 0 ? null : ps.length === 1 ? ps[0] : { kind: "and", span: [0, 0], parts: ps });
824
1009
  return { seed: rebuild(seed), post: rebuild(post) };
825
1010
  }
826
1011
  function whereHasRecur(w) {
@@ -836,19 +1021,15 @@ function exprHasRecur(e) {
836
1021
  switch (e.kind) {
837
1022
  case "ident": return RECUR.has(e.name);
838
1023
  case "member": return exprHasRecur(e.recv);
839
- case "index": return exprHasRecur(e.recv) || exprHasRecur(e.index);
840
1024
  case "call": return (e.recv ? exprHasRecur(e.recv) : false) || e.args.some(exprHasRecur);
841
- case "unary": return exprHasRecur(e.expr);
1025
+ case "unary":
1026
+ case "required": return exprHasRecur(e.expr);
842
1027
  case "binary":
843
1028
  case "logical":
844
1029
  case "in": return exprHasRecur(e.left) || exprHasRecur(e.right);
845
1030
  case "range": return (e.lo != null && exprHasRecur(e.lo)) || (e.hi != null && exprHasRecur(e.hi));
1031
+ case "tuple": return e.items.some(exprHasRecur);
846
1032
  default: return false;
847
1033
  }
848
1034
  }
849
- /** Convenience: run a query with plain-object roots (the default context). */
850
- export function runQuery(query, bindings, roots) {
851
- const ctx = new DefaultContext(roots ?? {});
852
- return new InMemoryEngine(ctx).run(query, bindings);
853
- }
854
1035
  //# sourceMappingURL=engine.js.map