@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/src/ast.ts CHANGED
@@ -1,8 +1,20 @@
1
- // OQX query AST for the generic kernel. The parser produces this directly and
2
- // the interpreter walks it — there is no separate lowering/IR pass, because a
3
- // "relation" in the generic kernel is just an expression evaluated against the
4
- // current row (property navigation on the host object model), resolved at run
5
- // time rather than against a fixed schema.
1
+ // The OQX AST — a first-class, language-level contract (spec/oqx/AST.md, since
2
+ // language 0.16). The parser produces it, the interpreter walks it, and tools
3
+ // reflect on it without re-parsing: every node carries `kind` and `span`, every
4
+ // optional field is materialized (`null` / `false`), and the in-memory objects
5
+ // ARE the JSON shape both implementations serialize (plain data, no parent
6
+ // pointers, no methods). There is no separate lowering/IR pass: a "relation" is
7
+ // just an expression evaluated against the current row.
8
+ //
9
+ // `where` keeps its surface form: a `select` alias referenced in `where` stays a
10
+ // bare identifier here; `resolveAliases` (resolve.ts) performs the substitution
11
+ // before evaluation. Nodes built by a tool rather than the parser carry the
12
+ // empty span `[0, 0]` (see `build.ts`).
13
+
14
+ /** A half-open source range `[start, end)` in Unicode code points over the raw
15
+ * source (`rawSource` for a template, where a binding occupies its `${n}`
16
+ * marker). The empty span `[0, 0]` marks a node that did not come from source. */
17
+ export type Span = [start: number, end: number];
6
18
 
7
19
  /** Query consumers — how a (sub)query's row set is shaped. `none` is the
8
20
  * zero-cardinality complement of `exists` (true iff the block yields no rows). */
@@ -11,50 +23,85 @@ export type Consumer = "collect" | "exists" | "none" | "count" | "first" | "sing
11
23
  /** Comparison operators usable in a `count { … } <op> <int>` test. */
12
24
  export type RelOp = "==" | "!=" | "<" | "<=" | ">" | ">=";
13
25
 
26
+ /** The identity operators (SEMANTICS §5b, since 0.17). */
27
+ export type IdentityOp = "is" | "is not";
28
+
29
+ /** The operator of a `binary` expression: a comparison, an identity test, or arithmetic. */
30
+ export type BinaryOp = RelOp | IdentityOp | "+" | "-" | "*" | "/" | "%";
31
+
32
+ /** The operator of a `unary` expression. */
33
+ export type UnaryOp = "!" | "-";
34
+
35
+ /** The operator of a `logical` expression. */
36
+ export type LogicalOp = "&&" | "||";
37
+
14
38
  /** Scalar value/predicate expression, evaluated against a row scope + bindings. */
15
39
  export type Expr =
16
- | { kind: "lit"; value: string | number | boolean | null }
17
- | { kind: "ident"; name: string } // bare property of the CURRENT row/scope only (never climbs); `$it` is the row itself
18
- | { kind: "outer"; levels: number; name: string } // `^name` — read from exactly `levels` scopes out
19
- | { kind: "binding"; index: number } // a ${…} interpolated host value
20
- | { kind: "member"; recv: Expr; name: string } // .prop navigation on the value to its left
21
- | { kind: "index"; recv: Expr; index: Expr } // [expr] navigation
22
- | { kind: "call"; recv: Expr | null; name: string; args: Expr[] } // fn / method
23
- | { kind: "unary"; op: "!" | "-"; expr: Expr }
24
- | { kind: "binary"; op: string; left: Expr; right: Expr } // arithmetic + comparison
25
- | { kind: "logical"; op: "&&" | "||"; left: Expr; right: Expr }
26
- | { kind: "in"; left: Expr; right: Expr }
40
+ | { kind: "lit"; span: Span; value: string | number | boolean | null }
41
+ | { kind: "ident"; span: Span; name: string } // bare property of the CURRENT row/scope only (never climbs); `$it` is the row itself
42
+ | { kind: "outer"; span: Span; levels: number; name: string } // `^name` — read from exactly `levels` scopes out (also what `N^name` parses to, since 0.18)
43
+ | { kind: "binding"; span: Span; index: number } // a ${…} interpolated host value
44
+ | { kind: "member"; span: Span; recv: Expr; name: string } // .prop navigation on the value to its left
45
+ | { kind: "call"; span: Span; recv: Expr | null; name: string; args: Expr[] } // fn / method
46
+ | { kind: "unary"; span: Span; op: UnaryOp; expr: Expr }
47
+ // `x!` — the value of `expr`, or an eval error when it is absent (since 0.17).
48
+ | { kind: "required"; span: Span; expr: Expr }
49
+ | { kind: "binary"; span: Span; op: BinaryOp; left: Expr; right: Expr } // arithmetic + comparison + identity
50
+ | { kind: "logical"; span: Span; op: LogicalOp; left: Expr; right: Expr }
51
+ | { kind: "in"; span: Span; left: Expr; right: Expr }
27
52
  // A Ruby-style range value. `lo`/`hi` are null for the open-ended forms
