@omgbase/core 0.7.1 → 0.10.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 (37) hide show
  1. package/dist/src/core/cursor.d.ts +13 -1
  2. package/dist/src/core/cursor.js +21 -2
  3. package/dist/src/core/paths.d.ts +10 -0
  4. package/dist/src/core/paths.js +34 -0
  5. package/dist/src/core/read/document.d.ts +1 -0
  6. package/dist/src/core/read/document.js +3 -2
  7. package/dist/src/core/read/reader.d.ts +6 -3
  8. package/dist/src/core/read/reader.js +31 -16
  9. package/dist/src/graph/history.js +16 -10
  10. package/dist/src/graph/link-health.d.ts +2 -1
  11. package/dist/src/graph/link-health.js +13 -7
  12. package/dist/src/index.d.ts +3 -1
  13. package/dist/src/index.js +3 -1
  14. package/dist/src/mcp/errors.js +1 -1
  15. package/dist/src/mcp/graph.js +50 -15
  16. package/dist/src/mcp/reference.d.ts +1 -1
  17. package/dist/src/mcp/reference.js +102 -33
  18. package/dist/src/mcp/server.js +32 -28
  19. package/dist/src/oqx-js/context.d.ts +2 -1
  20. package/dist/src/oqx-js/context.js +181 -49
  21. package/dist/src/oqx-js/planner.d.ts +8 -1
  22. package/dist/src/oqx-js/planner.js +22 -42
  23. package/dist/src/oqx-js/root-row.d.ts +21 -0
  24. package/dist/src/oqx-js/root-row.js +55 -0
  25. package/dist/src/oqx-js/run.d.ts +3 -1
  26. package/dist/src/oqx-js/run.js +128 -164
  27. package/dist/src/oqx-js/sql/scan.d.ts +27 -0
  28. package/dist/src/oqx-js/sql/scan.js +62 -0
  29. package/dist/src/oqx-js/sql/translate.d.ts +7 -0
  30. package/dist/src/oqx-js/sql/translate.js +42 -11
  31. package/dist/src/oqx-js/store-index.d.ts +20 -0
  32. package/dist/src/oqx-js/store-index.js +193 -0
  33. package/dist/src/spec-versions.d.ts +3 -3
  34. package/dist/src/spec-versions.js +2 -2
  35. package/dist/src/surface-paths.d.ts +33 -0
  36. package/dist/src/surface-paths.js +75 -0
  37. package/package.json +2 -2
@@ -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.
@@ -198,7 +152,7 @@ const VALUE_KEY = "__oqx_value";
198
152
  // Rows as values (spec/surface §1.4, 1.2). The engine hands back store rows
199
153
  // wherever the projection made a row a VALUE — a nested `collect { }` /
200
154
  // `first { }` / `single { }` with an empty projection ("the row itself",
201
- // spec/oqx §12), a `values` projection of `$value`/`$self`, a field bound to a
155
+ // spec/oqx §12), a `values` projection of `$it`/`$self`, a field bound to a
202
156
  // row — and a store row is not a wire shape (column names, `attrs` as a JSON
203
157
  // string, the `__path` join column, Buffers). Walk the projected value and
204
158
  // render every tagged row as `{ id, path }`; everything else passes through
