@omgbase/oqx 0.18.0 → 0.21.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 (57) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +177 -64
  3. package/dist/ast.d.ts +28 -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 +11 -2
  8. package/dist/build.d.ts.map +1 -1
  9. package/dist/build.js +10 -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 +263 -84
  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/lexer.d.ts +3 -3
  23. package/dist/lexer.d.ts.map +1 -1
  24. package/dist/lexer.js +79 -2
  25. package/dist/lexer.js.map +1 -1
  26. package/dist/optimize/analysis.d.ts +3 -2
  27. package/dist/optimize/analysis.d.ts.map +1 -1
  28. package/dist/optimize/analysis.js +18 -15
  29. package/dist/optimize/analysis.js.map +1 -1
  30. package/dist/parser.d.ts.map +1 -1
  31. package/dist/parser.js +250 -118
  32. package/dist/parser.js.map +1 -1
  33. package/dist/print.d.ts.map +1 -1
  34. package/dist/print.js +47 -29
  35. package/dist/print.js.map +1 -1
  36. package/dist/resolve.d.ts.map +1 -1
  37. package/dist/resolve.js +15 -8
  38. package/dist/resolve.js.map +1 -1
  39. package/dist/version.d.ts +1 -1
  40. package/dist/version.js +1 -1
  41. package/dist/walk.d.ts +5 -3
  42. package/dist/walk.d.ts.map +1 -1
  43. package/dist/walk.js +34 -11
  44. package/dist/walk.js.map +1 -1
  45. package/package.json +1 -1
  46. package/src/ast.ts +33 -6
  47. package/src/build.ts +12 -8
  48. package/src/context.ts +22 -11
  49. package/src/engine.ts +258 -91
  50. package/src/index.ts +2 -1
  51. package/src/lexer.ts +76 -6
  52. package/src/optimize/analysis.ts +17 -12
  53. package/src/parser.ts +227 -101
  54. package/src/print.ts +44 -16
  55. package/src/resolve.ts +15 -7
  56. package/src/version.ts +1 -1
  57. package/src/walk.ts +39 -14
package/src/engine.ts CHANGED
@@ -26,15 +26,18 @@ import type { DataContext } from "./context.ts";
26
26
  import { DefaultContext } from "./context.ts";
27
27
  import { OqxError } from "./errors.ts";
28
28
  import {
29
- relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange,
29
+ relate, arith, membership, truthy, compareForSort, compare, canonicalKey, makeRange, isEntry, isRange, stringForm,
30
30
  } from "./semantics.ts";
31
31
  import type { BlockPlan, Correlation, Rule, RowIndex } from "./optimize/index.ts";
32
32
  import { printTemplate } from "./print.ts";
33
33
  import { DEFAULT_RULES, HashIndex, intersectPositions, lookupOrder, planFor, residualWithout } from "./optimize/index.ts";
34
34
 
35
- /** The shaped result of a top-level query, discriminated by consumer. */
35
+ /** The shaped result of a top-level query, discriminated by consumer. A
36
+ * `collect` over a **single** receiver (one object, SEMANTICS §3) is one record
37
+ * or absent rather than a list: `single` is true and `rows` holds at most that
38
+ * one row (`unwrap` yields `rows[0] ?? null`). */
36
39
  export type OqxResult =
37
- | { consumer: "collect"; rows: unknown[] }
40
+ | { consumer: "collect"; rows: unknown[]; single: boolean }
38
41
  | { consumer: "exists"; exists: boolean }
39
42
  | { consumer: "none"; none: boolean }
40
43
  | { consumer: "count"; count: number }
@@ -84,7 +87,7 @@ interface RunState {
84
87
  interface CollectionState {
85
88
  value: unknown;
86
89
  seen: number;
87
- rows: unknown[] | null;
90
+ rows: Rows | null;
88
91
  indexes: Map<string, RowIndex | null>;
89
92
  contextIndexes: Map<string, RowIndex | null>;
90
93
  }
@@ -119,6 +122,20 @@ const HARD_DEPTH_CAP = 8;
119
122
  // `Query` and `Subquery` carry this shape.
120
123
  type Projection = Pick<Subquery, "select" | "values">;
121
124
 
125
+ // The `$key: expr` item of a projection, if any — the identity the rows it
126
+ // yields carry (SEMANTICS §16, since 0.20).
127
+ function keyItem(proj: Projection): Extract<SelectItem, { kind: "field" }> | null {
128
+ for (const it of proj.select) if (it.kind === "field" && it.lift === 0 && it.name === KEY) return it;
129
+ return null;
130
+ }
131
+
132
+ // A row with the identity the walk knows it by.
133
+ interface Keyed { row: unknown; key: unknown; }
134
+
135
+ // The rows of a value in row position, and whether the value was a single row
136
+ // (one object) rather than a collection — which decides a block's result shape.
137
+ interface Rows { rows: unknown[]; single: boolean; }
138
+
122
139
  // An evaluated `limit`/`offset` pair. `limit === null` is unbounded.
123
140
  interface Bound { offset: number; limit: number | null; }
124
141
  const UNBOUNDED: Bound = { offset: 0, limit: null };
@@ -144,7 +161,11 @@ export class InMemoryEngine implements Engine {
144
161
  };
145
162
  if (this.trace) run.trace = this.trace;
146
163
  const root: Scope = { row: this.ctx.rootObject?.(), parent: null, bindings, run };
147
- const rows = this.reproject(this.rowsOf(this.evalRaw(query.source, root)), query.from, root);
164
+ const src = this.coerceRows(this.evalRaw(query.source, root), query.source);
165
+ const rows = this.reproject(src.rows, query.from, root);
166
+ // One object as the source yields one record (SEMANTICS §3) — unless a
167
+ // `from` re-projection or a walk fans it out.
168
+ const single = src.single && query.from.length === 0 && !query.follow;
148
169
 
149
170
  const bound = this.boundOf(query, root);
150
171
  if (query.follow) return this.runFollow(query, rows, root, bound);
@@ -179,10 +200,11 @@ export class InMemoryEngine implements Engine {
179
200
  if (kept.length >= cap) break;
180
201
  }
181
202
  }
