@omgbase/core 0.2.0 → 0.4.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 (43) hide show
  1. package/dist/src/core/clock.d.ts +9 -0
  2. package/dist/src/core/clock.js +46 -0
  3. package/dist/src/core/ingest.js +15 -12
  4. package/dist/src/core/read/document.js +6 -1
  5. package/dist/src/core/read/nodes.js +5 -1
  6. package/dist/src/core/store/fts.d.ts +38 -7
  7. package/dist/src/core/store/fts.js +60 -9
  8. package/dist/src/core/store/rebuild.js +5 -4
  9. package/dist/src/core/store/writers.d.ts +6 -0
  10. package/dist/src/core/store/writers.js +1 -0
  11. package/dist/src/format/markdown.js +27 -23
  12. package/dist/src/graph/extract.d.ts +41 -1
  13. package/dist/src/graph/extract.js +59 -1
  14. package/dist/src/graph/history.d.ts +33 -1
  15. package/dist/src/graph/history.js +141 -18
  16. package/dist/src/index.d.ts +4 -2
  17. package/dist/src/index.js +4 -1
  18. package/dist/src/mcp/server.js +23 -12
  19. package/dist/src/mutate/apply.d.ts +2 -0
  20. package/dist/src/mutate/apply.js +8 -5
  21. package/dist/src/mutate/known-ids.js +2 -2
  22. package/dist/src/mutate/ops.d.ts +3 -3
  23. package/dist/src/mutate/ops.js +38 -13
  24. package/dist/src/oqx-js/context.d.ts +11 -0
  25. package/dist/src/oqx-js/context.js +30 -1
  26. package/dist/src/oqx-js/planner.d.ts +20 -1
  27. package/dist/src/oqx-js/planner.js +95 -2
  28. package/dist/src/oqx-js/run.js +27 -2
  29. package/dist/src/oqx-js/sql/translate.d.ts +71 -1
  30. package/dist/src/oqx-js/sql/translate.js +165 -70
  31. package/dist/src/search/rrf.js +2 -1
  32. package/dist/src/search/tasks.d.ts +34 -1
  33. package/dist/src/search/tasks.js +42 -23
  34. package/dist/src/search/vector.js +19 -18
  35. package/dist/src/sync/external-source.js +20 -7
  36. package/dist/src/sync/freshness.d.ts +8 -1
  37. package/dist/src/sync/freshness.js +23 -3
  38. package/dist/src/sync/plugin.d.ts +30 -5
  39. package/dist/src/sync/plugin.js +13 -1
  40. package/dist/src/sync/reconciling-ingest.js +5 -3
  41. package/dist/src/sync/watcher.d.ts +2 -0
  42. package/dist/src/sync/watcher.js +10 -2
  43. package/package.json +2 -2
@@ -1,6 +1,25 @@
1
- import type { Query, QueryPlanner, Plan } from "@omgbase/oqx";
1
+ import type { Query, Where, QueryPlanner, Plan } from "@omgbase/oqx";
2
2
  import type { Store } from "../core/store/store.js";
3
3
  import { type StoreContextOptions } from "./context.js";