@@ -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). */
@@ -0,0 +1,27 @@
1
+ import type { Target } from "./translate.js";
2
+ export declare const ALIAS: Record<Target, {
3
+ self: string;
4
+ doc: string;
5
+ }>;
6
+ export declare const FROM: Record<Target, string>;
7
+ /** Row columns + the owning-doc path as `__path` (matches the context roots so
8
+ * produced rows are indistinguishable from a full scan's). */
9
+ export declare const COLS: Record<Target, string>;
10
+ /** The joined targets driven FROM the document: the same rows and columns as
11
+ * `FROM`, with the loop order fixed by `CROSS JOIN` so a predicate on the
12
+ * document (`d.path = ?`) is the outer search and the target's rows are
13
+ * reached through their `doc_id` / `src_doc` index. `guardsByDoc` goes with it. */
14
+ export declare const FROM_BY_DOC: Record<Exclude<Target, "docs">, string>;
15
+ export declare const ORDER: Record<Target, string>;
16
+ /** The live-row and repository guards of a target scan. */
17
+ export declare function guards(target: Target, repoId: string): {
18
+ sql: string;
19
+ params: unknown[];
20
+ };
21
+ /** `guards` for a `FROM_BY_DOC` statement: the same tests, with the document's
22
+ * repo first and the target's repo term behind SQLite's unary `+` so it stays a
23
+ * filter and never selects a `(repo_id, …)` index over the `doc_id` one. */
24
+ export declare function guardsByDoc(target: Exclude<Target, "docs">, repoId: string): {
25
+ sql: string;
26
+ params: unknown[];
27
+ };
@@ -0,0 +1,62 @@
1
+ // The SQL shape of a root scan, shared by the tier-3 planner (planner.ts) and
2
+ // the store-backed indexes (store-index.ts): one FROM clause per target, the
3
+ // columns a produced row carries (the target's own columns plus the owning
4
+ // document's path as `__path`, exactly what the context's root scans select),
5
+ // the default `(path, id)` order and the live-row/repo guards. Any statement
6
+ // that produces rows the engine will treat as a target's rows must come from
7
+ // here, so a planned or probed row is indistinguishable from a scanned one.
8
+ export const ALIAS = {
9
+ docs: { self: "d", doc: "d" },
10
+ blocks: { self: "b", doc: "d" },
11
+ nodes: { self: "n", doc: "d" },
12
+ edges: { self: "e", doc: "d" },
13
+ };
14
+ export const FROM = {
15
+ docs: "docs d",
16
+ blocks: "blocks b JOIN docs d ON d.doc_id = b.doc_id",
17
+ nodes: "nodes n JOIN docs d ON d.doc_id = n.doc_id",
18
+ edges: "edges e JOIN docs d ON d.doc_id = e.src_doc",
19
+ };
20
+ /** Row columns + the owning-doc path as `__path` (matches the context roots so
21
+ * produced rows are indistinguishable from a full scan's). */
22
+ export const COLS = {
23
+ docs: "d.*",
24
+ blocks: "b.*, d.path AS __path",
25
+ nodes: "n.*, d.path AS __path",
26
+ edges: "e.*, d.path AS __path",
27
+ };
28
+ /** The joined targets driven FROM the document: the same rows and columns as
29
+ * `FROM`, with the loop order fixed by `CROSS JOIN` so a predicate on the
30
+ * document (`d.path = ?`) is the outer search and the target's rows are
31
+ * reached through their `doc_id` / `src_doc` index. `guardsByDoc` goes with it. */
32
+ export const FROM_BY_DOC = {
33
+ blocks: "docs d CROSS JOIN blocks b ON b.doc_id = d.doc_id",
34
+ nodes: "docs d CROSS JOIN nodes n ON n.doc_id = d.doc_id",
35
+ edges: "docs d CROSS JOIN edges e ON e.src_doc = d.doc_id",
36
+ };
37
+ export const ORDER = {
38
+ docs: "d.path, d.doc_id",
39
+ blocks: "d.path, b.block_id",
40
+ nodes: "d.path, n.node_id",
41
+ edges: "d.path, e.edge_id",
42
+ };
43
+ /** The live-row and repository guards of a target scan. */
44
+ export function guards(target, repoId) {
45
+ switch (target) {
46
+ case "docs": return { sql: "d.repo_id = ? AND d.deleted_commit IS NULL", params: [repoId] };
47
+ case "blocks": return { sql: "b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL", params: [repoId] };
48
+ case "nodes": return { sql: "n.repo_id = ? AND d.deleted_commit IS NULL", params: [repoId] };
49
+ case "edges": return { sql: "e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL", params: [repoId] };
50
+ }
51
+ }
52
+ /** `guards` for a `FROM_BY_DOC` statement: the same tests, with the document's
53
+ * repo first and the target's repo term behind SQLite's unary `+` so it stays a
54
+ * filter and never selects a `(repo_id, …)` index over the `doc_id` one. */
55
+ export function guardsByDoc(target, repoId) {
56
+ switch (target) {
57
+ case "blocks": return { sql: "d.repo_id = ? AND +b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL", params: [repoId, repoId] };
58
+ case "nodes": return { sql: "d.repo_id = ? AND +n.repo_id = ? AND d.deleted_commit IS NULL", params: [repoId, repoId] };
59
+ case "edges": return { sql: "d.repo_id = ? AND +e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL", params: [repoId, repoId] };
60
+ }
61
+ }
62
+ //# sourceMappingURL=scan.js.map
@@ -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)). */
@@ -39,6 +39,8 @@
39
39
  // translated (that is the only context `partitionPushable` pushes into). `||`,
40
40
  // `!`, `in`, `matches` (needs a regexp UDF), and bare content-property routing
41
41
  // are deliberately declined here and left residual for now.
