@omgbase/core 0.10.0 → 0.11.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.
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 +215 -121
  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 +71 -24
  44. package/dist/src/oqx-js/sql/scan.js +7 -7
  45. package/dist/src/oqx-js/sql/translate.d.ts +12 -9
  46. package/dist/src/oqx-js/sql/translate.js +183 -75
  47. package/dist/src/oqx-js/store-index.d.ts +1 -1
  48. package/dist/src/oqx-js/store-index.js +28 -26
  49. package/dist/src/search/cel/ast.d.ts +1 -1
  50. package/dist/src/search/cel/compile.d.ts +1 -1
  51. package/dist/src/search/cel/compile.js +43 -70
  52. package/dist/src/search/cel/parser.js +1 -1
  53. package/dist/src/search/resolve.d.ts +2 -0
  54. package/dist/src/search/rrf.js +2 -2
  55. package/dist/src/search/text.d.ts +2 -2
  56. package/dist/src/search/text.js +1 -1
  57. package/dist/src/spec-versions.d.ts +6 -6
  58. package/dist/src/spec-versions.js +5 -5
  59. package/dist/src/surface-paths.d.ts +41 -8
  60. package/dist/src/surface-paths.js +34 -18
  61. package/dist/src/sync/admin.d.ts +2 -1
  62. package/dist/src/sync/admin.js +1 -1
  63. package/package.json +2 -2
@@ -13,7 +13,7 @@ export function docLinks(store, docId, opts = {}) {
13
13
  .all(docId);
14
14
  for (const r of rows)
15
15
  if (inPred(r.predicate))
16
- out.push({ predicate: r.predicate, node: r.dst_node, kind: r.dst_kind, count: 1, block: r.src_block });
16
+ out.push({ predicate: r.predicate, doc_id: r.dst_node, kind: r.dst_kind, count: 1, block_id: r.src_block });
17
17
  }
18
18
  else {
19
19
  const rows = store.db
@@ -23,7 +23,7 @@ export function docLinks(store, docId, opts = {}) {
23
23
  for (const r of rows) {
24
24
  if (!inPred(r.predicate))
25
25
  continue;
26
- out.push({ predicate: r.predicate, node: r.dst_node, kind: r.dst_kind, count: r.count, samples: JSON.parse(r.samples) });
26
+ out.push({ predicate: r.predicate, doc_id: r.dst_node, kind: r.dst_kind, count: r.count, samples: JSON.parse(r.samples) });
27
27
  }
28
28
  }
29
29
  }
@@ -35,7 +35,7 @@ export function docLinks(store, docId, opts = {}) {
35
35
  .all(docId);
36
36
  for (const r of rows)
37
37
  if (inPred(r.predicate))
38
- inbound.push({ predicate: r.predicate, node: r.src_doc, kind: "document", count: 1, block: r.src_block });
38
+ inbound.push({ predicate: r.predicate, doc_id: r.src_doc, kind: "document", count: 1, block_id: r.src_block });
39
39
  }
40
40
  else {
41
41
  const rows = store.db
@@ -45,7 +45,7 @@ export function docLinks(store, docId, opts = {}) {
45
45
  .all(docId);
46
46
  for (const r of rows)
47
47
  if (inPred(r.predicate))
48
- inbound.push({ predicate: r.predicate, node: r.src_doc, kind: "document", count: r.cnt });
48
+ inbound.push({ predicate: r.predicate, doc_id: r.src_doc, kind: "document", count: r.cnt });
49
49
  }
50
50
  }
51
51
  return { out, in: inbound };
