@omgbase/oqx 0.18.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 (52) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +149 -64
  3. package/dist/ast.d.ts +21 -5
  4. package/dist/ast.d.ts.map +1 -1
  5. package/dist/ast.js +1 -1
  6. package/dist/ast.js.map +1 -1
  7. package/dist/build.d.ts +7 -1
  8. package/dist/build.d.ts.map +1 -1
  9. package/dist/build.js +7 -3
  10. package/dist/build.js.map +1 -1
  11. package/dist/context.d.ts +19 -5
  12. package/dist/context.d.ts.map +1 -1
  13. package/dist/context.js +6 -9
  14. package/dist/context.js.map +1 -1
  15. package/dist/engine.d.ts +12 -1
  16. package/dist/engine.d.ts.map +1 -1
  17. package/dist/engine.js +235 -83
  18. package/dist/engine.js.map +1 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -1
  21. package/dist/index.js.map +1 -1
  22. package/dist/optimize/analysis.d.ts +3 -2
  23. package/dist/optimize/analysis.d.ts.map +1 -1
  24. package/dist/optimize/analysis.js +14 -15
  25. package/dist/optimize/analysis.js.map +1 -1
  26. package/dist/parser.d.ts.map +1 -1
  27. package/dist/parser.js +196 -114
  28. package/dist/parser.js.map +1 -1
  29. package/dist/print.d.ts.map +1 -1
  30. package/dist/print.js +13 -13
  31. package/dist/print.js.map +1 -1
  32. package/dist/resolve.d.ts.map +1 -1
  33. package/dist/resolve.js +11 -8
  34. package/dist/resolve.js.map +1 -1
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/dist/walk.d.ts +5 -3
  38. package/dist/walk.d.ts.map +1 -1
  39. package/dist/walk.js +26 -8
  40. package/dist/walk.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/ast.ts +23 -6
  43. package/src/build.ts +8 -7
  44. package/src/context.ts +22 -11
  45. package/src/engine.ts +236 -90
  46. package/src/index.ts +2 -1
  47. package/src/optimize/analysis.ts +13 -12
  48. package/src/parser.ts +180 -96
  49. package/src/print.ts +12 -10
  50. package/src/resolve.ts +11 -7
  51. package/src/version.ts +1 -1
  52. package/src/walk.ts +32 -11
package/dist/engine.js CHANGED
@@ -26,6 +26,14 @@ import { DEFAULT_RULES, HashIndex, intersectPositions, lookupOrder, planFor, res
26
26
  const RECUR = new Set(["$depth", "$stop", "$leaf", "$frontier", "$ordinal"]);
27
27
  const KEY = "$key";
28
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
+ }
29
37
  const UNBOUNDED = { offset: 0, limit: null };
30
38
  export class InMemoryEngine {
31
39
  ctx;
@@ -47,7 +55,11 @@ export class InMemoryEngine {
47
55
  if (this.trace)
48
56
  run.trace = this.trace;
49
57
  const root = { row: this.ctx.rootObject?.(), parent: null, bindings, run };
50
- const rows = this.reproject(this.rowsOf(this.evalRaw(query.source, root)), query.from, root);
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;
51
63
  const bound = this.boundOf(query, root);
52
64
  if (query.follow)
53
65
  return this.runFollow(query, rows, root, bound);
@@ -87,11 +99,12 @@ export class InMemoryEngine {
87
99
  break;
88
100
  }
89
101
  }
102
+ this.applyKey(kept, query);
90
103
  this.sortScopes(kept, query.orderBy);
91
104
  if (query.distinct)
92
105
  kept = this.dedupByProjection(kept, query);
93
106
  kept = sliceBound(kept, bound);
94
- return this.shape(query.consumer, kept, query);
107
+ return this.shape(query.consumer, kept, query, single);
95
108
  }
96
109
  // Evaluate a block's `limit`/`offset`. The bound is part of the block, so it
97
110
  // is read in a row-less scope INSIDE it: a bare name is absent (there is no
@@ -123,8 +136,60 @@ export class InMemoryEngine {
123
136
  return true;
124
137
  return this.evalWhere(where, this.enter(row, parent));
125
138
  }
