@omgbase/core 0.10.1 → 0.11.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 (63) hide show
  1. package/README.md +10 -10
  2. package/dist/src/core/read/document.d.ts +18 -5
  3. package/dist/src/core/read/document.js +21 -7
  4. package/dist/src/core/read/nodes.d.ts +7 -7
  5. package/dist/src/core/read/nodes.js +5 -3
  6. package/dist/src/core/read/reader.d.ts +11 -7
  7. package/dist/src/core/read/reader.js +8 -10
  8. package/dist/src/format/adapter.d.ts +1 -1
  9. package/dist/src/graph/history.d.ts +20 -14
  10. package/dist/src/graph/history.js +12 -12
  11. package/dist/src/graph/inbound-links.d.ts +4 -4
  12. package/dist/src/graph/inbound-links.js +4 -4
  13. package/dist/src/graph/link-health.d.ts +8 -8
  14. package/dist/src/graph/link-health.js +6 -6
  15. package/dist/src/graph/links.d.ts +3 -3
  16. package/dist/src/graph/links.js +4 -4
  17. package/dist/src/index.d.ts +2 -1
  18. package/dist/src/index.js +2 -1
  19. package/dist/src/mcp/arguments.d.ts +27 -0
  20. package/dist/src/mcp/arguments.js +147 -0
  21. package/dist/src/mcp/errors.d.ts +1 -1
  22. package/dist/src/mcp/graph.d.ts +16 -9
  23. package/dist/src/mcp/graph.js +58 -35
  24. package/dist/src/mcp/reference.d.ts +1 -1
  25. package/dist/src/mcp/reference.js +200 -111
  26. package/dist/src/mcp/server.d.ts +9 -1
  27. package/dist/src/mcp/server.js +359 -268
  28. package/dist/src/mutate/apply.d.ts +11 -4
  29. package/dist/src/mutate/apply.js +3 -2
  30. package/dist/src/mutate/docs.js +7 -7
  31. package/dist/src/mutate/macros.d.ts +4 -1
  32. package/dist/src/mutate/macros.js +1 -1
  33. package/dist/src/mutate/ops.js +2 -2
  34. package/dist/src/mutate/opset.d.ts +8 -5
  35. package/dist/src/mutate/plan-update.js +9 -8
  36. package/dist/src/oqx-js/context.d.ts +17 -4
  37. package/dist/src/oqx-js/context.js +268 -131
  38. package/dist/src/oqx-js/planner.d.ts +3 -3
  39. package/dist/src/oqx-js/planner.js +14 -15
  40. package/dist/src/oqx-js/root-row.d.ts +10 -3
  41. package/dist/src/oqx-js/root-row.js +18 -8
  42. package/dist/src/oqx-js/run.d.ts +6 -0
  43. package/dist/src/oqx-js/run.js +72 -25
  44. package/dist/src/oqx-js/sql/scan.js +7 -7
  45. package/dist/src/oqx-js/sql/translate.d.ts +12 -9
  46. package/dist/src/oqx-js/sql/translate.js +183 -75
  47. package/dist/src/oqx-js/store-index.d.ts +1 -1
  48. package/dist/src/oqx-js/store-index.js +28 -26
  49. package/dist/src/search/cel/ast.d.ts +1 -1
  50. package/dist/src/search/cel/compile.d.ts +1 -1
  51. package/dist/src/search/cel/compile.js +43 -70
  52. package/dist/src/search/cel/parser.js +1 -1
  53. package/dist/src/search/resolve.d.ts +2 -0
  54. package/dist/src/search/rrf.js +2 -2
  55. package/dist/src/search/text.d.ts +2 -2
  56. package/dist/src/search/text.js +1 -1
  57. package/dist/src/spec-versions.d.ts +6 -6
  58. package/dist/src/spec-versions.js +5 -5
  59. package/dist/src/surface-paths.d.ts +41 -8
  60. package/dist/src/surface-paths.js +34 -18
  61. package/dist/src/sync/admin.d.ts +2 -1
  62. package/dist/src/sync/admin.js +1 -1
  63. package/package.json +2 -2
@@ -13,7 +13,7 @@
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
16
- // `substr`/`instr`, NEVER `LIKE`. `$path.lower().startsWith("lab/")` becomes
16
+ // `substr`/`instr`, NEVER `LIKE`. `path.lower().startsWith("lab/")` becomes
17
17
  // `substr(lower(d.path), 1, length(?)) = ?` — the lowercasing is explicit and
18
18
  // the match is byte-exact, matching the in-memory path.
19
19
  // • oqx-js `==`/`!=` are absence-NORMALIZED (two absent values are equal;
@@ -27,8 +27,9 @@
27
27
  // `card='scalar'` row (reads NULL). So a comparison is only pushed when the
28
28
  // KINDS of its two operands are provably compared the same way — see
