@omgbase/core 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/src/core/ids.d.ts +34 -3
  2. package/dist/src/core/ids.js +79 -6
  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 +47 -7
  7. package/dist/src/core/store/fts.js +73 -9
  8. package/dist/src/core/store/rebuild.js +5 -4
  9. package/dist/src/core/store/store.d.ts +4 -0
  10. package/dist/src/core/store/store.js +40 -1
  11. package/dist/src/format/index.d.ts +12 -1
  12. package/dist/src/format/index.js +14 -1
  13. package/dist/src/format/markdown.d.ts +7 -0
  14. package/dist/src/format/markdown.js +7 -0
  15. package/dist/src/graph/history.d.ts +21 -0
  16. package/dist/src/graph/history.js +48 -6
  17. package/dist/src/graph/links.d.ts +2 -0
  18. package/dist/src/graph/links.js +22 -11
  19. package/dist/src/index.d.ts +8 -6
  20. package/dist/src/index.js +7 -5
  21. package/dist/src/mcp/errors.d.ts +8 -0
  22. package/dist/src/mcp/errors.js +17 -3
  23. package/dist/src/mcp/server.js +50 -57
  24. package/dist/src/mutate/apply.js +12 -4
  25. package/dist/src/mutate/doc-store.js +4 -1
  26. package/dist/src/mutate/docs.d.ts +19 -0
  27. package/dist/src/mutate/docs.js +64 -16
  28. package/dist/src/mutate/ops.js +25 -13
  29. package/dist/src/oqx-js/context.d.ts +11 -0
  30. package/dist/src/oqx-js/context.js +16 -0
  31. package/dist/src/oqx-js/planner.d.ts +20 -1
  32. package/dist/src/oqx-js/planner.js +95 -2
  33. package/dist/src/oqx-js/run.js +27 -2
  34. package/dist/src/oqx-js/sql/translate.d.ts +94 -1
  35. package/dist/src/oqx-js/sql/translate.js +263 -79
  36. package/dist/src/search/tasks.d.ts +15 -1
  37. package/dist/src/search/tasks.js +31 -3
  38. package/dist/src/sync/admin.d.ts +13 -0
  39. package/dist/src/sync/admin.js +11 -0
  40. package/dist/src/sync/external-source.js +20 -7
  41. package/dist/src/sync/plugin.d.ts +30 -5
  42. package/dist/src/sync/plugin.js +13 -1
  43. package/dist/src/sync/watcher.d.ts +2 -0
  44. package/dist/src/sync/watcher.js +10 -2
  45. package/package.json +1 -1
@@ -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,83 +22,133 @@
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`). A bool or num literal against a JSON or property
32
+ // read is a TYPED push (the 1.2 patch): the stored type is tested in SQL
33
+ // before the value (`json_type(x) = 'true'`, `p.type = 'number' AND
34
+ // p.val_num >= ?`) and the whole test is wrapped `(…) IS 1` / `IS NOT 1`,
35
+ // so an absent or differently typed value compares as in memory —
36
+ // unequal, never ordered. See `typedComparison`.
25
37
  //
26
38
  // Only forms that are faithful in a POSITIVE, AND-composed context are
27
39
  // translated (that is the only context `partitionPushable` pushes into). `||`,
28
40
  // `!`, `in`, `matches` (needs a regexp UDF), and bare content-property routing
29
41
  // are deliberately declined here and left residual for now.
42
+ // ---- names that are never property reads ------------------------------------
43
+ /**
44
+ * Per target, the bare identifiers (and `doc.<k>` keys, via the docs set) that
45
+ * `context.ts` resolves to something other than a scalar field or property: the
46
+ * self alias, the reach-through handles, the relations, the property source
47
+ * handles and (blocks/nodes) the whole `attrs` object. A comparison naming one of
48
+ * these is declined (spec/surface §1 decline (b)) — read as a property it would
49
+ * be `NULL`, where in memory the value is a row, an array or an object.
50
+ * `translate.test.ts` asserts this set against the store context.
51
+ */
52
+ export const NON_PROPERTY_NAMES = {
53
+ docs: new Set(["doc", "nodes", "blocks", "out", "in", "out_edges", "in_edges", "frontmatter", "inline"]),
54
+ blocks: new Set(["block", "doc", "children", "nodes", "out_edges", "section", "attrs"]),
55
+ nodes: new Set(["section", "doc", "block", "blocks", "subsections", "children", "attrs"]),
56
+ edges: new Set(["doc"]),
57
+ };
30
58
  // ---- 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.