126
- rowsOf(v) {
127
- 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
+ }
128
193
  }
129
194
  child(row, parent) {
130
195
  return this.enter(row, parent);
@@ -150,7 +215,7 @@ export class InMemoryEngine {
150
215
  runFollow(query, rows, root, bound) {
151
216
  const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
152
217
  const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
153
- const occ = this.followWalk(seeds, query.follow, root);
218
+ const occ = this.followWalk(this.keyed(seeds, query, root), query.follow, query.frontier, root);
154
219
  let scopes = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
155
220
  if (post)
156
221
  scopes = scopes.filter((s) => this.evalWhere(post, s));
@@ -158,7 +223,13 @@ export class InMemoryEngine {
158
223
  if (query.distinct)
159
224
  scopes = this.dedupByProjection(scopes, query);
160
225
  scopes = sliceBound(scopes, bound);
161
- 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) }));
162
233
  }
163
234
  // Dedup scopes by their PROJECTED value (`distinct`): keep the first scope per
164
235
  // distinct projection, preserving order. An empty projection dedups by row
@@ -170,7 +241,7 @@ export class InMemoryEngine {
170
241
  const out = [];
171
242
  for (const s of scopes) {
172
243
  const key = proj.select.length === 0
173
- ? `i:${canonicalKey(this.ctx.identity(s.row))}`
244
+ ? `i:${canonicalKey(this.scopeIdentity(s))}`
174
245
  : `p:${canonicalKey(this.projectRow(proj, s))}`;
175
246
  if (seen.has(key))
176
247
  continue;
@@ -180,81 +251,102 @@ export class InMemoryEngine {
180
251
  return out;
181
252
  }
182
253
  // A bounded, per-path recursive walk. Each occurrence carries recursion
183
- // metadata: `$depth` (seed = 1), a categorical `$stop`, and a deterministic
184
- // `$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):
185
256
  // • per-path — a node reached by N distinct paths yields N occurrences
186
257
  // (unless `distinct`, which keeps the minimal (depth, path) per identity);
187
- // • cycles are safe — revisiting a key already on the current path admits ONE
188
- // 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;
189
260
  // • `$stop` ∈ interior | leaf | frontier | depth | cycle, with precedence
190
261
  // cycle > frontier > depth > leaf > interior; only `interior` rows expand;
191
262
  // • `$leaf` = (stop == leaf); `$frontier` = (stop ∈ {frontier, depth}) — the
192
263
  // "there is unfollowed graph beyond me" signal;
193
- // • identity for cycle detection + `distinct` is `by <expr>` when given, else
194
- // `ctx.identity(row)` (an entry's identity is its value's); identities are
195
- // compared structurally via `canonicalKey`, and `$ordinal` paths compare
196
- // 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`;
197
269
  // • a row's successors are the destinations' rows in source order — a plain
198
270
  // relation read in the row's scope, a destination block evaluated as a
199
271
  // select-position directive in that scope (so `^` inside it is the frontier
200
- // row and its `^people` receiver resolves one scope further out) — filtered
201
- // by the follow `where`, then unioned by identity within the step (a
202
- // recurring identity is kept once, at its first position);
203
- // • the follow `where` reads the candidate's scope hung off the FRONTIER
204
- // row's scope: a bare name is the candidate's, `^name` the row being
205
- // expanded, `^^name` the walk's enclosing scope. `frontier` and `by` read
206
- // the occurrence's own scope with `^` the enclosing scope, as the body does.
207
- followWalk(seedRows, follow, parent) {
208
- const cap = follow.depth ?? HARD_DEPTH_CAP;
209
- const scopeFor = (row) => this.enter(row, parent);
210
- const identityOf = (row) => follow.by ? this.evalExpr(follow.by, scopeFor(row)) : this.ctx.identity(isEntry(row) ? row.value : row);
211
- const succOf = (row) => {
212
- 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);
213
289
  const raw = [];
214
- for (const dest of follow.destinations) {
215
- const v = dest.kind === "op" ? this.evalCollectValue(dest, rowScope) : this.evalRaw(dest, rowScope);
216
- raw.push(...this.rowsOf(v));
217
- }
218
- 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
+ });
219
305
  const seen = new Set();
220
- const out = [];
221
- for (const x of kept) {
222
- const key = canonicalKey(identityOf(x));
306
+ const succ = [];
307
+ for (const x of raw) {
308
+ const key = canonicalKey(x.key);
223
309
  if (seen.has(key))
224
310
  continue;
225
311
  seen.add(key);
226
- out.push(x);
312
+ succ.push(x);
227
313
  }
228
- 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)));
229
323
  };
