@omgbase/core 0.8.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,10 +2,22 @@
2
2
  * tool). The MCP layer maps it to `filter_invalid`. */
3
3
  export declare class CursorInvalid extends Error {
4
4
  readonly surface: string;
5
- constructor(surface: string);
5
+ readonly reason?: string | undefined;
6
+ /** `reason` names the fault when it is not the generic "not issued by this
7
+ * surface" — a 1.x cursor, say (see `decodePathCursor`). */
8
+ constructor(surface: string, reason?: string | undefined);
6
9
  }
7
10
  /** Encode a keyset position as an opaque cursor. */
8
11
  export declare function encodeCursor(parts: readonly string[]): string;
9
12
  /** Decode a cursor issued by `encodeCursor`, requiring exactly `arity` string
10
13
  * parts. `surface` names the caller for the error. */
11
14
  export declare function decodeCursor(cursor: string, surface: string, arity: number): string[];
15
+ /**
16
+ * `decodeCursor` for the keysets whose FIRST part is a document path (`query`'s
17
+ * `[path, id]`, `docs_list`/`docs_tree`'s `[path]`). Since spec/surface 2.0 the
18
+ * surface speaks the reference form, so an issued cursor's path is `/`-rooted;
19
+ * a bare path can only come from a 1.x cursor, whose keyset would sort before
20
+ * every rooted row and silently replay the first page — it is refused with a
21
+ * reason that names the cause.
22
+ */
23
+ export declare function decodePathCursor(cursor: string, surface: string, arity: number): string[];
@@ -8,9 +8,13 @@
8
8
  * tool). The MCP layer maps it to `filter_invalid`. */