59
+ // A `$`-namespaced intrinsic → a param-free SQL scalar, per target, with its kind.
60
+ // Anything not mapped (e.g. docs `$body`, reconstructed; `$title`/`$tags`,
61
+ // computed) returns null → residual. `$updated_at`/`$dst_path`/`$dst_uri` are
62
+ // correlated subqueries.
34
63
  function intrinsicSql(name, ctx) {
35
64
  const { self, doc, target } = ctx;
65
+ const text = (sql) => ({ sql, params: [], kind: "text" });
66
+ const int = (sql) => ({ sql, params: [], kind: "int" });
36
67
  if (target === "docs")
37
68
  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)`;
69
+ case "$id": return text(`${self}.doc_id`);
70
+ case "$path": return text(`${doc}.path`);
71
+ case "$content_hash": return text(`lower(hex(${self}.file_hash))`);
72
+ 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
73
  }
43
74
  else if (target === "blocks")
44
75
  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))`;
76
+ case "$id": return text(`${self}.block_id`);
77
+ case "$doc": return text(`${self}.doc_id`);
78
+ case "$path": return text(`${doc}.path`);
79
+ case "$ordinal": return int(`${self}.ordinal`);
80
+ case "$depth": return int(`${self}.depth`);
81
+ case "$body": return text(`${self}.text`);
82
+ case "$content_hash": return text(`lower(hex(${self}.raw_hash))`);
52
83
  }
53
84
  else if (target === "nodes")