230
- const frontierHit = (row) => follow.frontier ? truthy(this.evalExpr(follow.frontier, scopeFor(row))) : false;
231
324
  const walked = [];
232
- const visit = (row, depth, ancestors, ancestorIds) => {
233
- const id = identityOf(row);
234
- const key = canonicalKey(id);
235
- const path = [...ancestorIds, id];
325
+ const visit = (k, via, depth, ancestors, ancestorIds) => {
326
+ const key = canonicalKey(k.key);
327
+ const path = [...ancestorIds, k.key];
236
328
  let stop;
237
329
  if (ancestors.includes(key))
238
330
  stop = "cycle";
239
- else if (frontierHit(row))
331
+ else if (frontierHit(k, via))
240
332
  stop = "frontier";
241
- else if (depth >= cap)
333
+ else if (depth >= HARD_DEPTH_CAP)
242
334
  stop = "depth";
243
335
  else {
244
- const succ = succOf(row);
336
+ const { succ, declined } = succOf(k, depth);
245
337
  if (succ.length === 0)
246
- stop = "leaf";
338
+ stop = declined ? "depth" : "leaf";
247
339
  else {
248
- walked.push({ row, depth, path, key, stop: "interior" });
249
- for (const s of succ)
250
- 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);
251
343
  return;
252
344
  }
253
345
  }
254
- walked.push({ row, depth, path, key, stop });
346
+ walked.push({ row: k.row, depth, path, id: k.key, key, stop });
255
347
  };
256
- for (const r of seedRows)
257
- visit(r, 1, [], []);
348
+ for (const k of seeds)
349
+ visit(k, null, 1, [], []);
258
350
  let rows = walked;
259
351
  if (follow.distinct) {
260
352
  // keep the minimal (depth, path) occurrence per identity key.
@@ -271,6 +363,7 @@ export class InMemoryEngine {
271
363
  return rows.map((w, i) => ({
272
364
  row: w.row,
273
365
  meta: {
366
+ [KEY]: w.id,
274
367
  $depth: w.depth,
275
368
  $stop: w.stop,
276
369
  $leaf: w.stop === "leaf",
@@ -279,13 +372,30 @@ export class InMemoryEngine {
279
372
  },
280
373
  }));
281
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
+ }
282
390
  // ---- consumer shaping -----------------------------------------------------
283
- 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) {
284
394
  switch (consumer) {
285
395
  case "exists": return { consumer, exists: scopes.length > 0 };
286
396
  case "none": return { consumer, none: scopes.length === 0 };
287
397
  case "count": return { consumer, count: scopes.length };
288
- 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 };
289
399
  case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]) : null };
290
400
  case "single":
291
401
  if (scopes.length > 1)
