@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.
- package/dist/src/core/cursor.d.ts +13 -1
- package/dist/src/core/cursor.js +21 -2
- package/dist/src/core/paths.d.ts +10 -0
- package/dist/src/core/paths.js +34 -0
- package/dist/src/core/read/document.d.ts +1 -0
- package/dist/src/core/read/document.js +3 -2
- package/dist/src/core/read/reader.d.ts +6 -3
- package/dist/src/core/read/reader.js +31 -16
- package/dist/src/graph/history.js +16 -10
- package/dist/src/graph/link-health.d.ts +2 -1
- package/dist/src/graph/link-health.js +13 -7
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +3 -1
- package/dist/src/mcp/errors.js +1 -1
- package/dist/src/mcp/graph.js +50 -15
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +102 -33
- package/dist/src/mcp/server.js +32 -28
- package/dist/src/oqx-js/context.d.ts +2 -1
- package/dist/src/oqx-js/context.js +181 -49
- package/dist/src/oqx-js/planner.d.ts +8 -1
- package/dist/src/oqx-js/planner.js +22 -42
- package/dist/src/oqx-js/root-row.d.ts +21 -0
- package/dist/src/oqx-js/root-row.js +55 -0
- package/dist/src/oqx-js/run.d.ts +3 -1
- package/dist/src/oqx-js/run.js +128 -164
- package/dist/src/oqx-js/sql/scan.d.ts +27 -0
- package/dist/src/oqx-js/sql/scan.js +62 -0
- package/dist/src/oqx-js/sql/translate.d.ts +7 -0
- package/dist/src/oqx-js/sql/translate.js +42 -11
- package/dist/src/oqx-js/store-index.d.ts +20 -0
- package/dist/src/oqx-js/store-index.js +193 -0
- package/dist/src/spec-versions.d.ts +3 -3
- package/dist/src/spec-versions.js +2 -2
- package/dist/src/surface-paths.d.ts +33 -0
- package/dist/src/surface-paths.js +75 -0
- package/package.json +2 -2
package/dist/src/oqx-js/run.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
51
|
-
return { ...
|
|
37
|
+
function rootLiteral(e) {
|
|
38
|
+
return e.kind === "lit" && typeof e.value === "string" ? { ...e, value: referencePath(e.value) } : e;
|
|
52
39
|
}
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
182
|
-
q
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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 =
|
|
193
|
-
const PATH_ITEM =
|
|
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 `$
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
297
|
-
const base = consumer === "collect" ?
|
|
298
|
-
const
|
|
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)
|
|
345
|
-
// (
|
|
346
|
-
// which the MCP layer maps
|
|
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] =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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[];
|