4
+ import { type Target } from "./sql/translate.js";
5
+ /**
6
+ * True when evaluating `where` in memory could raise an OQX error the pushed
7
+ * conjuncts might hide by emptying the scan. The walk is generic over the whole
8
+ * residual tree — every `Where` node, every nested block (its `from`, `where`,
9
+ * `select`, `order by`, `follow`, `limit`/`offset`) and every `Expr` — and flags:
10
+ *
11
+ * • any `call` node (a function or method: unknown name, bad regex, wrong target…)
12
+ * • any nested block whose consumer is `single` (raises on 0 or >1 rows)
13
+ * • any `^`-escaped name: an `outer` reference or a `^name:` lift
14
+ * • a bare reserved docs basename (`id path updated_at content_hash body`): as
15
+ * an `ident` at the ROOT scope when the target is `docs`, or as `doc.<name>`
16
+ * on any target at any depth (the reach-through row is a doc)
17
+ *
18
+ * A residual made only of comparisons, logical operators, `in`/ranges, `!`,
19
+ * literals, bindings and plain reads (including exists/none/count/collect/first
20
+ * blocks of the same) cannot raise, so the pushed conjuncts are kept.
21
+ */
22
+ export declare function residualMayRaise(where: Where | null, target: Target): boolean;
4
23
  export declare class SQLiteQueryPlanner implements QueryPlanner {
5
24
  private store;
6
25
  private repoId;
@@ -7,10 +7,16 @@
7
7
  //
8
8
  // This is deliberately conservative: only simple top-level target scans (no
9
9
  // `follow`, no source re-projection) with at least one pushable scalar conjunct
10
- // are planned; everything else declines to a full in-memory run.
10
+ // are planned; everything else declines to a full in-memory run. One more
11
+ // decline keeps the planned path invisible (spec/surface §1 decline (c)): a
12
+ // pushed conjunct that empties the scan would also hide an OQX eval error a
13
+ // residual conjunct raises in memory (`path == "x" && $path == "nope.md"` was
14
+ // `[]` planned, `filter_invalid` in memory), so when any residual conjunct could
15
+ // raise — a function or method call, a `single` block, a `^`-escaped name, or a
16
+ // bare reserved docs basename — the WHOLE query runs unplanned.
11
17
  import { ROWS_ROOT, partitionPushable, residualQuery } from "@omgbase/oqx";
12
18
  import { makeStoreContext, tagRows } from "./context.js";
13
- import { translatePredicate } from "./sql/translate.js";
19
+ import { translatePredicate, RESERVED_DOC_BASENAMES } from "./sql/translate.js";
14
20
  const ALIAS = {
15
21
  docs: { self: "d", doc: "d" },
16
22
  blocks: { self: "b", doc: "d" },
@@ -56,6 +62,91 @@ function rootTarget(source) {
56
62
  }
57
63
  return null;
58
64
  }
65
+ // ---- the residual walk (decline (c)) -----------------------------------------
66
+ /**
67
+ * True when evaluating `where` in memory could raise an OQX error the pushed
68
+ * conjuncts might hide by emptying the scan. The walk is generic over the whole
69
+ * residual tree — every `Where` node, every nested block (its `from`, `where`,
70
+ * `select`, `order by`, `follow`, `limit`/`offset`) and every `Expr` — and flags:
71
+ *
72
+ * • any `call` node (a function or method: unknown name, bad regex, wrong target…)
73
+ * • any nested block whose consumer is `single` (raises on 0 or >1 rows)
74
+ * • any `^`-escaped name: an `outer` reference or a `^name:` lift
75
+ * • a bare reserved docs basename (`id path updated_at content_hash body`): as
76
+ * an `ident` at the ROOT scope when the target is `docs`, or as `doc.<name>`
77
+ * on any target at any depth (the reach-through row is a doc)
78
+ *
79
+ * A residual made only of comparisons, logical operators, `in`/ranges, `!`,
80
+ * literals, bindings and plain reads (including exists/none/count/collect/first
81
+ * blocks of the same) cannot raise, so the pushed conjuncts are kept.
82
+ */
83
+ export function residualMayRaise(where, target) {
84
+ return where !== null && whereMayRaise(where, target, true);
85
+ }
86
+ function whereMayRaise(w, target, root) {
87
+ switch (w.kind) {
88
+ case "and":
89
+ case "or": return w.parts.some((p) => whereMayRaise(p, target, root));
90
+ case "not": return whereMayRaise(w.expr, target, root);
91
+ case "scalar": return exprMayRaise(w.expr, target, root);
92
+ case "op": return opMayRaise(w, target, root);
93
+ }
94
+ }
95
+ function opMayRaise(op, target, root) {
96
+ if (op.op === "single")
97
+ return true;
98
+ return exprMayRaise(op.receiver, target, root) || subMayRaise(op.sub, target);
99
+ }
100
+ // Inside a block the rows are a different scope (a relation's rows), so a bare
101
+ // reserved name is an ordinary read there — only `doc.<reserved>` still raises.
102
+ function subMayRaise(s, target) {
103
+ if (s.from.some((e) => exprMayRaise(e, target, false)))
104
+ return true;
105
+ if (s.where && whereMayRaise(s.where, target, false))
106
+ return true;
107
+ for (const item of s.select) {
108
+ if (item.kind === "field") {
109
+ if (item.lift > 0 || exprMayRaise(item.expr, target, false))
110
+ return true;
111
+ }
112
+ else if (opMayRaise(item.op, target, false))
113
+ return true;
114
+ }
115
+ if (s.orderBy?.some((o) => exprMayRaise(o.expr, target, false)))
116
+ return true;
117
+ if (s.follow && followMayRaise(s.follow, target))
118
+ return true;
119
+ if (s.limit && exprMayRaise(s.limit, target, false))
120
+ return true;
121
+ if (s.offset && exprMayRaise(s.offset, target, false))
122
+ return true;
123
+ return false;
124
+ }
125
+ function followMayRaise(f, target) {
126
+ return [f.receiver, f.where, f.frontier, f.by].some((e) => e !== null && exprMayRaise(e, target, false));
127
+ }
128
+ function exprMayRaise(e, target, root) {
129
+ switch (e.kind) {
130
+ case "call": return true;
131
+ case "outer": return true;
132
+ case "ident": return root && target === "docs" && RESERVED_DOC_BASENAMES.has(e.name);
133
+ case "member":
134
+ if (e.recv.kind === "ident" && e.recv.name === "doc" && RESERVED_DOC_BASENAMES.has(e.name))
135
+ return true;
136
+ return exprMayRaise(e.recv, target, root);
137
+ case "index": return exprMayRaise(e.recv, target, root) || exprMayRaise(e.index, target, root);
138
+ case "unary": return exprMayRaise(e.expr, target, root);
139
+ case "binary":
140
+ case "logical":
141
+ case "in":
142
+ return exprMayRaise(e.left, target, root) || exprMayRaise(e.right, target, root);
143
+ case "range":
144
+ return (e.lo !== null && exprMayRaise(e.lo, target, root)) || (e.hi !== null && exprMayRaise(e.hi, target, root));
145
+ case "lit":
146
+ case "binding": return false;
147
+ }
148
+ }
149
+ // ---- the planner -------------------------------------------------------------
59
150
  export class SQLiteQueryPlanner {
60
151
  store;
61
152
  repoId;
@@ -76,6 +167,8 @@ export class SQLiteQueryPlanner {
76
167
  const { pushed, residual } = partitionPushable(query.where, (e) => translatePredicate(e, ctx) !== null);
77
168
  if (pushed.length === 0)
78
169
  return null; // nothing to push — let the engine do it all
170
+ if (residualMayRaise(residual, target))
171
+ return null; // decline (c): run unplanned
79
172
  const frags = pushed.map((e) => translatePredicate(e, ctx));
80
173
  const g = guards(target, this.repoId);
81
174
  const whereSql = [g.sql, ...frags.map((f) => `(${f.sql})`)].join(" AND ");
@@ -4,7 +4,7 @@
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
6
  import { parse, PlannedEngine, InMemoryEngine, OqxError } from "@omgbase/oqx";
7
- import { makeStoreContext } from "./context.js";
7
+ import { makeStoreContext, rowRef } from "./context.js";
8
8
  import { SQLiteQueryPlanner } from "./planner.js";
9
9
  import { FilterInvalid } from "../search/cel/parser.js";
10
10
  import { float32ToBlob } from "../core/vec.js";
@@ -194,10 +194,35 @@ const PATH_ITEM = { kind: "field", name: "__oqx_path", expr: { kind: "ident", na
194
194
  // so it rides through id/path injection, keyset paging, and distinct as an
195
195
  // ordinary record field and is peeled off at the end.
196
196
  const VALUE_KEY = "__oqx_value";
197
+ // Rows as values (spec/surface §1.4, 1.2). The engine hands back store rows
198
+ // wherever the projection made a row a VALUE — a nested `collect { }` /
199
+ // `first { }` / `single { }` with an empty projection ("the row itself",
200
+ // spec/oqx §12), a `values` projection of `$value`/`$self`, a field bound to a
201
+ // row — and a store row is not a wire shape (column names, `attrs` as a JSON
202
+ // string, the `__path` join column, Buffers). Walk the projected value and
203
+ // render every tagged row as `{ id, path }`; everything else passes through
204
+ // untouched (plain records and arrays are descended, scalars kept). Hits are
205
+ // records the engine built from the injected id/path items, never rows.
206
+ function renderValue(v) {
207
+ if (v === null || typeof v !== "object")
208
+ return v;
209
+ const ref = rowRef(v);
210
+ if (ref)
211
+ return ref;
212
+ if (Array.isArray(v))
213
+ return v.map(renderValue);
214
+ const proto = Object.getPrototypeOf(v);
215
+ if (proto !== Object.prototype && proto !== null)
216
+ return v; // Buffer, Map, … — not a record
217
+ const out = {};
218
+ for (const [k, x] of Object.entries(v))
219
+ out[k] = renderValue(x);
220
+ return out;
221
+ }
197
222
  function toHit(row) {
198
223
  const o = row;
199
224
  const { __oqx_id, __oqx_path, ...rest } = o;
200
- return { id: String(__oqx_id), path: String(__oqx_path ?? ""), ...rest };
225
+ return { id: String(__oqx_id), path: String(__oqx_path ?? ""), ...renderValue(rest) };
201
226
  }
202
227
  // Top-level `select distinct`: dedup hits by their USER projection (every field
203
228
  // except the injected id/path), keeping the first (so the retained hit carries a
@@ -19,9 +19,79 @@ export interface Frag {
19
19
  sql: string;
20
20
  params: unknown[];
21
21
  }
22
+ /**
23
+ * What a translated operand denotes, for the comparison matrix (spec/surface §1
24
+ * decline (a)):
25
+ *
26
+ * text a string literal/binding, a text column or intrinsic, `.lower()`/`.upper()`
27
+ * int an integer-typed intrinsic column (blocks `$ordinal`, `$depth`)
28
+ * num a number literal/binding
29
+ * bool a boolean literal/binding
30
+ * null a null (or undefined) literal/binding
31
+ * json a JSON read: an `attrs` path or a bare attribute name on blocks/nodes (json_extract)
32
+ * prop a document property read: a bare key on docs or `doc.<k>` (the scalar-in-scope subquery)
33
+ */
34
+ export type OperandKind = "text" | "int" | "num" | "bool" | "null" | "json" | "prop";
35
+ /** A value-position fragment with the kind of value it denotes. */
36
+ export interface Operand extends Frag {
37
+ kind: OperandKind;
38
+ }
39
+ /**
40
+ * Per target, the bare identifiers (and `doc.<k>` keys, via the docs set) that
41
+ * `context.ts` resolves to something other than a scalar field or property: the
42
+ * self alias, the reach-through handles, the relations, the property source
43
+ * handles and (blocks/nodes) the whole `attrs` object. A comparison naming one of
44
+ * these is declined (spec/surface §1 decline (b)) — read as a property it would
45
+ * be `NULL`, where in memory the value is a row, an array or an object.
46
+ * `translate.test.ts` asserts this set against the store context.
47
+ */
48
+ export declare const NON_PROPERTY_NAMES: Readonly<Record<Target, ReadonlySet<string>>>;
49
+ /** docs intrinsics whose BARE (non-$) form is a loud error in-memory (10 §2) — not
50
+ * pushable, so the residual raises it (and the planner keeps the whole query
51
+ * unplanned when one is residual: spec/surface §1 decline (c)). */
52
+ export declare const RESERVED_DOC_BASENAMES: ReadonlySet<string>;
22
53
  /** Translate an expression used as a VALUE (comparison operand, method receiver,
23
- * function argument) to a SQL scalar. Returns null if not faithfully translatable. */
54
+ * function argument) to a SQL scalar with its operand kind. Returns null if not
55
+ * faithfully translatable. */
56
+ export declare function translateOperand(e: Expr, ctx: TranslateCtx): Operand | null;
57
+ /** `translateOperand` without the kind — the plain fragment. */
24
58
  export declare function translateValue(e: Expr, ctx: TranslateCtx): Frag | null;
59
+ /**
60
+ * The comparison rule (spec/surface §1, decline (a)), stated positively: a
61
+ * comparison is pushed only when its two operand kinds are provably compared
62
+ * the same way by SQLite and by the in-memory `equals`/`compare`.
63
+ *
64
+ * Equality (`==`, `!=`) pushes iff
65
+ * • one operand is text — SQLite's typed `IS` and strict `===` agree that a
66
+ * string equals nothing but an equal string (`5 IS '5'` is false);
67
+ * • or one operand is null and the other is NOT a property read — the same
68
+ * absence both ways, except that a list-valued or nested property has no
69
+ * scalar row (SQL NULL) where in memory the value is an array or object;
70
+ * • or both operands are numeric (int or num) — an exact integer comparison.
71
+ * Relational (`<`, `<=`, `>`, `>=`) pushes iff both operands are text or both
72
+ * are numeric.
73
+ * Every other pair declines: SQLite sees JSON `true` and `1`, `val_bool` and
74
+ * `val_num` alike (`checked == 1`, `$ordinal == checked`, `verified == 1`),
75
+ * orders every integer before every text (`$ordinal < "3"`, `level < "x"`,
76
+ * `$path > 5`), and two JSON or property reads carry no type at plan time
77
+ * (`attrs.a == attrs.b`, `era == stages`).
78
+ *
79
+ * The resulting grid (P = pushed, D = declined; symmetric) for `==` / `!=`:
80
+ *
81
+ * text int num bool null json prop
82
+ * text P P P P P P P
83
+ * int P P P D P D D
84
+ * num P P P D P D D
85
+ * bool P D D D P D D
86
+ * null P P P P P P D
87
+ * json P D D D P D D
88
+ * prop P D D D D D D
89
+ *
90
+ * and for `<` `<=` `>` `>=` only text×text, int×int, int×num, num×num push.
91
+ */
92
+ export declare function comparable(op: string, a: OperandKind, b: OperandKind): boolean;
93
+ /** The complement of `comparable` — kept for readers of the earlier decline-list form. */
94
+ export declare function declinedPair(a: OperandKind, b: OperandKind, op?: string): boolean;
25
95
  /** Translate an expression used as a boolean PREDICATE to a SQL boolean, or null
26
96
  * if it cannot be pushed faithfully. Only positive, AND-safe forms are handled. */
27
97
  export declare function translatePredicate(e: Expr, ctx: TranslateCtx): Frag | null;
@@ -9,7 +9,7 @@
9
9
  // The cardinal rule is FIDELITY, not cleverness: the SQL a fragment emits MUST
10
10
  // evaluate to the same result as `@omgbase/oqx`'s in-memory `semantics.ts` for
11
11
  // every input, because the differential conformance suite runs each query BOTH
12
- // ways and asserts equality. Two consequences drive the design:
12
+ // ways and asserts equality. Three consequences drive the design:
13
13
  //
14
14
  // • oqx-js string ops are CASE-SENSITIVE, but SQLite `LIKE` is case-insensitive
15
15
  // for ASCII. So `startsWith`/`contains`/`endsWith` translate to
@@ -22,59 +22,86 @@
22
22
  // `equals(a,b)` including the both-absent and negation cases, while still
23
23
  // honoring SQLite's typed comparison (`5 IS '5'` is false, matching strict
24
24
  // `5 === "5"`).
25
+ // • SQLite has no boolean and no array/object scalars: JSON `true` reads as
26
+ // `1`, `val_bool` is 1/0, and a list-valued or nested property has no
27
+ // `card='scalar'` row (reads NULL). So a comparison is only pushed when the
28
+ // KINDS of its two operands are provably compared the same way — see
29
+ // `comparable` (the spec/surface §1 decline (a)) — and a name that is a relation, reach-through
30
+ // or source handle is never read as a property (decline (b),
31
+ // `NON_PROPERTY_NAMES`).
25
32
  //
26
33
  // Only forms that are faithful in a POSITIVE, AND-composed context are
27
34
  // translated (that is the only context `partitionPushable` pushes into). `||`,
28
35
  // `!`, `in`, `matches` (needs a regexp UDF), and bare content-property routing
29
36
  // are deliberately declined here and left residual for now.
37
+ // ---- names that are never property reads ------------------------------------
38
+ /**
39
+ * Per target, the bare identifiers (and `doc.<k>` keys, via the docs set) that
40
+ * `context.ts` resolves to something other than a scalar field or property: the
41
+ * self alias, the reach-through handles, the relations, the property source
42
+ * handles and (blocks/nodes) the whole `attrs` object. A comparison naming one of
43
+ * these is declined (spec/surface §1 decline (b)) — read as a property it would
44
+ * be `NULL`, where in memory the value is a row, an array or an object.
45
+ * `translate.test.ts` asserts this set against the store context.
46
+ */
47
+ export const NON_PROPERTY_NAMES = {
48
+ docs: new Set(["doc", "nodes", "blocks", "out", "in", "out_edges", "in_edges", "frontmatter", "inline"]),
49
+ blocks: new Set(["block", "doc", "children", "nodes", "out_edges", "section", "attrs"]),
50
+ nodes: new Set(["section", "doc", "block", "blocks", "subsections", "children", "attrs"]),
51
+ edges: new Set(["doc"]),
52
+ };
30
53
  // ---- intrinsics -------------------------------------------------------------
31
- // A `$`-namespaced intrinsic → a param-free SQL scalar, per target. Anything not
32
- // mapped (e.g. docs `$body`, reconstructed; `$title`/`$tags`, computed) returns
33
- // null → residual. `$updated_at`/`$dst_path`/`$dst_uri` are correlated subqueries.
54
+ // A `$`-namespaced intrinsic → a param-free SQL scalar, per target, with its kind.
55
+ // Anything not mapped (e.g. docs `$body`, reconstructed; `$title`/`$tags`,
56
+ // computed) returns null → residual. `$updated_at`/`$dst_path`/`$dst_uri` are
57
+ // correlated subqueries.
34
58
  function intrinsicSql(name, ctx) {
35
59
  const { self, doc, target } = ctx;
60
+ const text = (sql) => ({ sql, params: [], kind: "text" });
61
+ const int = (sql) => ({ sql, params: [], kind: "int" });
36
62
  if (target === "docs")
37
63
  switch (name) {
38
- case "$id": return `${self}.doc_id`;
39
- case "$path": return `${doc}.path`;
40
- case "$content_hash": return `lower(hex(${self}.file_hash))`;
41
- case "$updated_at": return `(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ${self}.current_rev)`;
64
+ case "$id": return text(`${self}.doc_id`);
65
+ case "$path": return text(`${doc}.path`);
66
+ case "$content_hash": return text(`lower(hex(${self}.file_hash))`);
67
+ 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)`);
42
68
  }
43
69
  else if (target === "blocks")
44
70
  switch (name) {
45
- case "$id": return `${self}.block_id`;
46
- case "$doc": return `${self}.doc_id`;
47
- case "$path": return `${doc}.path`;
48
- case "$ordinal": return `${self}.ordinal`;
49
- case "$depth": return `${self}.depth`;
50
- case "$body": return `${self}.text`;
51
- case "$content_hash": return `lower(hex(${self}.raw_hash))`;
71
+ case "$id": return text(`${self}.block_id`);
72
+ case "$doc": return text(`${self}.doc_id`);
73
+ case "$path": return text(`${doc}.path`);
74
+ case "$ordinal": return int(`${self}.ordinal`);
75
+ case "$depth": return int(`${self}.depth`);
76
+ case "$body": return text(`${self}.text`);
77
+ case "$content_hash": return text(`lower(hex(${self}.raw_hash))`);
52
78
  }
53
79
  else if (target === "nodes")
54
80
  switch (name) {
55
81
  case "$id":
56
- case "$node_id": return `${self}.node_id`;
57
- case "$doc_id": return `${self}.doc_id`;
58
- case "$block_id": return `${self}.block_id`;
59
- case "$path": return `${doc}.path`;
82
+ case "$node_id": return text(`${self}.node_id`);
83
+ case "$doc_id": return text(`${self}.doc_id`);
84
+ case "$block_id": return text(`${self}.block_id`);
85
+ case "$path": return text(`${doc}.path`);
60
86
  }
61
87
  else if (target === "edges")
62
88
  switch (name) {
63
- case "$id": return `${self}.edge_id`;
64
- case "$src": return `${self}.src_doc`;
65
- case "$dst": return `${self}.dst_node`;
66
- case "$src_block": return `${self}.src_block`;
67
- case "$via": return `${self}.via_node`;
68
- case "$from_commit": return `${self}.from_commit`;
69
- case "$path": return `${doc}.path`;
70
- case "$dst_path": return `(SELECT dd.path FROM docs dd WHERE dd.doc_id = ${self}.dst_node)`;
71
- case "$dst_uri": return `(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = ${self}.dst_node)`;
89
+ case "$id": return text(`${self}.edge_id`);
90
+ case "$src": return text(`${self}.src_doc`);
91
+ case "$dst": return text(`${self}.dst_node`);
92
+ case "$src_block": return text(`${self}.src_block`);
93
+ case "$via": return text(`${self}.via_node`);
94
+ case "$from_commit": return text(`${self}.from_commit`);
95
+ case "$path": return text(`${doc}.path`);
96
+ case "$dst_path": return text(`(SELECT dd.path FROM docs dd WHERE dd.doc_id = ${self}.dst_node)`);
97
+ case "$dst_uri": return text(`(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = ${self}.dst_node)`);
72
98
  }
73
99
  return null;
74
100
  }
75
- // docs intrinsics whose BARE (non-$) form is a loud error in-memory (10 §2) — not
76
- // pushable, so the residual raises it.
77
- const RESERVED_DOC_BASENAMES = new Set(["id", "path", "updated_at", "content_hash", "body"]);
101
+ /** docs intrinsics whose BARE (non-$) form is a loud error in-memory (10 §2) — not
102
+ * pushable, so the residual raises it (and the planner keeps the whole query
103
+ * unplanned when one is residual: spec/surface §1 decline (c)). */
104
+ export const RESERVED_DOC_BASENAMES = new Set(["id", "path", "updated_at", "content_hash", "body"]);
78
105
  const SEG = /^[A-Za-z_][A-Za-z0-9_]*$/; // injection-safe inlined identifier
79
106
  // A single-valued document property (the CEL scalar-in-scope rule): the scalar
80
107
  // value only when the key has exactly one row in scope and it is card='scalar',
@@ -82,23 +109,33 @@ const SEG = /^[A-Za-z_][A-Za-z0-9_]*$/; // injection-safe inlined identifier
82
109
  function propScalar(docAlias, key) {
83
110
  if (!SEG.test(key))
84
111
  return null;
85
- return `(SELECT COALESCE(p.val_text, p.val_num, p.val_bool) FROM properties p
112
+ return {
113
+ sql: `(SELECT COALESCE(p.val_text, p.val_num, p.val_bool) FROM properties p
86
114
  WHERE p.doc_id = ${docAlias}.doc_id AND p.key = '${key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL
87
115
  AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = ${docAlias}.doc_id AND p2.key = '${key}' AND p2.deleted_commit IS NULL) = 1
88
- LIMIT 1)`;
116
+ LIMIT 1)`,
117
+ params: [],
118
+ kind: "prop",
119
+ };
89
120
  }
90
121
  // json_extract path from validated segments, or null if any segment is unsafe.
91
122
  function jsonExtract(col, segs) {
92
123
  if (segs.some((s) => !SEG.test(s)))
93
124
  return null;
94
- return `json_extract(${col}, '$.${segs.join(".")}')`;
125
+ return { sql: `json_extract(${col}, '$.${segs.join(".")}')`, params: [], kind: "json" };
95
126
  }
96
- // better-sqlite3 binds only number/string/bigint/buffer/null — coerce booleans
97
- // to 1/0 (matching how json_extract surfaces JSON booleans, so `attrs.b == true`
98
- // compares against `1`). Non-bindable values are left as-is (they only reach a
99
- // bound `?` for lit/binding operands, which are scalars in practice).
100
- function bindable(v) {
101
- return typeof v === "boolean" ? (v ? 1 : 0) : v;
127
+ // A literal or binding value as a bound `?` with its kind. better-sqlite3 binds
128
+ // only number/string/bigint/buffer/null — booleans are coerced to 1/0 (how
129
+ // json_extract surfaces JSON booleans); anything else (an array binding, say) is
130
+ // not a scalar and declines.
131
+ function constOperand(v) {
132
+ switch (typeof v) {
133
+ case "string": return { sql: "?", params: [v], kind: "text" };
134
+ case "number": return { sql: "?", params: [v], kind: "num" };
135
+ case "boolean": return { sql: "?", params: [v ? 1 : 0], kind: "bool" };
136
+ case "undefined": return { sql: "?", params: [null], kind: "null" };
137
+ default: return v === null ? { sql: "?", params: [null], kind: "null" } : null;
138
+ }
102
139
  }
103
140
  // The dotted `attrs.a.b` / `doc.x` receiver chain as segments, or null if it is
104
141
  // not a plain identifier navigation.
@@ -113,46 +150,47 @@ function memberSegments(e) {
113
150
  }
114
151
  // ---- value position ---------------------------------------------------------
115
152
  /** Translate an expression used as a VALUE (comparison operand, method receiver,
116
- * function argument) to a SQL scalar. Returns null if not faithfully translatable. */
117
- export function translateValue(e, ctx) {
153
+ * function argument) to a SQL scalar with its operand kind. Returns null if not
154
+ * faithfully translatable. */
155
+ export function translateOperand(e, ctx) {
118
156
  const { self, doc, target } = ctx;
157
+ const text = (sql) => ({ sql, params: [], kind: "text" });
119
158
  switch (e.kind) {
120
159
  case "lit":
121
- return { sql: "?", params: [bindable(e.value)] };
160
+ return constOperand(e.value);
122
161
  case "binding":
123
- return { sql: "?", params: [bindable(ctx.params[e.index])] };
162
+ return constOperand(ctx.params[e.index]);
124
163
  case "ident": {
125
- if (e.name.startsWith("$")) {
126
- const sql = intrinsicSql(e.name, ctx);
127
- return sql ? { sql, params: [] } : null;
128
- }
164
+ if (e.name.startsWith("$"))
165
+ return intrinsicSql(e.name, ctx);
166
+ // A relation, reach-through, source handle or the whole attrs object is not a
167
+ // scalar read (decline (b)).
168
+ if (NON_PROPERTY_NAMES[target].has(e.name))
169
+ return null;
129
170
  // Bare field per target.
130
171
  if (target === "docs") {
131
172
  if (e.name === "format")
132
- return { sql: `${self}.format`, params: [] }; // a column, not a property
173
+ return text(`${self}.format`); // a column, not a property
133
174
  if (RESERVED_DOC_BASENAMES.has(e.name))
134
175
  return null; // residual raises the guard
135
- const sql = propScalar(doc, e.name);
136
- return sql ? { sql, params: [] } : null;
176
+ return propScalar(doc, e.name);
137
177
  }
138
178
  if (target === "blocks") {
139
179
  if (e.name === "type" || e.name === "text")
140
- return { sql: `${self}.${e.name}`, params: [] };
180
+ return text(`${self}.${e.name}`);
141
181
  // Bare non-structural identifier flattens into attrs — same pushdown as
142
182
  // the `attrs.<k>` member form (json_extract), so `checked` == `attrs.checked`.
143
- const sql = jsonExtract(`${self}.attrs`, [e.name]);
144
- return sql ? { sql, params: [] } : null;
183
+ return jsonExtract(`${self}.attrs`, [e.name]);
145
184
  }
146
185
  if (target === "nodes") {
147
186
  if (e.name === "kind" || e.name === "name" || e.name === "value")
148
- return { sql: `${self}.${e.name}`, params: [] };
187
+ return text(`${self}.${e.name}`);
149
188
  // Bare non-structural identifier flattens into attrs (see blocks above).
150
- const sql = jsonExtract(`${self}.attrs`, [e.name]);
151
- return sql ? { sql, params: [] } : null;
189
+ return jsonExtract(`${self}.attrs`, [e.name]);
152
190
  }
153
191
  if (target === "edges") {
154
192
  if (["predicate", "provenance", "dst_kind", "anchor", "src_field"].includes(e.name))
155
- return { sql: `${self}.${e.name}`, params: [] };
193
+ return text(`${self}.${e.name}`);
156
194
  return null;
157
195
  }
158
196
  return null;
@@ -164,8 +202,7 @@ export function translateValue(e, ctx) {
164
202
  const [head, ...rest] = segs;
165
203
  // attrs.<path> → json_extract on the row's attrs (blocks/nodes).
166
204
  if (head === "attrs" && (target === "blocks" || target === "nodes")) {
167
- const sql = jsonExtract(`${self}.attrs`, rest);
168
- return sql ? { sql, params: [] } : null;
205
+ return jsonExtract(`${self}.attrs`, rest);
169
206
  }
170
207
  // doc.<x> reach-through — the owning doc (alias `doc`, which is `d`). On the
171
208
  // docs target `doc` is the row itself; either way it resolves against `doc`.
@@ -174,29 +211,31 @@ export function translateValue(e, ctx) {
174
211
  return null;
175
212
  const k = rest[0];
176
213
  if (k === "$path")
177
- return { sql: `${doc}.path`, params: [] };
214
+ return text(`${doc}.path`);
178
215
  if (k === "format")
179
- return { sql: `${doc}.format`, params: [] };
216
+ return text(`${doc}.format`);
180
217
  if (k.startsWith("$"))
181
218
  return null;
182
219
  if (RESERVED_DOC_BASENAMES.has(k))
183
220
  return null;
184
- const sql = propScalar(doc, k);
185
- return sql ? { sql, params: [] } : null;
221
+ if (NON_PROPERTY_NAMES.docs.has(k))
222
+ return null; // `doc.nodes`, `doc.frontmatter`, … (decline (b))
223
+ return propScalar(doc, k);
186
224
  }
187
225
  // block.type / block.text reach-through from a node.
188
226
  if (head === "block" && target === "nodes" && rest.length === 1 && (rest[0] === "type" || rest[0] === "text")) {
189
- return { sql: `(SELECT bb.${rest[0]} FROM blocks bb WHERE bb.block_id = ${self}.block_id)`, params: [] };
227
+ return text(`(SELECT bb.${rest[0]} FROM blocks bb WHERE bb.block_id = ${self}.block_id)`);
190
228
  }
191
229
  return null;
192
230
  }
193
231
  case "call": {
194
- // `.lower()` / `.upper()` are the value-position string methods.
232
+ // `.lower()` / `.upper()` are the value-position string methods; their
233
+ // output is text whatever the receiver.
195
234
  if (e.recv !== null && e.args.length === 0 && (e.name === "lower" || e.name === "upper")) {
196
- const recv = translateValue(e.recv, ctx);
235
+ const recv = translateOperand(e.recv, ctx);
197
236
  if (!recv)
198
237
  return null;
199
- return { sql: `${e.name}(${recv.sql})`, params: recv.params };
238
+ return { sql: `${e.name}(${recv.sql})`, params: recv.params, kind: "text" };
200
239
  }
201
240
  return null;
202
241
  }
@@ -204,6 +243,11 @@ export function translateValue(e, ctx) {
204
243
  return null;
205
244
  }
206
245
  }
246
+ /** `translateOperand` without the kind — the plain fragment. */
247
+ export function translateValue(e, ctx) {
248
+ const o = translateOperand(e, ctx);
249
+ return o ? { sql: o.sql, params: o.params } : null;
250
+ }
207
251
  // ---- predicate position -----------------------------------------------------
208
252
  const IS_OP = {
209
253
  "==": "IS",
@@ -213,6 +257,55 @@ const IS_OP = {
213
257
  ">": ">",
214
258
  ">=": ">=",
215
259
  };
260
+ /**
261
+ * The comparison rule (spec/surface §1, decline (a)), stated positively: a
262
+ * comparison is pushed only when its two operand kinds are provably compared
263
+ * the same way by SQLite and by the in-memory `equals`/`compare`.
264
+ *
265
+ * Equality (`==`, `!=`) pushes iff
266
+ * • one operand is text — SQLite's typed `IS` and strict `===` agree that a
267
+ * string equals nothing but an equal string (`5 IS '5'` is false);
268
+ * • or one operand is null and the other is NOT a property read — the same
269
+ * absence both ways, except that a list-valued or nested property has no
270
+ * scalar row (SQL NULL) where in memory the value is an array or object;
271
+ * • or both operands are numeric (int or num) — an exact integer comparison.
272
+ * Relational (`<`, `<=`, `>`, `>=`) pushes iff both operands are text or both
273
+ * are numeric.
274
+ * Every other pair declines: SQLite sees JSON `true` and `1`, `val_bool` and
275
+ * `val_num` alike (`checked == 1`, `$ordinal == checked`, `verified == 1`),
276
+ * orders every integer before every text (`$ordinal < "3"`, `level < "x"`,
277
+ * `$path > 5`), and two JSON or property reads carry no type at plan time
278
+ * (`attrs.a == attrs.b`, `era == stages`).
279
+ *
280
+ * The resulting grid (P = pushed, D = declined; symmetric) for `==` / `!=`:
281
+ *
282
+ * text int num bool null json prop
283
+ * text P P P P P P P
284
+ * int P P P D P D D
285
+ * num P P P D P D D
286
+ * bool P D D D P D D
287
+ * null P P P P P P D
288
+ * json P D D D P D D
289
+ * prop P D D D D D D
290
+ *
291
+ * and for `<` `<=` `>` `>=` only text×text, int×int, int×num, num×num push.
292
+ */
293
+ export function comparable(op, a, b) {
294
+ const numeric = (k) => k === "int" || k === "num";
295
+ const bothNumeric = numeric(a) && numeric(b);
296
+ if (op === "==" || op === "!=") {
297
+ if (a === "text" || b === "text")
298
+ return true;
299
+ if ((a === "null" && b !== "prop") || (b === "null" && a !== "prop"))
300
+ return true;
301
+ return bothNumeric;
302
+ }
303
+ return (a === "text" && b === "text") || bothNumeric;
304
+ }
305
+ /** The complement of `comparable` — kept for readers of the earlier decline-list form. */
306
+ export function declinedPair(a, b, op = "==") {
307
+ return !comparable(op, a, b);
308
+ }
216
309
  /** Translate an expression used as a boolean PREDICATE to a SQL boolean, or null
217
310
  * if it cannot be pushed faithfully. Only positive, AND-safe forms are handled. */
218
311
  export function translatePredicate(e, ctx) {
@@ -236,10 +329,12 @@ function translateComparison(e, ctx) {
236
329
  const op = IS_OP[e.op];
237
330
  if (!op)
238
331
  return null; // arithmetic operator in predicate position → residual
239
- const l = translateValue(e.left, ctx);
240
- const r = translateValue(e.right, ctx);
332
+ const l = translateOperand(e.left, ctx);
333
+ const r = translateOperand(e.right, ctx);
241
334
  if (!l || !r)
242
335
  return null;
336
+ if (!comparable(e.op, l.kind, r.kind))
337
+ return null; // decline (a): the positive comparison rule
243
338
  // `==`/`!=` → null-safe IS / IS NOT (absence-normalized equality, faithful in
244
339
  // any context). Relational ops → plain SQL: a NULL operand yields NULL, which
245
340
  // is excluded in the positive AND context these fragments are pushed into,