@omgbase/core 0.8.0 → 0.10.1

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.
@@ -18,15 +18,14 @@ import { ROWS_ROOT, partitionPushable, residualQuery } from "@omgbase/oqx";
18
18
  import { makeStoreContext, tagRows } from "./context.js";
19
19
  import { translatePredicate, RESERVED_DOC_BASENAMES } from "./sql/translate.js";
20
20
  import { ALIAS, COLS, FROM, ORDER, guards } from "./sql/scan.js";
21
+ import { REACH_THROUGH, TARGETS, isTargetRelationOf } from "./root-row.js";
21
22
  // The root collection a query scans, if it is a bare `docs|blocks|nodes|edges`
22
- // or `$repo.<target>` source (else null — not a pushable shape).
23
- function rootTarget(source) {
24
- const TARGETS = new Set(["docs", "blocks", "nodes", "edges"]);
23
+ // source at the root scope (else null — not a pushable shape; a nested `^docs`
24
+ // is a block's receiver, never the query's source). run.ts also asks: a bare
25
+ // root scan's rows are store rows by construction.
26
+ export function rootTarget(source) {
25
27
  if (source.kind === "ident" && TARGETS.has(source.name))
26
28
  return source.name;
27
- if (source.kind === "member" && source.recv.kind === "ident" && source.recv.name === "$repo" && TARGETS.has(source.name)) {
28
- return source.name;
29
- }
30
29
  return null;
31
30
  }
32
31
  // ---- the residual walk (decline (c)) -----------------------------------------
@@ -42,6 +41,12 @@ function rootTarget(source) {
42
41
  * • a bare reserved docs basename (`id path updated_at content_hash body`): as
43
42
  * an `ident` at the ROOT scope when the target is `docs`, or as `doc.<name>`
44
43
  * on any target at any depth (the reach-through row is a doc)
44
+ * • a bare target name (`docs blocks nodes edges`, root-row.ts) read as a
45
+ * property: as an `ident` at the ROOT scope when it is not a relation of
46
+ * the target's rows (`blocks` on blocks, `nodes` on nodes, any on edges),
47
+ * as an `ident` at any depth below (the rows there are unknown), or as
48
+ * `<head>.<name>` unless the head is a reach-through whose row has that
49
+ * relation (`doc.nodes`, `doc.blocks`, `block.nodes`, `section.blocks`)
45
50
  *
46
51
  * A residual made only of comparisons, logical operators, `in`/ranges, `!`,
47
52
  * literals, bindings and plain reads (including exists/none/count/collect/first
@@ -100,13 +105,21 @@ function exprMayRaise(e, target, root) {
100
105
  switch (e.kind) {
101
106
  case "call": return true;
102
107
  case "outer": return true;
103
- case "ident": return root && target === "docs" && RESERVED_DOC_BASENAMES.has(e.name);
108
+ case "ident":
109
+ if (TARGETS.has(e.name))
110
+ return !root || !isTargetRelationOf(target, e.name);
111
+ return root && target === "docs" && RESERVED_DOC_BASENAMES.has(e.name);
104
112
  case "member":
105
113
  if (e.recv.kind === "ident" && e.recv.name === "doc" && RESERVED_DOC_BASENAMES.has(e.name))
106
114
  return true;
115
+ if (TARGETS.has(e.name) && !(e.recv.kind === "ident" && isTargetRelationOf(REACH_THROUGH[e.recv.name], e.name)))
116
+ return true;
107
117
  return exprMayRaise(e.recv, target, root);
108
- case "index": return exprMayRaise(e.recv, target, root) || exprMayRaise(e.index, target, root);
109
118
  case "unary": return exprMayRaise(e.expr, target, root);
119
+ // `x!` raises when absent; a value-position directive may `single`-fail or
120
+ // read anything its block reads.
121
+ case "required": return true;
122
+ case "op": return opMayRaise(e, target, root);
110
123
  case "binary":
111
124
  case "logical":
112
125
  case "in":
@@ -0,0 +1,21 @@
1
+ export type Target = "docs" | "blocks" | "nodes" | "edges";
2
+ export declare const TARGETS: ReadonlySet<string>;
3
+ /** The target names that are a relation of each target's rows (§1.2). */
4
+ export declare const TARGET_RELATIONS: Readonly<Record<Target, ReadonlySet<string>>>;
5
+ /** The reach-through heads and the target of the row they reach (`doc.x`, `block.x`, `section.x`). */
6
+ export declare const REACH_THROUGH: Readonly<Record<string, Target>>;
7
+ export declare const REPO_REMOVED_MESSAGE = "`$repo` was removed in surface 2.0 \u2014 reach the repository's collections through the root row: `^docs` from a top-level row (one caret per enclosing block, or the absolute `0^docs`), and the repository id as `^$id` / `0^$id`";
8
+ /**
9
+ * The bare-target error. `depth` is the scope depth the name was read at when
10
+ * known statically (the carets in the hint count it); `null` when raised at read
11
+ * time, where the hint spells out the rule instead.
12
+ */
13
+ export declare function bareTargetMessage(name: string, depth: number | null): string;
14
+ /**
15
+ * The past-the-root error: `^docs` at the root scope (more carets than there
16
+ * are enclosing scopes) would read absent and count 0 — refused, naming the
17
+ * bare spelling. `levels` is the caret count written.
18
+ */
19
+ export declare function pastRootMessage(name: string, levels: number): string;
20
+ /** Whether `name`, read as `<head>.<name>` or bare on a row of `target`, is one of that row's relations named after a target. */
21
+ export declare function isTargetRelationOf(target: Target | undefined, name: string): boolean;
@@ -0,0 +1,55 @@
1
+ // The root row (spec/surface §1.1, 2.0; spec/oqx 0.18). The repository is OQX's
2
+ // root row: from a top-level row `^docs` reaches the documents (one caret per
3
+ // enclosing block, or the absolute `0^docs`), `^$id` the repository id, and the
4
+ // root object (`^$it`) exposes the four collections. Two spellings are refused
5
+ // as `filter_invalid`, with the messages below, in both engines:
6
+ //
7
+ // • `$repo` (removed in 2.0) anywhere — bare, as a member head (`$repo.docs`),
8
+ // `^$repo`, `0^$repo` — statically, before any row is read, so the error is
9
+ // deterministic whether or not a row would have reached it (run.ts
10
+ // `checkRootSpellings`);
11
+ // • a bare target name at scope depth ≥ 1 (inside a block, or in a top-level
12
+ // row's clauses), where a bare name reads a property of the current row,
13
+ // which has none: `docs` and `edges` are no row's relation, so they are
14
+ // refused statically by the same walk; `blocks` and `nodes` ARE relations
15
+ // of some rows (`docs.blocks`, `docs.nodes`, `blocks.nodes`,
16
+ // `section.blocks`), so the context raises the same error at read time on a
17
+ // row that lacks the relation (context.ts). A frontmatter key named after a
18
+ // target is unreachable by bare name (use `frontmatter.<k>`).
19
+ export const TARGETS = new Set(["docs", "blocks", "nodes", "edges"]);
20
+ /** The target names that are a relation of each target's rows (§1.2). */
21
+ export const TARGET_RELATIONS = {
22
+ docs: new Set(["nodes", "blocks"]),
23
+ blocks: new Set(["nodes"]),
24
+ nodes: new Set(["blocks"]),
25
+ edges: new Set(),
26
+ };
27
+ /** The reach-through heads and the target of the row they reach (`doc.x`, `block.x`, `section.x`). */
28
+ export const REACH_THROUGH = { doc: "docs", block: "blocks", section: "nodes" };
29
+ const NOUN = { docs: "documents", blocks: "blocks", nodes: "nodes", edges: "edges" };
30
+ export const REPO_REMOVED_MESSAGE = "`$repo` was removed in surface 2.0 — reach the repository's collections through the root row: `^docs` from a top-level row (one caret per enclosing block, or the absolute `0^docs`), and the repository id as `^$id` / `0^$id`";
31
+ /**
32
+ * The bare-target error. `depth` is the scope depth the name was read at when
33
+ * known statically (the carets in the hint count it); `null` when raised at read
34
+ * time, where the hint spells out the rule instead.
35
+ */
36
+ export function bareTargetMessage(name, depth) {
37
+ const t = name;
38
+ const carets = "^".repeat(depth ?? 1);
39
+ const how = depth === null ? `; one caret per enclosing block, or the absolute \`0^${name}\`` : "";
40
+ return `\`${name}\` inside a block reads a property of the current row, which has none — did you mean \`${carets}${name}\` (the repository's ${NOUN[t]}${how})?`;
41
+ }
42
+ /**
43
+ * The past-the-root error: `^docs` at the root scope (more carets than there
44
+ * are enclosing scopes) would read absent and count 0 — refused, naming the
45
+ * bare spelling. `levels` is the caret count written.
46
+ */
47
+ export function pastRootMessage(name, levels) {
48
+ const t = name;
49
+ return `\`${"^".repeat(levels)}${name}\` reaches past the root — there is no enclosing row at this depth; at the top level the repository's ${NOUN[t]} are the bare \`${name}\` (\`${name} count { … }\`, \`from ${name}\`, \`entries(${name})\`)`;
50
+ }
51
+ /** Whether `name`, read as `<head>.<name>` or bare on a row of `target`, is one of that row's relations named after a target. */
52
+ export function isTargetRelationOf(target, name) {
53
+ return target !== undefined && TARGET_RELATIONS[target].has(name);
54
+ }
55
+ //# sourceMappingURL=root-row.js.map
@@ -1,4 +1,4 @@
1
- import type { Query } from "@omgbase/oqx";
1
+ import type { Query, Expr } from "@omgbase/oqx";
2
2
  import type { Store } from "../core/store/store.js";
3
3
  import type { SemanticVec } from "../search/cel/compile.js";
4
4
  export type OqxConsumer = "collect" | "count" | "exists" | "none" | "first" | "single";
@@ -34,6 +34,8 @@ export interface OqxOptions {
34
34
  * the differential conformance suite to prove planned == in-memory. */
35
35
  plan?: boolean;
36
36
  }
37
+ export declare function rootPathLiterals(e: Expr): Expr;
38
+ export declare function checkRootSpellings(q: Query): void;
37
39
  export declare function collectSemanticPhrases(source: string): string[];
38
40
  export declare function oqxRun(store: Store, repoId: string, source: string, opts?: OqxOptions): OqxResult;
39
41
  export declare function oqxRunAsync(store: Store, repoId: string, source: string, opts?: OqxOptions, embedQuery?: EmbedQuery): Promise<OqxResult>;
@@ -3,92 +3,79 @@
3
3
  // shape the engine's result into omgbase's OqxResult (lean {id, path, …} hits,
4
4
  // keyset pagination, count/exists scalars). Same signature + shape as the former
5
5
  // in-tree compiler, so every caller and the corpus are unchanged.
6
- import { parse, PlannedEngine, InMemoryEngine, OqxError } from "@omgbase/oqx";
6
+ import { parse, PlannedEngine, InMemoryEngine, OqxError, resolveAliases, transform, visit, build, semantics } from "@omgbase/oqx";
7
7
  import { makeStoreContext, rowRef } from "./context.js";
8
- import { SQLiteQueryPlanner } from "./planner.js";
8
+ import { SQLiteQueryPlanner, rootTarget } from "./planner.js";
9
+ import { REPO_REMOVED_MESSAGE, TARGETS, bareTargetMessage, pastRootMessage } from "./root-row.js";
9
10
  import { FilterInvalid } from "../search/cel/parser.js";
10
11
  import { float32ToBlob } from "../core/vec.js";
11
- import { encodeCursor as encodeKeyset, decodeCursor as decodeKeyset } from "../core/cursor.js";
12
+ import { encodeCursor as encodeKeyset, decodePathCursor } from "../core/cursor.js";
13
+ import { referencePath } from "../core/paths.js";
12
14
  // Row-scoped domain functions: authored as free calls (`text("x")`) that
13
15
  // implicitly reference the current row. `@omgbase/oqx` free functions receive no
14
- // row, so we rewrite them to `$self.fn(…)` (a method whose receiver is the row).
16
+ // row, so we rewrite them to `$self.fn(…)` (a method whose receiver is the row)
17
+ // — one `transform` over the AST (spec/oqx/AST.md §5), every block included.
18
+ // `refs(x)` is NOT here: it reads no row (its argument is the value), so it stays
19
+ // a free function served by the context's `callFunction`. The planner treats
20
+ // every call alike (translate declines it; `exprMayRaise` flags it), `has_edge`
21
+ // and `refs` included.
15
22
  const ROW_FNS = new Set([
16
23
  "text", "semantic", "under", "under_heading", "within", "under_kind",
17
24
  "yaml_path", "json_pointer", "has_edge", "has_anchor", "child_count", "parent_type",
18
25
  ]);
19
- const selfRef = { kind: "ident", name: "$self" };
20
- function rewriteExpr(e) {
21
- switch (e.kind) {
22
- case "member": return { ...e, recv: rewriteExpr(e.recv) };
23
- case "index": return { ...e, recv: rewriteExpr(e.recv), index: rewriteExpr(e.index) };
24
- case "unary": return { ...e, expr: rewriteExpr(e.expr) };
25
- case "binary":
26
- case "logical":
27
- case "in":
28
- return { ...e, left: rewriteExpr(e.left), right: rewriteExpr(e.right) };
29
- case "range":
30
- return { ...e, lo: e.lo ? rewriteExpr(e.lo) : null, hi: e.hi ? rewriteExpr(e.hi) : null };
31
- case "call": {
32
- const recv = e.recv ? rewriteExpr(e.recv) : null;
33
- const args = e.args.map(rewriteExpr);
34
- if (recv === null && ROW_FNS.has(e.name))
35
- return { kind: "call", recv: selfRef, name: e.name, args };
36
- return { kind: "call", recv, name: e.name, args };
37
- }
38
- default: return e; // lit, ident, outer, binding
39
- }
40
- }
41
- function rewriteWhere(w) {
42
- switch (w.kind) {
43
- case "and": return { kind: "and", parts: w.parts.map(rewriteWhere) };
44
- case "or": return { kind: "or", parts: w.parts.map(rewriteWhere) };
45
- case "not": return { kind: "not", expr: rewriteWhere(w.expr) };
46
- case "scalar": return { kind: "scalar", expr: rewriteExpr(w.expr) };
47
- case "op": return rewriteOp(w);
48
- }
26
+ // Paths on the surface are the reference form (spec/surface §1 "Paths", 2.0):
27
+ // `$path` and `$dst_path` read `/a/b.md`. A string literal a query compares
28
+ // with one of them — `$path == "a.md"`, `"a.md" != $dst_path`,
29
+ // `$path.startsWith("lab/")` — is rooted first, so both spellings match; a
30
+ // property or binding compared with a path is never touched (it is the
31
+ // author's value). The planner sees the rewritten tree too, so the pushed SQL
32
+ // and the in-memory engine agree by construction.
33
+ const PATH_INTRINSICS = new Set(["$path", "$dst_path"]);
34
+ function isPathRead(e) {
35
+ return (e.kind === "ident" || e.kind === "outer" || e.kind === "member") && PATH_INTRINSICS.has(e.name);
49
36
  }
50
- function rewriteOp(op) {
51
- return { ...op, receiver: rewriteExpr(op.receiver), sub: rewriteSub(op.sub) };
37
+ function rootLiteral(e) {
38
+ return e.kind === "lit" && typeof e.value === "string" ? { ...e, value: referencePath(e.value) } : e;
52
39
  }
53
- function rewriteFollow(f) {
54
- return {
55
- ...f,
56
- // each destination is a relation Expr or a destination block (an OpNode)
57
- destinations: f.destinations.map((d) => (d.kind === "op" ? rewriteOp(d) : rewriteExpr(d))),
58
- where: f.where ? rewriteExpr(f.where) : null,
59
- frontier: f.frontier ? rewriteExpr(f.frontier) : null,
60
- by: f.by ? rewriteExpr(f.by) : null,
61
- };
62
- }
63
- function rewriteSelect(items) {
64
- return items.map((it) => it.kind === "field" ? { ...it, expr: rewriteExpr(it.expr) } : { ...it, op: rewriteOp(it.op) });
40
+ export function rootPathLiterals(e) {
41
+ if (e.kind === "binary" && (e.op === "==" || e.op === "!=")) {
42
+ if (isPathRead(e.left))
43
+ return { ...e, right: rootLiteral(e.right) };
44
+ if (isPathRead(e.right))
45
+ return { ...e, left: rootLiteral(e.left) };
46
+ }
47
+ if (e.kind === "call" && e.recv !== null && e.name === "startsWith" && e.args.length === 1 && isPathRead(e.recv)) {
48
+ return { ...e, args: [rootLiteral(e.args[0])] };
49
+ }
50
+ return e;
65
51
  }
66
- function rewriteSub(s) {
67
- return {
68
- from: s.from.map(rewriteExpr),
69
- where: s.where ? rewriteWhere(s.where) : null,
70
- select: rewriteSelect(s.select),
71
- orderBy: s.orderBy ? s.orderBy.map((o) => ({ ...o, expr: rewriteExpr(o.expr) })) : null,
72
- follow: s.follow ? rewriteFollow(s.follow) : null,
73
- ...(s.values ? { values: true } : {}),
74
- ...(s.limit ? { limit: rewriteExpr(s.limit) } : {}),
75
- ...(s.offset ? { offset: rewriteExpr(s.offset) } : {}),
76
- };
52
+ // The root row (root-row.ts, spec/surface §1.1, 2.0): `$repo` anywhere, and a
53
+ // bare `docs`/`edges` at scope depth ≥ 1 (a property read of a row that has
54
+ // none), are refused before evaluation — one `visit` over the parsed tree, so
55
+ // the error does not depend on which rows the data happens to hold and the
56
+ // planned and in-memory paths agree trivially. `blocks`/`nodes` are relations of
57
+ // some rows and are judged by the context at read time (same message). A
58
+ // `^<target>` with more carets than enclosing scopes (`^docs count { }` at the
59
+ // top level) would read absent and count 0: refused too, naming the bare
60
+ // spelling.
61
+ export function checkRootSpellings(q) {
62
+ visit(q, {
63
+ enter(node, ctx) {
64
+ if (node.kind !== "ident" && node.kind !== "outer")
65
+ return;
66
+ if (node.name === "$repo")
67
+ throw new FilterInvalid(REPO_REMOVED_MESSAGE, "OQX");
68
+ if (node.kind === "ident" && ctx.depth >= 1 && (node.name === "docs" || node.name === "edges")) {
69
+ throw new FilterInvalid(bareTargetMessage(node.name, ctx.depth), "OQX");
70
+ }
71
+ if (node.kind === "outer" && TARGETS.has(node.name) && node.levels > ctx.depth) {
72
+ throw new FilterInvalid(pastRootMessage(node.name, node.levels), "OQX");
73
+ }
74
+ },
75
+ });
77
76
  }
78
77
  function rewriteQuery(q) {
79
- return {
80
- source: rewriteExpr(q.source),
81
- from: q.from.map(rewriteExpr),
82
- where: q.where ? rewriteWhere(q.where) : null,
83
- select: rewriteSelect(q.select),
84
- orderBy: q.orderBy ? q.orderBy.map((o) => ({ ...o, expr: rewriteExpr(o.expr) })) : null,
85
- consumer: q.consumer,
86
- follow: q.follow ? rewriteFollow(q.follow) : null,
87
- ...(q.distinct ? { distinct: true } : {}),
88
- ...(q.values ? { values: true } : {}),
89
- ...(q.limit ? { limit: rewriteExpr(q.limit) } : {}),
90
- ...(q.offset ? { offset: rewriteExpr(q.offset) } : {}),
91
- };
78
+ return transform(q, (e) => (e.kind === "call" && e.recv === null && ROW_FNS.has(e.name) ? { ...e, recv: build.ident("$self") } : rootPathLiterals(e)));
92
79
  }
93
80
  // A top-level `limit`/`offset` on the collect path is applied by the runner (see
94
81
  // oqxRunInner), so it must be a plain number literal here — there is no row to
@@ -101,76 +88,9 @@ function constBound(e, word) {
101
88
  return e.value;
102
89
  throw new FilterInvalid(`top-level ${word} must be a non-negative integer literal`, "OQX");
103
90
  }
104
- // Distinct phrases referenced by `semantic("…")` (free calls in the raw parse).
91
+ // Distinct phrases referenced by `semantic("…")` (free calls in the raw parse),
92
+ // found by one `visit` over the whole tree.
105
93
  export function collectSemanticPhrases(source) {
106
- const phrases = new Set();
107
- const visitExpr = (e) => {
108
- switch (e.kind) {
109
- case "call":
110
- if (e.recv === null && e.name === "semantic" && e.args[0]?.kind === "lit" && typeof e.args[0].value === "string") {
111
- phrases.add(e.args[0].value);
112
- }
113
- if (e.recv)
114
- visitExpr(e.recv);
115
- e.args.forEach(visitExpr);
116
- return;
117
- case "member":
118
- visitExpr(e.recv);
119
- return;
120
- case "index":
121
- visitExpr(e.recv);
122
- visitExpr(e.index);
123
- return;
124
- case "unary":
125
- visitExpr(e.expr);
126
- return;
127
- case "binary":
128
- case "logical":
129
- case "in":
130
- visitExpr(e.left);
131
- visitExpr(e.right);
132
- return;
133
- case "range":
134
- if (e.lo)
135
- visitExpr(e.lo);
136
- if (e.hi)
137
- visitExpr(e.hi);
138
- return;
139
- default: return;
140
- }
141
- };
142
- const visitWhere = (w) => {
143
- switch (w.kind) {
144
- case "and":
145
- case "or":
146
- w.parts.forEach(visitWhere);
147
- return;
148
- case "not":
149
- visitWhere(w.expr);
150
- return;
151
- case "scalar":
152
- visitExpr(w.expr);
153
- return;
154
- case "op":
155
- visitOp(w);
156
- return;
157
- }
158
- };
159
- const visitOp = (op) => { visitExpr(op.receiver); visitSub(op.sub); };
160
- const visitFollow = (f) => {
161
- f.destinations.forEach((d) => (d.kind === "op" ? visitOp(d) : visitExpr(d)));
162
- [f.where, f.frontier, f.by].forEach((x) => x && visitExpr(x));
163
- };
164
- const visitSub = (s) => {
165
- s.from.forEach(visitExpr);
166
- if (s.where)
167
- visitWhere(s.where);
168
- s.select.forEach((it) => it.kind === "field" ? visitExpr(it.expr) : visitOp(it.op));
169
- if (s.orderBy)
170
- s.orderBy.forEach((o) => visitExpr(o.expr));
171
- if (s.follow)
172
- visitFollow(s.follow);
173
- };
174
94
  let q;
175
95
  try {
176
96
  q = parse(source);
@@ -178,19 +98,53 @@ export function collectSemanticPhrases(source) {
178
98
  catch {
179
99
  return [];
180
100
  }
181
- visitExpr(q.source);
182
- q.from.forEach(visitExpr);
183
- if (q.where)
184
- visitWhere(q.where);
185
- q.select.forEach((it) => it.kind === "field" ? visitExpr(it.expr) : visitOp(it.op));
186
- if (q.orderBy)
187
- q.orderBy.forEach((o) => visitExpr(o.expr));
188
- if (q.follow)
189
- visitFollow(q.follow);
101
+ const phrases = new Set();
102
+ visit(q, {
103
+ enter(node) {
104
+ if (node.kind === "call" && node.recv === null && node.name === "semantic") {
105
+ const arg = node.args[0];
106
+ if (arg?.kind === "lit" && typeof arg.value === "string")
107
+ phrases.add(arg.value);
108
+ }
109
+ },
110
+ });
190
111
  return [...phrases];
191
112
  }
192
- const ID_ITEM = { kind: "field", name: "__oqx_id", expr: { kind: "ident", name: "$id" }, lift: 0 };
193
- const PATH_ITEM = { kind: "field", name: "__oqx_path", expr: { kind: "ident", name: "$path" }, lift: 0 };
113
+ const ID_ITEM = build.field("__oqx_id", build.ident("$id"));
114
+ const PATH_ITEM = build.field("__oqx_path", build.ident("$path"));
115
+ // A hit is a store row (spec/surface §1.4, 1.5). The engine's top-level rows
116
+ // are store rows by construction when the source is a bare root scan and
117
+ // nothing re-projects them; a `follow` destination (`follow before` over a
118
+ // frontmatter list of paths), a `from E` re-projection or any other source can
119
+ // reach a scalar, which the injected `$id`/`$path` reads would render as the
120
+ // junk hit `{ id: "undefined", path: "" }`. For those queries the row itself is
121
+ // projected too (`$it`), and `toHit` fails the query when it is not a row. Only
122
+ // then: a row clone per hit is free here but not in the Rust port, and a root
123
+ // scan cannot need it. A `values` projection returns no hits, so it is exempt
124
+ // (`from docs from tags select $it values` is a flat list of tags).
125
+ const SELF_KEY = "__oqx_self";
126
+ const SELF_ITEM = build.field(SELF_KEY, build.ident("$it"));
127
+ function mayReachNonRows(q) {
128
+ return q.follow !== null || q.from.length > 0 || rootTarget(q.source) === null;
129
+ }
130
+ function describeValue(v) {
131
+ if (v === null || v === undefined)
132
+ return "an absent value";
133
+ if (typeof v === "string")
134
+ return `a string (${JSON.stringify(v)})`;
135
+ if (typeof v === "number")
136
+ return `a number (${String(v)})`;
137
+ if (typeof v === "boolean")
138
+ return `a boolean (${String(v)})`;
139
+ if (Array.isArray(v))
140
+ return "an array";
141
+ if (semantics.isRange(v))
142
+ return "a range";
143
+ return "an object";
144
+ }
145
+ function notAStoreRow(v) {
146
+ return new FilterInvalid(`a hit must be a document, block, node or edge row — the query reached ${describeValue(v)}; to follow document references held in a property use refs(<field>)`, "OQX");
147
+ }
194
148
  // The reserved key a top-level `values` projection's single item is renamed to,
195
149
  // so it rides through id/path injection, keyset paging, and distinct as an
196
150
  // ordinary record field and is peeled off at the end.
@@ -222,7 +176,9 @@ function renderValue(v) {
222
176
  }
223
177
  function toHit(row) {
224
178
  const o = row;
225
- const { __oqx_id, __oqx_path, ...rest } = o;
179
+ const { __oqx_id, __oqx_path, __oqx_self, ...rest } = o;
180
+ if (SELF_KEY in o && !rowRef(__oqx_self))
181
+ throw notAStoreRow(__oqx_self);
226
182
  return { id: String(__oqx_id), path: String(__oqx_path ?? ""), ...renderValue(rest) };
227
183
  }
228
184
  // Top-level `select distinct`: dedup hits by their USER projection (every field
@@ -255,7 +211,11 @@ export function oqxRun(store, repoId, source, opts = {}) {
255
211
  }
256
212
  }
257
213
  function oqxRunInner(store, repoId, source, opts) {
258
- const parsed = rewriteQuery(parse(source));
214
+ // The query's `select` aliases are resolved HERE, once, before the runner
215
+ // renames/injects items (an engine evaluates the query it is given).
216
+ const raw = parse(source);
217
+ checkRootSpellings(raw);
218
+ const parsed = rewriteQuery(resolveAliases(raw));
259
219
  const consumer = parsed.consumer;
260
220
  const ctxOpts = opts.semanticVectors ? { semanticVectors: opts.semanticVectors } : {};
261
221
  const ctx = makeStoreContext(store, repoId, ctxOpts);
@@ -281,21 +241,22 @@ function oqxRunInner(store, repoId, source, opts) {
281
241
  // id/path are unique per row and would defeat the engine's projection dedup, so
282
242
  // we run without engine-distinct and dedup hits by their USER projection below,
283
243
  // keeping the first row's id/path.
284
- const topDistinct = !!parsed.distinct;
244
+ const topDistinct = parsed.distinct;
285
245
  // A top-level `values` projection runs as a RECORD projection whose single
286
246
  // item is renamed to VALUE_KEY (the engine's own values mode is switched off),
287
247
  // so pagination and distinct work unchanged; the bare values are peeled off
288
248
  // the final page below and returned as `values` with `hits` empty.
289
- const topValues = !!parsed.values;
249
+ const topValues = parsed.values;
290
250
  const userSelect = topValues ? [{ ...parsed.select[0], name: VALUE_KEY }] : parsed.select;
291
251
  // On the collect path a top-level `limit`/`offset` is ALSO taken out of the
292
252
  // engine query and applied here, after the runner's own distinct — the engine
293
253
  // would otherwise bound the raw rows before dedup (`select distinct type
294
254
  // limit 3` must be three distinct types). first/single keep theirs: the
295
255
  // engine's offset-aware cap is exactly right for them.
296
- const { limit: topLimit, offset: topOffset, ...unbounded } = parsed;
297
- const base = consumer === "collect" ? unbounded : parsed;
298
- const q = { ...base, distinct: false, values: false, select: [ID_ITEM, PATH_ITEM, ...userSelect] };
256
+ const { limit: topLimit, offset: topOffset } = parsed;
257
+ const base = consumer === "collect" ? { ...parsed, limit: null, offset: null } : parsed;
258
+ const guard = !topValues && mayReachNonRows(parsed) ? [SELF_ITEM] : [];
259
+ const q = { ...base, distinct: false, values: false, select: [ID_ITEM, PATH_ITEM, ...guard, ...userSelect] };
299
260
  const res = engine.run(q, []);
300
261
  if (consumer === "first" || consumer === "single") {
301
262
  const row = res.consumer === "first" || res.consumer === "single" ? res.row : null;
@@ -341,14 +302,17 @@ export async function oqxRunAsync(store, repoId, source, opts = {}, embedQuery)
341
302
  }
342
303
  return oqxRun(store, repoId, source, { ...opts, semanticVectors });
343
304
  }
344
- // The collect page's keyset is (path, id): the kernel's shared cursor encoding
345
- // (core/cursor.ts) with a two-part tuple. A malformed cursor is CursorInvalid,
346
- // which the MCP layer maps to filter_invalid.
305
+ // The collect page's keyset is (path, id) — the hit's path, so the reference
306
+ // form (`/a.md`): the kernel's shared cursor encoding (core/cursor.ts) with a
307
+ // two-part tuple. A malformed cursor is CursorInvalid, which the MCP layer maps
308
+ // to filter_invalid; so is a cursor whose path is not rooted — one issued by a
309
+ // 1.x surface, whose keyset would otherwise sort before every rooted hit and
310
+ // silently replay the first page (spec/surface §1.4, 2.0).
347
311
  function encodeCursor(path, id) {
348
312
  return encodeKeyset([path, id]);
349
313
  }
350
314
  function decodeCursor(cursor) {
351
- const [path, id] = decodeKeyset(cursor, "query", 2);
315
+ const [path, id] = decodePathCursor(cursor, "query", 2);
352
316
  return { path: path, id: id };
353
317
  }
354
318
  /** Parse an OQX source into the `@omgbase/oqx` AST (re-exported for callers). */
@@ -48,6 +48,11 @@ export interface Operand extends Frag {
48
48
  key: string;
49
49
  };
50
50
  value?: unknown;
51
+ /** The storage-form path column behind a `$path` / `doc.$path` read
52
+ * (`d.path`): the operand's SQL is the rooted `'/' || <col>`, and an
53
+ * equality against a rooted text constant is re-shaped onto the bare,
54
+ * indexed column (`pathEquality`). */
55
+ pathCol?: string;
51
56
  }
52
57
  /**
53
58
  * Per target, the bare identifiers (and `doc.<k>` keys, via the docs set) that
@@ -59,6 +64,8 @@ export interface Operand extends Frag {
59
64
  * `translate.test.ts` asserts this set against the store context.
60
65
  */
61
66
  export declare const NON_PROPERTY_NAMES: Readonly<Record<Target, ReadonlySet<string>>>;
67
+ /** The rooted path read over a storage path column. */
68
+ export declare function pathColumn(col: string): Operand;
62
69
  /** docs intrinsics whose BARE (non-$) form is a loud error in-memory (10 §2) — not
63
70
  * pushable, so the residual raises it (and the planner keeps the whole query
64
71
  * unplanned when one is residual: spec/surface §1 decline (c)). */