54
85
  switch (name) {
55
86
  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`;
87
+ case "$node_id": return text(`${self}.node_id`);
88
+ case "$doc_id": return text(`${self}.doc_id`);
89
+ case "$block_id": return text(`${self}.block_id`);
90
+ case "$path": return text(`${doc}.path`);
60
91
  }
61
92
  else if (target === "edges")
62
93
  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)`;
94
+ case "$id": return text(`${self}.edge_id`);
95
+ case "$src": return text(`${self}.src_doc`);
96
+ case "$dst": return text(`${self}.dst_node`);
97
+ case "$src_block": return text(`${self}.src_block`);
98
+ case "$via": return text(`${self}.via_node`);
99
+ 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)`);
102
+ case "$dst_uri": return text(`(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = ${self}.dst_node)`);
72
103
  }
73
104
  return null;
74
105
  }
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"]);
106
+ /** docs intrinsics whose BARE (non-$) form is a loud error in-memory (10 §2) — not
107
+ * pushable, so the residual raises it (and the planner keeps the whole query
108
+ * unplanned when one is residual: spec/surface §1 decline (c)). */
109
+ export const RESERVED_DOC_BASENAMES = new Set(["id", "path", "updated_at", "content_hash", "body"]);
78
110
  const SEG = /^[A-Za-z_][A-Za-z0-9_]*$/; // injection-safe inlined identifier
79
- // A single-valued document property (the CEL scalar-in-scope rule): the scalar
80
- // value only when the key has exactly one row in scope and it is card='scalar',
111
+ // The single-scalar-row scope of a document property (the CEL scalar-in-scope
112
+ // rule): the row for `key` on the document only when the key has exactly one
113
+ // row in scope and it is card='scalar' — a list-valued or nested key has none.
114
+ // `select` is the projected expression over the alias `p`.
115
+ function propSubquery(docAlias, key, select) {
116
+ return `(SELECT ${select} FROM properties p
117
+ WHERE p.doc_id = ${docAlias}.doc_id AND p.key = '${key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL
118
+ AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = ${docAlias}.doc_id AND p2.key = '${key}' AND p2.deleted_commit IS NULL) = 1
119
+ LIMIT 1)`;
120
+ }
121
+ // A single-valued document property: the scalar value of its one row in scope,
81
122
  // else NULL — matching the store context's `docProp` for a scalar read.
82
123
  function propScalar(docAlias, key) {
83
124
  if (!SEG.test(key))
84
125
  return null;
85
- return `(SELECT COALESCE(p.val_text, p.val_num, p.val_bool) FROM properties p
86
- WHERE p.doc_id = ${docAlias}.doc_id AND p.key = '${key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL
87
- 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)`;
126
+ return {
127
+ sql: propSubquery(docAlias, key, "COALESCE(p.val_text, p.val_num, p.val_bool)"),
128
+ params: [],
129
+ kind: "prop",
130
+ prop: { docAlias, key },
131
+ };
89
132
  }
90
133
  // json_extract path from validated segments, or null if any segment is unsafe.
91
134
  function jsonExtract(col, segs) {
92
135
  if (segs.some((s) => !SEG.test(s)))
93
136
  return null;
94
- return `json_extract(${col}, '$.${segs.join(".")}')`;
137
+ const path = `'$.${segs.join(".")}'`;
138
+ return { sql: `json_extract(${col}, ${path})`, params: [], kind: "json", json: { col, path } };
95
139
  }
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;
140
+ // A literal or binding value as a bound `?` with its kind. better-sqlite3 binds
141
+ // only number/string/bigint/buffer/null — booleans are coerced to 1/0 (how
142
+ // json_extract and `val_bool` surface booleans); anything else (an array
143
+ // binding, say) is not a scalar and declines.
144
+ function constOperand(v) {
145
+ switch (typeof v) {
146
+ case "string": return { sql: "?", params: [v], kind: "text", value: v };
147
+ case "number": return { sql: "?", params: [v], kind: "num", value: v };
148
+ case "boolean": return { sql: "?", params: [v ? 1 : 0], kind: "bool", value: v };
149
+ case "undefined": return { sql: "?", params: [null], kind: "null", value: null };
150
+ default: return v === null ? { sql: "?", params: [null], kind: "null", value: null } : null;
151
+ }
102
152
  }
103
153
  // The dotted `attrs.a.b` / `doc.x` receiver chain as segments, or null if it is
104
154
  // not a plain identifier navigation.
@@ -113,46 +163,47 @@ function memberSegments(e) {
113
163
  }
114
164
  // ---- value position ---------------------------------------------------------
115
165
  /** 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) {
166
+ * function argument) to a SQL scalar with its operand kind. Returns null if not
167
+ * faithfully translatable. */
168
+ export function translateOperand(e, ctx) {
118
169
  const { self, doc, target } = ctx;
170
+ const text = (sql) => ({ sql, params: [], kind: "text" });
119
171
  switch (e.kind) {
120
172
  case "lit":
121
- return { sql: "?", params: [bindable(e.value)] };
173
+ return constOperand(e.value);
122
174
  case "binding":
123
- return { sql: "?", params: [bindable(ctx.params[e.index])] };
175
+ return constOperand(ctx.params[e.index]);
124
176
  case "ident": {
125
- if (e.name.startsWith("$")) {
126
- const sql = intrinsicSql(e.name, ctx);
127
- return sql ? { sql, params: [] } : null;
128
- }
177
+ if (e.name.startsWith("$"))
178
+ return intrinsicSql(e.name, ctx);
179
+ // 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))
182
+ return null;
129
183
  // Bare field per target.
130
184
  if (target === "docs") {
131
185
  if (e.name === "format")
132
- return { sql: `${self}.format`, params: [] }; // a column, not a property
186
+ return text(`${self}.format`); // a column, not a property
133
187
  if (RESERVED_DOC_BASENAMES.has(e.name))
134
188
  return null; // residual raises the guard
135
- const sql = propScalar(doc, e.name);
136
- return sql ? { sql, params: [] } : null;
189
+ return propScalar(doc, e.name);
137
190
  }
138
191
  if (target === "blocks") {
139
192
  if (e.name === "type" || e.name === "text")
140
- return { sql: `${self}.${e.name}`, params: [] };
193
+ return text(`${self}.${e.name}`);
141
194
  // Bare non-structural identifier flattens into attrs — same pushdown as
142
195
  // 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;
196
+ return jsonExtract(`${self}.attrs`, [e.name]);
145
197
  }
146
198
  if (target === "nodes") {
147
199
  if (e.name === "kind" || e.name === "name" || e.name === "value")
148
- return { sql: `${self}.${e.name}`, params: [] };
200
+ return text(`${self}.${e.name}`);
149
201
  // Bare non-structural identifier flattens into attrs (see blocks above).
150
- const sql = jsonExtract(`${self}.attrs`, [e.name]);
151
- return sql ? { sql, params: [] } : null;
202
+ return jsonExtract(`${self}.attrs`, [e.name]);
152
203
  }
153
204
  if (target === "edges") {
154
205
  if (["predicate", "provenance", "dst_kind", "anchor", "src_field"].includes(e.name))
155
- return { sql: `${self}.${e.name}`, params: [] };
206
+ return text(`${self}.${e.name}`);
156
207
  return null;
157
208
  }
158
209
  return null;
@@ -164,8 +215,7 @@ export function translateValue(e, ctx) {
164
215
  const [head, ...rest] = segs;
165
216
  // attrs.<path> → json_extract on the row's attrs (blocks/nodes).
166
217
  if (head === "attrs" && (target === "blocks" || target === "nodes")) {
167
- const sql = jsonExtract(`${self}.attrs`, rest);
168
- return sql ? { sql, params: [] } : null;
218
+ return jsonExtract(`${self}.attrs`, rest);
169
219
  }
170
220
  // doc.<x> reach-through — the owning doc (alias `doc`, which is `d`). On the
171
221
  // docs target `doc` is the row itself; either way it resolves against `doc`.
@@ -174,29 +224,31 @@ export function translateValue(e, ctx) {
174
224
  return null;
175
225
  const k = rest[0];
176
226
  if (k === "$path")
177
- return { sql: `${doc}.path`, params: [] };
227
+ return text(`${doc}.path`);
178
228
  if (k === "format")
179
- return { sql: `${doc}.format`, params: [] };
229
+ return text(`${doc}.format`);
180
230
  if (k.startsWith("$"))
181
231
  return null;
182
232
  if (RESERVED_DOC_BASENAMES.has(k))
183
233
  return null;
184
- const sql = propScalar(doc, k);
185
- return sql ? { sql, params: [] } : null;
234
+ if (NON_PROPERTY_NAMES.docs.has(k))
235
+ return null; // `doc.nodes`, `doc.frontmatter`, … (decline (b))
236
+ return propScalar(doc, k);
186
237
  }
187
238
  // block.type / block.text reach-through from a node.
188
239
  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: [] };
240
+ return text(`(SELECT bb.${rest[0]} FROM blocks bb WHERE bb.block_id = ${self}.block_id)`);
190
241
  }
191
242
  return null;
192
243
  }
193
244
  case "call": {
194
- // `.lower()` / `.upper()` are the value-position string methods.
245
+ // `.lower()` / `.upper()` are the value-position string methods; their
246
+ // output is text whatever the receiver.
195
247
  if (e.recv !== null && e.args.length === 0 && (e.name === "lower" || e.name === "upper")) {
196
- const recv = translateValue(e.recv, ctx);
248
+ const recv = translateOperand(e.recv, ctx);
197
249
  if (!recv)
198
250
  return null;
199
- return { sql: `${e.name}(${recv.sql})`, params: recv.params };
251
+ return { sql: `${e.name}(${recv.sql})`, params: recv.params, kind: "text" };
200
252
  }
201
253
  return null;
202
254
  }
@@ -204,6 +256,11 @@ export function translateValue(e, ctx) {
204
256
  return null;
205
257
  }
206
258
  }
259
+ /** `translateOperand` without the kind — the plain fragment. */
260
+ export function translateValue(e, ctx) {
261
+ const o = translateOperand(e, ctx);
262
+ return o ? { sql: o.sql, params: o.params } : null;
263
+ }
207
264
  // ---- predicate position -----------------------------------------------------
208
265
  const IS_OP = {
209
266
  "==": "IS",
@@ -213,6 +270,81 @@ const IS_OP = {
213
270
  ">": ">",
214
271
  ">=": ">=",
215
272
  };
273
+ /**
274
+ * The comparison rule (spec/surface §1, decline (a)), stated positively: a
275
+ * comparison is pushed only when its two operand kinds are provably compared
276
+ * the same way by SQLite and by the in-memory `equals`/`compare`.
277
+ *
278
+ * Equality (`==`, `!=`) pushes PLAINLY (`IS` / `IS NOT`) iff
279
+ * • one operand is text — SQLite's typed `IS` and strict `===` agree that a
280
+ * string equals nothing but an equal string (`5 IS '5'` is false);
281
+ * • or one operand is null and the other is NOT a property read — the same
282
+ * absence both ways, except that a list-valued or nested property has no
283
+ * scalar row (SQL NULL) where in memory the value is an array or object;
284
+ * • or both operands are numeric (int or num) — an exact integer comparison.
285
+ * Relational (`<`, `<=`, `>`, `>=`) pushes plainly iff both operands are text
286
+ * or both are numeric.
287
+ * TYPED pushes (the 1.2 patch, `typedComparison`): a bool literal/binding
288
+ * against a json or prop read (`==`, `!=` only) and a num literal/binding
289
+ * against a json or prop read (every op) push with the stored type tested
290
+ * first — `json_type(x) = 'true'`, `json_type(x) IN ('integer','real') AND
291
+ * json_extract(x) <op> ?`, `p.type = 'bool' AND p.val_bool = ?`, `p.type =
292
+ * 'number' AND p.val_num <op> ?` — wrapped `(…) IS 1` (or `IS NOT 1` for
293
+ * `!=`), so an absent or differently typed value is unequal and never
294
+ * ordered, exactly as in memory.
295
+ * Every other pair declines: SQLite sees JSON `true` and `1`, `val_bool` and
296
+ * `val_num` alike (`$ordinal == checked`, `true == 1`), orders every
297
+ * integer before every text (`$ordinal < "3"`, `level < "x"`, `$path > 5`),
298
+ * and two JSON or property reads carry no type at plan time
299
+ * (`attrs.a == attrs.b`, `era == stages`).
300
+ *
301
+ * The resulting grid (P = pushed plainly, T = typed push, D = declined;
302
+ * symmetric) for `==` / `!=`:
303
+ *
304
+ * text int num bool null json prop
305
+ * text P P P P P P P
306
+ * int P P P D P D D
307
+ * num P P P D P T T
308
+ * bool P D D D P T T
309
+ * null P P P P P P D
310
+ * json P D T T P D D
311
+ * prop P D T T D D D
312
+ *
313
+ * and for `<` `<=` `>` `>=` text×text, int×int, int×num, num×num push plainly
314
+ * and num×json, num×prop push typed.
315
+ */
316
+ export function comparable(op, a, b) {
317
+ return plainComparable(op, a, b) || typedPair(op, a, b) !== null;
318
+ }
319
+ // The plain cells of the grid (P): the two operands compare the same way as-is.
320
+ function plainComparable(op, a, b) {
321
+ const numeric = (k) => k === "int" || k === "num";
322
+ const bothNumeric = numeric(a) && numeric(b);
323
+ if (op === "==" || op === "!=") {
324
+ if (a === "text" || b === "text")
325
+ return true;
326
+ if ((a === "null" && b !== "prop") || (b === "null" && a !== "prop"))
327
+ return true;
328
+ return bothNumeric;
329
+ }
330
+ return (a === "text" && b === "text") || bothNumeric;
331
+ }
332
+ // The typed cells of the grid (T): which side is the constant (`"left"` /
333
+ // `"right"`), or null when the pair is not a typed push. A bool constant pushes
334
+ // typed only under `==` / `!=` (booleans never order); a num constant under any op.
335
+ function typedPair(op, a, b) {
336
+ const read = (k) => k === "json" || k === "prop";
337
+ const constant = (k) => k === "num" || (k === "bool" && (op === "==" || op === "!="));
338
+ if (constant(a) && read(b))
339
+ return "left";
340
+ if (constant(b) && read(a))
341
+ return "right";
342
+ return null;
343
+ }
344
+ /** The complement of `comparable` — kept for readers of the earlier decline-list form. */
345
+ export function declinedPair(a, b, op = "==") {
346
+ return !comparable(op, a, b);
347
+ }
216
348
  /** Translate an expression used as a boolean PREDICATE to a SQL boolean, or null
217
349
  * if it cannot be pushed faithfully. Only positive, AND-safe forms are handled. */
218
350
  export function translatePredicate(e, ctx) {
@@ -236,15 +368,67 @@ function translateComparison(e, ctx) {
236
368
  const op = IS_OP[e.op];
237
369
  if (!op)
238
370
  return null; // arithmetic operator in predicate position → residual
239
- const l = translateValue(e.left, ctx);
240
- const r = translateValue(e.right, ctx);
371
+ const l = translateOperand(e.left, ctx);
372
+ const r = translateOperand(e.right, ctx);
241
373
  if (!l || !r)
242
374
  return null;
243
- // `==`/`!=` → null-safe IS / IS NOT (absence-normalized equality, faithful in
244
- // any context). Relational ops → plain SQL: a NULL operand yields NULL, which
245
- // is excluded in the positive AND context these fragments are pushed into,
246
- // matching `relate`'s absent-operand ⇒ false rule.
247
- return { sql: `(${l.sql} ${op} ${r.sql})`, params: [...l.params, ...r.params] };
375
+ if (plainComparable(e.op, l.kind, r.kind)) {
376
+ // `==`/`!=` → null-safe IS / IS NOT (absence-normalized equality, faithful in
377
+ // any context). Relational ops → plain SQL: a NULL operand yields NULL, which
378
+ // is excluded in the positive AND context these fragments are pushed into,
379
+ // matching `relate`'s absent-operand ⇒ false rule.
380
+ return { sql: `(${l.sql} ${op} ${r.sql})`, params: [...l.params, ...r.params] };
381
+ }
382
+ const side = typedPair(e.op, l.kind, r.kind);
383
+ if (side === null)
384
+ return null; // decline (a): the positive comparison rule
385
+ // Normalize to <read> <op> <constant>, flipping a relational op when the
386
+ // constant is on the left (`800 < era` ⇔ `era > 800`).
387
+ return side === "right" ? typedComparison(e.op, l, r) : typedComparison(FLIP[e.op] ?? e.op, r, l);
388
+ }
389
+ const FLIP = { "<": ">", "<=": ">=", ">": "<", ">=": "<=" };
390
+ /**
391
+ * The typed push (spec/surface §1, 1.2 patch): `read <op> constant` where the
392
+ * read is a JSON or property read and the constant a bool or num literal/binding.
393
+ * The stored type is tested before the value, and the whole test is wrapped
394
+ * `(…) IS 1` for `==` and the relational ops, `(…) IS NOT 1` for `!=` — so a
395
+ * NULL (absent path, no scalar row) or a `0` (other type, unequal value) reads
396
+ * as "not equal" / "not ordered", which is the in-memory answer: `checked == 1`
397
+ * finds nothing, `checked != 1` everything, `level >= 2` only numeric levels.
398
+ *
399
+ * json × bool (json_type(col, path) = 'true'|'false') IS [NOT] 1 no bind: the type string IS the value
400
+ * json × num (json_type(col, path) IN ('integer','real') AND json_extract(col, path) <op> ?) IS [NOT] 1
401
+ * prop × bool (SELECT p.type = 'bool' AND p.val_bool = ? FROM properties p WHERE <scope> LIMIT 1) IS [NOT] 1 ? = 1|0
402
+ * prop × num (SELECT p.type = 'number' AND p.val_num <op> ? FROM properties p WHERE <scope> LIMIT 1) IS [NOT] 1
403
+ *
404
+ * where <op> is `=` for both `==` and `!=` (the `!=` case negates the equality
405
+ * by its `IS NOT 1` wrap) and the relational operator itself otherwise.
406
+ *
407
+ * `<scope>` is `propSubquery`'s single-scalar-row condition, so a list-valued or
408
+ * nested key yields NULL → unequal/unordered, as in memory.
409
+ */
410
+ function typedComparison(op, read, constant) {
411
+ // `!=` is `!equals`: the EQUALITY test, wrapped IS NOT 1 (never `<>` inside —
412
+ // `(type AND val <> ?) IS NOT 1` would drop every row of the right type with a
413
+ // different value, the differential caught it).
414
+ const wrap = op === "!=" ? "IS NOT 1" : "IS 1";
415
+ const cmp = op === "==" || op === "!=" ? "=" : op;
416
+ if (read.json) {
417
+ const { col, path } = read.json;
418
+ if (constant.kind === "bool") {
419
+ return { sql: `((json_type(${col}, ${path}) = '${constant.value ? "true" : "false"}') ${wrap})`, params: [] };
420
+ }
421
+ return {
422
+ sql: `((json_type(${col}, ${path}) IN ('integer', 'real') AND json_extract(${col}, ${path}) ${cmp} ?) ${wrap})`,
423
+ params: [...constant.params],
424
+ };
425
+ }
426
+ if (read.prop) {
427
+ const { docAlias, key } = read.prop;
428
+ const test = constant.kind === "bool" ? `p.type = 'bool' AND p.val_bool = ?` : `p.type = 'number' AND p.val_num ${cmp} ?`;
429
+ return { sql: `(${propSubquery(docAlias, key, test)} ${wrap})`, params: [...constant.params] };
430
+ }
431
+ return null;
248
432
  }
249
433
  // startsWith / contains / endsWith — CASE-SENSITIVE, via substr/instr (never
250
434
  // LIKE). `matches` (regex) is declined until a `regexp` UDF is registered.
@@ -28,8 +28,22 @@ export declare function makeBlockContextResolver(store: Store, scope: {
28
28
  } | {
29
29
  docId: string;
30
30
  }): BlockContextResolver;
31
- /** Build EmbedTask[] for every embeddable live block in the repo. */
31
+ /**
32
+ * Build EmbedTask[] for every embeddable live block in the repo: the live
33
+ * LEAF blocks (spec/search §2.1, 1.2 — a container's text is its children's
34
+ * text, so embedding it would pool the same words twice) whose text clears the
35
+ * `shouldEmbed` floor.
36
+ */
32
37
  export declare function buildEmbedTasks(store: Store, repoId: string): EmbedTask[];
38
+ /**
39
+ * The embedding queue depth without a provider process (spec/search §2.3): the
40
+ * embeddable blocks (`buildEmbedTasks`) whose current `(content_hash, ctx_hash)`
41
+ * has no cached vector — for `model` when one is named (the `embedding.model`
42
+ * setting), else for any model. `omg status` reports it as `embedQueue`;
43
+ * `EmbeddingWorker.staleBlocks` is the same test keyed by the connected
44
+ * provider's model.
45
+ */
46
+ export declare function staleEmbedCount(store: Store, repoId: string, model?: string): number;
33
47
  /**
34
48
  * Build DocEmbedTask[] for every live document in the repo (doc-grain semantic
35
49
  * retrieval). Each task carries the whole-document embed input (header +
@@ -1,6 +1,7 @@
1
1
  import { reconstructContent } from "../core/read/document.js";
2
2
  import { docPropertiesMerged } from "../core/store/properties.js";
3
- import { contextPrefix, shouldEmbed, estimateTokens } from "./embeddings.js";
3
+ import { LIVE_LEAF_SQL } from "../core/store/fts.js";
4
+ import { contextPrefix, ctxHashHex, shouldEmbed, estimateTokens } from "./embeddings.js";
4
5
  function docTitle(titleByDoc, firstHeadingByDoc, docId, path) {
5
6
  const t = titleByDoc.get(docId);
6
7
  if (t && t.trim())
@@ -61,13 +62,18 @@ export function makeBlockContextResolver(store, scope) {
61
62
  }),
62
63
  };
63
64
  }
64
- /** Build EmbedTask[] for every embeddable live block in the repo. */
65
+ /**
66
+ * Build EmbedTask[] for every embeddable live block in the repo: the live
67
+ * LEAF blocks (spec/search §2.1, 1.2 — a container's text is its children's
68
+ * text, so embedding it would pool the same words twice) whose text clears the
69
+ * `shouldEmbed` floor.
70
+ */
65
71
  export function buildEmbedTasks(store, repoId) {
66
72
  const blocks = store.db
67
73
  .prepare(`SELECT b.block_id, b.doc_id, d.path AS path,