28
53
  // (`..5` / `5..`); `exclusiveEnd` distinguishes `1...5` from `1..5`. Evaluates
29
54
  // to a runtime range value (see semantics.makeRange); primarily the RHS of `in`.
30
- | { kind: "range"; lo: Expr | null; hi: Expr | null; exclusiveEnd: boolean };
55
+ | { kind: "range"; span: Span; lo: Expr | null; hi: Expr | null; exclusiveEnd: boolean }
56
+ // A tuple `(e1, e2, …)` (since 0.20): two or more expressions whose values
57
+ // concatenate with one level of flattening (SEMANTICS §3b) — a value and a
58
+ // receiver (`(a, b) { … }`, `follow (a, b) { depth 2 }`). One expression in
59
+ // parentheses is grouping and leaves no node.
60
+ | { kind: "tuple"; span: Span; items: Expr[] }
61
+ // A value-position directive (since 0.17): `recv first { … }.pay`, and what a
62
+ // bracket lookup `x[…]` desugars to. `isExpr` is false for it — an op is an
63
+ // expression only by position; discriminate on `kind === "op"`.
64
+ | OpNode;
31
65
 
66
+ /** One `order by` term. */
32
67
  export interface OrderSpec {
68
+ kind: "order";
69
+ span: Span;
33
70
  expr: Expr;
34
71
  desc: boolean;
35
72
  }
36
73
 
37
74
  /** One projection item: a named scalar/navigation value, or a named nested
38
- * collection consumer. `lift` marks a `^name` one-scope lift. */
75
+ * collection consumer. `lift` marks a `^name` lift. */
39
76
  export type SelectItem =
40
77
  // `lift` is the number of `^` carets: 0 = an ordinary projection, N = a lift
41
- // that binds this value N scopes out (see the engine's flatten-append).
42
- | { kind: "field"; name: string; expr: Expr; lift: number }
43
- | { kind: "collect"; name: string; op: OpNode };
78
+ // that binds this value N scopes out (see the engine's flatten-append). `name`
79
+ // is `""` for an unaliased non-navigation item, which the parser only admits
80
+ // under `values`.
81
+ | { kind: "field"; span: Span; name: string; expr: Expr; lift: number }
82
+ | { kind: "collect"; span: Span; name: string; op: OpNode };
83
+
84
+ /** The `count { … } <op> <int>` test attached to an `OpNode` — an attribute of
85
+ * the directive, not a node of its own. */
86
+ export interface CountCmp {
87
+ op: RelOp;
88
+ value: number;
89
+ }
44
90
 
45
91
  /** A postfix consumer directive over a receiver collection:
46
92
  * `<receiver> <op> { <sub> }`, optionally `count { … } <relop> <int>`. */
47
93
  export interface OpNode {
48
94
  kind: "op";
95
+ span: Span;
49
96
  receiver: Expr;
50
97
  op: Consumer;
51
98
  sub: Subquery;
52
- countCmp?: { op: RelOp; value: number };
99
+ countCmp: CountCmp | null;
53
100
  /** `distinct` — dedup the rows this directive consumes by their projected
54
101
  * value (the `select`), so `count distinct { … }` counts distinct projections
55
102
  * and `collect distinct { … }` yields distinct rows. Empty select ⇒ dedup by
56
103
  * row identity. Spellable as `<op> distinct { … }` or `{ select distinct … }`. */
57
- distinct?: boolean;
104
+ distinct: boolean;
58
105
  }
59
106
 
60
107
  /** One `follow` destination: a relation of the current row (an `Expr` receiver), or a
@@ -62,48 +109,61 @@ export interface OpNode {
62
109
  * per frontier row, inside which `^` is that row. Discriminate on `kind === "op"`. */
63
110
  export type FollowDestination = Expr | OpNode;