@@ -5,6 +5,7 @@ export type { FormatAdapter, AdapterEdge, ProjectedNode, EmbeddingChunk, Reconci
5
5
  export { AdapterCapability } from "./format/adapter.js";
6
6
  export { registerAdapter, adapterForFormat, adapterForPath, registeredFormats, detectFormat } from "./format/registry.js";
7
7
  export { markdownAdapter, MARKDOWN_FORMAT } from "./format/markdown.js";
8
+ export { isContainerBlock } from "./format/index.js";
8
9
  export { yamlAdapter, YAML_FORMAT } from "./format/yaml.js";
9
10
  export { jsonAdapter, JSON_FORMAT } from "./format/json.js";
10
11
  export { writeDocNodes, deleteDocNodes } from "./core/store/nodes.js";
@@ -34,7 +35,7 @@ export { reposStatus, syncStatus, reposList, type RepoStatus, type SyncStatus, t
34
35
  export { docsOutline, type OutlineResult, type OutlineOptions } from "./core/read/outline.js";
35
36
  export { docsRead, reconstructContent, type DocsReadResult, type DocsReadOptions } from "./core/read/document.js";
36
37
  export { nodesGet, nodesGetMany, type GetNode, type Resolution } from "./core/read/nodes.js";
37
- export { findDoc, loadDocBlocks, blockRaw, docsList, docsTree, normalizeTreePrefix, DOCS_LIST_DEFAULT_LIMIT, DOCS_TREE_DEFAULT_LIMIT, type DocInfo, type BlockNode, type DocListRow, type DocListPage, type DocListOptions, type DocTreeEntry, type DocTreePage, type DocTreeOptions, } from "./core/read/reader.js";
38
+ export { findDoc, findDocByRef, loadDocBlocks, blockRaw, docsList, docsTree, normalizeTreePrefix, DOCS_LIST_DEFAULT_LIMIT, DOCS_TREE_DEFAULT_LIMIT, type DocInfo, type BlockNode, type DocListRow, type DocListPage, type DocListOptions, type DocTreeEntry, type DocTreePage, type DocTreeOptions, } from "./core/read/reader.js";
38
39
  export { CursorInvalid, encodeCursor, decodeCursor, decodePathCursor } from "./core/cursor.js";
39
40
  export { referencePath, storagePath, referencePathOrNull, referenceKeyed } from "./core/paths.js";
40
41
  export * from "./surface-paths.js";
package/dist/src/index.js CHANGED
@@ -4,6 +4,7 @@ export { SPEC_VERSIONS } from "./spec-versions.js";
4
4
  export { AdapterCapability } from "./format/adapter.js";
5
5
  export { registerAdapter, adapterForFormat, adapterForPath, registeredFormats, detectFormat } from "./format/registry.js";
6
6
  export { markdownAdapter, MARKDOWN_FORMAT } from "./format/markdown.js";
7
+ export { isContainerBlock } from "./format/index.js";
7
8
  export { yamlAdapter, YAML_FORMAT } from "./format/yaml.js";
8
9
  export { jsonAdapter, JSON_FORMAT } from "./format/json.js";
9
10
  export { writeDocNodes, deleteDocNodes } from "./core/store/nodes.js";
@@ -35,7 +36,7 @@ export { reposStatus, syncStatus, reposList } from "./sync/admin.js";
35
36
  export { docsOutline } from "./core/read/outline.js";
36
37
  export { docsRead, reconstructContent } from "./core/read/document.js";
37
38
  export { nodesGet, nodesGetMany } from "./core/read/nodes.js";
38
- export { findDoc, loadDocBlocks, blockRaw, docsList, docsTree, normalizeTreePrefix, DOCS_LIST_DEFAULT_LIMIT, DOCS_TREE_DEFAULT_LIMIT, } from "./core/read/reader.js";
39
+ export { findDoc, findDocByRef, loadDocBlocks, blockRaw, docsList, docsTree, normalizeTreePrefix, DOCS_LIST_DEFAULT_LIMIT, DOCS_TREE_DEFAULT_LIMIT, } from "./core/read/reader.js";
39
40
  export { CursorInvalid, encodeCursor, decodeCursor, decodePathCursor } from "./core/cursor.js";
40
41
  export { referencePath, storagePath, referencePathOrNull, referenceKeyed } from "./core/paths.js";
41
42
  export * from "./surface-paths.js";
@@ -0,0 +1,27 @@
1
+ import { z } from "zod";
2
+ import { EngineError } from "./errors.js";
3
+ /** One tool's argument shape: the zod raw shape it was registered with. */
4
+ export type ArgShape = Record<string, z.ZodTypeAny>;
5
+ /** The `(tool takes …)` tail: names in bytewise order, `?` on the optional ones. */
6
+ export declare function argumentsHint(tool: string, shape: ArgShape): string;
7
+ /** A `filter_invalid` for an argument-shape failure: the message plus the tool's shape. */
8
+ export declare function argumentError(tool: string, message: string, shape: ArgShape): EngineError;
9
+ /**
10
+ * Validate `raw` against `shape`: unknown names first (in bytewise order), then
11
+ * each declared argument (bytewise order) for presence and type. Returns the
12
+ * arguments with any `undefined` members dropped; throws the first failure.
13
+ */
14
+ export declare function validateArguments(tool: string, shape: ArgShape, raw: unknown): Record<string, unknown>;
15
+ /**
16
+ * What to say about an argument name the tool does not take. The names 3.0
17
+ * retired are called out with their replacement (`doc` → `id`/`path`,
18
+ * `block`/`node` → `id`, `heading` → `section`,
19
+ * `markdown` → `text`) when the tool has the replacement; otherwise the
20
+ * nearest valid name (Levenshtein ≤ 2, or one a prefix of the other).
21
+ */
22
+ export declare function unknownArgumentMessage(key: string, shape: ArgShape): string;
23
+ /** The nearest candidate: Levenshtein distance ≤ 2, or a prefix relation either way; ties → the bytewise-first. */
24
+ export declare function nearest(name: string, candidates: readonly string[]): string | null;
25
+ export declare function levenshtein(a: string, b: string): number;
26
+ /** The expected-type phrase of an argument, as the Rust catalog spells it from its JSON schema. */
27
+ export declare function phraseFor(schema: z.ZodTypeAny): string;
@@ -0,0 +1,147 @@
1
+ import { z } from "zod";
2
+ import { EngineError } from "./errors.js";
3
+ /** The `(tool takes …)` tail: names in bytewise order, `?` on the optional ones. */
4
+ export function argumentsHint(tool, shape) {
5
+ const names = Object.keys(shape).sort(cmpBytes);
6
+ if (names.length === 0)
7
+ return `${tool} takes no arguments`;
8
+ return `${tool} takes ${names.map((n) => (isOptional(shape[n]) ? `${n}?` : n)).join(", ")}`;
9
+ }
10
+ /** A `filter_invalid` for an argument-shape failure: the message plus the tool's shape. */
11
+ export function argumentError(tool, message, shape) {
12
+ const full = `${message} (${argumentsHint(tool, shape)})`;
13
+ return new EngineError("filter_invalid", full, { data: { reason: full, hint: "arguments" } });
14
+ }
15
+ /**
16
+ * Validate `raw` against `shape`: unknown names first (in bytewise order), then
17
+ * each declared argument (bytewise order) for presence and type. Returns the
18
+ * arguments with any `undefined` members dropped; throws the first failure.
19
+ */
20
+ export function validateArguments(tool, shape, raw) {
21
+ if (raw === undefined || raw === null)
22
+ raw = {};
23
+ if (typeof raw !== "object" || Array.isArray(raw))
24
+ throw argumentError(tool, "arguments must be an object", shape);
25
+ const args = raw;
26
+ for (const key of Object.keys(args).sort(cmpBytes)) {
27
+ if (!(key in shape))
28
+ throw argumentError(tool, unknownArgumentMessage(key, shape), shape);
29
+ }
30
+ const out = {};
31
+ for (const key of Object.keys(shape).sort(cmpBytes)) {
32
+ const schema = shape[key];
33
+ const value = args[key];
34
+ if (value === undefined) {
35
+ if (!isOptional(schema))
36
+ throw argumentError(tool, `\`${key}\` is required`, shape);
37
+ continue;
38
+ }
39
+ const parsed = schema.safeParse(value);
40
+ if (!parsed.success)
41
+ throw argumentError(tool, issueMessage(key, schema, parsed.error.issues), shape);
42
+ // The value as sent, not zod's copy: the schemas here coerce nothing, and
43
+ // the kernel-op grammar (`apply.ops`) keeps keys the schema does not name.
44
+ out[key] = value;
45
+ }
46
+ return out;
47
+ }
48
+ /** The first failure, spelled for the argument: a top-level type mismatch is
49
+ * "`key` must be <phrase>"; a failure inside a nested shape (`ops[0].block`)
50
+ * names the path — "is required" when the member is absent, else "must be
51
+ * <zod's expected type>". Nested wording is the reference's own (the Rust
52
+ * catalog checks nested shapes in its handlers); the top-level form is pinned. */
53
+ function issueMessage(key, schema, issues) {
54
+ const issue = issues[0];
55
+ if (!issue || issue.path.length === 0)
56
+ return `\`${key}\` must be ${phraseFor(schema)}`;
57
+ const at = key + issue.path.map((p) => (typeof p === "number" ? `[${p}]` : `.${String(p)}`)).join("");
58
+ if (issue.code === "invalid_type" && issue.input === undefined)
59
+ return `\`${at}\` is required`;
60
+ const expected = { string: "a string", number: "an integer", boolean: "a boolean", object: "an object", array: "an array" };
61
+ return `\`${at}\` must be ${issue.expected !== undefined ? (expected[issue.expected] ?? issue.expected) : "valid"}`;
62
+ }
63
+ /**
64
+ * What to say about an argument name the tool does not take. The names 3.0
65
+ * retired are called out with their replacement (`doc` → `id`/`path`,
66
+ * `block`/`node` → `id`, `heading` → `section`,
67
+ * `markdown` → `text`) when the tool has the replacement; otherwise the
68
+ * nearest valid name (Levenshtein ≤ 2, or one a prefix of the other).
69
+ */
70
+ export function unknownArgumentMessage(key, shape) {
71
+ const has = (n) => n in shape;
72
+ const retired = (replacement) => `\`${key}\` is no longer an argument — pass ${replacement}`;
73
+ if (key === "doc" && has("id") && has("path"))
74
+ return retired("`id` or `path`");
75
+ if ((key === "block" || key === "node") && has("id"))
76
+ return retired("`id`");
77
+ if (key === "heading" && has("section"))
78
+ return retired("`section` (the heading's text), or blocks_append with the heading's `id`");
79
+ if (key === "markdown" && has("text"))
80
+ return retired("`text`");
81
+ const near = nearest(key, Object.keys(shape));
82
+ return near === null ? `unknown argument \`${key}\`` : `unknown argument \`${key}\`; did you mean \`${near}\`?`;
83
+ }
84
+ /** The nearest candidate: Levenshtein distance ≤ 2, or a prefix relation either way; ties → the bytewise-first. */
85
+ export function nearest(name, candidates) {
86
+ let best = null;
87
+ for (const c of [...candidates].sort(cmpBytes)) {
88
+ const d = levenshtein(name, c);
89
+ const prefix = name.length > 0 && c.length > 0 && (c.startsWith(name) || name.startsWith(c));
90
+ if (d > 2 && !prefix)
91
+ continue;
92
+ const score = prefix && d > 2 ? 3 : d;
93
+ if (best === null || score < best.score)
94
+ best = { name: c, score };
95
+ }
96
+ return best?.name ?? null;
97
+ }
98
+ export function levenshtein(a, b) {
99
+ const m = a.length;
100
+ const n = b.length;
101
+ if (m === 0)
102
+ return n;
103
+ if (n === 0)
104
+ return m;
105
+ let prev = Array.from({ length: n + 1 }, (_, j) => j);
106
+ for (let i = 1; i <= m; i++) {
107
+ const cur = [i];
108
+ for (let j = 1; j <= n; j++) {
109
+ cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
110
+ }
111
+ prev = cur;
112
+ }
113
+ return prev[n];
114
+ }
115
+ function cmpBytes(a, b) {
116
+ return a < b ? -1 : a > b ? 1 : 0;
117
+ }
118
+ function defOf(schema) {
119
+ return schema._zod.def;
120
+ }
121
+ function isOptional(schema) {
122
+ const d = defOf(schema);
123
+ return d.type === "optional" || (d.type === "nullable" && isOptional(d.innerType));
124
+ }
125
+ function unwrap(schema) {
126
+ const d = defOf(schema);
127
+ return d.type === "optional" || d.type === "nullable" ? unwrap(d.innerType) : schema;
128
+ }
129
+ /** The expected-type phrase of an argument, as the Rust catalog spells it from its JSON schema. */
130
+ export function phraseFor(schema) {
131
+ const d = defOf(unwrap(schema));
132
+ switch (d.type) {
133
+ case "string": return "a string";
134
+ case "number": return "an integer";
135
+ case "boolean": return "a boolean";
136
+ case "object":
137
+ case "record": return "an object";
138
+ case "enum": return `one of ${Object.values(d.entries ?? {}).map(String).join(", ")}`;
139
+ case "array": {
140
+ const inner = defOf(unwrap(d.element)).type;
141
+ return `an array of ${inner === "string" ? "strings" : inner === "number" ? "integers" : "objects"}`;
142
+ }
143
+ case "union": return "\"start\", \"end\", {before} or {after}";
144
+ default: return "valid";
145
+ }
146
+ }
147
+ //# sourceMappingURL=arguments.js.map
@@ -1,4 +1,4 @@
1
- export type ErrorCode = "stale_expectation" | "parent_missing" | "target_missing" | "block_missing" | "doc_missing" | "cycle_move" | "opaque_block" | "not_contiguous" | "type_mismatch" | "conflicted_document" | "path_taken" | "create_conflict" | "ambiguous_locator" | "ambiguous_heading" | "filter_invalid" | "budget_exceeded" | "semantic_unavailable" | "embedder_failed" | "sync_conflict" | "repo_not_found" | "node_not_editable" | "seed_unresolved";
1
+ export type ErrorCode = "stale_expectation" | "parent_missing" | "target_missing" | "block_missing" | "doc_missing" | "cycle_move" | "opaque_block" | "not_contiguous" | "type_mismatch" | "conflicted_document" | "path_taken" | "create_conflict" | "ambiguous_locator" | "ambiguous_heading" | "filter_invalid" | "budget_exceeded" | "provider_unavailable" | "embedder_failed" | "sync_conflict" | "repo_not_found" | "node_not_editable" | "seed_unresolved" | "block_not_container";
2
2
  export interface EngineErrorBody {
3
3
  error: ErrorCode;
4
4
  message: string;
@@ -11,25 +11,32 @@ export interface GraphArgs {
11
11
  /** restrict the neighborhood to documents reachable from a root via edges of
12
12
  * this predicate, within `degrees` (both the docs and the edges are filtered). */
13
13
  predicate?: string | undefined;
14
- /** extra document projections (OQX select expressions, e.g. "layer", "$path"). */
14
+ /** extra document projections (OQX select expressions, e.g. "layer", "path"). */
15
15
  select?: string[] | undefined;
16
16
  /** cap on the distinct documents returned (default 200). */
17
17
  max_documents?: number | undefined;
18
18
  }
19
- /** A traversed edge with full provenance (mirrors the `edges` target's rows). */
19
+ /** A traversed edge: a row of the `links` target under its own field names
20
+ * (spec/surface §1.2, 3.0 — `id` the edge, `doc_id`/`block_id` the source,
21
+ * `via_id`, `dst_id` the destination node, `dst_path`/`uri` its path or URI,
22
+ * `field` the authoring key). No rename table: what the walk projects is what
23
+ * the result carries. */
20
24
  export interface GraphEdge {
21
25
  id: string;
22
- src: string;
23
- dst: string;
26
+ doc_id: string;
27
+ block_id: string | null;
28
+ via_id: string | null;
29
+ dst_id: string;
24
30
  dst_path: string | null;
25
- dst_uri: string | null;
31
+ uri: string | null;
26
32
  dst_kind: string;
27
33
  predicate: string;
28
34
  provenance: string;
29
35
  anchor: string | null;
30
- src_field: string | null;
36
+ field: string | null;
31
37
  }
32
- /** A reached document: id/path + walk metadata + any requested projections. */
38
+ /** A reached document: `id`/`path` (the row's own names, as a `query` hit)
39
+ * + walk metadata + any requested projections. */
33
40
  export interface GraphDoc {
34
41
  id: string;
35
42
  path: string;
@@ -44,7 +51,7 @@ export interface GraphResult {
44
51
  degrees: number;
45
52
  direction: GraphDirection;
46
53
  documents: GraphDoc[];
47
- edges: GraphEdge[];
54
+ links: GraphEdge[];
48
55
  frontier: {
49
56
  id: string;
50
57
  path: string;
@@ -57,7 +64,7 @@ export interface GraphResult {
57
64
  /**
58
65
  * Run the `graph` neighborhood macro: compile the args into OQX `follow`
59
66
  * query(ies), execute via the shared runner, and shape the walk output into
60
- * { documents, edges, frontier }. Throws EngineError (doc_missing/target_missing)
67
+ * { documents, links, frontier }. Throws EngineError (doc_missing/target_missing)
61
68
  * for an empty or unresolvable roots list — never a silent empty result.
62
69
  */
63
70
  export declare function graphNeighborhood(store: Store, repoId: string, args: GraphArgs, embedQuery?: EmbedQuery): Promise<GraphResult>;
@@ -2,27 +2,30 @@
2
2
  //
3
3
  // ADR-006: OQX `follow` is the ONE traversal engine; the removed
4
4
  // graph_traverse/path/subgraph tools are not resurrected. This macro owns NO
5
- // graph-walking logic — it compiles its arguments into an OQX `follow doc.out`
6
- // / `doc.in` query (the exact recursive-CTE walk the `query` tool exposes),
5
+ // graph-walking logic — it compiles its arguments into an OQX `follow out_links
6
+ // collect { from dst }` / `follow in_links collect { from doc }` query (the
7
+ // exact walk the `query` tool exposes over the link rows),
7
8
  // runs it through the shared runner (oqxRunAsync), and then only SHAPES the
8
9
  // walk's own output (its `$depth`/`$stop` intrinsics and projected edges) into
9
- // the { documents, edges, frontier } envelope. Every hop, cycle-guard and depth
10
+ // the { documents, links, frontier } envelope. Every hop, cycle-guard and depth
10
11
  // bound is `follow`'s; nothing here re-implements BFS/DFS.
11
12
  //
12
13
  // Mapping (args → follow query):
13
- // roots → the seed `where $id == … or …` (refs resolved to doc ids first; literals in the AST, never spliced text)
14
- // degrees → `follow … { depth degrees+1 }` (seed is $depth 1, so N hops = depth N+1)
15
- // direction → `follow doc.out` | `doc.in` | both (two walks, unioned)
14
+ // roots → the seed `where id == … or …` (refs resolved to doc ids first; literals in the AST, never spliced text)
15
+ // degrees → `follow … collect { … depth degrees+1 }` (seed is $depth 1, so N hops = depth N+1)
16
+ // direction → `follow out_links collect { from dst }` (the linked documents)
17
+ // | `follow in_links collect { from doc }` (the linking documents)
18
+ // | both (two walks, unioned)
16
19
  // predicate → restricts the neighborhood to docs reachable via that predicate
17
20
  // (recomputed over the walk's collected edges; no `via` clause)
18
21
  // select → extra doc projections spliced into the walk's `select`
19
22
  // max_documents → a post-walk cap on the distinct document set
20
23
  //
21
- // Edges are surfaced by projecting the reached doc's `doc.out_edges`/`doc.in_edges`
24
+ // Edges are surfaced by projecting the reached doc's `doc.out_links`/`doc.in_links`
22
25
  // in the SAME walk (a select-position collect), then keeping the induced-subgraph
23
26
  // edges (both endpoints in the neighborhood) plus dangling stubs to external/
24
27
  // phantom targets — so external (`x_…`) and phantom endpoints are never dropped,
25
- // exactly as `from edges` surfaces them.
28
+ // exactly as `from links` surfaces them.
26
29
  import { build, parse, print, OqxError } from "@omgbase/oqx";
27
30
  import { findDocByRef } from "../core/read/reader.js";
28
31
  import { oqxRunAsync } from "../oqx/run.js";
@@ -38,21 +41,30 @@ function cmpBytes(a, b) {
38
41
  // Build the extra `select` items for caller projections. Each expression gets a
39
42
  // stable internal alias (_u0, _u1, …) so it can never collide with a reserved
40
43
  // word or the macro's own aliases; the clean output name is derived separately.
44
+ // A bare-name selection (`layer`, `title`) is its own output key, exactly as a
45
+ // `query` hit projects it (spec/surface §1.4, 3.0) — so `id`/`path` collapse
46
+ // onto the envelope keys (the engine reads the same value) and any other name
47
+ // is an ordinary column, deduped by suffix. Anything else (an expression) is
48
+ // `sel_<i>`, so a projection can never overwrite the envelope here.
41
49
  function buildUserSelect(select) {
42
50
  const items = [];
43
51
  const outNames = [];
44
52
  if (!select || select.length === 0)
45
53
  return { items, outNames };
46
- const used = new Set();
54
+ const used = new Set(["id", "path"]);
47
55
  select.forEach((expr, i) => {
48
56
  const trimmed = expr.trim();
49
57
  if (!trimmed)
50
58
  return;
51
- const m = /^\$?([A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
59
+ const m = /^(\$?[A-Za-z_][A-Za-z0-9_]*)$/.exec(trimmed);
52
60
  let name = m ? m[1] : `sel_${i}`;
53
- while (used.has(name))
54
- name = `${name}_${i}`;
55
- used.add(name);
61
+ // The envelope keys are the row's own identity: an `id`/`path` selection
62
+ // lands on them rather than beside them (the engine reads the same value).
63
+ if (name !== "id" && name !== "path") {
64
+ while (used.has(name))
65
+ name = `${name}_${i}`;
66
+ used.add(name);
67
+ }
56
68
  outNames[i] = name;
57
69
  items.push(...userItems(trimmed, `_u${i}`));
58
70
  });
@@ -74,39 +86,47 @@ function userItems(src, alias) {
74
86
  }
75
87
  return q.select;
76
88
  }
77
- // The edge fields projected from doc.out_edges/doc.in_edges — identical for both
78
- // directions so a single filter handles either scan (an edge is a directional
79
- // src→dst fact regardless of which endpoint the walk reached it from).
89
+ // The edge fields projected from doc.out_links/doc.in_links — the `links`
90
+ // target's own names, projected under themselves (spec/surface §1.2, 3.0: no
91
+ // rename between the language and the result) — identical for both directions
92
+ // so a single filter handles either scan (an edge is a directional fact
93
+ // regardless of which endpoint the walk reached it from).
80
94
  const EDGE_FIELDS = [
81
- ["id", "$id"], ["src", "$src"], ["dst", "$dst"], ["dst_path", "$dst_path"], ["dst_uri", "$dst_uri"],
82
- ["dst_kind", "dst_kind"], ["predicate", "predicate"], ["provenance", "provenance"], ["anchor", "anchor"], ["src_field", "src_field"],
95
+ "id", "doc_id", "block_id", "via_id", "dst_id", "dst_path", "uri",
96
+ "dst_kind", "predicate", "provenance", "anchor", "field",
83
97
  ];
84
98
  // The walk, as an AST (spec/oqx/AST.md §7) printed canonically — never spliced
85
99
  // strings: the seed ids are literals, the caller's projections parsed items.
86
100
  //
87
- // select _depth: $depth, _stop: $stop, _edges: doc.<dir>_edges collect { <edge fields> } <user items>
88
- // from docs where $id == <root> or … follow distinct doc.<dir> { depth <depth> }
101
+ // select _depth: $depth, _stop: $stop, _links: doc.<dir>_links collect { <edge fields> } <user items>
102
+ // from docs where id == <root> or … follow distinct <dir>_links collect { from <dst | doc> depth <depth> }
103
+ //
104
+ // The destination block walks the link rows to the documents at their other
105
+ // end (spec/surface §1.2, 3.0 follow-up: there are no `out`/`in` relations —
106
+ // `dst` is a link's destination document, `doc` its source; a `from` over an
107
+ // absent `dst` — an external or phantom target — contributes no successor).
89
108
  function buildQuery(rootIds, dir, depth, userSelect) {
90
- const edges = build.op(build.path("doc", `${dir}_edges`), "collect", build.subquery({
91
- select: EDGE_FIELDS.map(([name, expr]) => build.field(name, build.ident(expr))),
109
+ const edges = build.op(build.path("doc", `${dir}_links`), "collect", build.subquery({
110
+ select: EDGE_FIELDS.map((name) => build.field(name, build.ident(name))),
92
111
  }));
93
- const seed = rootIds.map((id) => build.scalar(build.binary("==", build.ident("$id"), build.lit(id))));
112
+ const seed = rootIds.map((id) => build.scalar(build.binary("==", build.ident("id"), build.lit(id))));
94
113
  const q = build.query(build.ident("docs"), {
95
114
  select: [
96
115
  build.field("_depth", build.ident("$depth")),
97
116
  build.field("_stop", build.ident("$stop")),
98
- build.collect("_edges", edges),
117
+ build.collect("_links", edges),
99
118
  ...userSelect,
100
119
  ],
101
120
  where: seed.length === 1 ? seed[0] : build.or(seed),
102
- follow: build.follow([build.path("doc", dir)], { distinct: true, depth }),
121
+ // `depth` is the destination block's own clause (oqx 0.20).
122
+ follow: build.follow([build.op(build.ident(`${dir}_links`), "collect", build.subquery({ from: [build.ident(dir === "out" ? "dst" : "doc")], depth }))], { distinct: true }),
103
123
  });
104
124
  return print(q);
105
125
  }
106
126
  /**
107
127
  * Run the `graph` neighborhood macro: compile the args into OQX `follow`
108
128
  * query(ies), execute via the shared runner, and shape the walk output into
109
- * { documents, edges, frontier }. Throws EngineError (doc_missing/target_missing)
129
+ * { documents, links, frontier }. Throws EngineError (doc_missing/target_missing)
110
130
  * for an empty or unresolvable roots list — never a silent empty result.
111
131
  */
112
132
  export async function graphNeighborhood(store, repoId, args, embedQuery) {
@@ -145,18 +165,21 @@ export async function graphNeighborhood(store, repoId, args, embedQuery) {
145
165
  for (const hit of res.hits) {
146
166
  const hopDepth = Number(hit._depth); // $depth: seed = 1
147
167
  const degree = hopDepth - 1; // root = 0
168
+ // The walk's hits carry `id`/`path` (spec/surface §1.4, 3.0) and so
169
+ // does `graph`'s document: one notation for a document everywhere.
148
170
  const prev = docMap.get(hit.id);
149
171
  if (!prev || degree < prev.degree) {
150
172
  const doc = { id: hit.id, path: hit.path, degree, frontier: false };
151
173
  outNames.forEach((name, i) => {
152
- if (name !== undefined)
174
+ // An `id`/`path` selection is the envelope key itself (same value).
175
+ if (name !== undefined && name !== "id" && name !== "path")
153
176
  doc[name] = hit[`_u${i}`] ?? null;
154
177
  });
155
178
  docMap.set(hit.id, doc);
156
179
  }
157
180
  // Gather this node's edges; dedup by edge id (an a→b edge is reachable as
158
181
  // a's out-edge AND b's in-edge in "both" mode).
159
- const raw = hit._edges ?? [];
182
+ const raw = hit._links ?? [];
160
183
  for (const e of raw)
161
184
  if (!edgeMap.has(e.id))
162
185
  edgeMap.set(e.id, e);
@@ -176,9 +199,9 @@ export async function graphNeighborhood(store, repoId, args, embedQuery) {
176
199
  if (e.predicate !== P)
177
200
  continue;
178
201
  if (dirs.includes("out"))
179
- link(e.src, e.dst); // forward
202
+ link(e.doc_id, e.dst_id); // forward
180
203
  if (dirs.includes("in"))
181
- link(e.dst, e.src); // backward
204
+ link(e.dst_id, e.doc_id); // backward
182
205
  }
183
206
  const depthOf = new Map(rootIds.map((id) => [id, 0]));
184
207
  let wave = [...rootIds];
@@ -223,26 +246,26 @@ export async function graphNeighborhood(store, repoId, args, embedQuery) {
223
246
  }
224
247
  // Keep the induced-subgraph edges (both endpoints in the neighborhood) plus
225
248
  // dangling stubs to external/phantom targets, so external (x_…) and phantom
226
- // endpoints survive the way `from edges` surfaces them. A `predicate` filter
249
+ // endpoints survive the way `from links` surfaces them. A `predicate` filter
227
250
  // keeps only that predicate's edges (the neighborhood was already restricted to
228
251
  // predicate-reachable docs above).
229
252
  const edges = [];
230
253
  for (const e of edgeMap.values()) {
231
254
  if (args.predicate && e.predicate !== args.predicate)
232
255
  continue;
233
- const srcIn = reached.has(e.src);
256
+ const srcIn = reached.has(e.doc_id);
234
257
  const dstDangling = e.dst_kind === "external" || (e.dst_kind === "document" && e.dst_path === null);
235
- const dstIn = reached.has(e.dst) || dstDangling;
258
+ const dstIn = reached.has(e.dst_id) || dstDangling;
236
259
  if (srcIn && dstIn)
237
260
  edges.push(e);
238
261
  }
239
- edges.sort((a, b) => cmpBytes(a.src, b.src) || cmpBytes(a.id, b.id));
262
+ edges.sort((a, b) => cmpBytes(a.doc_id, b.doc_id) || cmpBytes(a.id, b.id));
240
263
  return {
241
264
  roots: rootIds,
242
265
  degrees: effectiveDegrees,
243
266
  direction,
244
267
  documents,
245
- edges,
268
+ links: edges,
246
269
  frontier,
247
270
  truncated,
248
271
  queries,