@omgbase/core 0.7.0 → 0.8.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/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +10 -8
- package/dist/src/mcp/server.js +1 -1
- package/dist/src/oqx-js/context.js +90 -22
- package/dist/src/oqx-js/planner.js +1 -34
- package/dist/src/oqx-js/run.js +1 -1
- package/dist/src/oqx-js/sql/scan.d.ts +27 -0
- package/dist/src/oqx-js/sql/scan.js +62 -0
- package/dist/src/oqx-js/store-index.d.ts +20 -0
- package/dist/src/oqx-js/store-index.js +184 -0
- package/dist/src/spec-versions.d.ts +1 -1
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const QUERY_SYNTAX = "# query \u2014 OQX syntax reference\n\nThe `query` tool takes ONE plain OQX string (+ optional `limit`/`cursor`). There\nis no {from, filter, order} envelope: every concern is a clause of the string.\n\n [select <items>] from <target> [where <pred>] [follow <dest>, \u2026 [{ \u2026 }]]\n [order by <expr> [asc|desc], \u2026] [limit N] [offset N]\n $repo.<target> count|exists|none|first|single { <block> } (scalar/one-row form)\n\nCLAUSE ORDER IS FIXED: select, from, where, follow, order by, limit, offset \u2014\neach at most once; an out-of-order clause is a parse error naming the order.\nOnly `select` may drop its keyword, and only as the first clause\n(`$path, era from docs where \u2026`). Every other clause always carries its keyword:\na predicate is NEVER implicit (`nodes exists { where kind == \"md:task\" }`, not\n`{ kind == \"md:task\" }`), and `from docs count` is an error (write\n`$repo.docs count { \u2026 }`). `where` may use the same body's `select` aliases\n(`select $path, old: era < 1000 from docs where old`; an alias shadows a\nsame-named field there).\n\nResults are LEAN hits \u2014 {id, path} + whatever `select` projects (or `values`,\n`count`, `exists`, `none`). Hydrate full content by id via nodes_get/docs_read.\n\n## Targets & field namespaces\n\nThe sigil rule: `$`-prefixed names are engine intrinsics; BARE identifiers are\nyour content. On docs a bare first segment that collides with an intrinsic base\nname (id/path/repo/updated_at/content_hash/body) is REJECTED with a \"did you mean\n$X?\" hint (bare `path` would silently read an absent frontmatter key). Force the\nproperty with `frontmatter.<k>`. `title`/`tags` are NOT reserved: `$title`/`$tags`\nare computed and never shadow an authored `title:`/`tags:` key.\n\ndocs:\n - bare identifier = a document PROPERTY (frontmatter + inline `key:: value`,\n unioned; nested via dots: meta.owner). YAML/JSON docs\n expose the parsed object's keys the same way.\n - source-scoped = frontmatter.<k> / inline.<k>; the whole bag as a collection\n via entries(frontmatter) / entries(inline) (see entries()).\n - computed = $title (first H1), $tags (body #hashtags), format\n (the doc's format \u2014 \"markdown\"/\"yaml\"/\"json\"; a bare\n computed field, present on EVERY doc, so has(format) is\n always true and `format == \"yaml\"` is the filter).\n - intrinsics = $id, $path, $updated_at (ISO-8601, sorts chronologically),\n $body (whole file), $content_hash.\n - relations = nodes, blocks, doc.out / doc.in (citation graph),\n doc.out_edges / doc.in_edges (a doc's edges as rows).\n\nblocks:\n - bare fields = type, text, and every attrs key FLATTENED onto the row\n (bare `checked` reads attrs.checked; `attrs.<k>` still\n works; type/text win on collision; absent \u21D2 false).\n - intrinsics = $id, $doc, $path, $ordinal, $depth, $updated_at, $body\n (the block text), $content_hash (the block's OWN raw hash \u2014\n the value update/split expect).\n - reach-through = doc.<key> / doc.$path / doc.format = the CONTAINING doc.\n - relations = block.children, block.nodes, block.out_edges, section.\n\nnodes:\n - bare fields = kind (md:task / md:link / md:section / yaml:\u2026), name,\n value, + attrs keys flattened (checked, level).\n - intrinsics = $id (=$node_id), $doc_id, $block_id, $path.\n - reach-through = doc.<key>, block.type / block.text.\n - relations = section.blocks, section.children, section.subsections.\n\nedges: (the authored link graph as rows \u2014 one per open edge)\n - bare fields = predicate (\"references\"/\"embeds\"/freeform), provenance\n (\"link\"/\"frontmatter\"/\"inline_field\"/\u2026), dst_kind\n (\"document\"/\"external\"/\"collection\"), anchor, src_field\n - intrinsics = $id, $src, $dst, $dst_path (target doc path; NULL when\n dangling/external), $dst_uri (external URL; NULL\n otherwise), $src_block, $via, $from_commit; $path and\n doc.<key> reach the SOURCE document.\n - text()/semantic() are N/A on edges.\n\n## Scoping (ADR-015): bare names are LOCAL\n\n name the CURRENT row only \u2014 never falls through to an enclosing row\n or the repository; an absent field is simply absent.\n ^name one scope OUT (the enclosing row's fields/intrinsics/lifts);\n ^^name two scopes out. `^$path` = the enclosing row's path.\n $repo.<t> the root scan \u2014 $repo.docs / $repo.nodes / $repo.blocks /\n $repo.edges \u2014 available from ANY depth (uncorrelated until you\n add a ^ predicate). $repo.$id = the repository id.\n $value the current item itself (a row, or each ELEMENT when the\n receiver is a list property); ^$value = the enclosing row.\n $key inside an entries(x) block: the entry's key.\n\n## Scalar semantics (@omgbase/oqx)\n\n comparisons == != < <= > >= strict & typed (5 == \"5\" is false); either\n side may be a field (field-vs-field OK).\n absence null and a missing field are the same \"absent\" value; two absents\n are EQUAL, so `absent != \"x\"` is TRUE and `absent == null` is TRUE.\n Relational (< <= > >=) on an absent operand \u2192 FALSE. Bare `f` in\n boolean position uses JS truthiness (absent/false/0/\"\" \u21D2 false;\n NOTE: \"false\", [] and {} are truthy). has(f) = explicit presence.\n boolean && || ! grouping ( \u2026 )\n arithmetic + - * / % (+ concatenates if either side is a string)\n membership x in y array \u2192 typed membership; string \u2192 substring;\n object \u2192 key exists; range \u2192 coverage. Any list\n property works directly: \"pricing\" in tags. list(f)\n coerces scalar-or-list (absent \u2192 []) \u2014 use it when a\n key is sometimes scalar, sometimes a list.\n ranges lo..hi (inclusive) lo...hi (exclusive hi) ..hi lo..\n era in 800..1680; works over ISO dates. range(prop) reads a\n range-shaped STRING property as an interval: \"2026-01-15\" in range(window).\n strings x.contains(\"s\") x.startsWith(\"p\") x.endsWith(\"s\") x.matches(\"^re$\")\n (real regex) x.lower() x.upper() size(x). ALL CASE-SENSITIVE \u2014\n fold with .lower(): $title.lower().contains(\"aurora\").\n functions has(f) size(x) list(x) entries(x) range(s)\n literals \"str\" 'str' 123 1.5 true false null\n determinism no now()/clock/random. Lists/structs are not literals; ternary is\n not supported. Errors surface as filter_invalid.\n scalars vs lists a list-authored key never == a scalar (tags == \"x\" is false\n when tags is a list) \u2014 use `\"x\" in tags` / `in list(tags)`.\n\n## Domain predicates (omgbase)\n\n text(\"terms\") FTS5 PRUNING predicate (docs match via their blocks, nodes\n via the node index); composes anywhere, incl. nested scopes.\n semantic(\"phrase\") embedding cosine SCORE (docs/blocks): threshold it\n (semantic(\"x\") > 0.6), project it, or `order by semantic(\"x\")\n desc` for top-K. Needs a provider; else semantic_unavailable.\n blocks target only:\n under(id_or_heading) block in the containment subtree / section range\n under_heading(\"Launch\") an ancestor section heading contains the text (ci)\n under_kind(type[, name]) an ancestor block has that type (name: its text\n contains / its key equals name)\n within(\"path\" | \"d_..\") containing doc = id, exact path, or path glob (*)\n yaml_path(\"a.b\") YAML docs: the block at that key path\n json_pointer(\"#/a/b\") JSON docs: the block at that pointer\n has_edge(\"pred\"[, target]) an open authored edge with that predicate leaves here\n has_anchor() block carries an authored ^ref\n parent_type() == \"x\" parent block's type (must be compared)\n child_count() > 0 number of direct children (must be compared)\n\n## Nested queries & consumers\n\n <receiver> collect|exists|none|count|first|single { <block> }\n receiver = a relation (nodes, blocks, doc.out_edges, section.blocks, \u2026), a\n list property (tags), entries(x), or a root ($repo.docs).\n block = [select \u2026] [where \u2026] [follow \u2026] [order by \u2026] [limit N] [offset N]\n \u2014 the same fixed order as the top level (`from` optional; the\n receiver supplies the rows). A leading name/alias list is the\n projection (`select` dropped); a predicate always needs `where`.\n exists \u22651 row nodes exists { where kind == \"md:task\" && !checked }\n none 0 rows nodes none { where kind == \"md:task\" && !checked } (\"every task done\")\n count a number nodes count { where kind == \"md:task\" } >= 2\n collect rows (default) \u2014 in select for nested results; in where with a lift\n first / single zero-or-one (single errors on >1) \u2014 lookups in select\n distinct on any consumer: dedup by projected value \u2014 nodes count distinct { select kind }\n limit/offset bound the block's rows AFTER where/order/distinct, BEFORE the\n consumer reduces: nodes exists { offset 1 } = at least two;\n first { \u2026 offset 1 } = the second.\n lift `^name:` in a where-collect binds values outward:\n select $path, open from docs where nodes collect { ^open: value where kind == \"md:task\" && !checked }\n joins $repo.<t> exists { where slug == ^ref } semi-join; !\u2026 exists anti-join;\n $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id } lookup;\n x in ^keys membership over a lifted set.\n\n## select \u2014 projection\n\n select a, alias: expr, nested: nodes collect { \u2026 }\n bare key \u2192 the property (scalar-authored \u2192 scalar; list \u2192 array); $-intrinsics;\n a call/arithmetic needs an alias (n: size(tags)) unless in values mode.\n \"$body\" whole file bytes (docs) \u2014 docs_read is cheaper for one doc.\n distinct select distinct type (dedup hits by projected value)\n values select <ONE item> values \u2192 bare values instead of records:\n from docs select distinct type values \u2192 values: [\"hub\",\"lab-note\",\u2026]\n tags: tags collect { $value values } \u2192 a plain array\n h: nodes first { name values where kind == \"md:section\" order by first_ordinal }\n entries(x) a record as a collection ($key / $value per entry), key order:\n select fm: entries(frontmatter) collect { k: $key, v: $value }\n where entries(frontmatter) exists { where $key == \"era\" && $value > 1600 }\n entries(inline), entries(attrs), a nested map, or a list (index keys)\n $semantic_score is not a field \u2014 project semantic(\"\u2026\") under an alias instead.\n\n## order by, limit/offset, pagination\n\n order by era desc, $path asc absent values sort LAST (asc); ties \u2192 (path, id)\n limit N / offset N define the result SET (integer literals);\n the tool's limit/cursor then page WITHIN it\n cursor opaque keyset on (path,id); a custom order by\n disables it (you get the top page + truncated)\n\n## Graph traversal (follow) and edges\n\n from docs where <seed> follow doc.out docs the seed links TO\n from docs where <seed> follow doc.in backlinks\n follow doc.out { depth 3 } bound (1..8, default 8)\n follow doc.out { where layer == \"canon\" } keep only matching successors\n follow doc.in { where before.contains(^$path) } ^ = the row being expanded\n (^^ = the walk's enclosing scope)\n follow doc.out, doc.in several destinations: their union\n (one step dedups by identity)\n follow $repo.docs collect { where doc.out exists { where $path == ^^$path } }\n a destination block, re-read per\n frontier row (^ = that row) = doc.in\n follow doc.out { frontier type == \"practitioner\" } cut, keeping the frontier row\n follow distinct doc.out / follow doc.out { by group } identity control\n Per-occurrence metadata: $depth (seed = 1), $stop (interior|leaf|frontier|\n depth|cycle), $leaf/$frontier, $ordinal \u2014 usable in select/order by and the\n top-level post-walk where, NOT metadata of the candidates the follow-local\n where/frontier read.\n Type-preserving relations: doc.out/doc.in, block.children, section.children,\n section.subsections.\n from edges where $dst_path == \"notes/x.md\" inbound edges to a doc, as rows\n from edges where predicate == \"depends_on\" select $src, $dst_path\n from docs where !doc.in_edges exists { } orphans (nothing links in)\n (The `graph` tool is a convenience wrapper that compiles to a follow query.)\n\n## semantic grain\n\nfrom docs ranks whole DOCUMENTS (one hit per file); from blocks ranks PASSAGES.\ntext()/where prune the candidate set; only order by reweights.\n\n## Examples\n\n from docs where layer == \"working\"\n from docs where $path.startsWith(\"guides/\") && $updated_at >= \"2026-08-01\"\n from docs where \"pricing\" in list(tags) select layer, tags\n from docs where inline.owner == \"alice\"\n from docs where $title.lower().contains(\"q3 plan\")\n from docs where format == \"yaml\"\n from docs where type == \"practitioner\" order by era desc limit 2\n from docs where nodes none { where kind == \"md:task\" && !checked }\n from docs where tags exists { where $value == \"tria-prima\" }\n from docs where $path == \"x.md\" select fm: entries(frontmatter) collect { k: $key, v: $value }\n from docs select owner_id, owner: $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id }\n from blocks where type == \"task\" && !checked && under_heading(\"Launch\") && doc.layer == \"working\"\n from blocks where type == \"paragraph\" && has_edge(\"references\", \"d_92aaaaa\")\n from blocks order by semantic(\"identity preservation across edits\") desc limit 10\n from edges where dst_kind == \"external\" select $dst_uri\n $repo.docs count { where layer == \"canon\" }\n\nCommon mistake: bare `path.startsWith(...)` meant the intrinsic \u2014 it fails loud\nwith a hint; write `$path.startsWith(...)`. Another: `where tags == \"x\"` on a\nlist-valued key is false \u2014 write `\"x\" in tags`.\n";
|
|
1
|
+
export declare const QUERY_SYNTAX = "# query \u2014 OQX syntax reference\n\nThe `query` tool takes ONE plain OQX string (+ optional `limit`/`cursor`). There\nis no {from, filter, order} envelope: every concern is a clause of the string.\n\n [select <items>] from <target> [where <pred>] [follow <dest>, \u2026 [{ \u2026 }]]\n [order by <expr> [asc|desc], \u2026] [limit N] [offset N]\n $repo.<target> count|exists|none|first|single { <block> } (scalar/one-row form)\n\nCLAUSE ORDER IS FIXED: select, from, where, follow, order by, limit, offset \u2014\neach at most once; an out-of-order clause is a parse error naming the order.\nOnly `select` may drop its keyword, and only as the first clause\n(`$path, era from docs where \u2026`). Every other clause always carries its keyword:\na predicate is NEVER implicit (`nodes exists { where kind == \"md:task\" }`, not\n`{ kind == \"md:task\" }`), and `from docs count` is an error (write\n`$repo.docs count { \u2026 }`). `where` may use the same body's `select` aliases\n(`select $path, old: era < 1000 from docs where old`; an alias shadows a\nsame-named field there).\n\nResults are LEAN hits \u2014 {id, path} + whatever `select` projects (or `values`,\n`count`, `exists`, `none`). Hydrate full content by id via nodes_get/docs_read.\n\n## Targets & field namespaces\n\nThe sigil rule: `$`-prefixed names are engine intrinsics; BARE identifiers are\nyour content. On docs a bare first segment that collides with an intrinsic base\nname (id/path/repo/updated_at/content_hash/body) is REJECTED with a \"did you mean\n$X?\" hint (bare `path` would silently read an absent frontmatter key). Force the\nproperty with `frontmatter.<k>`. `title`/`tags` are NOT reserved: `$title`/`$tags`\nare computed and never shadow an authored `title:`/`tags:` key.\n\ndocs:\n - bare identifier = a document PROPERTY (frontmatter + inline `key:: value`,\n unioned; nested via dots: meta.owner). YAML/JSON docs\n expose the parsed object's keys the same way.\n - source-scoped = frontmatter.<k> / inline.<k>; the whole bag as a collection\n via entries(frontmatter) / entries(inline) (see entries()).\n - computed = $title (first H1), $tags (body #hashtags), format\n (the doc's format \u2014 \"markdown\"/\"yaml\"/\"json\"; a bare\n computed field, present on EVERY doc, so has(format) is\n always true and `format == \"yaml\"` is the filter).\n - intrinsics = $id, $path, $updated_at (ISO-8601, sorts chronologically),\n $body (whole file), $content_hash.\n - relations = nodes, blocks, doc.out / doc.in (citation graph),\n doc.out_edges / doc.in_edges (a doc's edges as rows).\n\nblocks:\n - bare fields = type, text, and every attrs key FLATTENED onto the row\n (bare `checked` reads attrs.checked; `attrs.<k>` still\n works; type/text win on collision; absent \u21D2 false).\n - intrinsics = $id, $doc, $path, $ordinal, $depth, $updated_at, $body\n (the block text), $content_hash (the block's OWN raw hash \u2014\n the value update/split expect).\n - reach-through = doc.<key> / doc.$path / doc.format = the CONTAINING doc.\n - relations = block.children, block.nodes, block.out_edges, section.\n\nnodes:\n - bare fields = kind (md:task / md:link / md:section / yaml:\u2026), name,\n value, + attrs keys flattened (checked, level).\n - intrinsics = $id (=$node_id), $doc_id, $block_id, $path.\n - reach-through = doc.<key>, block.type / block.text.\n - relations = section.blocks, section.children, section.subsections.\n\nedges: (the authored link graph as rows \u2014 one per open edge)\n - bare fields = predicate (\"references\"/\"embeds\"/freeform), provenance\n (\"link\"/\"frontmatter\"/\"inline_field\"/\u2026), dst_kind\n (\"document\"/\"external\"/\"collection\"), anchor, src_field\n - intrinsics = $id, $src, $dst, $dst_path (target doc path; NULL when\n dangling/external), $dst_uri (external URL; NULL\n otherwise), $src_block, $via, $from_commit; $path and\n doc.<key> reach the SOURCE document.\n - text()/semantic() are N/A on edges.\n\n## Scoping (ADR-015): bare names are LOCAL\n\n name the CURRENT row only \u2014 never falls through to an enclosing row\n or the repository; an absent field is simply absent.\n ^name one scope OUT (the enclosing row's fields/intrinsics/lifts);\n ^^name two scopes out. `^$path` = the enclosing row's path.\n $repo.<t> the root scan \u2014 $repo.docs / $repo.nodes / $repo.blocks /\n $repo.edges \u2014 available from ANY depth (uncorrelated until you\n add a ^ predicate). $repo.$id = the repository id.\n $it the current item itself (a row, or each ELEMENT when the\n receiver is a list property); ^$it = the enclosing row.\n $value is NOT an intrinsic (since oqx 0.15): it reads a\n property literally named $value, absent when there is none.\n $key inside an entries(x) block: the entry's key.\n\n## Scalar semantics (@omgbase/oqx)\n\n comparisons == != < <= > >= strict & typed (5 == \"5\" is false); either\n side may be a field (field-vs-field OK).\n absence null and a missing field are the same \"absent\" value; two absents\n are EQUAL, so `absent != \"x\"` is TRUE and `absent == null` is TRUE.\n Relational (< <= > >=) on an absent operand \u2192 FALSE. Bare `f` in\n boolean position uses JS truthiness (absent/false/0/\"\" \u21D2 false;\n NOTE: \"false\", [] and {} are truthy). has(f) = explicit presence.\n boolean && || ! grouping ( \u2026 )\n arithmetic + - * / % (+ concatenates if either side is a string)\n membership x in y array \u2192 typed membership; string \u2192 substring;\n object \u2192 key exists; range \u2192 coverage. Any list\n property works directly: \"pricing\" in tags. list(f)\n coerces scalar-or-list (absent \u2192 []) \u2014 use it when a\n key is sometimes scalar, sometimes a list.\n ranges lo..hi (inclusive) lo...hi (exclusive hi) ..hi lo..\n era in 800..1680; works over ISO dates. range(prop) reads a\n range-shaped STRING property as an interval: \"2026-01-15\" in range(window).\n strings x.contains(\"s\") x.startsWith(\"p\") x.endsWith(\"s\") x.matches(\"^re$\")\n (real regex) x.lower() x.upper() size(x). ALL CASE-SENSITIVE \u2014\n fold with .lower(): $title.lower().contains(\"aurora\").\n functions has(f) size(x) list(x) entries(x) range(s)\n literals \"str\" 'str' 123 1.5 true false null\n determinism no now()/clock/random. Lists/structs are not literals; ternary is\n not supported. Errors surface as filter_invalid.\n scalars vs lists a list-authored key never == a scalar (tags == \"x\" is false\n when tags is a list) \u2014 use `\"x\" in tags` / `in list(tags)`.\n\n## Domain predicates (omgbase)\n\n text(\"terms\") FTS5 PRUNING predicate (docs match via their blocks, nodes\n via the node index); composes anywhere, incl. nested scopes.\n semantic(\"phrase\") embedding cosine SCORE (docs/blocks): threshold it\n (semantic(\"x\") > 0.6), project it, or `order by semantic(\"x\")\n desc` for top-K. Needs a provider; else semantic_unavailable.\n blocks target only:\n under(id_or_heading) block in the containment subtree / section range\n under_heading(\"Launch\") an ancestor section heading contains the text (ci)\n under_kind(type[, name]) an ancestor block has that type (name: its text\n contains / its key equals name)\n within(\"path\" | \"d_..\") containing doc = id, exact path, or path glob (*)\n yaml_path(\"a.b\") YAML docs: the block at that key path\n json_pointer(\"#/a/b\") JSON docs: the block at that pointer\n has_edge(\"pred\"[, target]) an open authored edge with that predicate leaves here\n has_anchor() block carries an authored ^ref\n parent_type() == \"x\" parent block's type (must be compared)\n child_count() > 0 number of direct children (must be compared)\n\n## Nested queries & consumers\n\n <receiver> collect|exists|none|count|first|single { <block> }\n receiver = a relation (nodes, blocks, doc.out_edges, section.blocks, \u2026), a\n list property (tags), entries(x), or a root ($repo.docs).\n block = [select \u2026] [where \u2026] [follow \u2026] [order by \u2026] [limit N] [offset N]\n \u2014 the same fixed order as the top level (`from` optional; the\n receiver supplies the rows). A leading name/alias list is the\n projection (`select` dropped); a predicate always needs `where`.\n exists \u22651 row nodes exists { where kind == \"md:task\" && !checked }\n none 0 rows nodes none { where kind == \"md:task\" && !checked } (\"every task done\")\n count a number nodes count { where kind == \"md:task\" } >= 2\n collect rows (default) \u2014 in select for nested results; in where with a lift\n first / single zero-or-one (single errors on >1) \u2014 lookups in select\n distinct on any consumer: dedup by projected value \u2014 nodes count distinct { select kind }\n limit/offset bound the block's rows AFTER where/order/distinct, BEFORE the\n consumer reduces: nodes exists { offset 1 } = at least two;\n first { \u2026 offset 1 } = the second.\n lift `^name:` in a where-collect binds values outward:\n select $path, open from docs where nodes collect { ^open: value where kind == \"md:task\" && !checked }\n joins $repo.<t> exists { where slug == ^ref } semi-join; !\u2026 exists anti-join;\n $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id } lookup;\n x in ^keys membership over a lifted set.\n\n## select \u2014 projection\n\n select a, alias: expr, nested: nodes collect { \u2026 }\n bare key \u2192 the property (scalar-authored \u2192 scalar; list \u2192 array); $-intrinsics;\n a call/arithmetic needs an alias (n: size(tags)) unless in values mode.\n \"$body\" whole file bytes (docs) \u2014 docs_read is cheaper for one doc.\n distinct select distinct type (dedup hits by projected value)\n values select <ONE item> values \u2192 bare values instead of records:\n from docs select distinct type values \u2192 values: [\"hub\",\"lab-note\",\u2026]\n tags: tags collect { $it values } \u2192 a plain array\n h: nodes first { name values where kind == \"md:section\" order by first_ordinal }\n entries(x) a record as a collection ($key / $it per entry), key order:\n select fm: entries(frontmatter) collect { k: $key, v: $it }\n where entries(frontmatter) exists { where $key == \"era\" && $it > 1600 }\n entries(inline), entries(attrs), a nested map, or a list (index keys)\n $semantic_score is not a field \u2014 project semantic(\"\u2026\") under an alias instead.\n\n## order by, limit/offset, pagination\n\n order by era desc, $path asc absent values sort LAST (asc); ties \u2192 (path, id)\n limit N / offset N define the result SET (integer literals);\n the tool's limit/cursor then page WITHIN it\n cursor opaque keyset on (path,id); a custom order by\n disables it (you get the top page + truncated)\n\n## Graph traversal (follow) and edges\n\n from docs where <seed> follow doc.out docs the seed links TO\n from docs where <seed> follow doc.in backlinks\n follow doc.out { depth 3 } bound (1..8, default 8)\n follow doc.out { where layer == \"canon\" } keep only matching successors\n follow doc.in { where before.contains(^$path) } ^ = the row being expanded\n (^^ = the walk's enclosing scope)\n follow doc.out, doc.in several destinations: their union\n (one step dedups by identity)\n follow $repo.docs collect { where doc.out exists { where $path == ^^$path } }\n a destination block, re-read per\n frontier row (^ = that row) = doc.in\n follow doc.out { frontier type == \"practitioner\" } cut, keeping the frontier row\n follow distinct doc.out / follow doc.out { by group } identity control\n Per-occurrence metadata: $depth (seed = 1), $stop (interior|leaf|frontier|\n depth|cycle), $leaf/$frontier, $ordinal \u2014 usable in select/order by and the\n top-level post-walk where, NOT metadata of the candidates the follow-local\n where/frontier read.\n Type-preserving relations: doc.out/doc.in, block.children, section.children,\n section.subsections.\n from edges where $dst_path == \"notes/x.md\" inbound edges to a doc, as rows\n from edges where predicate == \"depends_on\" select $src, $dst_path\n from docs where !doc.in_edges exists { } orphans (nothing links in)\n (The `graph` tool is a convenience wrapper that compiles to a follow query.)\n\n## semantic grain\n\nfrom docs ranks whole DOCUMENTS (one hit per file); from blocks ranks PASSAGES.\ntext()/where prune the candidate set; only order by reweights.\n\n## Examples\n\n from docs where layer == \"working\"\n from docs where $path.startsWith(\"guides/\") && $updated_at >= \"2026-08-01\"\n from docs where \"pricing\" in list(tags) select layer, tags\n from docs where inline.owner == \"alice\"\n from docs where $title.lower().contains(\"q3 plan\")\n from docs where format == \"yaml\"\n from docs where type == \"practitioner\" order by era desc limit 2\n from docs where nodes none { where kind == \"md:task\" && !checked }\n from docs where tags exists { where $it == \"tria-prima\" }\n from docs where $path == \"x.md\" select fm: entries(frontmatter) collect { k: $key, v: $it }\n from docs select owner_id, owner: $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id }\n from blocks where type == \"task\" && !checked && under_heading(\"Launch\") && doc.layer == \"working\"\n from blocks where type == \"paragraph\" && has_edge(\"references\", \"d_92aaaaa\")\n from blocks order by semantic(\"identity preservation across edits\") desc limit 10\n from edges where dst_kind == \"external\" select $dst_uri\n $repo.docs count { where layer == \"canon\" }\n\nCommon mistake: bare `path.startsWith(...)` meant the intrinsic \u2014 it fails loud\nwith a hint; write `$path.startsWith(...)`. Another: `where tags == \"x\"` on a\nlist-valued key is false \u2014 write `\"x\" in tags`.\n";
|
|
@@ -87,8 +87,10 @@ edges: (the authored link graph as rows — one per open edge)
|
|
|
87
87
|
$repo.<t> the root scan — $repo.docs / $repo.nodes / $repo.blocks /
|
|
88
88
|
$repo.edges — available from ANY depth (uncorrelated until you
|
|
89
89
|
add a ^ predicate). $repo.$id = the repository id.
|
|
90
|
-
$
|
|
91
|
-
receiver is a list property); ^$
|
|
90
|
+
$it the current item itself (a row, or each ELEMENT when the
|
|
91
|
+
receiver is a list property); ^$it = the enclosing row.
|
|
92
|
+
$value is NOT an intrinsic (since oqx 0.15): it reads a
|
|
93
|
+
property literally named $value, absent when there is none.
|
|
92
94
|
$key inside an entries(x) block: the entry's key.
|
|
93
95
|
|
|
94
96
|
## Scalar semantics (@omgbase/oqx)
|
|
@@ -173,11 +175,11 @@ edges: (the authored link graph as rows — one per open edge)
|
|
|
173
175
|
distinct select distinct type (dedup hits by projected value)
|
|
174
176
|
values select <ONE item> values → bare values instead of records:
|
|
175
177
|
from docs select distinct type values → values: ["hub","lab-note",…]
|
|
176
|
-
tags: tags collect { $
|
|
178
|
+
tags: tags collect { $it values } → a plain array
|
|
177
179
|
h: nodes first { name values where kind == "md:section" order by first_ordinal }
|
|
178
|
-
entries(x) a record as a collection ($key / $
|
|
179
|
-
select fm: entries(frontmatter) collect { k: $key, v: $
|
|
180
|
-
where entries(frontmatter) exists { where $key == "era" && $
|
|
180
|
+
entries(x) a record as a collection ($key / $it per entry), key order:
|
|
181
|
+
select fm: entries(frontmatter) collect { k: $key, v: $it }
|
|
182
|
+
where entries(frontmatter) exists { where $key == "era" && $it > 1600 }
|
|
181
183
|
entries(inline), entries(attrs), a nested map, or a list (index keys)
|
|
182
184
|
$semantic_score is not a field — project semantic("…") under an alias instead.
|
|
183
185
|
|
|
@@ -230,8 +232,8 @@ text()/where prune the candidate set; only order by reweights.
|
|
|
230
232
|
from docs where format == "yaml"
|
|
231
233
|
from docs where type == "practitioner" order by era desc limit 2
|
|
232
234
|
from docs where nodes none { where kind == "md:task" && !checked }
|
|
233
|
-
from docs where tags exists { where $
|
|
234
|
-
from docs where $path == "x.md" select fm: entries(frontmatter) collect { k: $key, v: $
|
|
235
|
+
from docs where tags exists { where $it == "tria-prima" }
|
|
236
|
+
from docs where $path == "x.md" select fm: entries(frontmatter) collect { k: $key, v: $it }
|
|
235
237
|
from docs select owner_id, owner: $repo.nodes single { where kind == "person" && attrs.id == ^owner_id }
|
|
236
238
|
from blocks where type == "task" && !checked && under_heading("Launch") && doc.layer == "working"
|
|
237
239
|
from blocks where type == "paragraph" && has_edge("references", "d_92aaaaa")
|
package/dist/src/mcp/server.js
CHANGED
|
@@ -424,7 +424,7 @@ export function buildServer(ctx) {
|
|
|
424
424
|
inputSchema: {},
|
|
425
425
|
}, async () => ok({ syntax: QUERY_SYNTAX }));
|
|
426
426
|
server.registerTool("query", {
|
|
427
|
-
description: "OQX (omgbase Query eXpressions) — composable query in ONE expression. Two rules: DOT navigation belongs to the host object model (`doc.layer`, `section.blocks`, `target.out`); WHITESPACE query directives belong to OQX — a nested query is `<receiver> collect|exists|count|first|single { <block> }` (a postfix directive + a `{ … }` block), never a method. `from E` selects a property/relation relative to the current source scope and flattens it (a typed flatMap): top-level `from docs|blocks|nodes|edges` selects the repository's docs/blocks/nodes/edges (the `edges` target is the authored link graph as first-class rows — predicate/provenance/dst_kind/anchor/src_field + $src/$dst/$dst_path/$dst_uri, with $path/doc.* reaching the SOURCE doc); `where`; `select`. Its distinctive power is receiver-constrained nested queries that correlate to the current row: `from docs where nodes exists { where kind == \"md:task\" }` returns only the docs that themselves contain a matching node (not a global scan). Directives: `exists { … }` / `count { … }` in where — with an optional count comparison `nodes count { where kind == \"md:task\" } >= 2`; `collect { … }` in select for hierarchical results, nestable (`select secs: nodes collect { select h: name, items: section.blocks collect { where type == \"list_item\" } where kind == \"md:section\" } from docs`). CLAUSE ORDER IS FIXED (ADR-020): within one body — the top level or any `{ block }` — clauses appear at most once each, in exactly this order: `select … from … where … follow … order by … limit N offset N`. Every clause is optional except a top-level `from`; an out-of-order clause is a parse error naming the order. ONLY `select` may drop its keyword, and only when it is the first clause written (`$path, open from docs where …`; `nodes collect { attrs.text }` projects `attrs.text`). Every other clause always carries its keyword — a predicate is NEVER implicit: a block filters with `where` (`nodes exists { where kind == \"md:task\" }`; `nodes exists { kind == \"md:task\" }` is an error pointing at `where`), a bare boolean property in leading position PROJECTS (`nodes collect { active }`), and `from docs count` is an error (write `$repo.docs count { … }` for the consumer, or `select count from docs` to project a field named count). `where` may reference the same body's `select` aliases (`select $path, old: era < 1000 from docs where old` — inlined at parse time; an alias shadows a same-named field there). On the nodes/blocks targets a bare identifier that is not a structural field (kind/name/value; type/text) or `$`-intrinsic reads the flattened attrs bag — `checked` == `attrs.checked`, `level` == `attrs.level` — so the name you filter on matches the name a projection returns (`select attrs.checked` yields key `checked`); the `attrs.<key>` form still works and an absent key is silently false. Any consumer accepts `distinct` to dedup the rows it reduces by their projected value — `nodes count distinct { select kind }`, `nodes collect distinct { select kind }`, top-level `select distinct type` (empty projection dedups by identity). `values` after a projection of exactly ONE item returns the bare value instead of a `{name: value}` record: at the top level `select $path values from docs where layer == \"canon\"` returns `values: [\"a.md\", …]` (paged like hits; `hits` is empty), and inside a block `select tags: tags collect { $value values }` yields a plain array. `$value` is the current item itself — the row, or the scalar element when the receiver is a list property (`where tags exists { where $value == \"pricing\" }`); inside a block `^$value` is the enclosing row. `none { … }` is the zero-cardinality test — true iff the block yields no rows (≡ `!… exists { … }`), and how \"every\" is spelled: `nodes none { where kind == \"md:task\" && !checked }` = no open tasks; also a whole-query directive (`$repo.docs none { … }` returns `none`). `limit N` / `offset N` bound a row set AFTER where/order/distinct and BEFORE the consumer reduces it, so they mean the same under every consumer: at the top level (`order by era desc limit 3` — the tool's `limit`/`cursor` then page within that bounded set) or inside any block (`nodes collect { … order by ordinal limit 1 }`; `nodes exists { offset 1 }` = at least two). `entries(x)` turns a record into a collection of entries — inside the block `$key` is the property key and `$value` (and bare names) the value: `select fm: entries(frontmatter) collect { k: $key, v: $value }`, `from nodes where kind == \"md:task\" && entries(attrs) exists { where $key == \"checked\" && $value }`; works on `frontmatter` / `inline` (the whole authored bag, nested dotted keys folded back), `attrs`, a nested frontmatter map, or a list (numeric index keys). Plain objects never auto-iterate; `entries()` is the explicit bridge. The where clause is a boolean tree: compose scalar predicates and consumer directives with `&&`, `||`, `!`, and grouping (`layer == \"canon\" || nodes exists { where kind == \"md:task\" }`). Relative source: `from E` inside a top-level consumer block re-projects the rows — `$repo.docs collect { from nodes where kind == \"md:task\" }` starts at docs, projects each doc through its `nodes` relation, and queries the resulting nodes (a scalar relation like `from doc` contributes one row; a missing/non-navigable relation is a loud error, never a global scan). One-scope lift: a `collect` in `where` with a `^name` both filters (non-empty) and binds the matching values into the parent select in one expression — `select $path, open from docs where nodes collect { ^open: value where kind == \"md:task\" && !attrs.checked }` returns the docs with an open task, each carrying its open-task texts. Correlation / joins (the `^` sigil, symmetric with the lift): a nested query may READ a name bound one scope outward — bind it in the parent (a select value or a lift), then reference `^name` inside a nested query's where. Combined with the explicit root relations `$repo.docs` / `$repo.nodes` / `$repo.blocks` (an UNBOUNDED repository scan, uncorrelated until you add a `^` predicate) this expresses lateral/dependent joins without a JOIN keyword: `repo.<t> exists { where … == ^k }` = semi-join, `!… exists { … }` = anti-join, `… collect { … }` in select = nested left-join, and the select-only lookups `first { … }` / `single { … }` (zero-or-one / one-to-one; `single` errors if it matches >1) — e.g. `select owner_id, owner: $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id } from docs`. Membership over a lifted set: `<value> in ^keys`. `^` reads exactly ONE scope out (no arbitrary-ancestor search), and a bare name reads the CURRENT row only — it never falls through to an enclosing row or the root, so reach outward explicitly with `^name` (the enclosing row) or `$repo.<target>` (a root scan, from any depth). Top-level consumers: the whole query can be a consumer directive over a root receiver to change its result shape — `$repo.docs count { … }`, `$repo.docs exists { … }` and `$repo.docs none { … }` reduce to a scalar (returned as `count`/`exists`/`none`, no hits), `$repo.docs first { … }` / `$repo.docs single { … }` return zero-or-one hit (`single` errors if the query matches >1); a bare `from …` is the collect form. (The `$repo.<target>` receiver is the same root relation used inside where/select.) Scalar predicates follow the @omgbase/oqx semantics (strict typed equality with absence normalized so `!=` over an absent field matches; case-sensitive string ops with `.lower()`/`.upper()` and a regex `matches()`; absent sorts last; arithmetic supported), including doc.<key> reach-through and `text(\"terms\")` — a full-text (FTS5) PRUNING predicate that, because it is an ordinary predicate, composes inside correlated subqueries and collects (e.g. `nodes exists { where kind == \"md:task\" && text(\"ship\") }`), which the flat `query` tool cannot express. (`text` prunes; relevance ranking is separate.) And `semantic(\"phrase\")` — an embedding cosine SCORE (docs/blocks only) usable as a threshold prune (`semantic(\"the great work\") > 0.6`) or a projection (`select score: semantic(\"…\")`); it needs an embedding provider (else semantic_unavailable) and, like `text`, composes inside nested scopes. Ranking: `order by <expr> [asc|desc], …` sorts the result (`order by semantic(\"the great work\") desc` = semantic top-K; also orders by any frontmatter field / `$path`), always tie-broken by (path, id) for a total order — this is how score functions become a ranking. A custom order disables the keyset cursor (you still get the top `limit` with `truncated`). Recursion (`follow`): make the query recursive over a TYPE-PRESERVING relation (the relation's successor type must equal the query target). `from blocks where $id == \"b_x\" follow block.children` walks the block subtree from a seed; the `where` picks the SEED rows and `follow <relation>` expands them. A bare `follow <relation>` carries no sub-clauses; sub-clauses go in a `{ … }` block. Two orthogonal knobs shape it: a follow-local `follow <rel> { where <pred> }` filters which successors keep participating at each hop (running out ⇒ a leaf), and `{ frontier <pred> }` cuts a relation that could otherwise continue (⇒ a frontier); `{ depth <n> }` bounds the walk (1..8, default 8). `follow distinct` dedups reached rows by identity (the default keeps one occurrence per distinct walk path). `follow … { by <expr> }` sets the node identity used for cycle detection + dedup to a field/intrinsic (default: the entity id) — e.g. `follow doc.out { by group }` treats same-`group` documents as one node. Each reached row carries recursion metadata: `$depth` (seed = 1), the categorical `$stop` (interior | leaf | frontier | depth | cycle) with `$leaf`/`$frontier` boolean sugar, and `$ordinal` (a deterministic 1..N rank over the walk, ordered by depth then path — `order by $ordinal` for walk order, `where $ordinal <= N` for a deterministic budget cut). It is queryable in select / order by AND filterable post-walk in the top-level `where` (a `where` conjunct referencing a recursion intrinsic filters the walk's RESULT — `from blocks where type == \"list_item\" && $leaf follow block.children` = the leaves; the non-recursion conjuncts remain the seed predicate). Recursion intrinsics are NOT valid in the follow-local successor `where` or `frontier` (those run mid-walk, before the metadata exists), nor inside a consumer directive. Cyclic graphs are safe: a revisited node is admitted once as a `$stop == \"cycle\"` occurrence and never re-expanded. Type-preserving relations today: `block.children` (blocks→blocks, immediate child blocks), `section.children` (nodes→nodes, immediate child sections — a true outline depth ladder) and `section.subsections` (nodes→nodes, the whole transitive sub-tree — flattens to depth 2), and `doc.out` / `doc.in` (docs→docs, the authored citation graph — outgoing links / backlinks; inspect a doc's edges as rows via `doc.out_edges`/`doc.in_edges` (predicate/provenance/anchor filtered by a plain `where`)). Consumers compose over the walk (`$repo.blocks count { where … follow block.children }`). A `follow` may also nest inside a select-position `collect` for a per-row recursive subtree: `select outline: nodes collect { select n: name, d: $depth where kind == \"md:section\" && name == \"Overview\" follow section.children } from docs` (the collect's `where` seeds, correlated to the row; `follow` recurses; the collect's `select` projects each occurrence). Receivers: from docs — `nodes`, `blocks`, `doc.out`/`doc.in`, `doc.out_edges`/`doc.in_edges` (a doc's outgoing/incoming edges as rows); from nodes — `section.blocks` (content under an `md:section` node's heading, transitively including deeper headings), `section.children` (immediate child sections), and `section.subsections` (all contained sections); from blocks — `section` (enclosing `md:section` node(s)), `block.children` (child blocks), `block.nodes` (the projected nodes anchored to this block — links/tasks/anchors/…), and `block.out_edges` (this block's outgoing edges as rows — body links + inline fields; frontmatter edges are doc-grain, use `doc.out_edges`); from any scope — `$repo.docs` / `$repo.nodes` / `$repo.blocks`. Returns lean hits {id, path, ...projections} with truncated + cursor (or a `count`/`exists` scalar for those consumers). Covers structural + section navigation + ad-hoc correlation + bounded recursive traversal (`follow`).",
|
|
427
|
+
description: "OQX (omgbase Query eXpressions) — composable query in ONE expression. Two rules: DOT navigation belongs to the host object model (`doc.layer`, `section.blocks`, `target.out`); WHITESPACE query directives belong to OQX — a nested query is `<receiver> collect|exists|count|first|single { <block> }` (a postfix directive + a `{ … }` block), never a method. `from E` selects a property/relation relative to the current source scope and flattens it (a typed flatMap): top-level `from docs|blocks|nodes|edges` selects the repository's docs/blocks/nodes/edges (the `edges` target is the authored link graph as first-class rows — predicate/provenance/dst_kind/anchor/src_field + $src/$dst/$dst_path/$dst_uri, with $path/doc.* reaching the SOURCE doc); `where`; `select`. Its distinctive power is receiver-constrained nested queries that correlate to the current row: `from docs where nodes exists { where kind == \"md:task\" }` returns only the docs that themselves contain a matching node (not a global scan). Directives: `exists { … }` / `count { … }` in where — with an optional count comparison `nodes count { where kind == \"md:task\" } >= 2`; `collect { … }` in select for hierarchical results, nestable (`select secs: nodes collect { select h: name, items: section.blocks collect { where type == \"list_item\" } where kind == \"md:section\" } from docs`). CLAUSE ORDER IS FIXED (ADR-020): within one body — the top level or any `{ block }` — clauses appear at most once each, in exactly this order: `select … from … where … follow … order by … limit N offset N`. Every clause is optional except a top-level `from`; an out-of-order clause is a parse error naming the order. ONLY `select` may drop its keyword, and only when it is the first clause written (`$path, open from docs where …`; `nodes collect { attrs.text }` projects `attrs.text`). Every other clause always carries its keyword — a predicate is NEVER implicit: a block filters with `where` (`nodes exists { where kind == \"md:task\" }`; `nodes exists { kind == \"md:task\" }` is an error pointing at `where`), a bare boolean property in leading position PROJECTS (`nodes collect { active }`), and `from docs count` is an error (write `$repo.docs count { … }` for the consumer, or `select count from docs` to project a field named count). `where` may reference the same body's `select` aliases (`select $path, old: era < 1000 from docs where old` — inlined at parse time; an alias shadows a same-named field there). On the nodes/blocks targets a bare identifier that is not a structural field (kind/name/value; type/text) or `$`-intrinsic reads the flattened attrs bag — `checked` == `attrs.checked`, `level` == `attrs.level` — so the name you filter on matches the name a projection returns (`select attrs.checked` yields key `checked`); the `attrs.<key>` form still works and an absent key is silently false. Any consumer accepts `distinct` to dedup the rows it reduces by their projected value — `nodes count distinct { select kind }`, `nodes collect distinct { select kind }`, top-level `select distinct type` (empty projection dedups by identity). `values` after a projection of exactly ONE item returns the bare value instead of a `{name: value}` record: at the top level `select $path values from docs where layer == \"canon\"` returns `values: [\"a.md\", …]` (paged like hits; `hits` is empty), and inside a block `select tags: tags collect { $it values }` yields a plain array. `$it` is the current item itself — the row, or the scalar element when the receiver is a list property (`where tags exists { where $it == \"pricing\" }`); inside a block `^$it` is the enclosing row. `none { … }` is the zero-cardinality test — true iff the block yields no rows (≡ `!… exists { … }`), and how \"every\" is spelled: `nodes none { where kind == \"md:task\" && !checked }` = no open tasks; also a whole-query directive (`$repo.docs none { … }` returns `none`). `limit N` / `offset N` bound a row set AFTER where/order/distinct and BEFORE the consumer reduces it, so they mean the same under every consumer: at the top level (`order by era desc limit 3` — the tool's `limit`/`cursor` then page within that bounded set) or inside any block (`nodes collect { … order by ordinal limit 1 }`; `nodes exists { offset 1 }` = at least two). `entries(x)` turns a record into a collection of entries — inside the block `$key` is the property key and `$it` (and bare names) the value: `select fm: entries(frontmatter) collect { k: $key, v: $it }`, `from nodes where kind == \"md:task\" && entries(attrs) exists { where $key == \"checked\" && $it }`; works on `frontmatter` / `inline` (the whole authored bag, nested dotted keys folded back), `attrs`, a nested frontmatter map, or a list (numeric index keys). Plain objects never auto-iterate; `entries()` is the explicit bridge. The where clause is a boolean tree: compose scalar predicates and consumer directives with `&&`, `||`, `!`, and grouping (`layer == \"canon\" || nodes exists { where kind == \"md:task\" }`). Relative source: `from E` inside a top-level consumer block re-projects the rows — `$repo.docs collect { from nodes where kind == \"md:task\" }` starts at docs, projects each doc through its `nodes` relation, and queries the resulting nodes (a scalar relation like `from doc` contributes one row; a missing/non-navigable relation is a loud error, never a global scan). One-scope lift: a `collect` in `where` with a `^name` both filters (non-empty) and binds the matching values into the parent select in one expression — `select $path, open from docs where nodes collect { ^open: value where kind == \"md:task\" && !attrs.checked }` returns the docs with an open task, each carrying its open-task texts. Correlation / joins (the `^` sigil, symmetric with the lift): a nested query may READ a name bound one scope outward — bind it in the parent (a select value or a lift), then reference `^name` inside a nested query's where. Combined with the explicit root relations `$repo.docs` / `$repo.nodes` / `$repo.blocks` (an UNBOUNDED repository scan, uncorrelated until you add a `^` predicate) this expresses lateral/dependent joins without a JOIN keyword: `repo.<t> exists { where … == ^k }` = semi-join, `!… exists { … }` = anti-join, `… collect { … }` in select = nested left-join, and the select-only lookups `first { … }` / `single { … }` (zero-or-one / one-to-one; `single` errors if it matches >1) — e.g. `select owner_id, owner: $repo.nodes single { where kind == \"person\" && attrs.id == ^owner_id } from docs`. Membership over a lifted set: `<value> in ^keys`. `^` reads exactly ONE scope out (no arbitrary-ancestor search), and a bare name reads the CURRENT row only — it never falls through to an enclosing row or the root, so reach outward explicitly with `^name` (the enclosing row) or `$repo.<target>` (a root scan, from any depth). Top-level consumers: the whole query can be a consumer directive over a root receiver to change its result shape — `$repo.docs count { … }`, `$repo.docs exists { … }` and `$repo.docs none { … }` reduce to a scalar (returned as `count`/`exists`/`none`, no hits), `$repo.docs first { … }` / `$repo.docs single { … }` return zero-or-one hit (`single` errors if the query matches >1); a bare `from …` is the collect form. (The `$repo.<target>` receiver is the same root relation used inside where/select.) Scalar predicates follow the @omgbase/oqx semantics (strict typed equality with absence normalized so `!=` over an absent field matches; case-sensitive string ops with `.lower()`/`.upper()` and a regex `matches()`; absent sorts last; arithmetic supported), including doc.<key> reach-through and `text(\"terms\")` — a full-text (FTS5) PRUNING predicate that, because it is an ordinary predicate, composes inside correlated subqueries and collects (e.g. `nodes exists { where kind == \"md:task\" && text(\"ship\") }`), which the flat `query` tool cannot express. (`text` prunes; relevance ranking is separate.) And `semantic(\"phrase\")` — an embedding cosine SCORE (docs/blocks only) usable as a threshold prune (`semantic(\"the great work\") > 0.6`) or a projection (`select score: semantic(\"…\")`); it needs an embedding provider (else semantic_unavailable) and, like `text`, composes inside nested scopes. Ranking: `order by <expr> [asc|desc], …` sorts the result (`order by semantic(\"the great work\") desc` = semantic top-K; also orders by any frontmatter field / `$path`), always tie-broken by (path, id) for a total order — this is how score functions become a ranking. A custom order disables the keyset cursor (you still get the top `limit` with `truncated`). Recursion (`follow`): make the query recursive over a TYPE-PRESERVING relation (the relation's successor type must equal the query target). `from blocks where $id == \"b_x\" follow block.children` walks the block subtree from a seed; the `where` picks the SEED rows and `follow <relation>` expands them. A bare `follow <relation>` carries no sub-clauses; sub-clauses go in a `{ … }` block. Two orthogonal knobs shape it: a follow-local `follow <rel> { where <pred> }` filters which successors keep participating at each hop (running out ⇒ a leaf), and `{ frontier <pred> }` cuts a relation that could otherwise continue (⇒ a frontier); `{ depth <n> }` bounds the walk (1..8, default 8). `follow distinct` dedups reached rows by identity (the default keeps one occurrence per distinct walk path). `follow … { by <expr> }` sets the node identity used for cycle detection + dedup to a field/intrinsic (default: the entity id) — e.g. `follow doc.out { by group }` treats same-`group` documents as one node. Each reached row carries recursion metadata: `$depth` (seed = 1), the categorical `$stop` (interior | leaf | frontier | depth | cycle) with `$leaf`/`$frontier` boolean sugar, and `$ordinal` (a deterministic 1..N rank over the walk, ordered by depth then path — `order by $ordinal` for walk order, `where $ordinal <= N` for a deterministic budget cut). It is queryable in select / order by AND filterable post-walk in the top-level `where` (a `where` conjunct referencing a recursion intrinsic filters the walk's RESULT — `from blocks where type == \"list_item\" && $leaf follow block.children` = the leaves; the non-recursion conjuncts remain the seed predicate). Recursion intrinsics are NOT valid in the follow-local successor `where` or `frontier` (those run mid-walk, before the metadata exists), nor inside a consumer directive. Cyclic graphs are safe: a revisited node is admitted once as a `$stop == \"cycle\"` occurrence and never re-expanded. Type-preserving relations today: `block.children` (blocks→blocks, immediate child blocks), `section.children` (nodes→nodes, immediate child sections — a true outline depth ladder) and `section.subsections` (nodes→nodes, the whole transitive sub-tree — flattens to depth 2), and `doc.out` / `doc.in` (docs→docs, the authored citation graph — outgoing links / backlinks; inspect a doc's edges as rows via `doc.out_edges`/`doc.in_edges` (predicate/provenance/anchor filtered by a plain `where`)). Consumers compose over the walk (`$repo.blocks count { where … follow block.children }`). A `follow` may also nest inside a select-position `collect` for a per-row recursive subtree: `select outline: nodes collect { select n: name, d: $depth where kind == \"md:section\" && name == \"Overview\" follow section.children } from docs` (the collect's `where` seeds, correlated to the row; `follow` recurses; the collect's `select` projects each occurrence). Receivers: from docs — `nodes`, `blocks`, `doc.out`/`doc.in`, `doc.out_edges`/`doc.in_edges` (a doc's outgoing/incoming edges as rows); from nodes — `section.blocks` (content under an `md:section` node's heading, transitively including deeper headings), `section.children` (immediate child sections), and `section.subsections` (all contained sections); from blocks — `section` (enclosing `md:section` node(s)), `block.children` (child blocks), `block.nodes` (the projected nodes anchored to this block — links/tasks/anchors/…), and `block.out_edges` (this block's outgoing edges as rows — body links + inline fields; frontmatter edges are doc-grain, use `doc.out_edges`); from any scope — `$repo.docs` / `$repo.nodes` / `$repo.blocks`. Returns lean hits {id, path, ...projections} with truncated + cursor (or a `count`/`exists` scalar for those consumers). Covers structural + section navigation + ad-hoc correlation + bounded recursive traversal (`follow`).",
|
|
428
428
|
inputSchema: {
|
|
429
429
|
query: z.string(),
|
|
430
430
|
limit: z.number().int().optional(),
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// the corpus and the differential baseline. The tier-3 SQLite planner (planner.ts)
|
|
13
13
|
// pushes the hot predicates down; both must agree (conformance suite).
|
|
14
14
|
import { semantics } from "@omgbase/oqx";
|
|
15
|
+
import { storeIndexFor } from "./store-index.js";
|
|
16
|
+
import { COLS, FROM, ORDER, guards } from "./sql/scan.js";
|
|
15
17
|
import { detectRange } from "../core/store/properties.js";
|
|
16
18
|
import { docsRead } from "../core/read/document.js";
|
|
17
19
|
import { FilterInvalid } from "../search/cel/parser.js";
|
|
@@ -49,17 +51,61 @@ export function makeStoreContext(store, repoId, opts = {}) {
|
|
|
49
51
|
return undefined;
|
|
50
52
|
return Object.values(r)[0];
|
|
51
53
|
};
|
|
52
|
-
// ---- root collections (
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
54
|
+
// ---- root collections (the shared scan shape of sql/scan.ts: live rows of
|
|
55
|
+
// this repo, ordered for a stable (path, id) default) -----------------------
|
|
56
|
+
const scan = (t) => {
|
|
57
|
+
const g = guards(t, repoId);
|
|
58
|
+
return tagAll(all(`SELECT ${COLS[t]} FROM ${FROM[t]} WHERE ${g.sql} ORDER BY ${ORDER[t]}`, ...g.params), t);
|
|
59
|
+
};
|
|
60
|
+
// A root scan is handed out LAZILY: an array (a `Proxy` over one — `Array.isArray`,
|
|
61
|
+
// iteration, indexing, `length`, rendering all see a plain array of tagged
|
|
62
|
+
// rows) that runs its SELECT on first use. The engine's optimizer asks
|
|
63
|
+
// `indexFor` about a nested block's receiver BEFORE reading it, so a
|
|
64
|
+
// correlated probe on `$repo.docs` / `$repo.edges` / … is answered from the
|
|
65
|
+
// store's indexes (store-index.ts) and the scan never runs; anything else
|
|
66
|
+
// (a top-level `from docs`, a `count { }`, a non-indexable path) reads it
|
|
67
|
+
// whole exactly as before. One handle per target per context: a context lives
|
|
68
|
+
// for one run, during which the store does not change, so `$repo.docs` read
|
|
69
|
+
// from every outer row is one SELECT, not one per row — and the engine, which
|
|
70
|
+
// keys collections by identity, sees the same collection each time.
|
|
71
|
+
// `rootScans` recognizes the handles for `indexFor`. Because the handle IS an
|
|
72
|
+
// array, this context needs no `DataContext.materialize` (the Rust port's
|
|
73
|
+
// marker value does); `spec/surface` `lazy-root-*` pins what the language
|
|
74
|
+
// sees either way.
|
|
75
|
+
const rootScans = new WeakMap();
|
|
76
|
+
const handles = new Map();
|
|
77
|
+
const lazyRoot = (t) => {
|
|
78
|
+
let h = handles.get(t);
|
|
79
|
+
if (!h)
|
|
80
|
+
handles.set(t, (h = makeLazyRoot(t)));
|
|
81
|
+
return h;
|
|
82
|
+
};
|
|
83
|
+
const makeLazyRoot = (t) => {
|
|
84
|
+
const backing = [];
|
|
85
|
+
let filled = false;
|
|
86
|
+
const fill = () => {
|
|
87
|
+
if (!filled) {
|
|
88
|
+
filled = true;
|
|
89
|
+
for (const r of scan(t))
|
|
90
|
+
backing.push(r);
|
|
91
|
+
}
|
|
92
|
+
return backing;
|
|
93
|
+
};
|
|
94
|
+
const proxy = new Proxy(backing, {
|
|
95
|
+
get(target, prop, receiver) { fill(); return Reflect.get(target, prop, receiver); },
|
|
96
|
+
has(target, prop) { fill(); return Reflect.has(target, prop); },
|
|
97
|
+
ownKeys(target) { fill(); return Reflect.ownKeys(target); },
|
|
98
|
+
getOwnPropertyDescriptor(target, prop) { fill(); return Reflect.getOwnPropertyDescriptor(target, prop); },
|
|
99
|
+
set(target, prop, value, receiver) { fill(); return Reflect.set(target, prop, value, receiver); },
|
|
100
|
+
defineProperty(target, prop, desc) { fill(); return Reflect.defineProperty(target, prop, desc); },
|
|
101
|
+
deleteProperty(target, prop) { fill(); return Reflect.deleteProperty(target, prop); },
|
|
102
|
+
});
|
|
103
|
+
rootScans.set(proxy, { target: t, rows: fill });
|
|
104
|
+
return proxy;
|
|
105
|
+
};
|
|
106
|
+
const rootFns = {
|
|
107
|
+
docs: () => lazyRoot("docs"), blocks: () => lazyRoot("blocks"), nodes: () => lazyRoot("nodes"), edges: () => lazyRoot("edges"),
|
|
108
|
+
};
|
|
63
109
|
// The repository handle behind the `$repo` intrinsic: `$repo.<target>` is the
|
|
64
110
|
// explicit root scan (rootFns), `$repo.$id` the repository id.
|
|
65
111
|
const repoRoot = { [REPO_ROOT]: true };
|
|
@@ -531,6 +577,21 @@ export function makeStoreContext(store, repoId, opts = {}) {
|
|
|
531
577
|
return false;
|
|
532
578
|
return jattr(r, "key") === leaf || jattr(r, "key") === key;
|
|
533
579
|
};
|
|
580
|
+
// Store indexes by (target, path) — one object, one set of prepared statements,
|
|
581
|
+
// for the whole run (`null`: the path is not indexable).
|
|
582
|
+
const indexes = new Map();
|
|
583
|
+
const identity = (row) => {
|
|
584
|
+
const t = row?.[TARGET];
|
|
585
|
+
if (t === "docs")
|
|
586
|
+
return row.doc_id;
|
|
587
|
+
if (t === "blocks")
|
|
588
|
+
return row.block_id;
|
|
589
|
+
if (t === "nodes")
|
|
590
|
+
return row.node_id;
|
|
591
|
+
if (t === "edges")
|
|
592
|
+
return row.edge_id;
|
|
593
|
+
return row;
|
|
594
|
+
};
|
|
534
595
|
return {
|
|
535
596
|
root(name) {
|
|
536
597
|
if (opts.rowsRoot && name === opts.rowsRoot.name)
|
|
@@ -551,17 +612,24 @@ export function makeStoreContext(store, repoId, opts = {}) {
|
|
|
551
612
|
return value;
|
|
552
613
|
return [value];
|
|
553
614
|
},
|
|
554
|
-
identity
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
if (
|
|
561
|
-
return
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
615
|
+
identity,
|
|
616
|
+
// A store-backed equality index for a ROOT SCAN handle on a column or
|
|
617
|
+
// property SQLite indexes (store-index.ts); `undefined` for any other
|
|
618
|
+
// collection (a relation's rows, a lifted array) or path, so the engine
|
|
619
|
+
// builds its own over the materialized rows.
|
|
620
|
+
indexFor(collection, path) {
|
|
621
|
+
if (collection === null || typeof collection !== "object")
|
|
622
|
+
return undefined;
|
|
623
|
+
const root = rootScans.get(collection);
|
|
624
|
+
if (!root)
|
|
625
|
+
return undefined;
|
|
626
|
+
const key = `${root.target}\u0000${JSON.stringify(path)}`;
|
|
627
|
+
let index = indexes.get(key);
|
|
628
|
+
if (index === undefined) {
|
|
629
|
+
index = storeIndexFor(root.target, path, { db, repoId, tag: (rows, t) => tagAll(rows, t), get: getFrom, rows: root.rows, identity }) ?? null;
|
|
630
|
+
indexes.set(key, index);
|
|
631
|
+
}
|
|
632
|
+
return index ?? undefined;
|
|
565
633
|
},
|
|
566
634
|
// Free functions: oqx-js builtins (list/size/has) plus `range(s)`, which
|
|
567
635
|
// coerces a string value to a range so `<point> in range(prop)` tests
|
|
@@ -17,40 +17,7 @@
|
|
|
17
17
|
import { ROWS_ROOT, partitionPushable, residualQuery } from "@omgbase/oqx";
|
|
18
18
|
import { makeStoreContext, tagRows } from "./context.js";
|
|
19
19
|
import { translatePredicate, RESERVED_DOC_BASENAMES } from "./sql/translate.js";
|
|
20
|
-
|
|
21
|
-
docs: { self: "d", doc: "d" },
|
|
22
|
-
blocks: { self: "b", doc: "d" },
|
|
23
|
-
nodes: { self: "n", doc: "d" },
|
|
24
|
-
edges: { self: "e", doc: "d" },
|
|
25
|
-
};
|
|
26
|
-
const FROM = {
|
|
27
|
-
docs: "docs d",
|
|
28
|
-
blocks: "blocks b JOIN docs d ON d.doc_id = b.doc_id",
|
|
29
|
-
nodes: "nodes n JOIN docs d ON d.doc_id = n.doc_id",
|
|
30
|
-
edges: "edges e JOIN docs d ON d.doc_id = e.src_doc",
|
|
31
|
-
};
|
|
32
|
-
// Row columns + the owning-doc path as __path (matches the context roots so
|
|
33
|
-
// produced rows are indistinguishable from a full scan's).
|
|
34
|
-
const COLS = {
|
|
35
|
-
docs: "d.*",
|
|
36
|
-
blocks: "b.*, d.path AS __path",
|
|
37
|
-
nodes: "n.*, d.path AS __path",
|
|
38
|
-
edges: "e.*, d.path AS __path",
|
|
39
|
-
};
|
|
40
|
-
const ORDER = {
|
|
41
|
-
docs: "d.path, d.doc_id",
|
|
42
|
-
blocks: "d.path, b.block_id",
|
|
43
|
-
nodes: "d.path, n.node_id",
|
|
44
|
-
edges: "d.path, e.edge_id",
|
|
45
|
-
};
|
|
46
|
-
function guards(target, repoId) {
|
|
47
|
-
switch (target) {
|
|
48
|
-
case "docs": return { sql: "d.repo_id = ? AND d.deleted_commit IS NULL", params: [repoId] };
|
|
49
|
-
case "blocks": return { sql: "b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL", params: [repoId] };
|
|
50
|
-
case "nodes": return { sql: "n.repo_id = ? AND d.deleted_commit IS NULL", params: [repoId] };
|
|
51
|
-
case "edges": return { sql: "e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL", params: [repoId] };
|
|
52
|
-
}
|
|
53
|
-
}
|
|
20
|
+
import { ALIAS, COLS, FROM, ORDER, guards } from "./sql/scan.js";
|
|
54
21
|
// The root collection a query scans, if it is a bare `docs|blocks|nodes|edges`
|
|
55
22
|
// or `$repo.<target>` source (else null — not a pushable shape).
|
|
56
23
|
function rootTarget(source) {
|
package/dist/src/oqx-js/run.js
CHANGED
|
@@ -198,7 +198,7 @@ const VALUE_KEY = "__oqx_value";
|
|
|
198
198
|
// Rows as values (spec/surface §1.4, 1.2). The engine hands back store rows
|
|
199
199
|
// wherever the projection made a row a VALUE — a nested `collect { }` /
|
|
200
200
|
// `first { }` / `single { }` with an empty projection ("the row itself",
|
|
201
|
-
// spec/oqx §12), a `values` projection of `$
|
|
201
|
+
// spec/oqx §12), a `values` projection of `$it`/`$self`, a field bound to a
|
|
202
202
|
// row — and a store row is not a wire shape (column names, `attrs` as a JSON
|
|
203
203
|
// string, the `__path` join column, Buffers). Walk the projected value and
|
|
204
204
|
// render every tagged row as `{ id, path }`; everything else passes through
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Target } from "./translate.js";
|
|
2
|
+
export declare const ALIAS: Record<Target, {
|
|
3
|
+
self: string;
|
|
4
|
+
doc: string;
|
|
5
|
+
}>;
|
|
6
|
+
export declare const FROM: Record<Target, string>;
|
|
7
|
+
/** Row columns + the owning-doc path as `__path` (matches the context roots so
|
|
8
|
+
* produced rows are indistinguishable from a full scan's). */
|
|
9
|
+
export declare const COLS: Record<Target, string>;
|
|
10
|
+
/** The joined targets driven FROM the document: the same rows and columns as
|
|
11
|
+
* `FROM`, with the loop order fixed by `CROSS JOIN` so a predicate on the
|
|
12
|
+
* document (`d.path = ?`) is the outer search and the target's rows are
|
|
13
|
+
* reached through their `doc_id` / `src_doc` index. `guardsByDoc` goes with it. */
|
|
14
|
+
export declare const FROM_BY_DOC: Record<Exclude<Target, "docs">, string>;
|
|
15
|
+
export declare const ORDER: Record<Target, string>;
|
|
16
|
+
/** The live-row and repository guards of a target scan. */
|
|
17
|
+
export declare function guards(target: Target, repoId: string): {
|
|
18
|
+
sql: string;
|
|
19
|
+
params: unknown[];
|
|
20
|
+
};
|
|
21
|
+
/** `guards` for a `FROM_BY_DOC` statement: the same tests, with the document's
|
|
22
|
+
* repo first and the target's repo term behind SQLite's unary `+` so it stays a
|
|
23
|
+
* filter and never selects a `(repo_id, …)` index over the `doc_id` one. */
|
|
24
|
+
export declare function guardsByDoc(target: Exclude<Target, "docs">, repoId: string): {
|
|
25
|
+
sql: string;
|
|
26
|
+
params: unknown[];
|
|
27
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// The SQL shape of a root scan, shared by the tier-3 planner (planner.ts) and
|
|
2
|
+
// the store-backed indexes (store-index.ts): one FROM clause per target, the
|
|
3
|
+
// columns a produced row carries (the target's own columns plus the owning
|
|
4
|
+
// document's path as `__path`, exactly what the context's root scans select),
|
|
5
|
+
// the default `(path, id)` order and the live-row/repo guards. Any statement
|
|
6
|
+
// that produces rows the engine will treat as a target's rows must come from
|
|
7
|
+
// here, so a planned or probed row is indistinguishable from a scanned one.
|
|
8
|
+
export const ALIAS = {
|
|
9
|
+
docs: { self: "d", doc: "d" },
|
|
10
|
+
blocks: { self: "b", doc: "d" },
|
|
11
|
+
nodes: { self: "n", doc: "d" },
|
|
12
|
+
edges: { self: "e", doc: "d" },
|
|
13
|
+
};
|
|
14
|
+
export const FROM = {
|
|
15
|
+
docs: "docs d",
|
|
16
|
+
blocks: "blocks b JOIN docs d ON d.doc_id = b.doc_id",
|
|
17
|
+
nodes: "nodes n JOIN docs d ON d.doc_id = n.doc_id",
|
|
18
|
+
edges: "edges e JOIN docs d ON d.doc_id = e.src_doc",
|
|
19
|
+
};
|
|
20
|
+
/** Row columns + the owning-doc path as `__path` (matches the context roots so
|
|
21
|
+
* produced rows are indistinguishable from a full scan's). */
|
|
22
|
+
export const COLS = {
|
|
23
|
+
docs: "d.*",
|
|
24
|
+
blocks: "b.*, d.path AS __path",
|
|
25
|
+
nodes: "n.*, d.path AS __path",
|
|
26
|
+
edges: "e.*, d.path AS __path",
|
|
27
|
+
};
|
|
28
|
+
/** The joined targets driven FROM the document: the same rows and columns as
|
|
29
|
+
* `FROM`, with the loop order fixed by `CROSS JOIN` so a predicate on the
|
|
30
|
+
* document (`d.path = ?`) is the outer search and the target's rows are
|
|
31
|
+
* reached through their `doc_id` / `src_doc` index. `guardsByDoc` goes with it. */
|
|
32
|
+
export const FROM_BY_DOC = {
|
|
33
|
+
blocks: "docs d CROSS JOIN blocks b ON b.doc_id = d.doc_id",
|
|
34
|
+
nodes: "docs d CROSS JOIN nodes n ON n.doc_id = d.doc_id",
|
|
35
|
+
edges: "docs d CROSS JOIN edges e ON e.src_doc = d.doc_id",
|
|
36
|
+
};
|
|
37
|
+
export const ORDER = {
|
|
38
|
+
docs: "d.path, d.doc_id",
|
|
39
|
+
blocks: "d.path, b.block_id",
|
|
40
|
+
nodes: "d.path, n.node_id",
|
|
41
|
+
edges: "d.path, e.edge_id",
|
|
42
|
+
};
|
|
43
|
+
/** The live-row and repository guards of a target scan. */
|
|
44
|
+
export function guards(target, repoId) {
|
|
45
|
+
switch (target) {
|
|
46
|
+
case "docs": return { sql: "d.repo_id = ? AND d.deleted_commit IS NULL", params: [repoId] };
|
|
47
|
+
case "blocks": return { sql: "b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL", params: [repoId] };
|
|
48
|
+
case "nodes": return { sql: "n.repo_id = ? AND d.deleted_commit IS NULL", params: [repoId] };
|
|
49
|
+
case "edges": return { sql: "e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL", params: [repoId] };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** `guards` for a `FROM_BY_DOC` statement: the same tests, with the document's
|
|
53
|
+
* repo first and the target's repo term behind SQLite's unary `+` so it stays a
|
|
54
|
+
* filter and never selects a `(repo_id, …)` index over the `doc_id` one. */
|
|
55
|
+
export function guardsByDoc(target, repoId) {
|
|
56
|
+
switch (target) {
|
|
57
|
+
case "blocks": return { sql: "d.repo_id = ? AND +b.repo_id = ? AND b.deleted_commit IS NULL AND d.deleted_commit IS NULL", params: [repoId, repoId] };
|
|
58
|
+
case "nodes": return { sql: "d.repo_id = ? AND +n.repo_id = ? AND d.deleted_commit IS NULL", params: [repoId, repoId] };
|
|
59
|
+
case "edges": return { sql: "d.repo_id = ? AND +e.repo_id = ? AND e.to_commit IS NULL AND d.deleted_commit IS NULL", params: [repoId, repoId] };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=scan.js.map
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RowIndex } from "@omgbase/oqx";
|
|
2
|
+
import type Database from "better-sqlite3";
|
|
3
|
+
import type { Target } from "./sql/translate.js";
|
|
4
|
+
/** What the context lends an index: the store, the repo, how it tags rows, how
|
|
5
|
+
* it reads a field off a row, and the materialized rows of the root scan the
|
|
6
|
+
* index stands in for (read lazily, only by the fallback). */
|
|
7
|
+
export interface StoreIndexDeps {
|
|
8
|
+
db: Database.Database;
|
|
9
|
+
repoId: string;
|
|
10
|
+
tag(rows: Record<string, unknown>[], target: Target): unknown[];
|
|
11
|
+
get(row: unknown, key: string): unknown;
|
|
12
|
+
rows(): unknown[];
|
|
13
|
+
identity(row: unknown): unknown;
|
|
14
|
+
}
|
|
15
|
+
/** A store-backed `RowIndex` for the root scan of `target` on `path`, or
|
|
16
|
+
* `undefined` when SQLite has no index for the path (per-target table above;
|
|
17
|
+
* multi-segment paths; `format`, edge `predicate`/`src_field`, `$tags`, …). */
|
|
18
|
+
export declare function storeIndexFor(target: Target, path: readonly string[], deps: StoreIndexDeps): RowIndex | undefined;
|
|
19
|
+
/** The paths `storeIndexFor` answers from SQLite, per target (for tests and docs). */
|
|
20
|
+
export declare function indexablePaths(target: Target): readonly string[];
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// Store-backed equality indexes for the OQX engine (`DataContext.indexFor`):
|
|
2
|
+
// when a nested block's receiver is a ROOT SCAN (`$repo.docs`, `$repo.edges`,
|
|
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"` —
|
|
6
|
+
// the probe runs ONE prepared statement against the store's indexes instead of
|
|
7
|
+
// materializing every row of the target and hashing it. The rows come back
|
|
8
|
+
// exactly as the root scan constructs them (same columns, same `__path`, same
|
|
9
|
+
// target tag, same `(path, id)` order) under the same live-row and repo guards
|
|
10
|
+
// (`sql/scan.ts`), so every intrinsic and relation keeps working downstream and
|
|
11
|
+
// the result cannot differ from the scan — only the work does.
|
|
12
|
+
//
|
|
13
|
+
// Typed equality (OQX SEMANTICS §5) is preserved by probing per VALUE KIND:
|
|
14
|
+
//
|
|
15
|
+
// • a text column (`doc_id`, `path`, `type`, `kind`, `name`, `src_doc`, …)
|
|
16
|
+
// matches a string probe by `= ?` (TEXT = TEXT, exact); a number, boolean,
|
|
17
|
+
// object or any other non-string probe matches NO row (the column holds
|
|
18
|
+
// strings or NULL and `5 == "5"` is false); an absent probe (`null`,
|
|
19
|
+
// `undefined`: `name == null`) is answered by the in-memory fallback below;
|
|
20
|
+
// • a document property (`customer`, `$title`) matches a string probe only on
|
|
21
|
+
// a `type = 'string'` row (`val_text = ?`), a number probe only on a
|
|
22
|
+
// `type = 'number'` row (`val_num = ?`), a boolean only on a `type = 'bool'`
|
|
23
|
+
// row (`val_bool = ?`), each under the scalar-in-scope rule the in-memory
|
|
24
|
+
// `docProp` applies (exactly one live row for the key on the document and it
|
|
25
|
+
// is `card = 'scalar'`; a list-valued, repeated or nested key is an array or
|
|
26
|
+
// object in memory and equals no scalar). An object/array probe equals
|
|
27
|
+
// nothing (decoded values are fresh objects; §5 compares them by
|
|
28
|
+
// reference), `NaN` equals nothing; an absent probe (`key == null`: a `null`
|
|
29
|
+
// scalar OR a document lacking the key) is an anti-join SQLite has no index
|
|
30
|
+
// for, so it is answered by the fallback.
|
|
31
|
+
//
|
|
32
|
+
// The fallback — a probe value no statement covers — reads the collection once
|
|
33
|
+
// (the same lazy root scan the engine would have walked) and filters it with
|
|
34
|
+
// the context's own `get`, under `semantics.equals`: the engine's own hash
|
|
35
|
+
// index could do no better, and the answer is by construction the scan's.
|
|
36
|
+
//
|
|
37
|
+
// `lookup(value)` (positions into the collection) is implemented for the seam's
|
|
38
|
+
// contract but the engine never calls it for these indexes: it probes
|
|
39
|
+
// `lookupRows` first and so never reads the root whole.
|
|
40
|
+
import { semantics } from "@omgbase/oqx";
|
|
41
|
+
import { NON_PROPERTY_NAMES, RESERVED_DOC_BASENAMES } from "./sql/translate.js";
|
|
42
|
+
import { COLS, FROM, FROM_BY_DOC, ORDER, guards, guardsByDoc } from "./sql/scan.js";
|
|
43
|
+
// ---- which paths SQLite answers ------------------------------------------------
|
|
44
|
+
/** The indexed TEXT column (or an equivalent indexed test) a one-segment local
|
|
45
|
+
* path reads, per target — the paths a probe pushes to SQLite. `?` is the
|
|
46
|
+
* probe value. Each is served by a primary key, a `UNIQUE`, or an index of
|
|
47
|
+
* `spec/store/schema.sql`: docs `doc_id` (PK) and `(repo_id, path)`; blocks
|
|
48
|
+
* `block_id` (PK), `idx_blocks_doc (doc_id, …)`, `idx_blocks_type (repo_id,
|
|
49
|
+
* type)`; nodes `node_id` (PK), `idx_nodes_doc`, `idx_nodes_kind`,
|
|
50
|
+
* `idx_nodes_name`; edges `edge_id` (PK), `idx_edges_src (src_doc, …)`,
|
|
51
|
+
* `idx_edges_dst (dst_node, …)`; `$path` on a joined target reaches the docs
|
|
52
|
+
* `(repo_id, path)` key and then the target's `doc_id`/`src_doc` index. */
|
|
53
|
+
const COLUMN_PROBES = {
|
|
54
|
+
docs: { $id: "d.doc_id = ?", $path: "d.path = ?" },
|
|
55
|
+
blocks: { $id: "b.block_id = ?", $doc: "b.doc_id = ?", type: "b.type = ?", $path: "d.path = ?" },
|
|
56
|
+
nodes: { $id: "n.node_id = ?", $node_id: "n.node_id = ?", $doc_id: "n.doc_id = ?", kind: "n.kind = ?", name: "n.name = ?", $path: "d.path = ?" },
|
|
57
|
+
edges: {
|
|
58
|
+
$id: "e.edge_id = ?", $src: "e.src_doc = ?", $dst: "e.dst_node = ?", $path: "d.path = ?",
|
|
59
|
+
// `$dst_path` is the destination document's path (deleted or not — the
|
|
60
|
+
// intrinsic reads `docs` by id without a liveness guard). A destination is
|
|
61
|
+
// resolved within the edge's repo (spec/graph §3), where `(repo_id, path)`
|
|
62
|
+
// is UNIQUE, so the probe is that one document; `?r` is the repo id.
|
|
63
|
+
$dst_path: "e.dst_node IN (SELECT doc_id FROM docs WHERE repo_id = ?r AND path = ?)",
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
/** How a one-segment path on `target` is probed, or null when SQLite has no
|
|
67
|
+
* index for it (the engine then builds its own over the materialized scan). */
|
|
68
|
+
function probeFor(target, path) {
|
|
69
|
+
if (path.length !== 1)
|
|
70
|
+
return null;
|
|
71
|
+
const key = path[0];
|
|
72
|
+
const column = COLUMN_PROBES[target][key];
|
|
73
|
+
if (column)
|
|
74
|
+
return { kind: "column", sql: column };
|
|
75
|
+
if (target !== "docs")
|
|
76
|
+
return null;
|
|
77
|
+
// A docs property: any bare name the context reads through `docProp` — not a
|
|
78
|
+
// relation / handle (`out`, `frontmatter`, …), not a reserved basename (a loud
|
|
79
|
+
// error in memory, left to the scan to raise), not the `format` column (not
|
|
80
|
+
// indexed), and of the intrinsics only `$title` (a computed scalar property).
|
|
81
|
+
if (key === "$title")
|
|
82
|
+
return { kind: "property", key: "$title", source: "computed" };
|
|
83
|
+
if (key.startsWith("$") || key === "format")
|
|
84
|
+
return null;
|
|
85
|
+
if (NON_PROPERTY_NAMES.docs.has(key) || RESERVED_DOC_BASENAMES.has(key))
|
|
86
|
+
return null;
|
|
87
|
+
return { kind: "property", key, source: null };
|
|
88
|
+
}
|
|
89
|
+
// ---- the index ---------------------------------------------------------------------
|
|
90
|
+
class StoreIndex {
|
|
91
|
+
target;
|
|
92
|
+
path;
|
|
93
|
+
probe;
|
|
94
|
+
deps;
|
|
95
|
+
statements = new Map();
|
|
96
|
+
positions = null;
|
|
97
|
+
constructor(target, path, probe, deps) {
|
|
98
|
+
this.target = target;
|
|
99
|
+
this.path = path;
|
|
100
|
+
this.probe = probe;
|
|
101
|
+
this.deps = deps;
|
|
102
|
+
}
|
|
103
|
+
lookupRows(value) {
|
|
104
|
+
const v = value === undefined ? null : value;
|
|
105
|
+
if (v === null)
|
|
106
|
+
return this.fallback(null);
|
|
107
|
+
if (this.probe.kind === "column")
|
|
108
|
+
return typeof v === "string" ? this.column(v) : [];
|
|
109
|
+
switch (typeof v) {
|
|
110
|
+
case "string": return this.property("p.type = 'string' AND p.val_text = ?", v);
|
|
111
|
+
case "number": return Number.isNaN(v) ? [] : this.property("p.type = 'number' AND p.val_num = ?", v);
|
|
112
|
+
case "boolean": return this.property("p.type = 'bool' AND p.val_bool = ?", v ? 1 : 0);
|
|
113
|
+
default: return []; // object, array, range, bigint, symbol: equals no decoded scalar
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
lookup(value) {
|
|
117
|
+
if (!this.positions) {
|
|
118
|
+
this.positions = new Map();
|
|
119
|
+
this.deps.rows().forEach((r, i) => this.positions.set(this.deps.identity(r), i));
|
|
120
|
+
}
|
|
121
|
+
const out = [];
|
|
122
|
+
for (const r of this.lookupRows(value)) {
|
|
123
|
+
const pos = this.positions.get(this.deps.identity(r));
|
|
124
|
+
if (pos !== undefined)
|
|
125
|
+
out.push(pos);
|
|
126
|
+
}
|
|
127
|
+
return out.sort((a, b) => a - b);
|
|
128
|
+
}
|
|
129
|
+
// A `$path` probe on a joined target drives from the document (`FROM_BY_DOC`),
|
|
130
|
+
// every other column probe from the target's own index. `?r` in a probe is
|
|
131
|
+
// the repo id, bound ahead of the value.
|
|
132
|
+
column(value) {
|
|
133
|
+
const t = this.target;
|
|
134
|
+
const probe = this.probe.sql;
|
|
135
|
+
const byDoc = t !== "docs" && this.path === "$path";
|
|
136
|
+
const g = byDoc ? guardsByDoc(t, this.deps.repoId) : guards(t, this.deps.repoId);
|
|
137
|
+
const from = byDoc ? FROM_BY_DOC[t] : FROM[t];
|
|
138
|
+
const params = probe.includes("?r") ? [this.deps.repoId, value] : [value];
|
|
139
|
+
const sql = `SELECT ${COLS[t]} FROM ${from} WHERE ${g.sql} AND ${probe.replace("?r", "?")} ORDER BY ${ORDER[t]}`;
|
|
140
|
+
return this.run(sql, [...g.params, ...params]);
|
|
141
|
+
}
|
|
142
|
+
// The scalar-in-scope rule as SQL: the document has exactly one live row for
|
|
143
|
+
// the key (within the source, for a sourced read) and that row is a scalar of
|
|
144
|
+
// the probe's type with the probe's value. The loop is driven from the
|
|
145
|
+
// properties index (`CROSS JOIN`: `idx_props_key_text` / `idx_props_key_num`,
|
|
146
|
+
// `(repo_id, key, val_*)`) and reaches the document by primary key; the count
|
|
147
|
+
// runs per candidate only, and with it at one no document repeats.
|
|
148
|
+
property(typed, bound) {
|
|
149
|
+
const { key, source } = this.probe;
|
|
150
|
+
const src = source === null ? "" : " AND p.source = ?";
|
|
151
|
+
const src2 = source === null ? "" : " AND p2.source = ?";
|
|
152
|
+
const g = guards("docs", this.deps.repoId);
|
|
153
|
+
const sql = `SELECT d.* FROM properties p CROSS JOIN docs d ON d.doc_id = p.doc_id
|
|
154
|
+
WHERE p.repo_id = ? AND p.key = ? AND ${typed} AND p.card = 'scalar' AND p.deleted_commit IS NULL${src}
|
|
155
|
+
AND ${g.sql}
|
|
156
|
+
AND (SELECT COUNT(*) FROM properties p2 WHERE p2.doc_id = d.doc_id AND p2.key = ? AND p2.deleted_commit IS NULL${src2}) = 1
|
|
157
|
+
ORDER BY ${ORDER.docs}`;
|
|
158
|
+
const params = [this.deps.repoId, key, bound, ...(source === null ? [] : [source]), ...g.params, key, ...(source === null ? [] : [source])];
|
|
159
|
+
return this.run(sql, params);
|
|
160
|
+
}
|
|
161
|
+
run(sql, params) {
|
|
162
|
+
let stmt = this.statements.get(sql);
|
|
163
|
+
if (!stmt)
|
|
164
|
+
this.statements.set(sql, (stmt = this.deps.db.prepare(sql)));
|
|
165
|
+
return this.deps.tag(stmt.all(...params), this.target);
|
|
166
|
+
}
|
|
167
|
+
// The scan's own answer for a probe no statement covers.
|
|
168
|
+
fallback(value) {
|
|
169
|
+
return this.deps.rows().filter((r) => semantics.equals(this.deps.get(r, this.path), value));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** A store-backed `RowIndex` for the root scan of `target` on `path`, or
|
|
173
|
+
* `undefined` when SQLite has no index for the path (per-target table above;
|
|
174
|
+
* multi-segment paths; `format`, edge `predicate`/`src_field`, `$tags`, …). */
|
|
175
|
+
export function storeIndexFor(target, path, deps) {
|
|
176
|
+
const probe = probeFor(target, path);
|
|
177
|
+
return probe ? new StoreIndex(target, path[0], probe, deps) : undefined;
|
|
178
|
+
}
|
|
179
|
+
/** The paths `storeIndexFor` answers from SQLite, per target (for tests and docs). */
|
|
180
|
+
export function indexablePaths(target) {
|
|
181
|
+
const cols = Object.keys(COLUMN_PROBES[target]);
|
|
182
|
+
return target === "docs" ? [...cols, "$title", "<property key>"] : cols;
|
|
183
|
+
}
|
|
184
|
+
//# sourceMappingURL=store-index.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@omgbase/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Open Markdown Graph Base — embedded engine",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
58
|
-
"@omgbase/oqx": "^0.
|
|
58
|
+
"@omgbase/oqx": "^0.15.0",
|
|
59
59
|
"better-sqlite3": "^13.0.3",
|
|
60
60
|
"mdast-util-from-markdown": "^2.0.2",
|
|
61
61
|
"mdast-util-frontmatter": "^2.0.1",
|