68
74
  b.ordinal, b.type, b.text, b.raw_hash
69
75
  FROM blocks b JOIN docs d ON d.doc_id = b.doc_id
70
- WHERE b.repo_id = ? AND b.deleted_commit IS NULL
76
+ WHERE b.repo_id = ? AND ${LIVE_LEAF_SQL}
71
77
  ORDER BY d.path, b.ordinal`)
72
78
  .all(repoId);
73
79
  const contexts = makeBlockContextResolver(store, { repoId });
@@ -79,6 +85,28 @@ export function buildEmbedTasks(store, repoId) {
79
85
  }
80
86
  return tasks;
81
87
  }
88
+ /**
89
+ * The embedding queue depth without a provider process (spec/search §2.3): the
90
+ * embeddable blocks (`buildEmbedTasks`) whose current `(content_hash, ctx_hash)`
91
+ * has no cached vector — for `model` when one is named (the `embedding.model`
92
+ * setting), else for any model. `omg status` reports it as `embedQueue`;
93
+ * `EmbeddingWorker.staleBlocks` is the same test keyed by the connected
94
+ * provider's model.
95
+ */
96
+ export function staleEmbedCount(store, repoId, model) {
97
+ const tasks = buildEmbedTasks(store, repoId);
98
+ const cached = model
99
+ ? store.db.prepare("SELECT 1 FROM embeddings WHERE content_hash = ? AND ctx_hash = ? AND model = ?")
100
+ : store.db.prepare("SELECT 1 FROM embeddings WHERE content_hash = ? AND ctx_hash = ?");
101
+ let stale = 0;
102
+ for (const t of tasks) {
103
+ const key = [Buffer.from(t.contentHashHex, "hex"), Buffer.from(ctxHashHex(t.ctx), "hex")];
104
+ const hit = model ? cached.get(...key, model) : cached.get(...key);
105
+ if (!hit)
106
+ stale++;
107
+ }
108
+ return stale;
109
+ }
82
110
  // The doc-embedding header: a lightweight identifying line prepended to the
83
111
  // body so schema-y atomic notes (a two-line canon definition) embed with their
84
112
  // title + type/layer context rather than as near-empty bodies. Kept minimal and
@@ -57,3 +57,16 @@ export interface SyncStatus {
57
57
  * re-ingested — the exact stale-under-green-light failure this guards against.
58
58
  */
59
59
  export declare function syncStatus(store: Store, repoId: string, rootPath?: string): SyncStatus;
60
+ /** The `repos` tool's result: every repo of the workspace and whether an `fs` source feeds it. */
61
+ export interface ReposList {
62
+ repos: {
63
+ slug: string;
64
+ hasSource: boolean;
65
+ }[];
66
+ }
67
+ /**
68
+ * `repos` (spec/surface §4): the workspace's repos by slug, `hasSource` when an
69
+ * `fs` source is attached. Shared by the MCP tool and `omg repos --json` so the
70
+ * shape cannot drift; per-repo counts are `reposStatus`.
71
+ */
72
+ export declare function reposList(store: Store): ReposList;
@@ -50,4 +50,15 @@ export function syncStatus(store, repoId, rootPath) {
50
50
  disk: status.disk,
51
51
  };
52
52
  }
53
+ /**
54
+ * `repos` (spec/surface §4): the workspace's repos by slug, `hasSource` when an
55
+ * `fs` source is attached. Shared by the MCP tool and `omg repos --json` so the
56
+ * shape cannot drift; per-repo counts are `reposStatus`.
57
+ */
58
+ export function reposList(store) {
59
+ const rows = store.db
60
+ .prepare("SELECT r.slug AS slug, MAX(CASE WHEN s.adapter = 'fs' THEN 1 ELSE 0 END) AS has_fs FROM repos r LEFT JOIN attachments a ON a.repo_id = r.repo_id LEFT JOIN sources s ON s.source_id = a.source_id GROUP BY r.repo_id, r.slug ORDER BY r.slug")
61
+ .all();
62
+ return { repos: rows.map((r) => ({ slug: r.slug, hasSource: r.has_fs === 1 })) };
63
+ }
53
64
  //# sourceMappingURL=admin.js.map