@@ -375,6 +485,7 @@ export class InMemoryEngine {
375
485
  // The rows a consumer op reduces: matched → ordered → distinct → bounded.
376
486
  opRows(op, scope, bound, acc) {
377
487
  let scopes = this.matchRows(op, scope, acc);
488
+ this.applyKey(scopes, op.sub);
378
489
  this.sortScopes(scopes, op.sub.orderBy);
379
490
  if (op.distinct)
380
491
  scopes = this.dedupByProjection(scopes, op.sub);
@@ -439,7 +550,7 @@ export class InMemoryEngine {
439
550
  if (plan.receiverStable)
440
551
  run.stableCollections.set(op, (state = newCollection(value)));
441
552
  else if (plan.correlated.length === 0)
442
- return this.scan(this.rowsOf(value), op, scope);
553
+ return this.scan(this.coerceRows(value, op.receiver), op, scope);
443
554
  else if (value !== null && typeof value === "object") {
444
555
  state = run.collections.get(value);
445
556
  if (!state)
@@ -447,7 +558,7 @@ export class InMemoryEngine {
447
558
  }
448
559
  else {
449
560
  run.trace?.({ kind: "fallback", reason: "not-a-collection" });
450
- return this.scan(this.rowsOf(value), op, scope);
561
+ return this.scan(this.coerceRows(value, op.receiver), op, scope);
451
562
  }
452
563
  }
453
564
  state.seen++;
@@ -456,26 +567,27 @@ export class InMemoryEngine {
456
567
  if (direct)
457
568
  return direct;
458
569
  if (state.seen >= 2 || plan.receiverStable) {
459
- const probed = this.probe(plan, state, scope);
570
+ const probed = this.probe(plan, state, scope, op);
460
571
  if (probed)
461
- return { rows: probed, where: plan.residual };
572
+ return { rows: probed, where: plan.residual, single: false };
462
573
  }
463
574
  else
464
575
  run.trace?.({ kind: "fallback", reason: "unstable" });
465
576
  }
466
- return this.scan(this.materialize(state), op, scope);
577
+ return this.scan(this.materialize(state, op), op, scope);
467
578
  }
468
579
  // The plain scan: the rows, re-projected by `from`, with the whole `where`.
469
- scan(rows, op, scope) {
470
- 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 };
471
583
  }
472
584
  reproject(rows, from, scope) {
473
585
  for (const proj of from)
474
- 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));
475
587
  return rows;
476
588
  }
477
- materialize(state) {
478
- return (state.rows ??= this.rowsOf(state.value));
589
+ materialize(state, op) {
590
+ return (state.rows ??= this.coerceRows(state.value, op.receiver));
479
591
  }
480
592
  // A probe answered by a context index that yields rows directly, before the
481
593
  // collection is materialized: one correlation is probed (`lookupOrder`) and
@@ -490,19 +602,19 @@ export class InMemoryEngine {
490
602
  return null;
491
603
  const rows = Array.from(index.lookupRows(outer.value));
492
604
  scope.run.trace?.({ kind: "lookup", path: c.path, candidates: rows.length });
493
- return { rows, where: residualWithout(plan, c) };
605
+ return { rows, where: residualWithout(plan, c), single: false };
494
606
  }
495
607
  return null;
496
608
  }
497
609
  // The engine's probe: one bucket per correlation, intersected smallest-first;
498
610
  // the candidates come back in receiver order. `null` (an index that could
499
611
  // not be built, an outer value whose evaluation threw) leaves the scan in charge.