9
9
  export class CursorInvalid extends Error {
10
10
  surface;
11
- constructor(surface) {
12
- super("invalid cursor");
11
+ reason;
12
+ /** `reason` names the fault when it is not the generic "not issued by this
13
+ * surface" — a 1.x cursor, say (see `decodePathCursor`). */
14
+ constructor(surface, reason) {
15
+ super(reason === undefined ? "invalid cursor" : `invalid cursor: ${reason}`);
13
16
  this.surface = surface;
17
+ this.reason = reason;
14
18
  this.name = "CursorInvalid";
15
19
  }
16
20
  }
@@ -32,4 +36,19 @@ export function decodeCursor(cursor, surface, arity) {
32
36
  throw new CursorInvalid(surface);
33
37
  return parts;
34
38
  }
39
+ /**
40
+ * `decodeCursor` for the keysets whose FIRST part is a document path (`query`'s
41
+ * `[path, id]`, `docs_list`/`docs_tree`'s `[path]`). Since spec/surface 2.0 the
42
+ * surface speaks the reference form, so an issued cursor's path is `/`-rooted;
43
+ * a bare path can only come from a 1.x cursor, whose keyset would sort before
44
+ * every rooted row and silently replay the first page — it is refused with a
45
+ * reason that names the cause.
46
+ */
47
+ export function decodePathCursor(cursor, surface, arity) {
48
+ const parts = decodeCursor(cursor, surface, arity);
49
+ if (!parts[0].startsWith("/")) {
50
+ throw new CursorInvalid(surface, `cursor was issued before surface 2.0 (its path ${JSON.stringify(parts[0])} is not /-rooted); start the page sequence again without a cursor`);
51
+ }
52
+ return parts;
53
+ }
35
54
  //# sourceMappingURL=cursor.js.map
@@ -0,0 +1,10 @@
1
+ /** The reference (surface) form of a storage path: exactly one leading `/`.
2
+ * `""` (the repo root, `docs_tree`'s prefix) becomes `/`. */
3
+ export declare function referencePath(path: string): string;
4
+ /** The storage form of a path a caller handed in: every leading `/` stripped
5
+ * (a missing slash is fine, an extra one is forgiven). */
6
+ export declare function storagePath(path: string): string;
7
+ /** `referencePath` over a nullable value; `null`/`undefined` pass through. */
8
+ export declare function referencePathOrNull<T extends string | null | undefined>(path: T): T extends string ? string : T;
9
+ /** A `{ <path>: … }` record (dry-run `diffs`) re-keyed by the reference form. */
10
+ export declare function referenceKeyed<V>(byPath: Record<string, V>): Record<string, V>;
@@ -0,0 +1,34 @@
1
+ // The two path forms (spec/surface §1 "Paths", 2.0). omgbase stores a
2
+ // document's path in git's repo-relative form (`projects/oqx.md`: `docs.path`,
3
+ // the filesystem adapters, every store/sync spec) while every reference a user
4
+ // authors — a Markdown link, a wikilink, a frontmatter relation — is
5
+ // root-absolute (`/projects/oqx.md`). The surface speaks the reference form:
6
+ // every path a query, a tool or a CLI verb RETURNS is `/`-rooted, and every
7
+ // path they ACCEPT tolerates both forms. These two functions are the whole
8
+ // conversion; the storage layer never sees a rooted path and the surface never
9
+ // hands out a bare one.
10
+ /** The reference (surface) form of a storage path: exactly one leading `/`.
11
+ * `""` (the repo root, `docs_tree`'s prefix) becomes `/`. */
12
+ export function referencePath(path) {
13
+ return "/" + storagePath(path);
14
+ }
15
+ /** The storage form of a path a caller handed in: every leading `/` stripped
16
+ * (a missing slash is fine, an extra one is forgiven). */
17
+ export function storagePath(path) {
18
+ let i = 0;
19
+ while (i < path.length && path[i] === "/")
20
+ i++;
21
+ return i === 0 ? path : path.slice(i);
22
+ }
23
+ /** `referencePath` over a nullable value; `null`/`undefined` pass through. */
24
+ export function referencePathOrNull(path) {
25
+ return (typeof path === "string" ? referencePath(path) : path);
26
+ }
27
+ /** A `{ <path>: … }` record (dry-run `diffs`) re-keyed by the reference form. */
28
+ export function referenceKeyed(byPath) {
29
+ const out = {};
30
+ for (const [k, v] of Object.entries(byPath))
31
+ out[referencePath(k)] = v;
32
+ return out;
33
+ }
34
+ //# sourceMappingURL=paths.js.map
@@ -1,6 +1,7 @@
1
1
  import type { Database } from "better-sqlite3";
2
2
  import type { Store } from "../store/store.js";
3
3
  export interface DocsReadResult {
4
+ /** The reference form (`/a/b.md`, spec/surface §1 "Paths"). */
4
5
  path: string;
5
6
  docId: string;
6
7
  rev: string | null;
@@ -1,6 +1,7 @@
1
1
  import { findDoc, findDocByRef, loadDocBlocks } from "./reader.js";
2
2
  import { docPropertiesGrouped } from "../store/properties.js";
3
3
  import { sha256 } from "../hash.js";
4
+ import { referencePath } from "../paths.js";
4
5
  function blob(db, hash) {
5
6
  const row = db.prepare("SELECT bytes FROM blobs WHERE hash = ?").get(hash);
6
7
  return row ? row.bytes.toString("utf8") : "";
@@ -49,7 +50,7 @@ export function docsRead(store, docId, opts = {}) {
49
50
  if (content === null)
50
51
  return null;
51
52
  const result = {
52
- path: info.path,
53
+ path: referencePath(info.path),
53
54
  docId: info.docId,
54
55
  rev: info.currentRev,
55
56
  properties: docPropertiesGrouped(store.db, docId),
@@ -184,7 +185,7 @@ export function readDocumentAtRevision(store, docId, revId) {
184
185
  const renderedHashMatch = sha256(content).equals(rev.rendered_hash);
185
186
  return {
186
187
  // The revision records the path as of that revision (docs may be moved).
187
- path: rev.path,
188
+ path: referencePath(rev.path),
188
189
  docId,
189
190
  rev: revId,
190
191
  content,
@@ -44,8 +44,8 @@ export interface DocTreeEntry {
44
44
  ts: string | null;
45
45
  }
46
46
  export interface DocTreePage {
47
- /** The normalized directory prefix the tree was taken under (`""` = root;
48
- * otherwise ends in `/`). */
47
+ /** The normalized directory prefix the tree was taken under, in the
48
+ * reference form: `/` for the repo root, else `/<dir>/`. */
49
49
  prefix: string;
50
50
  depth: number;
51
51
  /** Totals over EVERYTHING under `prefix`, regardless of paging. */
@@ -69,7 +69,8 @@ export interface DocTreeOptions {
69
69
  budgetTokens?: number;
70
70
  }
71
71
  export declare const DOCS_TREE_DEFAULT_LIMIT = 200;
72
- /** Normalize a tree prefix: no leading `/`, and either empty or ending in `/`. */
72
+ /** Normalize a tree prefix to its storage form: no leading `/`, and either
73
+ * empty or ending in `/` (either path form is accepted). */
73
74
  export declare function normalizeTreePrefix(path: string | undefined): string;
74
75
  /** The path-separator-aware orientation read (`tree -L depth` + `du`): every
75
76
  * live doc under `path` is collapsed at `depth` segments into one entry per
@@ -99,6 +100,8 @@ export declare function blockRaw(store: Store, rawHashHex: string): string;
99
100
  export interface DocInfo {
100
101
  docId: string;
101
102
  repoId: string;
103
+ /** The STORAGE form (`a/b.md`): `DocInfo` is the library's internal handle on
104
+ * a document (file writes, SQL); the surface roots what it returns. */
102
105
  path: string;
103
106
  format: string;
104
107
  currentRev: string | null;
@@ -1,9 +1,11 @@
1
1
  import { isValidId } from "../ids.js";
2
- import { encodeCursor, decodeCursor } from "../cursor.js";
2
+ import { encodeCursor, decodePathCursor } from "../cursor.js";
3
+ import { referencePath, storagePath } from "../paths.js";
3
4
  export const DOCS_LIST_DEFAULT_LIMIT = 200;
4
- /** Escape a user glob into a LIKE pattern (`*` → `%`; literal `%`/`_` escaped). */
5
+ /** Escape a user glob into a LIKE pattern over the storage path (`*` → `%`;
6
+ * literal `%`/`_` escaped); the glob may be spelled in either path form. */
5
7
  function globToLike(glob) {
6
- return glob.replace(/[%_\\]/g, "\\$&").replace(/\*/g, "%");
8
+ return storagePath(glob).replace(/[%_\\]/g, "\\$&").replace(/\*/g, "%");
7
9
  }
8
10
  /** Live docs of a repo (path, live block count, last-commit ts) whose path
9
11
  * matches `like`, ordered by path, optionally resuming strictly after `after`
@@ -20,7 +22,7 @@ function liveDocRows(store, repoId, like, after, limit) {
20
22
  tail = " LIMIT ?";
21
23
  params.push(limit);
22
24
  }
23
- return store.db
25
+ const rows = store.db
24
26
  .prepare(`SELECT d.path AS path,
25
27
  (SELECT count(*) FROM blocks b WHERE b.doc_id = d.doc_id AND b.deleted_commit IS NULL) AS blocks,
26
28
  (SELECT c.ts FROM revisions r JOIN commits c ON c.commit_id = r.commit_id WHERE r.rev_id = d.current_rev) AS ts
@@ -28,14 +30,20 @@ function liveDocRows(store, repoId, like, after, limit) {
28
30
  WHERE d.repo_id = ? AND d.deleted_commit IS NULL AND d.path LIKE ? ESCAPE '\\'${where}
29
31
  ORDER BY d.path${tail}`)
30
32
  .all(...params);
33
+ // The surface speaks the reference form (spec/surface §1 "Paths"): rooted on
34
+ // the way out, the storage form never leaves this module.
35
+ for (const r of rows)
36
+ r.path = referencePath(r.path);
37
+ return rows;
31
38
  }
32
39
  // Path-keyset cursors for the path-ordered list surfaces: the shared kernel
33
- // encoding (core/cursor.ts) with a one-part tuple.
40
+ // encoding (core/cursor.ts) with a one-part tuple holding the row's (rooted)
41
+ // path; a 1.x cursor (a bare path) is refused (core/cursor.ts).
34
42
  function encodePathCursor(path) {
35
43
  return encodeCursor([path]);
36
44
  }
37
- function decodePathCursor(cursor) {
38
- return decodeCursor(cursor, "docs_list/docs_tree", 1)[0];
45
+ function decodeListCursor(cursor) {
46
+ return decodePathCursor(cursor, "docs_list/docs_tree", 1)[0];
39
47
  }
40
48
  /** Page an already path-ordered row set under a limit + token budget, issuing
41
49
  * a keyset cursor when it stops early. Shared by docs_list and docs_tree. */
@@ -68,16 +76,18 @@ function pagePathOrdered(rows, limit, budgetTokens, moreBeyond) {
68
76
  export function docsList(store, repoId, opts = {}) {
69
77
  const like = opts.pathGlob ? globToLike(opts.pathGlob) : "%";
70
78
  const limit = Math.max(1, Math.floor(opts.limit ?? DOCS_LIST_DEFAULT_LIMIT));
71
- const after = opts.cursor ? decodePathCursor(opts.cursor) : null;
79
+ // The cursor carries the reference form; the keyset predicate runs on the column.
80
+ const after = opts.cursor ? storagePath(decodeListCursor(opts.cursor)) : null;
72
81
  // Fetch one past the limit so `truncated` is a fact, not a guess.
73
82
  const rows = liveDocRows(store, repoId, like, after, limit + 1);
74
83
  const moreBeyond = rows.length > limit;
75
84
  return pagePathOrdered(rows.slice(0, limit), limit, opts.budgetTokens, moreBeyond);
76
85
  }
77
86
  export const DOCS_TREE_DEFAULT_LIMIT = 200;
78
- /** Normalize a tree prefix: no leading `/`, and either empty or ending in `/`. */
87
+ /** Normalize a tree prefix to its storage form: no leading `/`, and either
88
+ * empty or ending in `/` (either path form is accepted). */
79
89
  export function normalizeTreePrefix(path) {
80
- const trimmed = (path ?? "").replace(/^\/+/, "").replace(/\/+$/, "");
90
+ const trimmed = storagePath(path ?? "").replace(/\/+$/, "");
81
91
  return trimmed === "" ? "" : trimmed + "/";
82
92
  }
83
93
  /** The path-separator-aware orientation read (`tree -L depth` + `du`): every
@@ -94,17 +104,21 @@ export function docsTree(store, repoId, opts = {}) {
94
104
  const limit = Math.max(1, Math.floor(opts.limit ?? DOCS_TREE_DEFAULT_LIMIT));
95
105
  const like = prefix === "" ? "%" : globToLike(prefix) + "%";
96
106
  const rows = liveDocRows(store, repoId, like, null, null);
107
+ // `liveDocRows` hands out reference paths; the tree is built over the storage
108
+ // form and rooted again on the way out, so the entries, the prefix and the
109
+ // cursor all speak the reference form.
97
110
  const byPath = new Map();
98
111
  const total = { docs: 0, blocks: 0 };
99
112
  for (const row of rows) {
113
+ const storage = storagePath(row.path);
100
114
  total.docs += 1;
101
115
  total.blocks += row.blocks;
102
- const segs = row.path.slice(prefix.length).split("/");
116
+ const segs = storage.slice(prefix.length).split("/");
103
117
  if (segs.length <= depth) {
104
- byPath.set(row.path, { path: row.path, kind: "doc", docs: 1, blocks: row.blocks, ts: row.ts });
118
+ byPath.set(storage, { path: row.path, kind: "doc", docs: 1, blocks: row.blocks, ts: row.ts });
105
119
  continue;
106
120
  }
107
- const dir = prefix + segs.slice(0, depth).join("/") + "/";
121
+ const dir = referencePath(prefix + segs.slice(0, depth).join("/") + "/");
108
122
  const cur = byPath.get(dir);
109
123
  if (cur) {
110
124
  cur.docs += 1;
@@ -118,11 +132,11 @@ export function docsTree(store, repoId, opts = {}) {
118
132
  }
119
133
  let entries = [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
120
134
  if (opts.cursor) {
121
- const after = decodePathCursor(opts.cursor);
135
+ const after = decodeListCursor(opts.cursor);
122
136
  entries = entries.filter((e) => e.path > after);
123
137
  }
124
138
  const { items, truncated, cursor } = pagePathOrdered(entries, limit, opts.budgetTokens, false);
125
- return { prefix, depth, total, entries: items, truncated, cursor };
139
+ return { prefix: referencePath(prefix), depth, total, entries: items, truncated, cursor };
126
140
  }
127
141
  function toNode(r) {
128
142
  return {
@@ -172,7 +186,8 @@ export function findDoc(store, ref) {
172
186
  row = store.db.prepare("SELECT doc_id, repo_id, path, format, current_rev FROM docs WHERE doc_id = ? AND deleted_commit IS NULL").get(ref.docId);
173
187
  }
174
188
  else if (ref.repoId && ref.path) {
175
- row = store.db.prepare("SELECT doc_id, repo_id, path, format, current_rev FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL").get(ref.repoId, ref.path);
189
+ // Either path form is accepted (spec/surface §1 "Paths"); the column holds the storage form.
190
+ row = store.db.prepare("SELECT doc_id, repo_id, path, format, current_rev FROM docs WHERE repo_id = ? AND path = ? AND deleted_commit IS NULL").get(ref.repoId, storagePath(ref.path));
176
191
  }
177
192
  if (!row)
178
193
  return null;
@@ -2,6 +2,7 @@ import { findDocByRef } from "../core/read/reader.js";
2
2
  import { readDocumentAtRevision } from "../core/read/document.js";
3
3
  import { isValidId } from "../core/ids.js";
4
4
  import { EngineError } from "../mcp/errors.js";
5
+ import { referencePath, storagePath } from "../core/paths.js";
5
6
  /** history_node: a block's biography from block_changes + commits (06 §3). */
6
7
  export function historyNode(store, blockId, opts = {}) {
7
8
  const limit = opts.limit ?? 100;
@@ -111,8 +112,8 @@ export function docDiffUnified(store, docId, opts = {}) {
111
112
  const from = opts.fromRev ?? revs[1]?.rev_id ?? revs[0]?.rev_id;
112
113
  if (!to || !from)
113
114
  throw new EngineError("target_missing", `no revisions to diff for ${opts.label ?? docId}`);
114
- const path = store.db.prepare("SELECT path FROM docs WHERE doc_id = ?").get(docId)?.path ?? "";
115
- return { doc: docId, path, from, to, diff: diffUnified(store, docId, from, to) };
115
+ const path = store.db.prepare("SELECT path FROM docs WHERE doc_id = ?").get(docId)?.path;
116
+ return { doc: docId, path: path === undefined ? "" : referencePath(path), from, to, diff: diffUnified(store, docId, from, to) };
116
117
  }
117
118
  // ---- unified diff (spec/surface §3) ------------------------------------------
118
119
  // A line-grain unified diff with a deterministic Myers script, so both engines
@@ -272,15 +273,18 @@ export function changesSince(store, repoId, opts = {}) {
272
273
  .all(...params);
273
274
  const truncated = commits.length > limit;
274
275
  const page = commits.slice(0, limit);
276
+ // Every path in a digest — the revisions', a deletion's, a move's two, and
277
+ // the summary's — is the reference form (spec/surface §1 "Paths").
275
278
  const digests = page.map((c) => {
276
- const revs = store.db.prepare("SELECT r.doc_id AS doc, r.path AS path, r.rendered_hash AS rendered_hash FROM revisions r WHERE r.commit_id = ?").all(c.commit_id).map((r) => ({ doc: r.doc, path: r.path, contentHash: r.rendered_hash.toString("hex") }));
279
+ const revs = store.db.prepare("SELECT r.doc_id AS doc, r.path AS path, r.rendered_hash AS rendered_hash FROM revisions r WHERE r.commit_id = ?").all(c.commit_id).map((r) => ({ doc: r.doc, path: referencePath(r.path), contentHash: r.rendered_hash.toString("hex") }));
277
280
  const dispCounts = store.db.prepare("SELECT kind, count(*) n FROM dispositions WHERE commit_id = ? GROUP BY kind").all(c.commit_id);
278
281
  // A deletion or a move writes no revision: the tombstone names the commit
279
282
  // (`docs.deleted_commit`), a move only its reason (`move <from> -> <to>`).
280
283
  const deleted = revs.length === 0
281
- ? store.db.prepare("SELECT path FROM docs WHERE deleted_commit = ? ORDER BY path").all(c.commit_id).map((r) => r.path)
284
+ ? store.db.prepare("SELECT path FROM docs WHERE deleted_commit = ? ORDER BY path").all(c.commit_id).map((r) => referencePath(r.path))
282
285
  : [];
283
- const summary = renderSummary(c.origin, c.actor, revs, dispCounts, { deleted, moved: revs.length === 0 ? parseMove(c.reason) : null });
286
+ const moved = revs.length === 0 ? parseMove(c.reason) : null;
287
+ const summary = renderSummary(c.origin, c.actor, revs, dispCounts, { deleted, moved: moved ? { from: referencePath(moved.from), to: referencePath(moved.to) } : null });
284
288
  return { commit: c.commit_id, seq: c.seq, ts: c.ts, origin: c.origin, actor: c.actor, summary, revisions: revs };
285
289
  });
286
290
  const nextCursor = page.length > 0 ? page[page.length - 1].seq : cursor;
@@ -317,14 +321,16 @@ export function docHistory(store, repoId, opts = {}) {
317
321
  const deletedClause = includeDeleted ? "" : "AND deleted_commit IS NULL";
318
322
  const params = [repoId];
319
323
  let pathClause;
320
- if (opts.pathGlob.includes("*")) {
321
- const like = opts.pathGlob.replace(/[%_]/g, "\\$&").replace(/\*/g, "%");
324
+ // The glob is spelled in either path form; the column holds the storage form.
325
+ const glob = storagePath(opts.pathGlob);
326
+ if (glob.includes("*")) {
327
+ const like = glob.replace(/[%_]/g, "\\$&").replace(/\*/g, "%");
322
328
  pathClause = "path LIKE ? ESCAPE '\\'";
323
329
  params.push(like);
324
330
  }
325
331
  else {
326
332
  pathClause = "path = ?";
327
- params.push(opts.pathGlob);
333
+ params.push(glob);
328
334
  }
329
335
  params.push(limit + 1);
330
336
  docRows = store.db
@@ -352,7 +358,7 @@ export function docHistory(store, repoId, opts = {}) {
352
358
  contentHash: r.rendered_hash.toString("hex"),
353
359
  isCurrent: r.rev === d.current_rev,
354
360
  }));
355
- return { docId: d.doc_id, path: d.path, deleted: d.deleted_commit !== null, currentRev: d.current_rev, versions };
361
+ return { docId: d.doc_id, path: referencePath(d.path), deleted: d.deleted_commit !== null, currentRev: d.current_rev, versions };
356
362
  });
357
363
  return { docs, truncated };
358
364
  }
@@ -372,7 +378,7 @@ function resolveDocRow(store, repoId, ref, includeDeleted) {
372
378
  const stmt = asId
373
379
  ? store.db.prepare("SELECT doc_id, path, current_rev, deleted_commit FROM docs WHERE doc_id = ?")
374
380
  : store.db.prepare("SELECT doc_id, path, current_rev, deleted_commit FROM docs WHERE repo_id = ? AND path = ?");
375
- return (asId ? stmt.get(ref) : stmt.get(repoId, ref));
381
+ return (asId ? stmt.get(ref) : stmt.get(repoId, storagePath(ref)));
376
382
  }
377
383
  /** The `reason` a document move records (`mutate/docs.ts`): `move <from> -> <to>`. */
378
384
  function parseMove(reason) {
@@ -10,7 +10,8 @@ export interface StaleLink {
10
10
  predicate: string;
11
11
  provenance: string;
12
12
  dstKind: string;
13
- /** canonical target PATH (phantom prefix stripped, no leading `/`), e.g. "guides/old.md" */
13
+ /** the canonical target path in the reference form (phantom prefix stripped,
14
+ * rooted), e.g. "/guides/old.md" — spec/surface §1 "Paths" */
14
15
  target: string;
15
16
  /** the destination exactly as authored in the source block, e.g. "/guides/old.md#Setup"
16
17
  * — paste-ready as `links_repair.from`. null when it can't be recovered (frontmatter
@@ -1,13 +1,16 @@
1
1
  import { canonicalLinkPath, docDirOf, splitDestination } from "./link-destinations.js";
2
+ import { referencePath, storagePath } from "../core/paths.js";
2
3
  // Convert a path glob to a SQL LIKE clause + param, mirroring compileWithin
3
4
  // (search/cel/compile.ts) and docHistory (graph/history.ts): escape %/_ then
4
5
  // map `*`→`%`. A glob without `*` is an exact-path match.
5
6
  export function globClause(column, glob) {
6
- if (glob.includes("*")) {
7
- const like = glob.replace(/[%_]/g, "\\$&").replace(/\*/g, "%");
7
+ // Either path form is accepted; the column holds the storage form.
8
+ const g = storagePath(glob);
9
+ if (g.includes("*")) {
10
+ const like = g.replace(/[%_]/g, "\\$&").replace(/\*/g, "%");
8
11
  return { clause: `${column} LIKE ? ESCAPE '\\'`, param: like };
9
12
  }
10
- return { clause: `${column} = ?`, param: glob };
13
+ return { clause: `${column} = ?`, param: g };
11
14
  }
12
15
  /**
13
16
  * linksStale: scan open edges repo-wide and return the dangling internal ones,
@@ -85,14 +88,16 @@ export function linksStale(store, repoId, opts = {}) {
85
88
  const stale = page.map((r) => {
86
89
  // Strip the "phantom:" prefix to expose the canonical path.
87
90
  const target = r.dstNode.slice("phantom:".length);
91
+ // The surface speaks the reference form: `srcPath` and `target` rooted;
92
+ // `authored` stays exactly as written in the source.
88
93
  return {
89
94
  srcDoc: r.srcDoc,
90
- srcPath: r.srcPath,
95
+ srcPath: referencePath(r.srcPath),
91
96
  srcBlock: r.srcBlock,
92
97
  predicate: r.predicate,
93
98
  provenance: r.provenance,
94
99
  dstKind: r.dstKind,
95
- target,
100
+ target: referencePath(target),
96
101
  authored: authoredFor(r.srcBlock, r.srcPath, target, r.anchor),
97
102
  anchor: r.anchor,
98
103
  // All phantom targets are dangling DOCUMENTS in this data model (cross-doc
@@ -131,10 +136,11 @@ export function linksStaleSummary(store, repoId, opts = {}) {
131
136
  const byTarget = store.db
132
137
  .prepare(`SELECT e.dst_node AS dstNode, count(*) AS count ${phantom} GROUP BY e.dst_node ORDER BY count DESC, e.dst_node`)
133
138
  .all(...params)
134
- .map((r) => ({ target: r.dstNode.slice("phantom:".length), count: r.count }));
139
+ .map((r) => ({ target: referencePath(r.dstNode.slice("phantom:".length)), count: r.count }));
135
140
  const bySource = store.db
136
141
  .prepare(`SELECT d.path AS srcPath, count(*) AS count ${phantom} GROUP BY d.path ORDER BY count DESC, d.path`)
137
- .all(...params);
142
+ .all(...params)
143
+ .map((r) => ({ srcPath: referencePath(r.srcPath), count: r.count }));
138
144
  return {
139
145
  staleCount: byTarget.reduce((n, t) => n + t.count, 0),
140
146
  byTarget,
@@ -35,7 +35,9 @@ export { docsOutline, type OutlineResult, type OutlineOptions } from "./core/rea
35
35
  export { docsRead, reconstructContent, type DocsReadResult, type DocsReadOptions } from "./core/read/document.js";
36
36
  export { nodesGet, nodesGetMany, type GetNode, type Resolution } from "./core/read/nodes.js";
37
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 { CursorInvalid, encodeCursor, decodeCursor } from "./core/cursor.js";
38
+ export { CursorInvalid, encodeCursor, decodeCursor, decodePathCursor } from "./core/cursor.js";
39
+ export { referencePath, storagePath, referencePathOrNull, referenceKeyed } from "./core/paths.js";
40
+ export * from "./surface-paths.js";
39
41
  export { resolveRef, type ResolvedRef } from "./core/read/refs.js";
40
42
  export { resolve, type ResolveHit, type ResolveInput } from "./search/resolve.js";
41
43
  export { oqxRun, oqxRunAsync, collectSemanticPhrases, parseOqx, type OqxResult, type OqxHit, type OqxOptions, type EmbedQuery } from "./oqx/run.js";
package/dist/src/index.js CHANGED
@@ -36,7 +36,9 @@ export { docsOutline } from "./core/read/outline.js";
36
36
  export { docsRead, reconstructContent } from "./core/read/document.js";
37
37
  export { nodesGet, nodesGetMany } from "./core/read/nodes.js";
38
38
  export { findDoc, loadDocBlocks, blockRaw, docsList, docsTree, normalizeTreePrefix, DOCS_LIST_DEFAULT_LIMIT, DOCS_TREE_DEFAULT_LIMIT, } from "./core/read/reader.js";
39
- export { CursorInvalid, encodeCursor, decodeCursor } from "./core/cursor.js";
39
+ export { CursorInvalid, encodeCursor, decodeCursor, decodePathCursor } from "./core/cursor.js";
40
+ export { referencePath, storagePath, referencePathOrNull, referenceKeyed } from "./core/paths.js";
41
+ export * from "./surface-paths.js";
40
42
  export { resolveRef } from "./core/read/refs.js";
41
43
  export { resolve } from "./search/resolve.js";
42
44
  // OQX — omgbase Query eXpressions (unified composable query language, slice 1:
@@ -22,7 +22,7 @@ export function errorBody(err) {
22
22
  if (err instanceof FilterInvalid)
23
23
  return { error: "filter_invalid", message: err.message, data: { reason: err.reason, hint: err.hint }, retriable: false };
24
24
  if (err instanceof CursorInvalid)
25
- return { error: "filter_invalid", message: err.message, data: { reason: `cursor was not issued by ${err.surface}`, hint: "resume only with a `cursor` returned by a truncated page of the same tool" }, retriable: false };
25
+ return { error: "filter_invalid", message: err.message, data: { reason: err.reason ?? `cursor was not issued by ${err.surface}`, hint: "resume only with a `cursor` returned by a truncated page of the same tool" }, retriable: false };
26
26
  if (err instanceof MutationError)
27
27
  return { error: err.code, message: err.message, data: err.data, retriable: Boolean(err.data.retriable) };
28
28
  if (err instanceof RevisionNotFound)
@@ -10,7 +10,7 @@
10
10
  // bound is `follow`'s; nothing here re-implements BFS/DFS.
11
11
  //
12
12
  // Mapping (args → follow query):
13
- // roots → the seed `where $id == … || …` (refs resolved to doc ids first)
13
+ // roots → the seed `where $id == … or …` (refs resolved to doc ids first; literals in the AST, never spliced text)
14
14
  // degrees → `follow … { depth degrees+1 }` (seed is $depth 1, so N hops = depth N+1)
15
15
  // direction → `follow doc.out` | `doc.in` | both (two walks, unioned)
16
16
  // predicate → restricts the neighborhood to docs reachable via that predicate
@@ -23,8 +23,10 @@
23
23
  // edges (both endpoints in the neighborhood) plus dangling stubs to external/
24
24
  // phantom targets — so external (`x_…`) and phantom endpoints are never dropped,
25
25
  // exactly as `from edges` surfaces them.
26
+ import { build, parse, print, OqxError } from "@omgbase/oqx";
26
27
  import { findDocByRef } from "../core/read/reader.js";
27
28
  import { oqxRunAsync } from "../oqx/run.js";
29
+ import { FilterInvalid } from "../search/cel/parser.js";
28
30
  import { EngineError } from "./errors.js";
29
31
  const DEFAULT_DEGREES = 1;
30
32
  const DEFAULT_MAX_DOCUMENTS = 200;
@@ -37,10 +39,10 @@ function cmpBytes(a, b) {
37
39
  // stable internal alias (_u0, _u1, …) so it can never collide with a reserved
38
40
  // word or the macro's own aliases; the clean output name is derived separately.
39
41
  function buildUserSelect(select) {
40
- if (!select || select.length === 0)
41
- return { clause: "", outNames: [] };
42
42
  const items = [];
43
43
  const outNames = [];
44
+ if (!select || select.length === 0)
45
+ return { items, outNames };
44
46
  const used = new Set();
45
47
  select.forEach((expr, i) => {
46
48
  const trimmed = expr.trim();
@@ -52,20 +54,54 @@ function buildUserSelect(select) {
52
54
  name = `${name}_${i}`;
53
55
  used.add(name);
54
56
  outNames[i] = name;
55
- items.push(`_u${i}: ${trimmed}`);
57
+ items.push(...userItems(trimmed, `_u${i}`));
56
58
  });
57
- return { clause: items.length ? `, ${items.join(", ")}` : "", outNames };
59
+ return { items, outNames };
60
+ }
61
+ // A caller's projection expression as AST: parsed as the item of a projection,
62
+ // so it may be anything a `select` item may be (a navigation, an expression, a
63
+ // nested block) and nothing more. A malformed expression is `filter_invalid`,
64
+ // as it would be in the `query` tool.
65
+ function userItems(src, alias) {
66
+ let q;
67
+ try {
68
+ q = parse(`select ${alias}: ${src} from docs`);
69
+ }
70
+ catch (e) {
71
+ if (e instanceof OqxError)
72
+ throw new FilterInvalid(e.message, "OQX");
73
+ throw e;
74
+ }
75
+ return q.select;
58
76
  }
59
77
  // The edge fields projected from doc.out_edges/doc.in_edges — identical for both
60
78
  // directions so a single filter handles either scan (an edge is a directional
61
79
  // src→dst fact regardless of which endpoint the walk reached it from).
62
- const EDGE_COLLECT = "{ id: $id, src: $src, dst: $dst, dst_path: $dst_path, dst_uri: $dst_uri, " +
63
- "dst_kind: dst_kind, predicate: predicate, provenance: provenance, anchor: anchor, src_field: src_field }";
64
- function buildQuery(seed, dir, depth, userSelect) {
65
- const edgesRel = dir === "out" ? "doc.out_edges" : "doc.in_edges";
66
- return (`select _depth: $depth, _stop: $stop, _edges: ${edgesRel} collect ${EDGE_COLLECT}${userSelect} ` +
67
- `from docs where ${seed} ` +
68
- `follow distinct doc.${dir} { depth ${depth} }`);
80
+ 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"],
83
+ ];
84
+ // The walk, as an AST (spec/oqx/AST.md §7) printed canonically — never spliced
85
+ // strings: the seed ids are literals, the caller's projections parsed items.
86
+ //
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> }
89
+ 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))),
92
+ }));
93
+ const seed = rootIds.map((id) => build.scalar(build.binary("==", build.ident("$id"), build.lit(id))));
94
+ const q = build.query(build.ident("docs"), {
95
+ select: [
96
+ build.field("_depth", build.ident("$depth")),
97
+ build.field("_stop", build.ident("$stop")),
98
+ build.collect("_edges", edges),
99
+ ...userSelect,
100
+ ],
101
+ where: seed.length === 1 ? seed[0] : build.or(seed),
102
+ follow: build.follow([build.path("doc", dir)], { distinct: true, depth }),
103
+ });
104
+ return print(q);
69
105
  }
70
106
  /**
71
107
  * Run the `graph` neighborhood macro: compile the args into OQX `follow`
@@ -93,14 +129,13 @@ export async function graphNeighborhood(store, repoId, args, embedQuery) {
93
129
  const direction = args.direction ?? "both";
94
130
  const maxDocuments = Math.max(1, Math.floor(args.max_documents ?? DEFAULT_MAX_DOCUMENTS));
95
131
  const dirs = direction === "both" ? ["out", "in"] : [direction];
96
- const seed = rootIds.map((id) => `$id == ${JSON.stringify(id)}`).join(" || ");
97
- const { clause: userSelect, outNames } = buildUserSelect(args.select);
132
+ const { items: userSelect, outNames } = buildUserSelect(args.select);
98
133
  const queries = [];
99
134
  const docMap = new Map();
100
135
  const edgeMap = new Map();
101
136
  let queryTruncated = false;
102
137
  for (const dir of dirs) {
103
- const q = buildQuery(seed, dir, depth, userSelect);
138
+ const q = buildQuery(rootIds, dir, depth, userSelect);
104
139
  queries.push(q);
105
140
  // Fetch one more than the cap to detect truncation of a single scan; `follow
106
141
  // distinct` yields one row per reached node so the row count == node count.