29
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
30
+ // or WHOLE source handle is never read as a property (decline (b),
31
+ // `NON_PROPERTY_NAMES`; a key read THROUGH a handle — `data.k`,
32
+ // `frontmatter.k` — is a property read, `handleRead`). A bool or num literal against a JSON or property
32
33
  // read is a TYPED push (the 1.2 patch): the stored type is tested in SQL
33
34
  // before the value (`json_type(x) = 'true'`, `p.type = 'number' AND
34
35
  // p.val_num >= ?`) and the whole test is wrapped `(…) IS 1` / `IS NOT 1`,
@@ -52,94 +53,137 @@ import { TARGETS } from "../root-row.js";
52
53
  * `translate.test.ts` asserts this set against the store context.
53
54
  */
54
55
  export const NON_PROPERTY_NAMES = {
55
- docs: new Set(["doc", "nodes", "blocks", "out", "in", "out_edges", "in_edges", "frontmatter", "inline"]),
56
- blocks: new Set(["block", "doc", "children", "nodes", "out_edges", "section", "attrs"]),
56
+ docs: new Set(["doc", "nodes", "blocks", "out_links", "in_links", "data", "frontmatter", "inline"]),
57
+ blocks: new Set(["block", "doc", "children", "nodes", "out_links", "section", "attrs"]),
57
58
  nodes: new Set(["section", "doc", "block", "blocks", "subsections", "children", "attrs"]),
58
- edges: new Set(["doc"]),
59
+ links: new Set(["doc", "dst"]),
59
60
  };
60
- // ---- intrinsics -------------------------------------------------------------
61
- // A `$`-namespaced intrinsic → a param-free SQL scalar, per target, with its kind.
62
- // Anything not mapped (e.g. docs `$body`, reconstructed; `$title`/`$tags`,
63
- // computed) returns null → residual. `$updated_at`/`$dst_path`/`$dst_uri` are
64
- // correlated subqueries. `$path` and `$dst_path` are the REFERENCE form on the
65
- // surface (spec/surface §1 "Paths", 2.0) — `'/' || <storage column>` in SQL —
66
- // so every comparison, `startsWith`, `contains`, `lower()`… sees exactly the
67
- // string the in-memory intrinsic yields; `pathColumn` keeps the bare indexed
68
- // column at hand for the equality fast path.
61
+ // ---- engine attributes ------------------------------------------------------
62
+ // The engine attributes (spec/surface §1.2, 3.0: bare, snake_case, winning on
63
+ // collision — `id`, `path`, `raw`, `hash`, `updated_at` on docs and blocks,
64
+ // `title`/`tags` on docs, `id`/`path` on nodes and edges) → a param-free SQL
65
+ // scalar, per target, with its kind. The structural columns and foreign keys are
66
+ // translated in `translateOperand`. Anything not mapped (`raw`, reconstructed or
67
+ // a blob; `title`, computed; `tags`, the union of the authored list and the
68
+ // computed hashtags) returns null → residual, and the caller must NOT fall
69
+ // through to a property read of the same name (the attribute wins in memory).
70
+ // `updated_at` is a correlated subquery. `path` (and the edge field `dst_path`)
71
+ // is the REFERENCE form on the surface (spec/surface §1 "Paths", 2.0) — `'/' ||
72
+ // <storage column>` in SQL — so every comparison, `startsWith`, `contains`,
73
+ // `lower()`… sees exactly the string the in-memory attribute yields;
74
+ // `pathColumn` keeps the bare indexed column at hand for the equality fast path.
69
75
  /** The rooted path read over a storage path column. */
70
76
  export function pathColumn(col) {
71
77
  return { sql: `('/' || ${col})`, params: [], kind: "text", pathCol: col };
72
78
  }