203
+ this.applyKey(kept, query);
182
204
  this.sortScopes(kept, query.orderBy);
183
205
  if (query.distinct) kept = this.dedupByProjection(kept, query);
184
206
  kept = sliceBound(kept, bound);
185
- return this.shape(query.consumer, kept, query);
207
+ return this.shape(query.consumer, kept, query, single);
186
208
  }
187
209
 
188
210
  // Evaluate a block's `limit`/`offset`. The bound is part of the block, so it
@@ -214,8 +236,56 @@ export class InMemoryEngine implements Engine {
214
236
  return this.evalWhere(where, this.enter(row, parent));
215
237
  }
216
238
 
217
- private rowsOf(v: unknown): unknown[] {
218
- return Array.from(this.ctx.toRows(v));
239
+ // The rows of a value in row position (SEMANTICS §3): absent → none; an array
240
+ // (or a host iterable) → its elements, a collection; an object → one row, and
241
+ // the value is SINGLE — a block over it yields one result rather than a list;
242
+ // a scalar (a string, a number, a boolean) or a range → an eval error. A
243
+ // context says which of its objects are single (`isSingle`); without it, any
244
+ // non-iterable object is.
245
+ private coerceRows(v: unknown, what: Expr | null): Rows {
246
+ if (v == null) return { rows: [], single: false };
247
+ if (Array.isArray(v)) return { rows: v, single: false };
248
+ if (typeof v !== "object" || isRange(v)) {
249
+ const kind = isRange(v) ? "a range" : typeof v === "string" ? "a string" : typeof v === "number" ? "a number" : typeof v === "boolean" ? "a boolean" : `a ${typeof v}`;
250
+ const shown = isRange(v) ? "" : ` (${JSON.stringify(v)})`;
251
+ const subject = what ? `\`${describe(what)}\` is` : "the receiver is";
252
+ 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");
253
+ }
254
+ const rows = Array.from(this.ctx.toRows(v));
255
+ const single = this.ctx.isSingle ? this.ctx.isSingle(v) : !(Symbol.iterator in v);
256
+ return { rows, single };
257
+ }
258
+
259
+ private rowsOf(v: unknown, what: Expr | null = null): unknown[] {
260
+ return this.coerceRows(v, what).rows;
261
+ }
262
+
263
+ // The host's identity of a row (SEMANTICS §16): an entry is known by its key;
264
+ // any other row by the context's `identityOf`, else by its structural value.
265
+ private hostIdentity(row: unknown): unknown {
266
+ if (isEntry(row)) return row.key;
267
+ if (this.ctx.identityOf) return this.ctx.identityOf(row);
268
+ if (this.ctx.identity) return this.ctx.identity(row);
269
+ return row;
270
+ }
271
+
272
+ // The identity a scope's row is known by — `$key`: the identity the walk or a
273
+ // `$key:` projection gave it (`meta`), else the host's.
274
+ private scopeIdentity(scope: Scope): unknown {
275
+ if (scope.meta && Object.hasOwn(scope.meta, KEY)) return scope.meta[KEY];
276
+ if (scope.parent === null && scope.row === undefined) return undefined;
277
+ return this.hostIdentity(scope.row);
278
+ }
279
+
280
+ // A projection's `$key: expr` item gives the rows it yields that identity:
281
+ // evaluate it on each kept scope and record it as the scope's `$key`.
282
+ private applyKey(scopes: Scope[], proj: Projection): void {
283
+ const item = keyItem(proj);
284
+ if (!item) return;
285
+ for (const s of scopes) {
286
+ const key = this.evalExpr(item.expr, s);
287
+ s.meta = { ...(s.meta ?? {}), [KEY]: key };
288
+ }
219
289
  }
220
290
 
221
291
  private child(row: unknown, parent: Scope): Scope {
@@ -243,13 +313,20 @@ export class InMemoryEngine implements Engine {
243
313
  private runFollow(query: Query, rows: unknown[], root: Scope, bound: Bound): OqxResult {
244
314
  const { seed, post } = query.where ? partitionRecur(query.where) : { seed: null, post: null };
245
315
  const seeds = seed ? rows.filter((r) => this.matches(seed, r, root)) : rows;
246
- const occ = this.followWalk(seeds, query.follow!, root);
316
+ const occ = this.followWalk(this.keyed(seeds, query, root), query.follow!, query.frontier, root);
247
317
  let scopes: Scope[] = occ.map((o) => this.enter(o.row, root, { lifts: {}, meta: o.meta }));
248
318
  if (post) scopes = scopes.filter((s) => this.evalWhere(post, s));
249
319
  this.sortScopes(scopes, query.orderBy);
250
320
  if (query.distinct) scopes = this.dedupByProjection(scopes, query);
251
321
  scopes = sliceBound(scopes, bound);
252
- return this.shape(query.consumer, scopes, query);
322
+ return this.shape(query.consumer, scopes, query, false);
323
+ }
324
+
325
+ // The seeds of a walk with their identities: the body's `$key: expr` item,
326
+ // read in each seed's scope, else the host's identity (SEMANTICS §20).
327
+ private keyed(rows: unknown[], proj: Projection, parent: Scope): Keyed[] {
328
+ const item = keyItem(proj);
329
+ return rows.map((row) => ({ row, key: item ? this.evalExpr(item.expr, this.enter(row, parent)) : this.hostIdentity(row) }));
253
330
  }
254
331
 
255
332
  // Dedup scopes by their PROJECTED value (`distinct`): keep the first scope per
@@ -262,7 +339,7 @@ export class InMemoryEngine implements Engine {
262
339
  const out: Scope[] = [];
263
340
  for (const s of scopes) {
264
341
  const key = proj.select.length === 0
265
- ? `i:${canonicalKey(this.ctx.identity(s.row))}`
342
+ ? `i:${canonicalKey(this.scopeIdentity(s))}`
266
343
  : `p:${canonicalKey(this.projectRow(proj, s))}`;
267
344
  if (seen.has(key)) continue;
268
345
  seen.add(key);
@@ -272,79 +349,90 @@ export class InMemoryEngine implements Engine {
272
349
  }
273
350
 
274
351
  // A bounded, per-path recursive walk. Each occurrence carries recursion
275
- // metadata: `$depth` (seed = 1), a categorical `$stop`, and a deterministic
276
- // `$ordinal`. Semantics:
352
+ // metadata: `$depth` (seed = 1), a categorical `$stop`, a deterministic
353
+ // `$ordinal`, and `$key`, the identity it is known by. Semantics (SEMANTICS §20):
277
354
  // • per-path — a node reached by N distinct paths yields N occurrences
278
355
  // (unless `distinct`, which keeps the minimal (depth, path) per identity);
279
- // • cycles are safe — revisiting a key already on the current path admits ONE
280
- // occurrence with `$stop == "cycle"` and does not expand it (no runaway);
356
+ // • cycles are safe — revisiting an identity already on the current path
357
+ // admits ONE occurrence with `$stop == "cycle"` and does not expand it;
281
358
  // • `$stop` ∈ interior | leaf | frontier | depth | cycle, with precedence
282
359
  // cycle > frontier > depth > leaf > interior; only `interior` rows expand;
283
360
  // • `$leaf` = (stop == leaf); `$frontier` = (stop ∈ {frontier, depth}) — the
284
361
  // "there is unfollowed graph beyond me" signal;
285
- // • identity for cycle detection + `distinct` is `by <expr>` when given, else
286
- // `ctx.identity(row)` (an entry's identity is its value's); identities are
287
- // compared structurally via `canonicalKey`, and `$ordinal` paths compare
288
- // component-wise (`comparePath`), so `10` follows `9`;
362
+ // • identity is `$key` (since 0.20): a seed's is the body's `$key:` item or
363
+ // the host's identity; a successor's is its destination block's `$key:`
364
+ // item, else the host's identity of the yielded row. Identities compare
365
+ // structurally (`canonicalKey`); `$ordinal` paths compare component-wise
366
+ // (`comparePath`), so `10` follows `9`;
289
367
  // • a row's successors are the destinations' rows in source order — a plain
290
368
  // relation read in the row's scope, a destination block evaluated as a
291
369
  // select-position directive in that scope (so `^` inside it is the frontier
292
- // row and its `^people` receiver resolves one scope further out) — filtered
293
- // by the follow `where`, then unioned by identity within the step (a
294
- // recurring identity is kept once, at its first position);
295
- // • the follow `where` reads the candidate's scope hung off the FRONTIER
296
- // row's scope: a bare name is the candidate's, `^name` the row being
297
- // expanded, `^^name` the walk's enclosing scope. `frontier` and `by` read
298
- // the occurrence's own scope with `^` the enclosing scope, as the body does.
299
- private followWalk(seedRows: unknown[], follow: Follow, parent: Scope): Occurrence[] {
300
- const cap = follow.depth ?? HARD_DEPTH_CAP;
301
- const scopeFor = (row: unknown): Scope => this.enter(row, parent);
302
- const identityOf = (row: unknown): unknown =>
303
- follow.by ? this.evalExpr(follow.by, scopeFor(row)) : this.ctx.identity(isEntry(row) ? row.value : row);
304
- const succOf = (row: unknown): unknown[] => {
305
- const rowScope = scopeFor(row);
306
- const raw: unknown[] = [];
307
- for (const dest of follow.destinations) {
308
- const v = dest.kind === "op" ? this.evalCollectValue(dest, rowScope) : this.evalRaw(dest, rowScope);
309
- raw.push(...this.rowsOf(v));
310
- }
311
- const kept = follow.where ? raw.filter((x) => truthy(this.evalExpr(follow.where!, this.enter(x, rowScope)))) : raw;
370
+ // row and its `^people` receiver resolves one scope further out), shaped by
371
+ // the block's items other than `$key` — then unioned by identity within
372
+ // the step (a recurring identity is kept once, at its first position, and
373
+ // the destination that reached it first governs the occurrence);
374
+ // • a destination block's `depth n` (0.20) makes it decline to expand a
375
+ // frontier row whose `$depth >= n`; a row with no successors some
376
+ // destination declined is `depth`, with none declined `leaf`; the hard
377
+ // cap 8 is every destination's ceiling;
378
+ // • a destination block's `frontier P` marks the occurrences IT reached for
379
+ // which `P` holds (seeds are reached by none); `P` reads the occurrence's
380
+ // own scope with `^` the walk's enclosing scope, as the body does;
381
+ // • the body's own `frontier P` (`seedFrontier`, 0.20) marks the SEEDS for
382
+ // which `P` holds, read in the seed's scope exactly like the body's `where`.
383
+ private followWalk(seeds: Keyed[], follow: Follow, seedFrontier: Expr | null, parent: Scope): Occurrence[] {
384
+ const frontierScope = (k: Keyed): Scope => this.enter(k.row, parent, { meta: { [KEY]: k.key } });
385
+ const succOf = (k: Keyed, depth: number): { succ: (Keyed & { via: number })[]; declined: boolean } => {
386
+ const rowScope = frontierScope(k);
387
+ const raw: (Keyed & { via: number })[] = [];
388
+ let declined = false;
389
+ follow.destinations.forEach((dest, via) => {
390
+ if (dest.kind === "op") {
391
+ if (dest.sub.depth !== null && depth >= dest.sub.depth) { declined = true; return; }
392
+ for (const x of this.destinationRows(dest, rowScope)) raw.push({ ...x, via });
393
+ } else {
394
+ for (const row of this.rowsOf(this.evalRaw(dest, rowScope), dest)) raw.push({ row, key: this.hostIdentity(row), via });
395
+ }
396
+ });
312
397
  const seen = new Set<string>();
313
- const out: unknown[] = [];
314
- for (const x of kept) {
315
- const key = canonicalKey(identityOf(x));
398
+ const succ: (Keyed & { via: number })[] = [];
399
+ for (const x of raw) {
400
+ const key = canonicalKey(x.key);
316
401
  if (seen.has(key)) continue;
317
402
  seen.add(key);
318
- out.push(x);
403
+ succ.push(x);
319
404
  }
320
- return out;
405
+ return { succ, declined };
406
+ };
407
+ const frontierHit = (k: Keyed, via: number | null): boolean => {
408
+ if (via === null) return seedFrontier !== null && truthy(this.evalExpr(seedFrontier, frontierScope(k)));
409
+ const dest = follow.destinations[via]!;
410
+ if (dest.kind !== "op" || !dest.sub.frontier) return false;
411
+ return truthy(this.evalExpr(dest.sub.frontier, frontierScope(k)));
321
412
  };
322
- const frontierHit = (row: unknown): boolean =>
323
- follow.frontier ? truthy(this.evalExpr(follow.frontier, scopeFor(row))) : false;
324
413
 
325
- interface Walked { row: unknown; depth: number; path: unknown[]; key: string; stop: string; }
414
+ interface Walked { row: unknown; depth: number; path: unknown[]; id: unknown; key: string; stop: string; }
326
415
  const walked: Walked[] = [];
327
416
 
328
- const visit = (row: unknown, depth: number, ancestors: string[], ancestorIds: unknown[]): void => {
329
- const id = identityOf(row);
330
- const key = canonicalKey(id);
331
- const path = [...ancestorIds, id];
417
+ const visit = (k: Keyed, via: number | null, depth: number, ancestors: string[], ancestorIds: unknown[]): void => {
418
+ const key = canonicalKey(k.key);
419
+ const path = [...ancestorIds, k.key];
332
420
  let stop: string;
333
421
  if (ancestors.includes(key)) stop = "cycle";
334
- else if (frontierHit(row)) stop = "frontier";
335
- else if (depth >= cap) stop = "depth";
422
+ else if (frontierHit(k, via)) stop = "frontier";
423
+ else if (depth >= HARD_DEPTH_CAP) stop = "depth";
336
424
  else {
337
- const succ = succOf(row);
338
- if (succ.length === 0) stop = "leaf";
425
+ const { succ, declined } = succOf(k, depth);
426
+ if (succ.length === 0) stop = declined ? "depth" : "leaf";
339
427
  else {
340
- walked.push({ row, depth, path, key, stop: "interior" });
341
- for (const s of succ) visit(s, depth + 1, [...ancestors, key], path);
428
+ walked.push({ row: k.row, depth, path, id: k.key, key, stop: "interior" });
429
+ for (const x of succ) visit(x, x.via, depth + 1, [...ancestors, key], path);
342
430
  return;
343
431
  }
344
432
  }
345
- walked.push({ row, depth, path, key, stop });
433
+ walked.push({ row: k.row, depth, path, id: k.key, key, stop });
346
434
  };
347
- for (const r of seedRows) visit(r, 1, [], []);
435
+ for (const k of seeds) visit(k, null, 1, [], []);
348
436
 
349
437
  let rows = walked;
350
438
  if (follow.distinct) {
@@ -361,6 +449,7 @@ export class InMemoryEngine implements Engine {
361
449
  return rows.map((w, i) => ({
362
450
  row: w.row,
363
451
  meta: {
452
+ [KEY]: w.id,
364
453
  $depth: w.depth,
365
454
  $stop: w.stop,
366
455
  $leaf: w.stop === "leaf",
@@ -370,14 +459,32 @@ export class InMemoryEngine implements Engine {
370
459
  }));
371
460
  }
372
461
 
462
+ // A destination block's successors for one frontier row, each with its
463
+ // identity: the block's rows (its `where`, order, `distinct`, bound and
464
+ // consumer applied — `first`/`single` yield at most one), shaped by its items
465
+ // other than `$key` (the raw rows when none remain), keyed by its `$key:`
466
+ // item read in the row's scope, else by the host's identity of the row yielded.
467
+ private destinationRows(dest: OpNode, rowScope: Scope): Keyed[] {
468
+ const { scopes } = this.evalCollectScopes(dest, rowScope, this.planOf(dest, rowScope), true);
469
+ const key = keyItem(dest.sub);
470
+ const shape: Projection = key ? { select: dest.sub.select.filter((it) => it !== key), values: dest.sub.values } : dest.sub;
471
+ return scopes.map((s) => {
472
+ const row = this.projectRow(shape, s);
473
+ const id = key ? this.evalExpr(key.expr, s) : shape.select.length === 0 ? this.scopeIdentity(s) : this.hostIdentity(row);
474
+ return { row, key: id };
475
+ });
476
+ }
477
+
373
478
  // ---- consumer shaping -----------------------------------------------------
374
479
 
375
- private shape(consumer: Query["consumer"], scopes: Scope[], proj: Projection): OqxResult {
480
+ // `single` — the receiver was one object: a `collect` yields its one record
481
+ // (or nothing) rather than a one-element list (SEMANTICS §3, §15).
482
+ private shape(consumer: Query["consumer"], scopes: Scope[], proj: Projection, single: boolean): OqxResult {
376
483
  switch (consumer) {
377
484
  case "exists": return { consumer, exists: scopes.length > 0 };
378
485
  case "none": return { consumer, none: scopes.length === 0 };
379
486
  case "count": return { consumer, count: scopes.length };
380
- case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)) };
487
+ case "collect": return { consumer, rows: scopes.map((s) => this.projectRow(proj, s)), single };
381
488
  case "first": return { consumer, row: scopes.length > 0 ? this.projectRow(proj, scopes[0]!) : null };
382
489
  case "single":
383
490
  if (scopes.length > 1) throw new OqxError(`single { … } matched ${scopes.length} rows; use first { … } for zero-or-one`, "eval");
@@ -462,6 +569,7 @@ export class InMemoryEngine implements Engine {
462
569
  // The rows a consumer op reduces: matched → ordered → distinct → bounded.
463
570
  private opRows(op: OpNode, scope: Scope, bound: Bound, acc?: Access): Scope[] {
464
571
  let scopes = this.matchRows(op, scope, acc);
572
+ this.applyKey(scopes, op.sub);
465
573
  this.sortScopes(scopes, op.sub.orderBy);
466
574
  if (op.distinct) scopes = this.dedupByProjection(scopes, op.sub);
467
575
  return sliceBound(scopes, bound);
@@ -523,13 +631,13 @@ export class InMemoryEngine implements Engine {
523
631
  if (!state) {
524
632
  const value = this.evalRaw(op.receiver, scope);
525
633
  if (plan.receiverStable) run.stableCollections.set(op, (state = newCollection(value)));
526
- else if (plan.correlated.length === 0) return this.scan(this.rowsOf(value), op, scope);
634
+ else if (plan.correlated.length === 0) return this.scan(this.coerceRows(value, op.receiver), op, scope);
527
635
  else if (value !== null && typeof value === "object") {
528
636
  state = run.collections.get(value);
529
637
  if (!state) run.collections.set(value, (state = newCollection(value)));
530
638
  } else {
531
639
  run.trace?.({ kind: "fallback", reason: "not-a-collection" });
532
- return this.scan(this.rowsOf(value), op, scope);
640
+ return this.scan(this.coerceRows(value, op.receiver), op, scope);
533
641
  }
534
642
  }
535
643
  state.seen++;
@@ -537,25 +645,26 @@ export class InMemoryEngine implements Engine {
537
645
  const direct = this.lookupRows(plan, state, scope);
538
646
  if (direct) return direct;
539
647
  if (state.seen >= 2 || plan.receiverStable) {
540
- const probed = this.probe(plan, state, scope);
541
- if (probed) return { rows: probed, where: plan.residual };
648
+ const probed = this.probe(plan, state, scope, op);
649
+ if (probed) return { rows: probed, where: plan.residual, single: false };
542
650
  } else run.trace?.({ kind: "fallback", reason: "unstable" });
543
651
  }
544
- return this.scan(this.materialize(state), op, scope);
652
+ return this.scan(this.materialize(state, op), op, scope);
545
653
  }
546
654
 
547
655
  // The plain scan: the rows, re-projected by `from`, with the whole `where`.
548
- private scan(rows: unknown[], op: OpNode, scope: Scope): Access {
549
- return { rows: this.reproject(rows, op.sub.from, scope), where: op.sub.where };
656
+ // A `from` re-projection fans a single receiver out: the block is plural.
657
+ private scan(src: Rows, op: OpNode, scope: Scope): Access {
658
+ return { rows: this.reproject(src.rows, op.sub.from, scope), where: op.sub.where, single: src.single && op.sub.from.length === 0 };
550
659
  }
551
660
 
552
661
  private reproject(rows: unknown[], from: readonly Expr[], scope: Scope): unknown[] {
553
- for (const proj of from) rows = rows.flatMap((r) => this.rowsOf(this.evalRaw(proj, this.child(r, scope))));
662
+ for (const proj of from) rows = rows.flatMap((r) => this.rowsOf(this.evalRaw(proj, this.child(r, scope)), proj));
554
663
  return rows;
555
664
  }
556
665
 
557
- private materialize(state: CollectionState): unknown[] {
558
- return (state.rows ??= this.rowsOf(state.value));
666
+ private materialize(state: CollectionState, op: OpNode): Rows {
667
+ return (state.rows ??= this.coerceRows(state.value, op.receiver));
559
668
  }
560
669
 
561
670
  // A probe answered by a context index that yields rows directly, before the
@@ -569,7 +678,7 @@ export class InMemoryEngine implements Engine {
569
678
  if (!outer) return null;
570
679
  const rows = Array.from(index.lookupRows(outer.value));
571
680
  scope.run.trace?.({ kind: "lookup", path: c.path, candidates: rows.length });
572
- return { rows, where: residualWithout(plan, c) };
681
+ return { rows, where: residualWithout(plan, c), single: false };
573
682
  }
574
683
  return null;
575
684
  }
@@ -577,12 +686,12 @@ export class InMemoryEngine implements Engine {
577
686
  // The engine's probe: one bucket per correlation, intersected smallest-first;
578
687
  // the candidates come back in receiver order. `null` (an index that could
579
688
  // not be built, an outer value whose evaluation threw) leaves the scan in charge.
580
- private probe(plan: BlockPlan, state: CollectionState, scope: Scope): unknown[] | null {
689
+ private probe(plan: BlockPlan, state: CollectionState, scope: Scope, op: OpNode): unknown[] | null {
581
690
  const run = scope.run;
582
- const rows = this.materialize(state);
691
+ const rows = this.materialize(state, op).rows;
583
692
  const buckets: (readonly number[])[] = [];
584
693
  for (const c of plan.correlated) {
585
- const index = this.indexFor(state, c, scope);
694
+ const index = this.indexFor(state, c, scope, op);
586
695
  if (!index) { run.trace?.({ kind: "fallback", reason: "index" }); return null; }
587
696
  const outer = this.outerValue(c, scope);
588
697
  if (!outer) return null;
@@ -615,11 +724,11 @@ export class InMemoryEngine implements Engine {
615
724
  // here by evaluating the local expression in each row's scope — the same read
616
725
  // the scan performs, so the keys are exactly what `==` would compare. A throw
617
726
  // while building (a context that rejects a read) leaves the path un-indexed.
618
- private indexFor(state: CollectionState, c: Correlation, scope: Scope): RowIndex | null {
727
+ private indexFor(state: CollectionState, c: Correlation, scope: Scope, op: OpNode): RowIndex | null {
619
728
  const key = pathKey(c.path);
620
729
  const hit = state.indexes.get(key);
621
730
  if (hit !== undefined) return hit;
622
- const rows = this.materialize(state);
731
+ const rows = this.materialize(state, op).rows;
623
732
  let index = this.contextIndex(state, c);
624
733
  let source: "context" | "engine" = "context";
625
734
  if (!index) {
@@ -643,27 +752,45 @@ export class InMemoryEngine implements Engine {
643
752
  }
644
753
 
645
754
  private evalCollectValuePlanned(op: OpNode, plan: BlockPlan, scope: Scope): unknown {
755
+ const { scopes, single } = this.evalCollectScopes(op, scope, plan);
756
+ switch (op.op) {
757
+ // Over a single receiver (one object) a `collect` is the record or absent,
758
+ // not a one-element list (SEMANTICS §3).
759
+ case "collect": return single ? (scopes.length > 0 ? this.projectRow(op.sub, scopes[0]!) : undefined) : scopes.map((s) => this.projectRow(op.sub, s));
760
+ default: return scopes.length > 0 ? this.projectRow(op.sub, scopes[0]!) : null;
761
+ }
762
+ }
763
+
764
+ // The rows a select-position directive yields, as scopes, reduced by its
765
+ // consumer (`first`/`single` keep at most one; `single` raises on more), and
766
+ // whether its receiver was a single object. `destination` marks a follow
767
+ // destination block: its `frontier` is the destination's (read by the outer
768
+ // walk), never a seed frontier for a `follow` of its own.
769
+ private evalCollectScopes(op: OpNode, scope: Scope, plan: BlockPlan = this.planOf(op, scope), destination = false): { scopes: Scope[]; single: boolean } {
646
770
  const sub = op.sub;
647
771
  const bound = this.boundOf(sub, scope);
648
772
  let scopes: Scope[];
773
+ let single = false;
649
774
  if (sub.follow) {
650
775
  // The seeds are the matched rows themselves (`followWalk` enters them); a
651
776
  // follow block is never correlated, so `where` here is the whole predicate.
652
777
  const { rows, where } = this.access(op, plan, scope);
653
778
  const seeds = where ? rows.filter((r) => this.evalWhere(where, this.enter(r, scope, { lifts: {} }))) : rows;
654
- scopes = this.followWalk(seeds, sub.follow, scope).map((o) => this.enter(o.row, scope, { meta: o.meta }));
779
+ 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 }));
655
780
  this.sortScopes(scopes, sub.orderBy);
656
781
  if (op.distinct) scopes = this.dedupByProjection(scopes, sub);
657
782
  scopes = sliceBound(scopes, bound);
658
783
  } else {
659
- scopes = this.opRows(op, scope, bound, this.access(op, plan, scope));
784
+ const acc = this.access(op, plan, scope);
785
+ single = acc.single;
786
+ scopes = this.opRows(op, scope, bound, acc);
660
787
  }
661
788
  switch (op.op) {
662
- case "collect": return scopes.map((s) => this.projectRow(sub, s));
663
- case "first": return scopes.length > 0 ? this.projectRow(sub, scopes[0]!) : null;
789
+ case "collect": return { scopes, single };
790
+ case "first": return { scopes: scopes.slice(0, 1), single };
664
791
  case "single":
665
792
  if (scopes.length > 1) throw new OqxError(`single { … } for '${describeReceiver(op.receiver)}' matched ${scopes.length} rows`, "eval");
666
- return scopes.length > 0 ? this.projectRow(sub, scopes[0]!) : null;
793
+ return { scopes, single };
667
794
  default: throw new OqxError(`${op.op} { … } is not valid in select position`, "eval");
668
795
  }
669
796
  }
@@ -745,9 +872,9 @@ export class InMemoryEngine implements Engine {
745
872
  const l = this.evalExpr(e.left, scope);
746
873
  const r = this.evalExpr(e.right, scope);
747
874
  if (e.op === "is" || e.op === "is not") {
748
- // Identity (SEMANTICS §5b): the context's identity of each side,
875
+ // Identity (SEMANTICS §5b): the host's identity of each side,
749
876
  // compared structurally — the key `distinct` and `follow` use.
750
- const same = canonicalKey(this.ctx.identity(l)) === canonicalKey(this.ctx.identity(r));
877
+ const same = canonicalKey(this.hostIdentity(l)) === canonicalKey(this.hostIdentity(r));
751
878
  return e.op === "is" ? same : !same;
752
879
  }
753
880
  return isRelOp(e.op) ? relate(e.op, l, r) : arith(e.op, l, r);
@@ -762,6 +889,41 @@ export class InMemoryEngine implements Engine {
762
889
  e.lo == null ? null : this.evalExpr(e.lo, scope),
763
890
  e.hi == null ? null : this.evalExpr(e.hi, scope),
764
891
  e.exclusiveEnd);
892
+ // A tuple (SEMANTICS §3b): the elements in order, concatenated with ONE
893
+ // level of flattening — a collection contributes its items, anything else
894
+ // itself; an absent (`undefined`) element contributes nothing, a `null`
895
+ // one `null`; duplicates are kept.
896
+ case "tuple": {
897
+ const out: unknown[] = [];
898
+ for (const item of e.items) {
899
+ const v = this.evalExpr(item, scope);
900
+ if (v === undefined) continue;
901
+ if (Array.isArray(v)) out.push(...v);
902
+ else if (v !== null && typeof v === "object" && !isEntry(v) && !isRange(v) && Symbol.iterator in v) out.push(...(v as Iterable<unknown>));
903
+ else out.push(v);
904
+ }
905
+ return out;
906
+ }
907
+ // A template literal (SEMANTICS §7b): the text parts verbatim and each
908
+ // interpolated value's string form (§7), concatenated. Every interpolation
909
+ // is evaluated, in order; a list, a record or a range is an eval error (it
910
+ // has no string form the language specifies); an absent value makes the
911
+ // whole literal absent, as `+` does.
912
+ case "template": {
913
+ let out = "";
914
+ let absent = false;
915
+ for (const part of e.parts) {
916
+ if (typeof part === "string") { out += part; continue; }
917
+ const v = this.evalExpr(part, scope);
918
+ if (v == null) { absent = true; continue; }
919
+ if (typeof v === "object") {
920
+ const what = isRange(v) ? "a range" : Array.isArray(v) || Symbol.iterator in v ? "a list" : "a record";
921
+ throw new OqxError(`cannot interpolate \`\${${describe(part)}}\` in a template literal: it is ${what} — only a string, a number, a boolean or an absent value has a string form`, "eval");
922
+ }
923
+ out += stringForm(v)!;
924
+ }
925
+ return absent ? undefined : out;
926
+ }
765
927
  }
766
928
  }
767
929
 
@@ -791,18 +953,20 @@ export class InMemoryEngine implements Engine {
791
953
  // 0, "") need no special case — there is no "absent, so look outward" rule.
792
954
  private resolveIn(name: string, scope: Scope): unknown {
793
955
  if (name === "$it") return scope.row;
794
- if ((name === KEY || RECUR.has(name)) && scope.meta && Object.hasOwn(scope.meta, name)) return scope.meta[name];
956
+ // `$key` is the row's identity on every row (SEMANTICS §16, since 0.20).
957
+ if (name === KEY) return this.scopeIdentity(scope);
958
+ if (RECUR.has(name) && scope.meta && Object.hasOwn(scope.meta, name)) return scope.meta[name];
795
959
  if (scope.lifts && Object.hasOwn(scope.lifts, name)) return scope.lifts[name];
796
960
  if (scope.parent === null) return this.ctx.root(name);
797
961
  return this.ctx.get(scope.row, name);
798
962
  }
799
963
 
800
- // ` on <identity>` for a required-value error when the scope has a row with a
801
- // scalar identity (its `id`, through the context); empty otherwise, and at the
802
- // root (the root object is not a row of the data).
964
+ // ` on <identity>` for a required-value error when the scope's row has a
965
+ // scalar identity (`$key`: the host's, an entry's key, a `$key:` projection);
966
+ // empty otherwise, and at the root (the root object is not a row of the data).
803
967
  private rowIdentity(scope: Scope): string {
804
968
  if (scope.parent === null || scope.row == null) return "";
805
- const id = this.ctx.identity(scope.row);
969
+ const id = this.scopeIdentity(scope);
806
970
  return typeof id === "string" || typeof id === "number" ? ` on ${JSON.stringify(id)}` : "";
807
971
  }
808
972
 
@@ -822,8 +986,9 @@ export class InMemoryEngine implements Engine {
822
986
 
823
987
  interface Occurrence { row: unknown; meta: Record<string, unknown>; }
824
988
 
825
- // A block's accessed rows and the predicate still to evaluate over each.
826
- interface Access { rows: unknown[]; where: Where | null; }
989
+ // A block's accessed rows, the predicate still to evaluate over each, and
990
+ // whether the receiver was a single object (SEMANTICS §3).
991
+ interface Access { rows: unknown[]; where: Where | null; single: boolean; }
827
992
 
828
993
  // ---- free helpers -----------------------------------------------------------
829
994
 
@@ -947,6 +1112,8 @@ function exprHasRecur(e: Expr): boolean {
947
1112
  case "unary": case "required": return exprHasRecur(e.expr);
948
1113
  case "binary": case "logical": case "in": return exprHasRecur(e.left) || exprHasRecur(e.right);
949
1114
  case "range": return (e.lo != null && exprHasRecur(e.lo)) || (e.hi != null && exprHasRecur(e.hi));
1115
+ case "tuple": return e.items.some(exprHasRecur);
1116
+ case "template": return e.parts.some((p) => typeof p !== "string" && exprHasRecur(p));
950
1117
  default: return false;
951
1118
  }
952
1119
  }
package/src/index.ts CHANGED
@@ -116,7 +116,8 @@ export function runQuery(query: Query, bindings: readonly unknown[], roots: unkn
116
116
 
117
117
  function unwrap(result: OqxResult): unknown {
118
118
  switch (result.consumer) {
119
- case "collect": return result.rows;
119
+ // A `collect` over one object is that one record, or `null` (SEMANTICS §3).
120
+ case "collect": return result.single ? (result.rows[0] ?? null) : result.rows;
120
121
  case "exists": return result.exists;
121
122
  case "none": return result.none;
122
123
  case "count": return result.count;