500
- probe(plan, state, scope) {
612
+ probe(plan, state, scope, op) {
501
613
  const run = scope.run;
502
- const rows = this.materialize(state);
614
+ const rows = this.materialize(state, op).rows;
503
615
  const buckets = [];
504
616
  for (const c of plan.correlated) {
505
- const index = this.indexFor(state, c, scope);
617
+ const index = this.indexFor(state, c, scope, op);
506
618
  if (!index) {
507
619
  run.trace?.({ kind: "fallback", reason: "index" });
508
620
  return null;
@@ -543,12 +655,12 @@ export class InMemoryEngine {
543
655
  // here by evaluating the local expression in each row's scope — the same read
544
656
  // the scan performs, so the keys are exactly what `==` would compare. A throw
545
657
  // while building (a context that rejects a read) leaves the path un-indexed.
546
- indexFor(state, c, scope) {
658
+ indexFor(state, c, scope, op) {
547
659
  const key = pathKey(c.path);
548
660
  const hit = state.indexes.get(key);
549
661
  if (hit !== undefined)
550
662
  return hit;
551
- const rows = this.materialize(state);
663
+ const rows = this.materialize(state, op).rows;
552
664
  let index = this.contextIndex(state, c);
553
665
  let source = "context";
554
666
  if (!index) {
@@ -574,30 +686,47 @@ export class InMemoryEngine {
574
686
  return this.memoized(op, scope, () => this.evalCollectValuePlanned(op, plan, scope));
575
687
  }
576
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) {
577
703
  const sub = op.sub;
578
704
  const bound = this.boundOf(sub, scope);
579
705
  let scopes;
706
+ let single = false;
580
707
  if (sub.follow) {
581
708
  // The seeds are the matched rows themselves (`followWalk` enters them); a
582
709
  // follow block is never correlated, so `where` here is the whole predicate.
583
710
  const { rows, where } = this.access(op, plan, scope);
584
711
  const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
585
- 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 }));
586
713
  this.sortScopes(scopes, sub.orderBy);
587
714
  if (op.distinct)
588
715
  scopes = this.dedupByProjection(scopes, sub);
589
716
  scopes = sliceBound(scopes, bound);
590
717
  }
591
718
  else {
592
- 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);
593
722
  }
594
723
  switch (op.op) {
595
- case "collect": return scopes.map((s) => this.projectRow(sub, s));
596
- 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 };
597
726
  case "single":
598
727
  if (scopes.length > 1)
599
728
  throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
600
- return scopes.length > 0 ? this.projectRow(sub, scopes[0]) : null;
729
+ return { scopes, single };
601
730
  default: throw new OqxError(`${op.op} { … } is not valid in select position`, "eval");
602
731
  }
603
732
  }
@@ -682,9 +811,9 @@ export class InMemoryEngine {
682
811
  const l = this.evalExpr(e.left, scope);
683
812
  const r = this.evalExpr(e.right, scope);
684
813
  if (e.op === "is" || e.op === "is not") {
685
- // Identity (SEMANTICS §5b): the context's identity of each side,
814
+ // Identity (SEMANTICS §5b): the host's identity of each side,
686
815
  // compared structurally — the key `distinct` and `follow` use.
687
- const same = canonicalKey(this.ctx.identity(l)) === canonicalKey(this.ctx.identity(r));
816
+ const same = canonicalKey(this.hostIdentity(l)) === canonicalKey(this.hostIdentity(r));
688
817
  return e.op === "is" ? same : !same;
689
818
  }
690
819
  return isRelOp(e.op) ? relate(e.op, l, r) : arith(e.op, l, r);
@@ -697,6 +826,25 @@ export class InMemoryEngine {
697
826
  }
698
827
  case "in": return membership(this.evalExpr(e.left, scope), this.evalExpr(e.right, scope));
699
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
+ }
700
848
  }
701
849
  }
702
850
  // Resolve a name against ONE scope — never its ancestors. A scope provides,
@@ -726,7 +874,10 @@ export class InMemoryEngine {
726
874
  resolveIn(name, scope) {
727
875
  if (name === "$it")
728
876
  return scope.row;
729
- if ((name === KEY || RECUR.has(name)) && scope.meta && Object.hasOwn(scope.meta, name))
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))
730
881
  return scope.meta[name];
731
882
  if (scope.lifts && Object.hasOwn(scope.lifts, name))
732
883
  return scope.lifts[name];
@@ -734,13 +885,13 @@ export class InMemoryEngine {
734
885
  return this.ctx.root(name);
735
886
  return this.ctx.get(scope.row, name);
736
887
  }
737
- // ` on <identity>` for a required-value error when the scope has a row with a
738
- // scalar identity (its `id`, through the context); empty otherwise, and at the
739
- // root (the root object is not a row of the data).
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).
740
891
  rowIdentity(scope) {
741
892
  if (scope.parent === null || scope.row == null)
742
893
  return "";
743
- const id = this.ctx.identity(scope.row);
894
+ const id = this.scopeIdentity(scope);
744
895
  return typeof id === "string" || typeof id === "number" ? ` on ${JSON.stringify(id)}` : "";
745
896
  }
746
897
  evalCall(e, scope) {
@@ -877,6 +1028,7 @@ function exprHasRecur(e) {
877
1028
  case "logical":
878
1029
  case "in": return exprHasRecur(e.left) || exprHasRecur(e.right);
879
1030
  case "range": return (e.lo != null && exprHasRecur(e.lo)) || (e.hi != null && exprHasRecur(e.hi));
1031
+ case "tuple": return e.items.some(exprHasRecur);
880
1032
  default: return false;
881
1033
  }
882
1034
  }