73
- function intrinsicSql(name, ctx) {
79
+ /** The identity/bytes/time attributes per kind — the names `attributeSql` owns
80
+ * (the structural columns are handled per target in `translateOperand`). */
81
+ export const ROW_ATTRIBUTES = {
82
+ docs: new Set(["id", "path", "raw", "hash", "updated_at", "title", "tags"]),
83
+ blocks: new Set(["id", "path", "raw", "hash", "updated_at"]),
84
+ nodes: new Set(["id", "path"]),
85
+ links: new Set(["id", "path"]),
86
+ };
87
+ function attributeSql(name, ctx) {
74
88
  const { self, doc, target } = ctx;
75
89
  const text = (sql) => ({ sql, params: [], kind: "text" });
76
- const int = (sql) => ({ sql, params: [], kind: "int" });
77
90
  if (target === "docs")
78
91
  switch (name) {
79
- case "$id": return text(`${self}.doc_id`);
80
- case "$path": return pathColumn(`${doc}.path`);
81
- case "$content_hash": return text(`lower(hex(${self}.file_hash))`);
82
- case "$updated_at": return text(`(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ${self}.current_rev)`);
92
+ case "id": return text(`${self}.doc_id`);
93
+ case "path": return pathColumn(`${doc}.path`);
94
+ case "hash": return text(`lower(hex(${self}.file_hash))`);
95
+ 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)`);
83
96
  }
84
97
  else if (target === "blocks")
85
98
  switch (name) {
86
- case "$id": return text(`${self}.block_id`);
87
- case "$doc": return text(`${self}.doc_id`);
88
- case "$path": return pathColumn(`${doc}.path`);
89
- case "$ordinal": return int(`${self}.ordinal`);
90
- case "$depth": return int(`${self}.depth`);
91
- case "$body": return text(`${self}.text`);
92
- case "$content_hash": return text(`lower(hex(${self}.raw_hash))`);
99
+ case "id": return text(`${self}.block_id`);
100
+ case "path": return pathColumn(`${doc}.path`);
101
+ case "hash": return text(`lower(hex(${self}.raw_hash))`);
93
102
  }
94
103
  else if (target === "nodes")
95
104
  switch (name) {
96
- case "$id":
97
- case "$node_id": return text(`${self}.node_id`);
98
- case "$doc_id": return text(`${self}.doc_id`);
99
- case "$block_id": return text(`${self}.block_id`);
100
- case "$path": return pathColumn(`${doc}.path`);
105
+ case "id": return text(`${self}.node_id`);
106
+ case "path": return pathColumn(`${doc}.path`);
101
107
  }
102
- else if (target === "edges")
108
+ else if (target === "links")
103
109
  switch (name) {
104
- case "$id": return text(`${self}.edge_id`);
105
- case "$src": return text(`${self}.src_doc`);
106
- case "$dst": return text(`${self}.dst_node`);
107
- case "$src_block": return text(`${self}.src_block`);
108
- case "$via": return text(`${self}.via_node`);
109
- case "$from_commit": return text(`${self}.from_commit`);
110
- case "$path": return pathColumn(`${doc}.path`);
111
- case "$dst_path": return text(`(SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = ${self}.dst_node)`);
112
- case "$dst_uri": return text(`(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = ${self}.dst_node)`);
110
+ case "id": return text(`${self}.edge_id`);
111
+ case "path": return pathColumn(`${doc}.path`);
113
112
  }
114
113
  return null;
115
114
  }
116
- /** docs intrinsics whose BARE (non-$) form is a loud error in-memory (10 §2) — not
117
- * pushable, so the residual raises it (and the planner keeps the whole query
118
- * unplanned when one is residual: spec/surface §1 decline (c)). */
119
- export const RESERVED_DOC_BASENAMES = new Set(["id", "path", "updated_at", "content_hash", "body"]);
115
+ /** The links target's plain fields → the store's columns (spec/surface §1.2,
116
+ * 3.0: a foreign key is named for what it is, never `$`-prefixed). `dst_path`
117
+ * and `uri` are correlated subqueries; `dst_path` is the reference form. */
118
+ function edgeField(name, self) {
119
+ const text = (sql) => ({ sql, params: [], kind: "text" });
120
+ switch (name) {
121
+ case "doc_id": return text(`${self}.src_doc`);
122
+ case "block_id": return text(`${self}.src_block`);
123
+ case "via_id": return text(`${self}.via_node`);
124
+ case "dst_id": return text(`${self}.dst_node`);
125
+ case "dst_path": return text(`(SELECT '/' || dd.path FROM docs dd WHERE dd.doc_id = ${self}.dst_node)`);
126
+ case "uri": return text(`(SELECT xn.uri FROM external_nodes xn WHERE xn.node_id = ${self}.dst_node)`);
127
+ case "field": return text(`${self}.src_field`);
128
+ case "predicate":
129
+ case "provenance":
130
+ case "dst_kind":
131
+ case "anchor": return text(`${self}.${name}`);
132
+ default: return null;
133
+ }
134
+ }
120
135
  const SEG = /^[A-Za-z_][A-Za-z0-9_]*$/; // injection-safe inlined identifier
121
136
  // The single-scalar-row scope of a document property (the CEL scalar-in-scope
122
137
  // rule): the row for `key` on the document only when the key has exactly one
123
138
  // row in scope and it is card='scalar' — a list-valued or nested key has none.
124
- // `select` is the projected expression over the alias `p`.
125
- function propSubquery(docAlias, key, select) {
139
+ // `select` is the projected expression over the alias `p`. `source` narrows
140
+ // the scope to one authored source — the read `frontmatter.<k>` / `inline.<k>`
141
+ // performs (spec/surface §1.2; the in-memory `docProp(docId, key, source)`):
142
+ // both the row and the count are of that source's rows only. The merged
143
+ // `data.<k>` read is the bare read, so it passes no source. `docId` is the SQL
144
+ // expression naming the document: the scan's `d.doc_id`, or a link's
145
+ // `e.dst_node` for a `dst.<k>` read (the destination's rows by its id — a
146
+ // non-document or phantom destination has none, so the read is NULL exactly
147
+ // where `dst` is absent in memory).
148
+ function propSubquery(docId, key, select, source) {
149
+ const src = source ? ` AND p.source = '${source}'` : "";
150
+ const src2 = source ? ` AND p2.source = '${source}'` : "";
126
151
  return `(SELECT ${select} FROM properties p
127
- WHERE p.doc_id = ${docAlias}.doc_id AND p.key = '${key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL
128
- AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = ${docAlias}.doc_id AND p2.key = '${key}' AND p2.deleted_commit IS NULL) = 1
152
+ WHERE p.doc_id = ${docId} AND p.key = '${key}' AND p.card = 'scalar' AND p.deleted_commit IS NULL${src}
153
+ AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = ${docId} AND p2.key = '${key}' AND p2.deleted_commit IS NULL${src2}) = 1
129
154
  LIMIT 1)`;
130
155
  }
131
156
  // A single-valued document property: the scalar value of its one row in scope,
132
157
  // else NULL — matching the store context's `docProp` for a scalar read.
133
- function propScalar(docAlias, key) {
158
+ // `docAlias` is the document's table alias; `propScalarOf` takes the id
159
+ // expression itself.
160
+ function propScalar(docAlias, key, source) {
161
+ return propScalarOf(`${docAlias}.doc_id`, key, source);
162
+ }
163
+ function propScalarOf(docId, key, source) {
134
164
  if (!SEG.test(key))
135
165
  return null;
136
166
  return {
137
- sql: propSubquery(docAlias, key, "COALESCE(p.val_text, p.val_num, p.val_bool)"),
167
+ sql: propSubquery(docId, key, "COALESCE(p.val_text, p.val_num, p.val_bool)", source),
138
168
  params: [],
139
169
  kind: "prop",
140
- prop: { docAlias, key },
170
+ prop: source ? { docId, key, source } : { docId, key },
141
171
  };
142
172
  }
173
+ // A key read through a source handle (spec/surface §1.2, 3.0): `data.<k>` is
174
+ // the merged authored read — exactly the bare property `<k>`; `frontmatter.<k>`
175
+ // / `inline.<k>` the same read scoped to one source. Pushed since 3.0 (batch 3;
176
+ // before, every handle member was residual). A whole-handle reference
177
+ // (`frontmatter == null`) and `entries(<handle>)` still decline.
178
+ const SOURCE_HANDLES = { data: undefined, frontmatter: "frontmatter", inline: "inline" };
179
+ function handleRead(docAlias, handle, key) {
180
+ return handleReadOf(`${docAlias}.doc_id`, handle, key);
181
+ }
182
+ function handleReadOf(docId, handle, key) {
183
+ if (!(handle in SOURCE_HANDLES))
184
+ return null;
185
+ return propScalarOf(docId, key, SOURCE_HANDLES[handle]);
186
+ }
143
187
  // json_extract path from validated segments, or null if any segment is unsafe.
144
188
  function jsonExtract(col, segs) {
145
189
  if (segs.some((s) => !SEG.test(s)))
@@ -184,8 +228,14 @@ export function translateOperand(e, ctx) {
184
228
  case "binding":
185
229
  return constOperand(ctx.params[e.index]);
186
230
  case "ident": {
231
+ // An OQX `$` name (`$it`, `$key`, the follow intrinsics) is the language's,
232
+ // never a column; omgbase adds no `$` names (spec/surface §1.2, 3.0).
187
233
  if (e.name.startsWith("$"))
188
- return intrinsicSql(e.name, ctx);
234
+ return null;
235
+ // The row's own attributes win on collision (§1.2): translated or declined
236
+ // (`raw`, `title`, `tags`), never read as a property or an attr.
237
+ if (ROW_ATTRIBUTES[target].has(e.name))
238
+ return attributeSql(e.name, ctx);
189
239
  // A relation, reach-through, source handle or the whole attrs object is not a
190
240
  // scalar read (decline (b)); a bare target name is a relation or a loud
191
241
  // error (root-row.ts), never a property.
@@ -195,28 +245,25 @@ export function translateOperand(e, ctx) {
195
245
  if (target === "docs") {
196
246
  if (e.name === "format")
197
247
  return text(`${self}.format`); // a column, not a property
198
- if (RESERVED_DOC_BASENAMES.has(e.name))
199
- return null; // residual raises the guard
200
248
  return propScalar(doc, e.name);
201
249
  }
202
250
  if (target === "blocks") {
203
- if (e.name === "type" || e.name === "text")
251
+ if (e.name === "doc_id" || e.name === "type" || e.name === "text")
204
252
  return text(`${self}.${e.name}`);
253
+ if (e.name === "ordinal" || e.name === "depth")
254
+ return { sql: `${self}.${e.name}`, params: [], kind: "int" };
205
255
  // Bare non-structural identifier flattens into attrs — same pushdown as
206
256
  // the `attrs.<k>` member form (json_extract), so `checked` == `attrs.checked`.
207
257
  return jsonExtract(`${self}.attrs`, [e.name]);
208
258
  }
209
259
  if (target === "nodes") {
210
- if (e.name === "kind" || e.name === "name" || e.name === "value")
260
+ if (["doc_id", "block_id", "kind", "name", "value"].includes(e.name))
211
261
  return text(`${self}.${e.name}`);
212
262
  // Bare non-structural identifier flattens into attrs (see blocks above).
213
263
  return jsonExtract(`${self}.attrs`, [e.name]);
214
264
  }
215
- if (target === "edges") {
216
- if (["predicate", "provenance", "dst_kind", "anchor", "src_field"].includes(e.name))
217
- return text(`${self}.${e.name}`);
218
- return null;
219
- }
265
+ if (target === "links")
266
+ return edgeField(e.name, self);
220
267
  return null;
221
268
  }
222
269
  case "member": {
@@ -228,27 +275,88 @@ export function translateOperand(e, ctx) {
228
275
  if (head === "attrs" && (target === "blocks" || target === "nodes")) {
229
276
  return jsonExtract(`${self}.attrs`, rest);
230
277
  }
278
+ // <handle>.<k> on docs — `data.layer`, `frontmatter.layer`, `inline.element`
279
+ // — and `doc.<handle>.<k>` through the owning document on the other
280
+ // targets: a property read, scoped to the handle's source.
281
+ if (target === "docs" && head in SOURCE_HANDLES && rest.length === 1)
282
+ return handleRead(doc, head, rest[0]);
283
+ if (head === "doc" && rest.length === 2 && rest[0] in SOURCE_HANDLES)
284
+ return handleRead(doc, rest[0], rest[1]);
231
285
  // doc.<x> reach-through — the owning doc (alias `doc`, which is `d`). On the
232
286
  // docs target `doc` is the row itself; either way it resolves against `doc`.
287
+ // FOREIGN-KEY ELIMINATION (spec/surface §1, 3.0): `doc.id` is the row's
288
+ // own FK column (`b.doc_id`, `n.doc_id`, `e.src_doc`) and `doc.path` /
289
+ // `doc.hash` / `doc.updated_at` the scan's already-joined document
290
+ // columns — no extra join, the same SQL as the bare `doc_id`.
233
291
  if (head === "doc") {
234
292
  if (rest.length !== 1)
235
293
  return null;
236
294
  const k = rest[0];
237
- if (k === "$path")
295
+ if (k === "id")
296
+ return text(target === "links" ? `${self}.src_doc` : `${self}.doc_id`);
297
+ if (k === "path")
238
298
  return pathColumn(`${doc}.path`);
299
+ if (k === "hash")
300
+ return text(`lower(hex(${doc}.file_hash))`);
301
+ if (k === "updated_at")
302
+ return text(`(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ${doc}.current_rev)`);
239
303
  if (k === "format")
240
304
  return text(`${doc}.format`);
241
305
  if (k.startsWith("$"))
242
306
  return null;
243
- if (RESERVED_DOC_BASENAMES.has(k))
244
- return null;
307
+ if (ROW_ATTRIBUTES.docs.has(k))
308
+ return null; // `doc.raw`, `doc.title`, `doc.tags`: the attribute, not pushable
245
309
  if (NON_PROPERTY_NAMES.docs.has(k) || TARGETS.has(k))
246
- return null; // `doc.nodes`, `doc.frontmatter`, `doc.docs` … (decline (b))
310
+ return null; // `doc.nodes`, `doc.data`, `doc.docs` … (decline (b))
247
311
  return propScalar(doc, k);
248
312
  }
249
- // block.type / block.text reach-through from a node.
250
- if (head === "block" && target === "nodes" && rest.length === 1 && (rest[0] === "type" || rest[0] === "text")) {
251
- return text(`(SELECT bb.${rest[0]} FROM blocks bb WHERE bb.block_id = ${self}.block_id)`);
313
+ // dst.<x> reach-through from a link (spec/surface §1.2, 3.0 follow-up): the
314
+ // destination DOCUMENT, keyed by the row's own `dst_node` — the lookup
315
+ // `dst_path` already runs. A property read scopes `properties` by
316
+ // `e.dst_node` directly; the document's own columns are a primary-key
317
+ // probe `(SELECT … FROM docs dd WHERE dd.doc_id = e.dst_node)` — NULL
318
+ // wherever `dst` is absent in memory (a block or external destination, a
319
+ // phantom). `dst.raw` / `dst.title` / `dst.tags` and the relations stay
320
+ // residual, as `doc.*`'s do.
321
+ if (head === "dst" && target === "links") {
322
+ const dstId = `${self}.dst_node`;
323
+ if (rest.length === 2 && rest[0] in SOURCE_HANDLES)
324
+ return handleReadOf(dstId, rest[0], rest[1]);
325
+ if (rest.length !== 1)
326
+ return null;
327
+ const k = rest[0];
328
+ const probe = (col) => text(`(SELECT ${col} FROM docs dd WHERE dd.doc_id = ${dstId})`);
329
+ if (k === "id")
330
+ return probe("dd.doc_id");
331
+ if (k === "path")
332
+ return probe("'/' || dd.path");
333
+ if (k === "hash")
334
+ return probe("lower(hex(dd.file_hash))");
335
+ if (k === "updated_at")
336
+ return probe("(SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = dd.current_rev)");
337
+ if (k === "format")
338
+ return probe("dd.format");
339
+ if (k.startsWith("$"))
340
+ return null;
341
+ if (ROW_ATTRIBUTES.docs.has(k) || NON_PROPERTY_NAMES.docs.has(k) || TARGETS.has(k))
342
+ return null;
343
+ return propScalarOf(dstId, k);
344
+ }
345
+ // block.<x> reach-through from a node: the block's identity is the node's
346
+ // FK (`block.id` → `n.block_id`, `block.path` → the joined `d.path`,
347
+ // `block.doc_id` → `n.doc_id` — FK elimination again); `type`/`text` are
348
+ // a correlated read of the block row.
349
+ if (head === "block" && target === "nodes" && rest.length === 1) {
350
+ const k = rest[0];
351
+ if (k === "id")
352
+ return text(`${self}.block_id`);
353
+ if (k === "path")
354
+ return pathColumn(`${doc}.path`);
355
+ if (k === "doc_id")
356
+ return text(`${self}.doc_id`);
357
+ if (k === "type" || k === "text")
358
+ return text(`(SELECT bb.${k} FROM blocks bb WHERE bb.block_id = ${self}.block_id)`);
359
+ return null;
252
360
  }
253
361
  return null;
254
362
  }
@@ -305,7 +413,7 @@ const IS_OP = {
305
413
  * ordered, exactly as in memory.
306
414
  * Every other pair declines: SQLite sees JSON `true` and `1`, `val_bool` and
307
415
  * `val_num` alike (`$ordinal == checked`, `true == 1`), orders every
308
- * integer before every text (`$ordinal < "3"`, `level < "x"`, `$path > 5`),
416
+ * integer before every text (`$ordinal < "3"`, `level < "x"`, `path > 5`),
309
417
  * and two JSON or property reads carry no type at plan time
310
418
  * (`attrs.a == attrs.b`, `era == stages`).
311
419
  *
@@ -402,7 +510,7 @@ function translateComparison(e, ctx) {
402
510
  }
403
511
  const FLIP = { "<": ">", "<=": ">=", ">": "<", ">=": "<=" };
404
512
  /**
405
- * The path equality fast path: `$path == "/a.md"` (or `!=`, either side) against
513
+ * The path equality fast path: `path == "/a.md"` (or `!=`, either side) against
406
514
  * a rooted text constant is `d.path IS ?` with the constant's storage form, so
407
515
  * the `(repo_id, path)` index serves it; `'/' || d.path IS ?` — what the general
408
516
  * form would emit — is the same predicate without the index. Only a constant
@@ -455,9 +563,9 @@ function typedComparison(op, read, constant) {
455
563
  };
456
564
  }
457
565
  if (read.prop) {
458
- const { docAlias, key } = read.prop;
566
+ const { docId, key, source } = read.prop;
459
567
  const test = constant.kind === "bool" ? `p.type = 'bool' AND p.val_bool = ?` : `p.type = 'number' AND p.val_num ${cmp} ?`;
460
- return { sql: `(${propSubquery(docAlias, key, test)} ${wrap})`, params: [...constant.params] };
568
+ return { sql: `(${propSubquery(docId, key, test, source)} ${wrap})`, params: [...constant.params] };
461
569
  }
462
570
  return null;
463
571
  }
@@ -14,7 +14,7 @@ export interface StoreIndexDeps {
14
14
  }
15
15
  /** A store-backed `RowIndex` for the root scan of `target` on `path`, or
16
16
  * `undefined` when SQLite has no index for the path (per-target table above;
17
- * multi-segment paths; `format`, edge `predicate`/`src_field`, `$tags`, …). */
17
+ * multi-segment paths; `format`, edge `predicate`/`field`, `tags`, …). */
18
18
  export declare function storeIndexFor(target: Target, path: readonly string[], deps: StoreIndexDeps): RowIndex | undefined;
19
19
  /** The paths `storeIndexFor` answers from SQLite, per target (for tests and docs). */
20
20
  export declare function indexablePaths(target: Target): readonly string[];
@@ -1,8 +1,8 @@
1
1
  // Store-backed equality indexes for the OQX engine (`DataContext.indexFor`):
2
- // when a nested block's receiver is a ROOT SCAN (`^docs`, `^edges`,
2
+ // when a nested block's receiver is a ROOT SCAN (`^docs`, `^links`,
3
3
  // a bare `docs` at the root scope) and its correlated or constant equality is on
4
- // a column or property SQLite can look up — `where $path == ^^$path`,
5
- // `where customer == ^$path`, `where $dst == ^$id`, `where type == "order"` —
4
+ // a column or property SQLite can look up — `where path == ^^path`,
5
+ // `where customer == ^path`, `where dst_id == ^id`, `where type == "order"` —
6
6
  // the probe runs ONE prepared statement against the store's indexes instead of
7
7
  // materializing every row of the target and hashing it. The rows come back
8
8
  // exactly as the root scan constructs them (same columns, same `__path`, same
@@ -12,12 +12,12 @@
12
12
  //
13
13
  // Typed equality (OQX SEMANTICS §5) is preserved by probing per VALUE KIND:
14
14
  //
15
- // • a text column (`doc_id`, `path`, `type`, `kind`, `name`, `src_doc`, …)
15
+ // • a text column (`id`, `path`, `type`, `kind`, `name`, `doc_id`, `dst_id`, …)
16
16
  // matches a string probe by `= ?` (TEXT = TEXT, exact); a number, boolean,
17
17
  // object or any other non-string probe matches NO row (the column holds
18
18
  // strings or NULL and `5 == "5"` is false); an absent probe (`null`,
19
19
  // `undefined`: `name == null`) is answered by the in-memory fallback below;
20
- // • a document property (`customer`, `$title`) matches a string probe only on
20
+ // • a document property (`customer`, `title`) matches a string probe only on
21
21
  // a `type = 'string'` row (`val_text = ?`), a number probe only on a
22
22
  // `type = 'number'` row (`val_num = ?`), a boolean only on a `type = 'bool'`
23
23
  // row (`val_bool = ?`), each under the scalar-in-scope rule the in-memory
@@ -38,7 +38,7 @@
38
38
  // contract but the engine never calls it for these indexes: it probes
39
39
  // `lookupRows` first and so never reads the root whole.
40
40
  import { semantics } from "@omgbase/oqx";
41
- import { NON_PROPERTY_NAMES, RESERVED_DOC_BASENAMES } from "./sql/translate.js";
41
+ import { NON_PROPERTY_NAMES, ROW_ATTRIBUTES } from "./sql/translate.js";
42
42
  import { COLS, FROM, FROM_BY_DOC, ORDER, guards, guardsByDoc } from "./sql/scan.js";
43
43
  import { storagePath } from "../core/paths.js";
44
44
  // ---- which paths SQLite answers ------------------------------------------------
@@ -49,19 +49,19 @@ import { storagePath } from "../core/paths.js";
49
49
  * `block_id` (PK), `idx_blocks_doc (doc_id, …)`, `idx_blocks_type (repo_id,
50
50
  * type)`; nodes `node_id` (PK), `idx_nodes_doc`, `idx_nodes_kind`,
51
51
  * `idx_nodes_name`; edges `edge_id` (PK), `idx_edges_src (src_doc, …)`,
52
- * `idx_edges_dst (dst_node, …)`; `$path` on a joined target reaches the docs
52
+ * `idx_edges_dst (dst_node, …)`; `path` on a joined target reaches the docs
53
53
  * `(repo_id, path)` key and then the target's `doc_id`/`src_doc` index. */
54
54
  const COLUMN_PROBES = {
55
- docs: { $id: "d.doc_id = ?", $path: "d.path = ?" },
56
- blocks: { $id: "b.block_id = ?", $doc: "b.doc_id = ?", type: "b.type = ?", $path: "d.path = ?" },
57
- nodes: { $id: "n.node_id = ?", $node_id: "n.node_id = ?", $doc_id: "n.doc_id = ?", kind: "n.kind = ?", name: "n.name = ?", $path: "d.path = ?" },
58
- edges: {
59
- $id: "e.edge_id = ?", $src: "e.src_doc = ?", $dst: "e.dst_node = ?", $path: "d.path = ?",
60
- // `$dst_path` is the destination document's path (deleted or not — the
61
- // intrinsic reads `docs` by id without a liveness guard). A destination is
55
+ docs: { id: "d.doc_id = ?", path: "d.path = ?" },
56
+ blocks: { id: "b.block_id = ?", doc_id: "b.doc_id = ?", type: "b.type = ?", path: "d.path = ?" },
57
+ nodes: { id: "n.node_id = ?", doc_id: "n.doc_id = ?", kind: "n.kind = ?", name: "n.name = ?", path: "d.path = ?" },
58
+ links: {
59
+ id: "e.edge_id = ?", doc_id: "e.src_doc = ?", dst_id: "e.dst_node = ?", path: "d.path = ?",
60
+ // `dst_path` is the destination document's path (deleted or not — the
61
+ // field reads `docs` by id without a liveness guard). A destination is
62
62
  // resolved within the edge's repo (spec/graph §3), where `(repo_id, path)`
63
63
  // is UNIQUE, so the probe is that one document; `?r` is the repo id.
64
- $dst_path: "e.dst_node IN (SELECT doc_id FROM docs WHERE repo_id = ?r AND path = ?)",
64
+ dst_path: "e.dst_node IN (SELECT doc_id FROM docs WHERE repo_id = ?r AND path = ?)",
65
65
  },
66
66
  };
67
67
  /** How a one-segment path on `target` is probed, or null when SQLite has no
@@ -76,14 +76,16 @@ function probeFor(target, path) {
76
76
  if (target !== "docs")
77
77
  return null;
78
78
  // A docs property: any bare name the context reads through `docProp` — not a
79
- // relation / handle (`out`, `frontmatter`, …), not a reserved basename (a loud
80
- // error in memory, left to the scan to raise), not the `format` column (not
81
- // indexed), and of the intrinsics only `$title` (a computed scalar property).
82
- if (key === "$title")
79
+ // relation / handle (`out_links`, `data`, `frontmatter`, …), not the `format` column
80
+ // (not indexed), not an OQX `$` name, and of the engine attributes only
81
+ // `title` (the computed `$title` row, a scalar property the author's `title:`
82
+ // never reaches — `data.title` does): `raw`, `hash`, `updated_at` and `tags`
83
+ // (authored ∪ hashtags) have no index and are never property probes.
84
+ if (key === "title")
83
85
  return { kind: "property", key: "$title", source: "computed" };
84
86
  if (key.startsWith("$") || key === "format")
85
87
  return null;
86
- if (NON_PROPERTY_NAMES.docs.has(key) || RESERVED_DOC_BASENAMES.has(key))
88
+ if (NON_PROPERTY_NAMES.docs.has(key) || ROW_ATTRIBUTES.docs.has(key))
87
89
  return null;
88
90
  return { kind: "property", key, source: null };
89
91
  }
@@ -108,10 +110,10 @@ class StoreIndex {
108
110
  if (this.probe.kind === "column") {
109
111
  if (typeof v !== "string")
110
112
  return [];
111
- // `$path` / `$dst_path` are the reference form in memory (`/a.md`,
113
+ // `path` / `dst_path` are the reference form in memory (`/a.md`,
112
114
  // spec/surface §1 "Paths"); the column holds the storage form. A rooted
113
115
  // probe is de-rooted; a bare one can equal no rooted path — no row.
114
- if (this.path === "$path" || this.path === "$dst_path")
116
+ if (this.path === "path" || this.path === "dst_path")
115
117
  return v.startsWith("/") ? this.column(storagePath(v)) : [];
116
118
  return this.column(v);
117
119
  }
@@ -135,13 +137,13 @@ class StoreIndex {
135
137
  }
136
138
  return out.sort((a, b) => a - b);
137
139
  }
138
- // A `$path` probe on a joined target drives from the document (`FROM_BY_DOC`),
140
+ // A `path` probe on a joined target drives from the document (`FROM_BY_DOC`),
139
141
  // every other column probe from the target's own index. `?r` in a probe is
140
142
  // the repo id, bound ahead of the value.
141
143
  column(value) {
142
144
  const t = this.target;
143
145
  const probe = this.probe.sql;
144
- const byDoc = t !== "docs" && this.path === "$path";
146
+ const byDoc = t !== "docs" && this.path === "path";
145
147
  const g = byDoc ? guardsByDoc(t, this.deps.repoId) : guards(t, this.deps.repoId);
146
148
  const from = byDoc ? FROM_BY_DOC[t] : FROM[t];
147
149
  const params = probe.includes("?r") ? [this.deps.repoId, value] : [value];
@@ -180,7 +182,7 @@ class StoreIndex {
180
182
  }
181
183
  /** A store-backed `RowIndex` for the root scan of `target` on `path`, or
182
184
  * `undefined` when SQLite has no index for the path (per-target table above;
183
- * multi-segment paths; `format`, edge `predicate`/`src_field`, `$tags`, …). */
185
+ * multi-segment paths; `format`, edge `predicate`/`field`, `tags`, …). */
184
186
  export function storeIndexFor(target, path, deps) {
185
187
  const probe = probeFor(target, path);
186
188
  return probe ? new StoreIndex(target, path[0], probe, deps) : undefined;
@@ -188,6 +190,6 @@ export function storeIndexFor(target, path, deps) {
188
190
  /** The paths `storeIndexFor` answers from SQLite, per target (for tests and docs). */
189
191
  export function indexablePaths(target) {
190
192
  const cols = Object.keys(COLUMN_PROBES[target]);
191
- return target === "docs" ? [...cols, "$title", "<property key>"] : cols;
193
+ return target === "docs" ? [...cols, "title", "<property key>"] : cols;
192
194
  }
193
195
  //# sourceMappingURL=store-index.js.map
@@ -61,7 +61,7 @@ export interface Quantifier {
61
61
  varName: string;
62
62
  predicate: Node;
63
63
  }
64
- /** dotted field path; `$` set when intrinsic ($id, $path, doc.$path handled by segments) */
64
+ /** dotted field path; `$` set when intrinsic (id, path, doc.path handled by segments) */
65
65
  export interface FieldRef {
66
66
  kind: "field";
67
67
  segments: string[];
@@ -1,5 +1,5 @@
1
1
  import type { Node } from "./ast.js";
2
- export type Target = "docs" | "blocks" | "nodes" | "edges";
2
+ export type Target = "docs" | "blocks" | "nodes" | "links";
3
3
  export interface AliasCtx {
4
4
  self: string;
5
5
  doc: string;