@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.
- package/dist/src/core/ids.d.ts +34 -3
- package/dist/src/core/ids.js +79 -6
- package/dist/src/core/ingest.js +15 -12
- package/dist/src/core/read/document.js +6 -1
- package/dist/src/core/read/nodes.js +5 -1
- package/dist/src/core/store/fts.d.ts +47 -7
- package/dist/src/core/store/fts.js +73 -9
- package/dist/src/core/store/rebuild.js +5 -4
- package/dist/src/core/store/store.d.ts +4 -0
- package/dist/src/core/store/store.js +40 -1
- package/dist/src/format/index.d.ts +12 -1
- package/dist/src/format/index.js +14 -1
- package/dist/src/format/markdown.d.ts +7 -0
- package/dist/src/format/markdown.js +7 -0
- package/dist/src/graph/history.d.ts +21 -0
- package/dist/src/graph/history.js +48 -6
- package/dist/src/graph/links.d.ts +2 -0
- package/dist/src/graph/links.js +22 -11
- package/dist/src/index.d.ts +8 -6
- package/dist/src/index.js +7 -5
- package/dist/src/mcp/errors.d.ts +8 -0
- package/dist/src/mcp/errors.js +17 -3
- package/dist/src/mcp/server.js +50 -57
- package/dist/src/mutate/apply.js +12 -4
- package/dist/src/mutate/doc-store.js +4 -1
- package/dist/src/mutate/docs.d.ts +19 -0
- package/dist/src/mutate/docs.js +64 -16
- package/dist/src/mutate/ops.js +25 -13
- package/dist/src/oqx-js/context.d.ts +11 -0
- package/dist/src/oqx-js/context.js +16 -0
- package/dist/src/oqx-js/planner.d.ts +20 -1
- package/dist/src/oqx-js/planner.js +95 -2
- package/dist/src/oqx-js/run.js +27 -2
- package/dist/src/oqx-js/sql/translate.d.ts +94 -1
- package/dist/src/oqx-js/sql/translate.js +263 -79
- package/dist/src/search/tasks.d.ts +15 -1
- package/dist/src/search/tasks.js +31 -3
- package/dist/src/sync/admin.d.ts +13 -0
- package/dist/src/sync/admin.js +11 -0
- package/dist/src/sync/external-source.js +20 -7
- package/dist/src/sync/plugin.d.ts +30 -5
- package/dist/src/sync/plugin.js +13 -1
- package/dist/src/sync/watcher.d.ts +2 -0
- package/dist/src/sync/watcher.js +10 -2
- 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.
|
|
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
|
|
32
|
-
// mapped (e.g. docs `$body`, reconstructed; `$title`/`$tags`,
|
|
33
|
-
// null → residual. `$updated_at`/`$dst_path`/`$dst_uri` are
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
//
|
|
80
|
-
//
|
|
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
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
137
|
+
const path = `'$.${segs.join(".")}'`;
|
|
138
|
+
return { sql: `json_extract(${col}, ${path})`, params: [], kind: "json", json: { col, path } };
|
|
95
139
|
}
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
function
|
|
101
|
-
|
|
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
|
|
117
|
-
|
|
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
|
|
173
|
+
return constOperand(e.value);
|
|
122
174
|
case "binding":
|
|
123
|
-
return
|
|
175
|
+
return constOperand(ctx.params[e.index]);
|
|
124
176
|
case "ident": {
|
|
125
|
-
if (e.name.startsWith("$"))
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
200
|
+
return text(`${self}.${e.name}`);
|
|
149
201
|
// Bare non-structural identifier flattens into attrs (see blocks above).
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
227
|
+
return text(`${doc}.path`);
|
|
178
228
|
if (k === "format")
|
|
179
|
-
return
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
240
|
-
const r =
|
|
371
|
+
const l = translateOperand(e.left, ctx);
|
|
372
|
+
const r = translateOperand(e.right, ctx);
|
|
241
373
|
if (!l || !r)
|
|
242
374
|
return null;
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
/**
|
|
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 +
|
package/dist/src/search/tasks.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { reconstructContent } from "../core/read/document.js";
|
|
2
2
|
import { docPropertiesMerged } from "../core/store/properties.js";
|
|
3
|
-
import {
|
|
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
|
-
/**
|
|
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
|
|
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
|
package/dist/src/sync/admin.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/sync/admin.js
CHANGED
|
@@ -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
|