@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
@@ -37,11 +37,44 @@ function tagAll(rows, t) {
37
37
  r[TARGET] = t;
38
38
  return rows;
39
39
  }
40
- // docs intrinsics whose BARE (non-$) form is almost always a typo (10 §2); a bare
41
- // read of one is a loud error, matching the CEL guard. (A bare target name —
42
- // `docs`, `edges`, … — is a loud error of its own, root-row.ts; `repo` is just an
43
- // ordinary frontmatter key.)
44
- const RESERVED_DOC_BASENAMES = new Set(["id", "path", "updated_at", "content_hash", "body"]);
40
+ // THE NAME RULE (spec/surface §1.2, 3.0). `$` belongs to OQX: `$it`, `$key`,
41
+ // `$depth`, `$stop`, `$leaf`, `$frontier`, `$ordinal` are values the LANGUAGE
42
+ // injects into a scope; omgbase adds no `$` names. Every engine attribute is a
43
+ // bare snake_case name that WINS on collision — the convention the relations
44
+ // (`children`, `blocks`, `out_links`, `doc`, `frontmatter`, …) always followed:
45
+ // a bare name that is an engine attribute reads the engine's value, never the
46
+ // author's. The attributes: every row's `id`/`path`; docs' and blocks' `raw`/
47
+ // `hash`/`updated_at`; docs' `title` (the computed title) and `tags` (authored
48
+ // tags ∪ body hashtags) and `format`; a foreign key named for what it is
49
+ // (`doc_id`, `block_id`, `via_id`, `dst_id`, `dst_path`, `uri`), a column its
50
+ // column (`type`, `text`, `depth`, `ordinal`, `kind`, `predicate`, `field`).
51
+ // User data is bare too, read by any non-colliding key; where it collides it is
52
+ // reached through a source handle — `data` (the merged authored view:
53
+ // frontmatter + inline by the property rule, exactly the read a bare
54
+ // non-colliding key performs), `frontmatter`, `inline` — so `data.path` is the
55
+ // author's `path:` key, `data.tags` the authored list alone, `data.title` the
56
+ // authored title. There is no loud-error set: a bare `id`/`path`/`updated_at`/
57
+ // `hash`/`raw` is simply the engine's attribute (before this batch of 3.0 the
58
+ // attributes were `$`-prefixed and the bare spelling was an error, §9). A bare
59
+ // target name — `docs`, `links`, … — is a loud error of its own (root-row.ts).
60
+ const DOC_ATTRIBUTES = new Set(["id", "path", "raw", "hash", "updated_at", "title", "tags", "format"]);
61
+ const BLOCK_ATTRIBUTES = new Set(["id", "path", "raw", "hash", "updated_at", "doc_id", "type", "text", "attrs", "depth", "ordinal"]);
62
+ const NODE_ATTRIBUTES = new Set(["id", "path", "doc_id", "block_id", "kind", "name", "value", "attrs"]);
63
+ const EDGE_ATTRIBUTES = new Set(["id", "path", "doc_id", "block_id", "via_id", "dst_id", "dst_path", "uri", "field", "dst_kind", "predicate", "provenance", "anchor"]);
64
+ /** The engine attributes per kind (spec/surface §1.2 table). Exported so the
65
+ * translator's tests pin the two against each other. */
66
+ export const ATTRIBUTES = {
67
+ docs: DOC_ATTRIBUTES, blocks: BLOCK_ATTRIBUTES, nodes: NODE_ATTRIBUTES, links: EDGE_ATTRIBUTES,
68
+ };
69
+ /** The source handles of a document (§1.2): `data` is the merged authored view
70
+ * (frontmatter + inline), the other two one source each. */
71
+ export const SOURCE_HANDLES = new Set(["data", "frontmatter", "inline"]);
72
+ /** The value of a statically SINGLE relation that has no row — `dst` on an
73
+ * external or dangling link, `block` on a node without one (oqx 0.20, SEMANTICS
74
+ * §3): an object the engine treats as one row with no rows, so `dst { title }`
75
+ * is absent rather than `[]`, while as a VALUE it is absent (`materialize`),
76
+ * so `dst == null`, `has(dst)` and `dst.title` read as before. */
77
+ export const ABSENT_SINGLE = Object.freeze({});
45
78
  export function makeStoreContext(store, repoId, opts = {}) {
46
79
  const db = store.db;
47
80
  const sv = opts.semanticVectors;
@@ -63,7 +96,7 @@ export function makeStoreContext(store, repoId, opts = {}) {
63
96
  // iteration, indexing, `length`, rendering all see a plain array of tagged
64
97
  // rows) that runs its SELECT on first use. The engine's optimizer asks
65
98
  // `indexFor` about a nested block's receiver BEFORE reading it, so a
66
- // correlated probe on `^docs` / `^edges` / … is answered from the
99
+ // correlated probe on `^docs` / `^links` / … is answered from the
67
100
  // store's indexes (store-index.ts) and the scan never runs; anything else
68
101
  // (a top-level `from docs`, a `count { }`, a non-indexable path) reads it
69
102
  // whole exactly as before. One handle per target per context: a context lives
@@ -106,19 +139,19 @@ export function makeStoreContext(store, repoId, opts = {}) {
106
139
  return proxy;
107
140
  };
108
141
  const rootFns = {
109
- docs: () => lazyRoot("docs"), blocks: () => lazyRoot("blocks"), nodes: () => lazyRoot("nodes"), edges: () => lazyRoot("edges"),
142
+ docs: () => lazyRoot("docs"), blocks: () => lazyRoot("blocks"), nodes: () => lazyRoot("nodes"), links: () => lazyRoot("links"),
110
143
  };
111
144
  // The root row (spec/surface §1.1, 2.0; spec/oqx 0.18 `rootObject`): the
112
145
  // repository, reached as `^$it` from a top-level row (`0^$it` from any depth).
113
146
  // Its own enumerable keys are the four collections — lazy getters, so
114
147
  // `entries(^$it)` names them without running a scan and `^$it.docs` is the
115
- // same handle as `^docs` — and `get` answers `$id` with the repository id (an
116
- // intrinsic, not an entry). Tagged so `get` recognizes it.
148
+ // same handle as `^docs` — and `get` answers `id` with the repository id (an
149
+ // attribute, not an entry). Tagged so `get` recognizes it.
117
150
  const rootRow = Object.defineProperties({ [ROOT_ROW]: true }, {
118
151
  docs: { enumerable: true, get: () => lazyRoot("docs") },
119
152
  blocks: { enumerable: true, get: () => lazyRoot("blocks") },
120
153
  nodes: { enumerable: true, get: () => lazyRoot("nodes") },
121
- edges: { enumerable: true, get: () => lazyRoot("edges") },
154
+ links: { enumerable: true, get: () => lazyRoot("links") },
122
155
  });
123
156
  // ---- attrs / property decoding --------------------------------------------
124
157
  const parseJson = (v) => {
@@ -165,14 +198,18 @@ export function makeStoreContext(store, repoId, opts = {}) {
165
198
  return decodeProp(rows[0]);
166
199
  return rows.map(decodeProp);
167
200
  };
168
- // All of a doc's properties from one source (`frontmatter` / `inline`) as OQX
169
- // entries, for `entries(frontmatter)`: one entry per TOP-LEVEL key, valued by
201
+ // All of a doc's authored properties as OQX entries, for `entries(frontmatter)`
202
+ // / `entries(inline)` / `entries(data)`: one entry per TOP-LEVEL key, valued by
170
203
  // the same scalar-vs-list rule as a bare read (`docProp`), with flattened dotted
171
- // keys (`logging.level`) folded back into one nested entry. Keys come in the
172
- // properties table's deterministic order — by key — because authored key
173
- // position is not indexed (`ord` is the position WITHIN a list-valued key).
204
+ // keys (`logging.level`) folded back into one nested entry. `data` (source
205
+ // null) is frontmatter ∪ inline — the keys deduped, each valued by the merged
206
+ // read. Keys come in the properties table's deterministic order — by key —
207
+ // because authored key position is not indexed (`ord` is the position WITHIN
208
+ // a list-valued key).
174
209
  const docPropEntries = (docId, source) => {
175
- const keys = all(`SELECT DISTINCT key FROM properties WHERE doc_id = ? AND source = ? AND deleted_commit IS NULL ORDER BY key`, docId, source).map((r) => String(r.key));
210
+ const keys = (source
211
+ ? all(`SELECT DISTINCT key FROM properties WHERE doc_id = ? AND source = ? AND deleted_commit IS NULL ORDER BY key`, docId, source)
212
+ : all(`SELECT DISTINCT key FROM properties WHERE doc_id = ? AND source IN ('frontmatter', 'inline') AND deleted_commit IS NULL ORDER BY key`, docId)).map((r) => String(r.key));
176
213
  const tops = [];
177
214
  const seen = new Set();
178
215
  for (const k of keys) {
@@ -182,7 +219,7 @@ export function makeStoreContext(store, repoId, opts = {}) {
182
219
  tops.push(top);
183
220
  }
184
221
  }
185
- return tops.map((t) => semantics.makeEntry(t, docProp(docId, t, source)));
222
+ return tops.map((t) => semantics.makeEntry(t, docProp(docId, t, source ?? undefined)));
186
223
  };
187
224
  // Reconstruct a nested object from flattened dotted keys under `prefix.`
188
225
  // (undefined when there are none). Leaves are decoded property values.
@@ -261,18 +298,17 @@ export function makeStoreContext(store, repoId, opts = {}) {
261
298
  docs: {
262
299
  nodes: (r) => docNodesInOrder(r.doc_id, r.path),
263
300
  blocks: (r) => tagAll(docBlocksPreorder(r.doc_id, r.path).map((x) => x.row), "blocks"),
264
- out: (r) => tagAll(all(`SELECT DISTINCT d2.* FROM docs d2 JOIN edges e ON e.dst_node = d2.doc_id
265
- WHERE e.src_doc = ? AND e.to_commit IS NULL AND d2.repo_id = ? AND d2.deleted_commit IS NULL ORDER BY d2.path, d2.doc_id`, r.doc_id, repoId), "docs"),
266
- in: (r) => tagAll(all(`SELECT DISTINCT d2.* FROM docs d2 JOIN edges e ON e.src_doc = d2.doc_id
267
- WHERE e.dst_node = ? AND e.to_commit IS NULL AND d2.repo_id = ? AND d2.deleted_commit IS NULL ORDER BY d2.path, d2.doc_id`, r.doc_id, repoId), "docs"),
268
- out_edges: (r) => tagAll(all(`SELECT e.*, ? AS __path FROM edges e WHERE e.src_doc = ? AND e.to_commit IS NULL ORDER BY e.predicate, e.edge_id`, r.path, r.doc_id), "edges"),
269
- in_edges: (r) => tagAll(all(`SELECT e.*, d.path AS __path FROM edges e JOIN docs d ON d.doc_id = e.src_doc
270
- WHERE e.dst_node = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL ORDER BY e.predicate, e.edge_id`, r.doc_id), "edges"),
301
+ // The linked / linking DOCUMENTS are reached through the link rows (3.0
302
+ // follow-up, §9: the `out` / `in` relations are gone): `out_links collect
303
+ // distinct { from dst }` and `in_links collect distinct { from doc }`.
304
+ out_links: (r) => tagAll(all(`SELECT e.*, ? AS __path FROM edges e WHERE e.src_doc = ? AND e.to_commit IS NULL ORDER BY e.predicate, e.edge_id`, r.path, r.doc_id), "links"),
305
+ in_links: (r) => tagAll(all(`SELECT e.*, d.path AS __path FROM edges e JOIN docs d ON d.doc_id = e.src_doc
306
+ WHERE e.dst_node = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL ORDER BY e.predicate, e.edge_id`, r.doc_id), "links"),
271
307
  },
272
308
  blocks: {
273
309
  children: (r) => tagAll(all(`SELECT b.*, ? AS __path FROM blocks b WHERE b.parent_block = ? AND b.deleted_commit IS NULL ORDER BY b.ordinal, b.block_id`, r.__path, r.block_id), "blocks"),
274
310
  nodes: (r) => tagAll(all(`SELECT n.*, ? AS __path FROM nodes n WHERE n.block_id = ? ORDER BY n.span_start, n.node_id`, r.__path, r.block_id), "nodes"),
275
- out_edges: (r) => tagAll(all(`SELECT e.*, ? AS __path FROM edges e WHERE e.src_block = ? AND e.to_commit IS NULL ORDER BY e.predicate, e.edge_id`, r.__path, r.block_id), "edges"),
311
+ out_links: (r) => tagAll(all(`SELECT e.*, ? AS __path FROM edges e WHERE e.src_block = ? AND e.to_commit IS NULL ORDER BY e.predicate, e.edge_id`, r.__path, r.block_id), "links"),
276
312
  // enclosing section node(s): md:section whose range contains this block's top ordinal.
277
313
  section: (r) => {
278
314
  const top = topOrdinal(r);
@@ -314,89 +350,193 @@ export function makeStoreContext(store, repoId, opts = {}) {
314
350
  ORDER BY json_extract(i.attrs,'$.first_ordinal'), i.node_id`, r.__path, r.doc_id, lvl, f, l, lvl), "nodes");
315
351
  },
316
352
  },
317
- edges: {},
353
+ links: {},
318
354
  };
319
355
  const jattr = (row, k) => {
320
356
  const a = row.attrs;
321
357
  const o = typeof a === "string" ? parseJson(a) : a;
322
358
  return o && typeof o === "object" ? o[k] : undefined;
323
359
  };
324
- // owning-entity reach-through (single row).
325
- const owningDoc = (row) => tag(one(`SELECT * FROM docs WHERE doc_id = ?`, row.doc_id ?? row.src_doc), "docs");
326
- const owningBlock = (row) => row.block_id
327
- ? tag(one(`SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ?`, row.block_id), "blocks")
328
- : undefined;
329
- // ---- intrinsics ($-fields) ------------------------------------------------
330
- // `$path` / `$dst_path` are the REFERENCE form (`/a/b.md`, spec/surface §1
331
- // "Paths", 2.0): the rows carry the storage form (`path`, the `__path` join
332
- // column) and the intrinsic roots it on read, so nothing below the binding
360
+ // Owning-entity reach-through (single row) with FOREIGN-KEY ELIMINATION
361
+ // (spec/surface §1, 3.0): the owner is handed out as a LAZY row whose identity
362
+ // columns are already known from the child — a block's/node's `doc_id` and
363
+ // `__path` name its document, an edge's `src_doc` and `__path` its source doc,
364
+ // a node's `block_id` (+ `doc_id`, `__path`) its block — so `doc.id` and
365
+ // `doc.path` (and `block.id`/`block.path`/`block.doc_id` on a node) read
366
+ // those columns and run NO statement, exactly like the bare `doc_id`; the full
367
+ // owner row is loaded on the first read of anything else (`doc.layer`,
368
+ // `block.text`). The translator mirrors this in SQL (`doc.id` → the FK
369
+ // column). A Proxy over the backing object so the engine sees a plain tagged
370
+ // row either way (indexing, rendering, `rowRef`).
371
+ const lazyOwner = (known, t, load) => {
372
+ const backing = tag({ ...known }, t);
373
+ const knownKeys = new Set(Object.keys(known));
374
+ let filled = false;
375
+ const fill = () => {
376
+ if (filled)
377
+ return;
378
+ filled = true;
379
+ const full = load();
380
+ if (full)
381
+ Object.assign(backing, full);
382
+ };
383
+ // Only an unknown STRING key is a column read worth the load: symbols (the
384
+ // target tag, the context's own row markers, `Symbol.iterator` probes) are
385
+ // never store columns.
386
+ const loads = (prop) => typeof prop === "string" && !knownKeys.has(prop);
387
+ const proxy = new Proxy(backing, {
388
+ get(target, prop, receiver) {
389
+ if (loads(prop))
390
+ fill();
391
+ return Reflect.get(target, prop, receiver);
392
+ },
393
+ has(target, prop) { if (loads(prop))
394
+ fill(); return Reflect.has(target, prop); },
395
+ ownKeys(target) { fill(); return Reflect.ownKeys(target); },
396
+ getOwnPropertyDescriptor(target, prop) { if (loads(prop))
397
+ fill(); return Reflect.getOwnPropertyDescriptor(target, prop); },
398
+ });
399
+ return proxy;
400
+ };
401
+ const owningDoc = (row) => {
402
+ const id = row.doc_id ?? row.src_doc;
403
+ if (id == null)
404
+ return undefined;
405
+ const path = row.__path;
406
+ if (typeof path !== "string")
407
+ return tag(one(`SELECT * FROM docs WHERE doc_id = ?`, id), "docs");
408
+ return lazyOwner({ doc_id: id, path }, "docs", () => one(`SELECT * FROM docs WHERE doc_id = ?`, id));
409
+ };
410
+ // `dst` on a link row (spec/surface §1.2, 3.0 follow-up): the destination
411
+ // DOCUMENT row when the link points at one (`dst_kind == "document"` and the
412
+ // document exists — a phantom target has the kind and no row), read by
413
+ // `dst_id` exactly as `dst_path` is; absent for a `block` or `external`
414
+ // destination (never reached into). `doc` stays the source document.
415
+ const destinationDoc = (row) => {
416
+ if (row.dst_kind !== "document")
417
+ return undefined;
418
+ return tag(one(`SELECT * FROM docs WHERE doc_id = ?`, row.dst_node), "docs");
419
+ };
420
+ const owningBlock = (row) => {
421
+ if (!row.block_id)
422
+ return undefined;
423
+ const load = () => one(`SELECT b.*, d.path AS __path FROM blocks b JOIN docs d ON d.doc_id = b.doc_id WHERE b.block_id = ?`, row.block_id);
424
+ if (typeof row.__path !== "string")
425
+ return tag(load(), "blocks");
426
+ return lazyOwner({ block_id: row.block_id, doc_id: row.doc_id, __path: row.__path }, "blocks", load);
427
+ };
428
+ // ---- engine attributes -----------------------------------------------------
429
+ // Bare, snake_case, winning on collision (see the name rule above; spec/surface
430
+ // §1.2, 3.0). `path` (and the edge field `dst_path`) is the REFERENCE form
431
+ // (`/a/b.md`, §1 "Paths", 2.0): the rows carry the storage form (`path`, the
432
+ // `__path` join column) and the read roots it, so nothing below the binding
333
433
  // changes. run.ts roots the string literals a query compares them with.
334
- const intrinsic = (row, t, name) => {
335
- // `$self` = the current row: run.ts rewrites row-scoped domain functions
336
- // (text/semantic/under_*/…) to `$self.fn(…)` so they arrive via callMethod
337
- // with the row as receiver (a free function sees no row).
338
- if (name === "$self")
339
- return row;
434
+ // `undefined` means "not an attribute" — the caller falls through to the
435
+ // handles, relations and the author's data.
436
+ const attribute = (row, t, name) => {
340
437
  if (t === "docs")
341
438
  switch (name) {
342
- case "$id": return row.doc_id;
343
- case "$path": return referencePath(String(row.path));
344
- case "$content_hash": return row.file_hash == null ? null : hex(row.file_hash);
345
- case "$updated_at": return scalar(`SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ?`, row.current_rev) ?? null;
346
- case "$body": return docsRead(store, String(row.doc_id))?.content ?? null;
347
- case "$title": return docProp(String(row.doc_id), "$title", "computed") ?? null;
348
- case "$tags": return docProp(String(row.doc_id), "$tags", "computed") ?? null;
439
+ case "id": return row.doc_id;
440
+ case "path": return referencePath(String(row.path));
441
+ case "hash": return row.file_hash == null ? null : hex(row.file_hash);
442
+ case "updated_at": return scalar(`SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = ?`, row.current_rev) ?? null;
443
+ // `raw`: the exact file bytes (spec/store §6.1, frontmatter included).
444
+ case "raw": return docsRead(store, String(row.doc_id))?.raw ?? null;
445
+ // `title`: the computed title (spec/properties §3.3 — the authored
446
+ // title, else the first H1, else absent → null). `data.title` is the
447
+ // authored one alone.
448
+ case "title": return docProp(String(row.doc_id), "$title", "computed") ?? null;
449
+ // `tags`: the union of the authored tags (frontmatter + inline, by the
450
+ // property rule) and the body `#hashtags` (the computed `$tags` row,
451
+ // spec/properties §3.3), deduped, authored first in authored order, then
452
+ // the hashtags in document order; null when both are absent.
453
+ // `data.tags` is the authored list alone.
454
+ case "tags": return docTags(String(row.doc_id));
455
+ case "format": return row.format;
349
456
  }
350
457
  if (t === "blocks")
351
458
  switch (name) {
352
- case "$id": return row.block_id;
353
- case "$doc": return row.doc_id;
354
- case "$path": return referencePath(String(row.__path));
355
- case "$ordinal": return row.ordinal;
356
- case "$depth": return row.depth;
357
- case "$body": return row.text;
358
- case "$content_hash": return row.raw_hash == null ? null : hex(row.raw_hash);
359
- case "$updated_at": return scalar(`SELECT MAX(c.ts) FROM block_changes bc JOIN commits c ON c.commit_id = bc.commit_id WHERE bc.block_id = ?`, row.block_id) ?? null;
459
+ case "id": return row.block_id;
460
+ case "path": return referencePath(String(row.__path));
461
+ // `raw`: the block's raw source bytes — the blob its `raw_hash` names (a
462
+ // container's raw holds its children verbatim); `text` is the normalized text.
463
+ case "raw": return row.raw_hash == null ? null : scalar(`SELECT bytes FROM blobs WHERE hash = ?`, row.raw_hash)?.toString("utf8") ?? null;
464
+ case "hash": return row.raw_hash == null ? null : hex(row.raw_hash);
465
+ case "updated_at": return scalar(`SELECT MAX(c.ts) FROM block_changes bc JOIN commits c ON c.commit_id = bc.commit_id WHERE bc.block_id = ?`, row.block_id) ?? null;
466
+ case "doc_id": return row.doc_id;
467
+ case "type": return row.type;
468
+ case "text": return row.text;
469
+ case "attrs": return parseJson(row.attrs);
470
+ case "ordinal": return row.ordinal;
471
+ case "depth": return row.depth;
360
472
  }
361
473
  if (t === "nodes")
362
474
  switch (name) {
363
- case "$id":
364
- case "$node_id": return row.node_id;
365
- case "$doc_id": return row.doc_id;
366
- case "$block_id": return row.block_id;
367
- case "$path": return referencePath(String(row.__path));
475
+ case "id": return row.node_id;
476
+ case "path": return referencePath(String(row.__path));
477
+ case "doc_id": return row.doc_id;
478
+ case "block_id": return row.block_id ?? null;
479
+ case "kind": return row.kind;
480
+ case "name": return row.name;
481
+ case "value": return row.value;
482
+ case "attrs": return parseJson(row.attrs);
368
483
  }
369
- if (t === "edges")
484
+ if (t === "links")
370
485
  switch (name) {
371
- case "$id": return row.edge_id;
372
- case "$src": return row.src_doc;
373
- case "$dst": return row.dst_node;
374
- case "$src_block": return row.src_block;
375
- case "$via": return row.via_node;
376
- case "$from_commit": return row.from_commit;
377
- case "$path": return referencePath(String(row.__path));
378
- case "$dst_path": {
486
+ // the store's columns under the surface's names (spec/surface §1.2).
487
+ case "id": return row.edge_id;
488
+ case "path": return referencePath(String(row.__path));
489
+ case "doc_id": return row.src_doc;
490
+ case "block_id": return row.src_block ?? null;
491
+ case "via_id": return row.via_node ?? null;
492
+ case "dst_id": return row.dst_node;
493
+ case "dst_path": {
379
494
  const p = scalar(`SELECT path FROM docs WHERE doc_id = ?`, row.dst_node);
380
495
  return p == null ? null : referencePath(String(p));
381
496
  }
382
- case "$dst_uri": return scalar(`SELECT uri FROM external_nodes WHERE node_id = ?`, row.dst_node) ?? null;
497
+ case "uri": return scalar(`SELECT uri FROM external_nodes WHERE node_id = ?`, row.dst_node) ?? null;
498
+ case "field": return row.src_field ?? null;
499
+ case "predicate":
500
+ case "provenance":
501
+ case "dst_kind":
502
+ case "anchor": return row[name];
383
503
  }
384
504
  return undefined;
385
505
  };
506
+ // `tags` on a document: authored ∪ hashtags, each list as the property rule
507
+ // reads it (a scalar-authored `tags: x` is one element), deduped by value
508
+ // (canonical JSON, so both engines agree on non-string elements).
509
+ const docTags = (docId) => {
510
+ const asList = (v) => (v === undefined || v === null ? [] : Array.isArray(v) ? v : [v]);
511
+ const authored = asList(docProp(docId, "tags"));
512
+ const hashtags = asList(docProp(docId, "$tags", "computed"));
513
+ if (authored.length === 0 && hashtags.length === 0)
514
+ return null;
515
+ const seen = new Set();
516
+ const out = [];
517
+ for (const v of [...authored, ...hashtags]) {
518
+ const k = JSON.stringify(v) ?? "undefined";
519
+ if (seen.has(k))
520
+ continue;
521
+ seen.add(k);
522
+ out.push(v);
523
+ }
524
+ return out;
525
+ };
386
526
  // ---- get ------------------------------------------------------------------
387
527
  const getFrom = (row, key) => {
388
528
  if (row == null)
389
529
  return undefined;
390
- // The root row: `^$it.$id` is the repository id, `^$it.<target>` the lazy
530
+ // The root row: `^$it.id` is the repository id, `^$it.<target>` the lazy
391
531
  // root scan (the same handle `^<target>` / `root(name)` hands out).
392
532
  if (row[ROOT_ROW]) {
393
- if (key === "$id")
533
+ if (key === "id")
394
534
  return repoId;
395
535
  return rootFns[key] ? rootFns[key]() : undefined;
396
536
  }
397
537
  if (row[PROP_SOURCE]) {
398
538
  const p = row;
399
- return docProp(p.docId, key, p.source);
539
+ return docProp(p.docId, key, p.source ?? undefined);
400
540
  }
401
541
  const t = row[TARGET];
402
542
  if (!t) {
@@ -404,8 +544,16 @@ export function makeStoreContext(store, repoId, opts = {}) {
404
544
  return row[key];
405
545
  }
406
546
  const r = row;
407
- if (key.startsWith("$"))
408
- return intrinsic(r, t, key);
547
+ // `$self` = the current row: run.ts rewrites row-scoped domain functions
548
+ // (text/semantic/under_*/…) to `$self.fn(…)` so they arrive via callMethod
549
+ // with the row as receiver (a free function sees no row). Any other `$`
550
+ // name is OQX's (resolved by the engine before the row) or unknown — never
551
+ // an omgbase attribute.
552
+ if (key === "$self")
553
+ return r;
554
+ // engine attributes win on collision (the name rule above).
555
+ if (ATTRIBUTES[t].has(key))
556
+ return attribute(r, t, key);
409
557
  // self-alias namespaces: `doc.x` (docs), `block.x` (blocks), `section.x` (nodes).
410
558
  if (t === "docs" && key === "doc")
411
559
  return r;
@@ -413,62 +561,40 @@ export function makeStoreContext(store, repoId, opts = {}) {
413
561
  return r;
414
562
  if (t === "nodes" && key === "section")
415
563
  return r;
416
- // reach-through to owning entities.
564
+ // reach-through to owning entities, and from a link row to its destination —
565
+ // statically single relations: absent is the single-with-no-row marker.
417
566
  if (t !== "docs" && key === "doc")
418
- return owningDoc(r);
567
+ return owningDoc(r) ?? ABSENT_SINGLE;
419
568
  if (t === "nodes" && key === "block")
420
- return owningBlock(r);
569
+ return owningBlock(r) ?? ABSENT_SINGLE;
570
+ if (t === "links" && key === "dst")
571
+ return destinationDoc(r) ?? ABSENT_SINGLE;
421
572
  // relations.
422
573
  const rels = rel[t];
423
574
  if (rels[key])
424
575
  return rels[key](r);
425
576
  // A bare target name that is not one of this row's relations reads a
426
577
  // property the row has none of (spec/surface §1.1, 2.0): loud, naming the
427
- // root-row spelling. `docs`/`edges` never get here (run.ts refuses them
578
+ // root-row spelling. `docs`/`links` never get here (run.ts refuses them
428
579
  // statically); `blocks` on a block or edge row and `nodes` on a node or
429
580
  // edge row do.
430
581
  if (TARGETS.has(key))
431
582
  throw new FilterInvalid(bareTargetMessage(key, null), "OQX");
432
- // per-target scalar fields.
583
+ // the author's data.
433
584
  if (t === "docs") {
434
- if (key === "format")
435
- return r.format;
436
- if (key === "frontmatter" || key === "inline")
437
- return { [PROP_SOURCE]: true, docId: String(r.doc_id), source: key };
438
- if (RESERVED_DOC_BASENAMES.has(key)) {
439
- throw new FilterInvalid(`bare '${key}' reads a frontmatter key; did you mean the intrinsic $${key}? (use frontmatter.${key} to force the property)`, "10 §2");
440
- }
585
+ // The source handles: `data` is the merged authored view (frontmatter +
586
+ // inline), the other two one source each; `data.<k>` reaches an authored
587
+ // key whose bare name is an engine attribute (`data.path`, `data.tags`).
588
+ if (SOURCE_HANDLES.has(key))
589
+ return { [PROP_SOURCE]: true, docId: String(r.doc_id), source: key === "data" ? null : key };
441
590
  return docProp(String(r.doc_id), key);
442
591
  }
443
- if (t === "blocks") {
444
- if (key === "type")
445
- return r.type;
446
- if (key === "text")
447
- return r.text;
448
- if (key === "attrs")
449
- return parseJson(r.attrs);
450
- // A bare non-structural identifier flattens into attrs: `checked` reads
451
- // `attrs.checked`. Structural fields above win on collision; an absent key
452
- // is undefined (silently false in a predicate), matching a doc's missing
453
- // frontmatter key. The `attrs.<k>` form still works unchanged.
592
+ // blocks / nodes: a bare non-structural identifier flattens into attrs:
593
+ // `checked` reads `attrs.checked`. The attributes above win on collision; an
594
+ // absent key is undefined (silently false in a predicate), matching a doc's
595
+ // missing frontmatter key. The `attrs.<k>` form still works unchanged.
596
+ if (t === "blocks" || t === "nodes")
454
597
  return jattr(r, key);
455
- }
456
- if (t === "nodes") {
457
- if (key === "kind")
458
- return r.kind;
459
- if (key === "name")
460
- return r.name;
461
- if (key === "value")
462
- return r.value;
463
- if (key === "attrs")
464
- return parseJson(r.attrs);
465
- // Bare non-structural identifier flattens into attrs (see blocks above):
466
- // `checked` reads `attrs.checked` on an `md:task`, `level` on an `md:section`.
467
- return jattr(r, key);
468
- }
469
- // edges
470
- if (key === "predicate" || key === "provenance" || key === "dst_kind" || key === "anchor" || key === "src_field")
471
- return r[key];
472
598
  return undefined;
473
599
  };
474
600
  // ---- domain functions (row-scoped, via callMethod on $self) ---------------
@@ -526,8 +652,8 @@ export function makeStoreContext(store, repoId, opts = {}) {
526
652
  }
527
653
  };
528
654
  const textMatch = (t, r, terms) => {
529
- if (t === "edges")
530
- throw new FilterInvalid("text(...) is not available on the edges target", "10 §5");
655
+ if (t === "links")
656
+ throw new FilterInvalid("text(...) is not available on the links target", "10 §5");
531
657
  const match = sanitizeFtsQuery(terms);
532
658
  if (match === "")
533
659
  return false;
@@ -550,7 +676,7 @@ export function makeStoreContext(store, repoId, opts = {}) {
550
676
  return one(`SELECT vec FROM embeddings WHERE content_hash = ? AND ctx_hash = ? AND model = ?`, r.raw_hash, Buffer.from(ctxHashHex(ctx), "hex"), model);
551
677
  };
552
678
  const semanticScore = (t, r, phrase) => {
553
- if (t === "nodes" || t === "edges")
679
+ if (t === "nodes" || t === "links")
554
680
  throw new FilterInvalid("semantic(...) is available on the docs and blocks targets", "OQX semantic");
555
681
  const resolved = sv?.get(phrase);
556
682
  if (!resolved)
@@ -633,6 +759,8 @@ export function makeStoreContext(store, repoId, opts = {}) {
633
759
  // Store indexes by (target, path) — one object, one set of prepared statements,
634
760
  // for the whole run (`null`: the path is not indexable).
635
761
  const indexes = new Map();
762
+ // A row's identity — `$key` (spec/oqx §16, 0.20): its id column for every
763
+ // kind; anything else (a property bag, a lifted value) is known structurally.
636
764
  const identity = (row) => {
637
765
  const t = row?.[TARGET];
638
766
  if (t === "docs")
@@ -641,18 +769,18 @@ export function makeStoreContext(store, repoId, opts = {}) {
641
769
  return row.block_id;
642
770
  if (t === "nodes")
643
771
  return row.node_id;
644
- if (t === "edges")
772
+ if (t === "links")
645
773
  return row.edge_id;
646
774
  return row;
647
775
  };
648
776
  return {
649
777
  // The root scope's names (spec/oqx §2 step 5 at the root): the four lazy
650
- // scans and `$id`, the repository id — so `^docs` / `0^docs` from any row is
651
- // the scan and `^$id` / `0^$id` the id.
778
+ // scans and `id`, the repository id — so `^docs` / `0^docs` from any row is
779
+ // the scan and `^id` / `0^id` the id.
652
780
  root(name) {
653
781
  if (opts.rowsRoot && name === opts.rowsRoot.name)
654
782
  return opts.rowsRoot.rows;
655
- if (name === "$id")
783
+ if (name === "id")
656
784
  return repoId;
657
785
  return rootFns[name] ? rootFns[name]() : undefined;
658
786
  },
@@ -662,8 +790,11 @@ export function makeStoreContext(store, repoId, opts = {}) {
662
790
  return rootRow;
663
791
  },
664
792
  get: getFrom,
793
+ // Row position (spec/oqx §3): a tagged row is ONE row (single — the engine's
794
+ // default for a non-iterable object), a lazy scan (an iterable Proxy) a
795
+ // collection, the absent-single marker one row with none.
665
796
  toRows(value) {
666
- if (value == null)
797
+ if (value == null || value === ABSENT_SINGLE)
667
798
  return [];
668
799
  if (Array.isArray(value))
669
800
  return value;
@@ -673,7 +804,11 @@ export function makeStoreContext(store, repoId, opts = {}) {
673
804
  return value;
674
805
  return [value];
675
806
  },
676
- identity,
807
+ // Value position: the absent-single marker is absent.
808
+ materialize(value) {
809
+ return value === ABSENT_SINGLE ? undefined : value;
810
+ },
811
+ identityOf: identity,
677
812
  // A store-backed equality index for a ROOT SCAN handle on a column or
678
813
  // property SQLite indexes (store-index.ts); `undefined` for any other
679
814
  // collection (a relation's rows, a lifted array) or path, so the engine
@@ -699,9 +834,10 @@ export function makeStoreContext(store, repoId, opts = {}) {
699
834
  // pushdown can serve the property's autopromoted/cached bounds — see
700
835
  // detectRange + val_json in store/properties.ts.
701
836
  callFunction(name, args) {
702
- // `entries(frontmatter)` / `entries(inline)`: the source handle is lazy (a
703
- // PropSourceRef, not a plain object), so materialize its property bag as
704
- // entries here; any other argument falls through to the oqx builtin.
837
+ // `entries(data)` / `entries(frontmatter)` / `entries(inline)`: the source
838
+ // handle is lazy (a PropSourceRef, not a plain object), so materialize its
839
+ // property bag as entries here; any other argument falls through to the
840
+ // oqx builtin.
705
841
  if (name === "entries" && args[0]?.[PROP_SOURCE]) {
706
842
  const p = args[0];
707
843
  return { handled: true, value: docPropEntries(p.docId, p.source) };
@@ -743,9 +879,10 @@ export function tagRows(rows, target) {
743
879
  }
744
880
  /**
745
881
  * A store row surfacing as a VALUE in a result (spec/surface §1.4, rows as
746
- * values, 1.2) is rendered as `{ id, path }` — its id column and its document's
747
- * path in the reference form (`/a.md`, §1 "Paths"), the same two keys every hit
748
- * carries — never the store row itself.
882
+ * values, 1.2) is rendered as `{ id, path }` (3.0) — its id column and its
883
+ * document's path in the reference form (`/a.md`, §1 "Paths"), the same two keys
884
+ * every hit carries, spelled as the attributes that read them — never the store
885
+ * row itself.
749
886
  * Returns undefined for anything that is not a tagged row (run.ts leaves those
750
887
  * alone).
751
888
  */
@@ -12,12 +12,12 @@ export declare function rootTarget(source: Expr): Target | null;
12
12
  * • any `call` node (a function or method: unknown name, bad regex, wrong target…)
13
13
  * • any nested block whose consumer is `single` (raises on 0 or >1 rows)
14
14
  * • any `^`-escaped name: an `outer` reference or a `^name:` lift
15
- * • a bare reserved docs basename (`id path updated_at content_hash body`): as
15
+ * • a bare reserved docs basename (`id path updated_at hash raw`): as
16
16
  * an `ident` at the ROOT scope when the target is `docs`, or as `doc.<name>`
17
17
  * on any target at any depth (the reach-through row is a doc)
18
- * • a bare target name (`docs blocks nodes edges`, root-row.ts) read as a
18
+ * • a bare target name (`docs blocks nodes links`, root-row.ts) read as a
19
19
  * property: as an `ident` at the ROOT scope when it is not a relation of
20
- * the target's rows (`blocks` on blocks, `nodes` on nodes, any on edges),
20
+ * the target's rows (`blocks` on blocks, `nodes` on nodes, any on links),
21
21
  * as an `ident` at any depth below (the rows there are unknown), or as
22
22
  * `<head>.<name>` unless the head is a reach-through whose row has that
23
23
  * relation (`doc.nodes`, `doc.blocks`, `block.nodes`, `section.blocks`)