42
+ import { storagePath } from "../../core/paths.js";
43
+ import { TARGETS } from "../root-row.js";
42
44
  // ---- names that are never property reads ------------------------------------
43
45
  /**
44
46
  * Per target, the bare identifiers (and `doc.<k>` keys, via the docs set) that
@@ -59,7 +61,15 @@ export const NON_PROPERTY_NAMES = {
59
61
  // A `$`-namespaced intrinsic → a param-free SQL scalar, per target, with its kind.
60
62
  // Anything not mapped (e.g. docs `$body`, reconstructed; `$title`/`$tags`,
61
63
  // computed) returns null → residual. `$updated_at`/`$dst_path`/`$dst_uri` are
62
- // correlated subqueries.
64
+ // correlated subqueries. `$path` and `$dst_path` are the REFERENCE form on the
65
+ // surface (spec/surface §1 "Paths", 2.0) — `'/' || <storage column>` in SQL —
66
+ // so every comparison, `startsWith`, `contains`, `lower()`… sees exactly the
67
+ // string the in-memory intrinsic yields; `pathColumn` keeps the bare indexed
68
+ // column at hand for the equality fast path.
69
+ /** The rooted path read over a storage path column. */
70
+ export function pathColumn(col) {
71
+ return { sql: `('/' || ${col})`, params: [], kind: "text", pathCol: col };
72
+ }
63
73
  function intrinsicSql(name, ctx) {
64
74
  const { self, doc, target } = ctx;
65
75
  const text = (sql) => ({ sql, params: [], kind: "text" });
@@ -67,7 +77,7 @@ function intrinsicSql(name, ctx) {
67
77
  if (target === "docs")
68
78
  switch (name) {
69
79
  case "$id": return text(`${self}.doc_id`);
70
- case "$path": return text(`${doc}.path`);
80
+ case "$path": return pathColumn(`${doc}.path`);
71
81
  case "$content_hash": return text(`lower(hex(${self}.file_hash))`);
72
82
  case "$updated_at": return text(`(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ${self}.current_rev)`);
73
83
  }
@@ -75,7 +85,7 @@ function intrinsicSql(name, ctx) {
75
85
  switch (name) {
76
86
  case "$id": return text(`${self}.block_id`);
77
87
  case "$doc": return text(`${self}.doc_id`);
78
- case "$path": return text(`${doc}.path`);
88
+ case "$path": return pathColumn(`${doc}.path`);
79
89
  case "$ordinal": return int(`${self}.ordinal`);
80
90
  case "$depth": return int(`${self}.depth`);
81
91
  case "$body": return text(`${self}.text`);
@@ -87,7 +97,7 @@ function intrinsicSql(name, ctx) {
87
97
  case "$node_id": return text(`${self}.node_id`);
88
98
  case "$doc_id": return text(`${self}.doc_id`);
89
99
  case "$block_id": return text(`${self}.block_id`);
90
- case "$path": return text(`${doc}.path`);
100
+ case "$path": return pathColumn(`${doc}.path`);
91
101
  }
92
102
  else if (target === "edges")
93
103
  switch (name) {
@@ -97,8 +107,8 @@ function intrinsicSql(name, ctx) {
97
107
  case "$src_block": return text(`${self}.src_block`);
98
108
  case "$via": return text(`${self}.via_node`);
99
109
  case "$from_commit": return text(`${self}.from_commit`);
100
- case "$path": return text(`${doc}.path`);
101
- case "$dst_path": return text(`(SELECT dd.path FROM docs dd WHERE dd.doc_id = ${self}.dst_node)`);
110
+ case "$path": return pathColumn(`${doc}.path`);
111
+ case "$dst_path": return text(`(SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = ${self}.dst_node)`);
102
112
  case "$dst_uri": return text(`(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = ${self}.dst_node)`);
103
113
  }
104
114
  return null;
@@ -177,8 +187,9 @@ export function translateOperand(e, ctx) {
177
187
  if (e.name.startsWith("$"))
178
188
  return intrinsicSql(e.name, ctx);
179
189
  // A relation, reach-through, source handle or the whole attrs object is not a
180
- // scalar read (decline (b)).
181
- if (NON_PROPERTY_NAMES[target].has(e.name))
190
+ // scalar read (decline (b)); a bare target name is a relation or a loud
191
+ // error (root-row.ts), never a property.
192
+ if (NON_PROPERTY_NAMES[target].has(e.name) || TARGETS.has(e.name))
182
193
  return null;
183
194
  // Bare field per target.
184
195
  if (target === "docs") {
@@ -224,15 +235,15 @@ export function translateOperand(e, ctx) {
224
235
  return null;
225
236
  const k = rest[0];
226
237
  if (k === "$path")
227
- return text(`${doc}.path`);
238
+ return pathColumn(`${doc}.path`);
228
239
  if (k === "format")
229
240
  return text(`${doc}.format`);
230
241
  if (k.startsWith("$"))
231
242
  return null;
232
243
  if (RESERVED_DOC_BASENAMES.has(k))
233
244
  return null;
234
- if (NON_PROPERTY_NAMES.docs.has(k))
235
- return null; // `doc.nodes`, `doc.frontmatter`, … (decline (b))
245
+ if (NON_PROPERTY_NAMES.docs.has(k) || TARGETS.has(k))
246
+ return null; // `doc.nodes`, `doc.frontmatter`, `doc.docs` … (decline (b))
236
247
  return propScalar(doc, k);
237
248
  }
238
249
  // block.type / block.text reach-through from a node.
@@ -372,6 +383,9 @@ function translateComparison(e, ctx) {
372
383
  const r = translateOperand(e.right, ctx);
373
384
  if (!l || !r)
374
385
  return null;
386
+ const fast = pathEquality(e.op, l, r);
387
+ if (fast)
388
+ return fast;
375
389
  if (plainComparable(e.op, l.kind, r.kind)) {
376
390
  // `==`/`!=` → null-safe IS / IS NOT (absence-normalized equality, faithful in
377
391
  // any context). Relational ops → plain SQL: a NULL operand yields NULL, which
@@ -387,6 +401,23 @@ function translateComparison(e, ctx) {
387
401
  return side === "right" ? typedComparison(e.op, l, r) : typedComparison(FLIP[e.op] ?? e.op, r, l);
388
402
  }
389
403
  const FLIP = { "<": ">", "<=": ">=", ">": "<", ">=": "<=" };
404
+ /**
405
+ * The path equality fast path: `$path == "/a.md"` (or `!=`, either side) against
406
+ * a rooted text constant is `d.path IS ?` with the constant's storage form, so
407
+ * the `(repo_id, path)` index serves it; `'/' || d.path IS ?` — what the general
408
+ * form would emit — is the same predicate without the index. Only a constant
409
+ * that IS rooted qualifies (run.ts roots every literal compared with a path
410
+ * read, so that is every literal); a bare binding stays on the general form,
411
+ * where `'/' || d.path` can never equal it — exactly the in-memory answer.
412
+ */
413
+ function pathEquality(op, l, r) {
414
+ if (op !== "==" && op !== "!=")
415
+ return null;
416
+ const [col, konst] = l.pathCol ? [l, r] : r.pathCol ? [r, l] : [null, null];
417
+ if (!col || !konst || konst.kind !== "text" || typeof konst.value !== "string" || !konst.value.startsWith("/"))
418
+ return null;
419
+ return { sql: `(${col.pathCol} ${IS_OP[op]} ?)`, params: [storagePath(konst.value)] };
420
+ }
390
421
  /**
391
422
  * The typed push (spec/surface §1, 1.2 patch): `read <op> constant` where the
392
423
  * read is a JSON or property read and the constant a bool or num literal/binding.
@@ -0,0 +1,20 @@
1
+ import type { RowIndex } from "@omgbase/oqx";
2
+ import type Database from "better-sqlite3";
3
+ import type { Target } from "./sql/translate.js";
4
+ /** What the context lends an index: the store, the repo, how it tags rows, how
5
+ * it reads a field off a row, and the materialized rows of the root scan the
6
+ * index stands in for (read lazily, only by the fallback). */
7
+ export interface StoreIndexDeps {
8
+ db: Database.Database;
9
+ repoId: string;
10
+ tag(rows: Record<string, unknown>[], target: Target): unknown[];
11
+ get(row: unknown, key: string): unknown;
12
+ rows(): unknown[];
13
+ identity(row: unknown): unknown;
14
+ }
15
+ /** A store-backed `RowIndex` for the root scan of `target` on `path`, or
16
+ * `undefined` when SQLite has no index for the path (per-target table above;
17
+ * multi-segment paths; `format`, edge `predicate`/`src_field`, `$tags`, …). */
18
+ export declare function storeIndexFor(target: Target, path: readonly string[], deps: StoreIndexDeps): RowIndex | undefined;
19
+ /** The paths `storeIndexFor` answers from SQLite, per target (for tests and docs). */
20
+ export declare function indexablePaths(target: Target): readonly string[];