@omgbase/core 0.3.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.
- 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 +38 -7
- package/dist/src/core/store/fts.js +60 -9
- package/dist/src/core/store/rebuild.js +5 -4
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +1 -0
- package/dist/src/mcp/server.js +20 -9
- 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 +71 -1
- package/dist/src/oqx-js/sql/translate.js +165 -70
- package/dist/src/search/tasks.d.ts +6 -1
- package/dist/src/search/tasks.js +8 -2
- 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
|
@@ -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
|
|
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.
|
|
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
|
|
32
|
-
// mapped (e.g. docs `$body`, reconstructed; `$title`/`$tags`,
|
|
33
|
-
// null → residual. `$updated_at`/`$dst_path`/`$dst_uri` are
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
function
|
|
101
|
-
|
|
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
|
|
117
|
-
|
|
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
|
|
160
|
+
return constOperand(e.value);
|
|
122
161
|
case "binding":
|
|
123
|
-
return
|
|
162
|
+
return constOperand(ctx.params[e.index]);
|
|
124
163
|
case "ident": {
|
|
125
|
-
if (e.name.startsWith("$"))
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
187
|
+
return text(`${self}.${e.name}`);
|
|
149
188
|
// Bare non-structural identifier flattens into attrs (see blocks above).
|
|
150
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
214
|
+
return text(`${doc}.path`);
|
|
178
215
|
if (k === "format")
|
|
179
|
-
return
|
|
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
|
-
|
|
185
|
-
|
|
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
|
|
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 =
|
|
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 =
|
|
240
|
-
const r =
|
|
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,
|
|
@@ -28,7 +28,12 @@ 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[];
|
|
33
38
|
/**
|
|
34
39
|
* Build DocEmbedTask[] for every live document in the repo (doc-grain semantic
|
package/dist/src/search/tasks.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { reconstructContent } from "../core/read/document.js";
|
|
2
2
|
import { docPropertiesMerged } from "../core/store/properties.js";
|
|
3
|
+
import { LIVE_LEAF_SQL } from "../core/store/fts.js";
|
|
3
4
|
import { contextPrefix, shouldEmbed, estimateTokens } from "./embeddings.js";
|
|
4
5
|
function docTitle(titleByDoc, firstHeadingByDoc, docId, path) {
|
|
5
6
|
const t = titleByDoc.get(docId);
|
|
@@ -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 });
|
|
@@ -17,7 +17,7 @@ export async function createExternalSource(spec) {
|
|
|
17
17
|
}
|
|
18
18
|
const rl = createInterface({ input: child.stdout });
|
|
19
19
|
let watchListener = null;
|
|
20
|
-
const router = messageRouter(rl, (
|
|
20
|
+
const router = messageRouter(rl, (ev) => watchListener?.(ev));
|
|
21
21
|
const died = new Promise((_, reject) => {
|
|
22
22
|
child.once("error", (err) => reject(new Error(`sync adapter '${spec.command}' failed to spawn: ${err.message}`)));
|
|
23
23
|
child.once("exit", (code) => reject(new Error(`sync adapter '${spec.command}' exited early (code ${code ?? "?"})`)));
|
|
@@ -69,10 +69,19 @@ export async function createExternalSource(spec) {
|
|
|
69
69
|
},
|
|
70
70
|
...(caps.watch
|
|
71
71
|
? {
|
|
72
|
-
async watch(
|
|
73
|
-
|
|
72
|
+
async watch(listener) {
|
|
73
|
+
// `ready` resolves on the adapter's {"event":"ready"}; the listener sees
|
|
74
|
+
// it as an event too, in wire order with the batches (spec/sync §5, §8).
|
|
75
|
+
let markReady = () => { };
|
|
76
|
+
const ready = new Promise((resolve) => { markReady = resolve; });
|
|
77
|
+
watchListener = (ev) => {
|
|
78
|
+
if (ev.event === "ready")
|
|
79
|
+
markReady();
|
|
80
|
+
listener(ev);
|
|
81
|
+
};
|
|
74
82
|
await call("watch");
|
|
75
83
|
return {
|
|
84
|
+
ready,
|
|
76
85
|
async stop() {
|
|
77
86
|
watchListener = null;
|
|
78
87
|
try {
|
|
@@ -105,9 +114,9 @@ export async function createExternalSource(spec) {
|
|
|
105
114
|
};
|
|
106
115
|
return source;
|
|
107
116
|
}
|
|
108
|
-
// Demultiplex adapter stdout: {"event"
|
|
109
|
-
// every other line is an id-addressed response buffered for nextResponse().
|
|
110
|
-
function messageRouter(rl,
|
|
117
|
+
// Demultiplex adapter stdout: {"event":…} lines feed the watch listener (ready,
|
|
118
|
+
// batch); every other line is an id-addressed response buffered for nextResponse().
|
|
119
|
+
function messageRouter(rl, onEvent) {
|
|
111
120
|
const buffered = [];
|
|
112
121
|
const waiters = [];
|
|
113
122
|
rl.on("line", (line) => {
|
|
@@ -117,7 +126,11 @@ function messageRouter(rl, onBatch) {
|
|
|
117
126
|
try {
|
|
118
127
|
const peek = JSON.parse(trimmed);
|
|
119
128
|
if (peek.event === "batch") {
|
|
120
|
-
|
|
129
|
+
onEvent({ event: "batch", paths: peek.paths ?? [] });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (peek.event === "ready") {
|
|
133
|
+
onEvent({ event: "ready" });
|
|
121
134
|
return;
|
|
122
135
|
}
|
|
123
136
|
}
|
|
@@ -25,13 +25,38 @@ export interface SourceItem extends SourceEntry {
|
|
|
25
25
|
/** The engine hashes this itself for the authoritative echo/convergence gate. */
|
|
26
26
|
content: string;
|
|
27
27
|
}
|
|
28
|
-
/**
|
|
29
|
-
|
|
28
|
+
/**
|
|
29
|
+
* An unsolicited event of a live watch (spec/sync §5): `ready` once — the feed
|
|
30
|
+
* is primed and every change from now on will be reported — then `batch` lines
|
|
31
|
+
* of changed storage keys. An adapter built before sync 1.2 never sends `ready`.
|
|
32
|
+
*/
|
|
33
|
+
export type WatchEvent = {
|
|
34
|
+
event: "ready";
|
|
35
|
+
} | {
|
|
36
|
+
event: "batch";
|
|
37
|
+
paths: string[];
|
|
38
|
+
};
|
|
39
|
+
/** Receives every event of a live watch, in wire order. */
|
|
40
|
+
export type WatchListener = (event: WatchEvent) => void;
|
|
30
41
|
/** A live subscription created by SyncSource.watch. */
|
|
31
42
|
export interface SourceWatch {
|
|
32
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Resolves when the source reported `ready` (spec/sync §5). An in-process
|
|
45
|
+
* source resolves it at once; an adapter that never reports leaves it pending,
|
|
46
|
+
* so a host bounds the wait (`awaitReady`).
|
|
47
|
+
*/
|
|
48
|
+
ready: Promise<void>;
|
|
49
|
+
/** Stop delivering events and release resources. */
|
|
33
50
|
stop(): Promise<void>;
|
|
34
51
|
}
|
|
52
|
+
/** How long a host waits for `ready` before proceeding as if ready (spec/sync §5: unpinned; 30 s). */
|
|
53
|
+
export declare const WATCH_READY_PATIENCE_MS = 30000;
|
|
54
|
+
/**
|
|
55
|
+
* Wait for a watch's `ready` with bounded patience: `true` when it resolved in
|
|
56
|
+
* time, `false` on timeout (the host proceeds as if ready, with a warning — an
|
|
57
|
+
* adapter without `ready` still works, with the old window).
|
|
58
|
+
*/
|
|
59
|
+
export declare function awaitReady(ready: Promise<void>, patienceMs?: number): Promise<boolean>;
|
|
35
60
|
/**
|
|
36
61
|
* The in-engine handle to an external source process. Only capabilities/
|
|
37
62
|
* enumerate/fetch are always meaningful; watch exists when capabilities().watch,
|
|
@@ -43,8 +68,8 @@ export interface SyncSource {
|
|
|
43
68
|
enumerate(): Promise<SourceEntry[]>;
|
|
44
69
|
/** Current state of one member, or null if it left the scope (a delete). */
|
|
45
70
|
fetch(path: string): Promise<SourceItem | null>;
|
|
46
|
-
/** Subscribe to
|
|
47
|
-
watch?(
|
|
71
|
+
/** Subscribe to the change feed (push sources): `ready` once, then debounced `batch` events. */
|
|
72
|
+
watch?(listener: WatchListener): Promise<SourceWatch>;
|
|
48
73
|
/** Persist engine-authored bytes back to the source (writeThrough only). */
|
|
49
74
|
write?(path: string, content: string): Promise<void>;
|
|
50
75
|
/** Remove a member from the source (writeThrough only). */
|
package/dist/src/sync/plugin.js
CHANGED
|
@@ -8,5 +8,17 @@
|
|
|
8
8
|
// reconciliation, commits, convergence, and durable revision/cursor state — so a
|
|
9
9
|
// source only reports membership and transports bytes; it can never write a
|
|
10
10
|
// commit, mint identity, or bypass the convergence check.
|
|
11
|
-
|
|
11
|
+
/** How long a host waits for `ready` before proceeding as if ready (spec/sync §5: unpinned; 30 s). */
|
|
12
|
+
export const WATCH_READY_PATIENCE_MS = 30_000;
|
|
13
|
+
/**
|
|
14
|
+
* Wait for a watch's `ready` with bounded patience: `true` when it resolved in
|
|
15
|
+
* time, `false` on timeout (the host proceeds as if ready, with a warning — an
|
|
16
|
+
* adapter without `ready` still works, with the old window).
|
|
17
|
+
*/
|
|
18
|
+
export function awaitReady(ready, patienceMs = WATCH_READY_PATIENCE_MS) {
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
const timer = setTimeout(() => resolve(false), patienceMs);
|
|
21
|
+
ready.then(() => { clearTimeout(timer); resolve(true); }, () => { clearTimeout(timer); resolve(false); });
|
|
22
|
+
});
|
|
23
|
+
}
|
|
12
24
|
//# sourceMappingURL=plugin.js.map
|
|
@@ -14,6 +14,8 @@ export declare class Watcher {
|
|
|
14
14
|
private sub;
|
|
15
15
|
constructor(store: Store, repoId: string, source: SyncSource, opts?: WatcherOptions);
|
|
16
16
|
start(): Promise<void>;
|
|
17
|
+
/** The live subscription's `ready` (spec/sync §5); throws before `start()`. */
|
|
18
|
+
ready(): Promise<void>;
|
|
17
19
|
private ingest;
|
|
18
20
|
stop(): Promise<void>;
|
|
19
21
|
}
|