64
111
 
65
- /** The recursive `follow` clause. */
112
+ /** The recursive `follow` clause (since 0.20 just the destinations: a
113
+ * destination's `where`, `frontier` and `depth` live in its own block). */
66
114
  export interface Follow {
115
+ kind: "follow";
116
+ span: Span;
67
117
  destinations: FollowDestination[]; // ≥ 1, in source order; the walk is their union
68
118
  distinct: boolean;
69
- where: Expr | null; // successor predicate: which successors keep participating
70
- frontier: Expr | null; // boundary predicate: cut a relation that could continue
71
- depth: number | null; // 1..8 cap; null = the hard cap
72
- by: Expr | null; // identity expression for cycle detection / dedup
73
119
  }
74
120
 
75
- /** A nested (sub)query body. */
121
+ /** A nested (sub)query body — the `{ … }` of a directive. */
76
122
  export interface Subquery {
123
+ kind: "subquery";
124
+ span: Span;
77
125
  from: Expr[]; // body-level `from E` re-projections (flatMap chain)
78
126
  where: Where | null;
79
127
  select: SelectItem[];
80
128
  orderBy: OrderSpec[] | null;
81
129
  follow: Follow | null;
130
+ /** `frontier P` / `depth n` (since 0.20). In a `follow` destination block
131
+ * `frontier` marks the occurrences reached through this destination for
132
+ * which `P` holds (not expanded; `P` read one level out, under the walk's
133
+ * enclosing scope), and `depth n` makes the destination expand a frontier
134
+ * row only while that row's `$depth < n` (1..8; `null` = the hard cap 8).
135
+ * In any other block `frontier` is legal only with a `follow` and marks the
136
+ * walk's SEEDS, read in the seed's scope like `where` (as on `Query`);
137
+ * `depth` is destination-only. Both `null` when absent. */
138
+ frontier: Expr | null;
139
+ depth: number | null;
82
140
  /** `values` — scalar projection mode: the (single) projected expression is the
83
141
  * row's result itself rather than being wrapped in a `{ name: value }` record,
84
142
  * so `name values` yields `["Bob", …]` and `$it values` yields the rows. */
85
- values?: boolean;
143
+ values: boolean;
86
144
  /** `limit N` / `offset N` — bound the row set AFTER where/order/distinct and
87
145
  * BEFORE the consumer reduces it, so `count { … limit 5 }` is at most 5 and
88
146
  * `first { … offset 1 }` is the second row. Each is a value expression
89
147
  * (a literal, a `${…}` binding, or an outer reference) read as part of the
90
148
  * block — `^n` is the enclosing row's field, as everywhere inside `{ … }` —
91
149
  * and must yield a non-negative integer. */
92
- limit?: Expr;
93
- offset?: Expr;
150
+ limit: Expr | null;
151
+ offset: Expr | null;
94
152
  }
95
153
 
96
154
  /** The where-clause boolean tree: OQX owns &&/||/!/grouping so consumer ops
97
155
  * (invisible to the scalar evaluator) compose with scalar predicates. */
98
156
  export type Where =
99
- | { kind: "and"; parts: Where[] }
100
- | { kind: "or"; parts: Where[] }
101
- | { kind: "not"; expr: Where }
102
- | { kind: "scalar"; expr: Expr }
157
+ | { kind: "and"; span: Span; parts: Where[] }
158
+ | { kind: "or"; span: Span; parts: Where[] }
159
+ | { kind: "not"; span: Span; expr: Where }
160
+ | { kind: "scalar"; span: Span; expr: Expr }
103
161
  | OpNode;
104
162
 
105
163
  /** A top-level OQX query. */
106
164
  export interface Query {
165
+ kind: "query";
166
+ span: Span;
107
167
  source: Expr; // the root collection (`from <source>` or the directive receiver)
108
168
  from: Expr[]; // further top-level `from E` re-projections
109
169
  where: Where | null;
@@ -111,11 +171,37 @@ export interface Query {
111
171
  orderBy: OrderSpec[] | null;
112
172
  consumer: Consumer;
113
173
  follow: Follow | null;
174
+ /** `frontier P` (since 0.20) — legal only when the body has a `follow`: a
175
+ * seed for which `P` holds (read in the seed's own scope, `^` the enclosing
176
+ * scope, like `where`) is a frontier (`$stop == "frontier"`, not expanded).
177
+ * `null` without one. */
178
+ frontier: Expr | null;
114
179
  /** `distinct` — dedup the result rows by their projected value (see OpNode). */
115
- distinct?: boolean;
180
+ distinct: boolean;
116
181
  /** `values` — scalar projection mode (see Subquery). */
117
- values?: boolean;
182
+ values: boolean;
118
183
  /** `limit N` / `offset N` (see Subquery); evaluated at the root scope. */
119
- limit?: Expr;
120
- offset?: Expr;
184
+ limit: Expr | null;
185
+ offset: Expr | null;
186
+ }
187
+
188
+ /** Every node of the tree. `AstNode["kind"]` is the closed set of node kinds. */
189
+ export type AstNode = Query | Subquery | OpNode | Follow | OrderSpec | SelectItem | Where | Expr;
190
+
191
+ /** The closed set of node kinds. */
192
+ export type AstKind = AstNode["kind"];
193
+
194
+ /** The expression kinds (`Expr["kind"]`), for `isExpr`. */
195
+ const EXPR_KINDS: ReadonlySet<string> = new Set([
196
+ "lit", "ident", "outer", "binding", "member", "call", "unary", "required", "binary", "logical", "in", "range", "tuple",
197
+ ]);
198
+
199
+ /** Whether a node is a scalar `Expr` (an `op` in expression position is not: it
200
+ * is an expression only by position, see AST.md §1). */
201
+ export function isExpr(node: AstNode): node is Expr {
202
+ return EXPR_KINDS.has(node.kind);
121
203
  }
204
+
205
+ /** The parsed query's JSON form (`toJSON`): the tree with the language version
206
+ * it was produced under. */
207
+ export type QueryJson = Query & { oqx: string };
package/src/build.ts ADDED
@@ -0,0 +1,73 @@
1
+ // Builders for AST nodes a tool constructs itself (spec/oqx/AST.md §7). Every
2
+ // node they make carries the empty span `[0, 0]` — "not from source" — and the
3
+ // materialized defaults (`distinct: false`, `limit: null`, …), so a hand-built
4
+ // tree has the same shape as a parsed one and prints with `print`.
5
+
6
+ import type {
7
+ BinaryOp, Consumer, CountCmp, Expr, Follow, FollowDestination, LogicalOp, OpNode, OrderSpec, Query, SelectItem, Span,
8
+ Subquery, UnaryOp, Where,
9
+ } from "./ast.ts";
10
+
11
+ /** A fresh empty span (never shared: spans are mutable tuples). */
12
+ export const span = (): Span => [0, 0];
13
+
14
+ export const lit = (value: string | number | boolean | null): Expr => ({ kind: "lit", span: span(), value });
15
+ export const ident = (name: string): Expr => ({ kind: "ident", span: span(), name });
16
+ export const outer = (levels: number, name: string): Expr => ({ kind: "outer", span: span(), levels, name });
17
+ export const binding = (index: number): Expr => ({ kind: "binding", span: span(), index });
18
+ export const member = (recv: Expr, name: string): Expr => ({ kind: "member", span: span(), recv, name });
19
+ /** `a.b.c` from a dotted path. */
20
+ export const path = (first: string, ...rest: string[]): Expr => rest.reduce<Expr>((e, n) => member(e, n), ident(first));
21
+ export const call = (recv: Expr | null, name: string, args: Expr[] = []): Expr => ({ kind: "call", span: span(), recv, name, args });
22
+ export const unary = (op: UnaryOp, expr: Expr): Expr => ({ kind: "unary", span: span(), op, expr });
23
+ /** `expr!` — the required value (SEMANTICS §5b). */
24
+ export const required = (expr: Expr): Expr => ({ kind: "required", span: span(), expr });
25
+ export const binary = (op: BinaryOp, left: Expr, right: Expr): Expr => ({ kind: "binary", span: span(), op, left, right });
26
+ export const logical = (op: LogicalOp, left: Expr, right: Expr): Expr => ({ kind: "logical", span: span(), op, left, right });
27
+ export const inOp = (left: Expr, right: Expr): Expr => ({ kind: "in", span: span(), left, right });
28
+ export const range = (lo: Expr | null, hi: Expr | null, exclusiveEnd = false): Expr => ({ kind: "range", span: span(), lo, hi, exclusiveEnd });
29
+ /** `(e1, e2, …)` — a tuple of two or more expressions (SEMANTICS §3b). */
30
+ export const tuple = (items: Expr[]): Expr => ({ kind: "tuple", span: span(), items });
31
+
32
+ export const scalar = (expr: Expr): Where => ({ kind: "scalar", span: span(), expr });
33
+ export const and = (parts: Where[]): Where => ({ kind: "and", span: span(), parts });
34
+ export const or = (parts: Where[]): Where => ({ kind: "or", span: span(), parts });
35
+ export const not = (expr: Where): Where => ({ kind: "not", span: span(), expr });
36
+
37
+ export const field = (name: string, expr: Expr, lift = 0): SelectItem => ({ kind: "field", span: span(), name, expr, lift });
38
+ export const collect = (name: string, op: OpNode): SelectItem => ({ kind: "collect", span: span(), name, op });
39
+ export const order = (expr: Expr, desc = false): OrderSpec => ({ kind: "order", span: span(), expr, desc });
40
+
41
+ /** A block body; every clause defaults to empty/absent. */
42
+ export function subquery(parts: Partial<Omit<Subquery, "kind" | "span">> = {}): Subquery {
43
+ return {
44
+ kind: "subquery", span: span(),
45
+ from: [], where: null, select: [], orderBy: null, follow: null, frontier: null, depth: null, values: false, limit: null, offset: null,
46
+ ...parts,
47
+ };
48
+ }
49
+
50
+ /** `<receiver> <op> [distinct] { sub } [<relop> N]`. */
51
+ export function op(
52
+ receiver: Expr, consumer: Consumer, sub: Subquery = subquery(),
53
+ opts: { distinct?: boolean; countCmp?: CountCmp | null } = {},
54
+ ): OpNode {
55
+ return { kind: "op", span: span(), receiver, op: consumer, sub, countCmp: opts.countCmp ?? null, distinct: opts.distinct ?? false };
56
+ }
57
+
58
+ /** `follow [distinct] <destinations>`; a destination's `where`/`frontier`/`depth`
59
+ * belong to its block (`op(recv, "collect", subquery({ depth: 2 }))`). */
60
+ export function follow(destinations: FollowDestination[], opts: { distinct?: boolean } = {}): Follow {
61
+ return { kind: "follow", span: span(), destinations, distinct: opts.distinct ?? false };
62
+ }
63
+
64
+ /** A top-level query over `source`; every other clause defaults to empty/absent
65
+ * and the consumer to `collect`. */
66
+ export function query(source: Expr, parts: Partial<Omit<Query, "kind" | "span" | "source">> = {}): Query {
67
+ return {
68
+ kind: "query", span: span(), source,
69
+ from: [], where: null, select: [], orderBy: null, consumer: "collect", follow: null, frontier: null,
70
+ distinct: false, values: false, limit: null, offset: null,
71
+ ...parts,
72
+ };
73
+ }
package/src/context.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  // The tier-2 seam: a DataContext binds OQX's query semantics to a concrete data
2
2
  // model. The engine never touches host objects directly — it asks the context to
3
3
  // resolve named roots, read properties/relations, coerce a relation result into
4
- // rows, compute identity (for `follow` dedup), and optionally supply custom
5
- // scalar functions/methods. This is what lets the same query semantics run over
4
+ // rows (and say which objects are one row rather than a collection), name a
5
+ // row's identity (`$key`: `follow` dedup, `distinct`, `is`), and optionally
6
+ // supply custom scalar functions/methods. This is what lets the same query semantics run over
6
7
  // plain objects, a lazy ORM graph, or a remote API without changing the engine.
7
8
  //
8
9
  // (Performant execution over a real store is the tier-3 seam — see planner.ts —
@@ -28,6 +29,14 @@ export interface CallResult {
28
29
  export interface DataContext {
29
30
  /** Resolve a named root collection (the `from <name>` / directive receiver). */
30
31
  root(name: string): unknown;
32
+ /** The root scope's row — the host's root object: what `$it` is at the root
33
+ * scope and `^$it` from a top-level row (SEMANTICS §2, since 0.18), so
34
+ * `^$it.people` navigates it through `get` and `entries(^$it)` enumerates it.
35
+ * Bare names at the root still resolve through `root(name)`, so a host with
36
+ * lazy roots is unaffected. Optional: a context without it (or returning
37
+ * `undefined`) has no root row — `$it` at the root is absent, the pre-0.18
38
+ * behavior. `DefaultContext` returns its roots record. */
39
+ rootObject?(): unknown;
31
40
  /** Read a property/relation off a row: a bare identifier (`field`), a
32
41
  * `.field` segment, or a `^field` outer reference all come through here, each
33
42
  * against exactly the row of the scope it names. An absent property is
@@ -37,6 +46,13 @@ export interface DataContext {
37
46
  get(row: unknown, key: string): unknown;
38
47
  /** Coerce a relation/source value into rows (may be lazy). */
39
48
  toRows(value: unknown): Iterable<unknown>;
49
+ /** Optional: whether an object this context handed out in row position is
50
+ * ONE row — a record, not a collection (SEMANTICS §3, since 0.20) — so a block
51
+ * over it yields one result rather than a one-element list (`doc { title }`
52
+ * is a record; `docs { title }` a list). Asked only for non-array objects;
53
+ * absent, any non-iterable object is one row and any iterable (a `Set`, a
54
+ * lazy handle with `Symbol.iterator`) is a collection. */
55
+ isSingle?(value: unknown): boolean;
40
56
  /** Optional: a value this context handed out as a stand-in for a collection
41
57
  * it has not read yet (a lazy table handle), resolved to what it stands for.
42
58
  * The engine calls it on every value it is about to observe AS A VALUE — an
@@ -49,8 +65,15 @@ export interface DataContext {
49
65
  * seen by the language. Absent = identity; a context whose handle already
50
66
  * behaves as the collection (a `Proxy` over an array) needs none. */
51
67
  materialize?(value: unknown): unknown;
52
- /** Identity of a row for `follow` cycle detection / dedup. */
53
- identity(row: unknown): unknown;
68
+ /** Optional: the identity a row is known by — `$key` (SEMANTICS §16, since
69
+ * 0.20): what `follow` cycle detection, the per-step union, `follow distinct`,
70
+ * `distinct` over unprojected rows and `is` compare, and what reading `$key`
71
+ * on the row returns. Absent, a row's identity is its structural value (an
72
+ * entry's is its key either way). A store-backed context answers its row id. */
73
+ identityOf?(row: unknown): unknown;
74
+ /** @deprecated The pre-0.20 spelling of `identityOf`; read when `identityOf`
75
+ * is absent. Removed in the next minor. */
76
+ identity?(row: unknown): unknown;
54
77
  /** Optional custom free function; return `{ handled: false }` to defer. */
55
78
  callFunction?(name: string, args: unknown[]): CallResult;
56
79
  /** Optional custom method; return `{ handled: false }` to defer. */
@@ -82,9 +105,11 @@ export interface DefaultContextOptions {
82
105
  }
83
106
 
84
107
  /** The default context: ordinary JavaScript objects. Named roots come from a
85
- * plain `{ name: collection }` map; properties are OWN keys only (an array
86
- * exposes only its integer indices, a primitive has none); identity is `.id`
87
- * when present, else the row itself (the engine keys it structurally). */
108
+ * plain `{ name: collection }` map, which is also the root object (`^$it` from
109
+ * a top-level row); properties are OWN keys only (an array exposes only its
110
+ * integer indices, a primitive has none); it names no identity of its own, so a
111
+ * row's `$key` is its structural value (SEMANTICS §16) unless a query projects
112
+ * one (`$key: id`). */
88
113
  export class DefaultContext implements DataContext {
89
114
  private roots: Record<string, unknown>;
90
115
  readonly regexDialect: RegexDialect;
@@ -98,6 +123,10 @@ export class DefaultContext implements DataContext {
98
123
  return Object.hasOwn(this.roots, name) ? this.roots[name] : undefined;
99
124
  }
100
125
 
126
+ rootObject(): unknown {
127
+ return this.roots;
128
+ }
129
+
101
130
  get(row: unknown, key: string): unknown {
102
131
  if (row == null || typeof row !== "object") return undefined;
103
132
  if (Array.isArray(row)) return isIndexKey(key) ? row[Number(key)] : undefined;
@@ -108,11 +137,6 @@ export class DefaultContext implements DataContext {
108
137
  return coerceCollection(value);
109
138
  }
110
139
 
111
- identity(row: unknown): unknown {
112
- if (row != null && typeof row === "object" && "id" in row) return (row as Record<string, unknown>).id;
113
- return row;
114
- }
115
-
116
140
  callFunction(name: string, args: unknown[]): CallResult {
117
141
  const fn = Object.hasOwn(BUILTIN_FUNCTIONS, name) ? BUILTIN_FUNCTIONS[name] : undefined;
118
142
  return fn ? { handled: true, value: fn(args) } : { handled: false };