@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.
- package/dist/src/core/cursor.d.ts +13 -1
- package/dist/src/core/cursor.js +21 -2
- package/dist/src/core/paths.d.ts +10 -0
- package/dist/src/core/paths.js +34 -0
- package/dist/src/core/read/document.d.ts +1 -0
- package/dist/src/core/read/document.js +3 -2
- package/dist/src/core/read/reader.d.ts +6 -3
- package/dist/src/core/read/reader.js +31 -16
- package/dist/src/graph/history.js +16 -10
- package/dist/src/graph/link-health.d.ts +2 -1
- package/dist/src/graph/link-health.js +13 -7
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +3 -1
- package/dist/src/mcp/errors.js +1 -1
- package/dist/src/mcp/graph.js +50 -15
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +111 -39
- package/dist/src/mcp/server.js +33 -29
- package/dist/src/oqx-js/context.d.ts +2 -1
- package/dist/src/oqx-js/context.js +93 -29
- package/dist/src/oqx-js/planner.d.ts +8 -1
- package/dist/src/oqx-js/planner.js +21 -8
- package/dist/src/oqx-js/root-row.d.ts +21 -0
- package/dist/src/oqx-js/root-row.js +55 -0
- package/dist/src/oqx-js/run.d.ts +3 -1
- package/dist/src/oqx-js/run.js +127 -163
- package/dist/src/oqx-js/sql/translate.d.ts +7 -0
- package/dist/src/oqx-js/sql/translate.js +42 -11
- package/dist/src/oqx-js/store-index.js +12 -3
- package/dist/src/spec-versions.d.ts +3 -3
- package/dist/src/spec-versions.js +2 -2
- package/dist/src/surface-paths.d.ts +33 -0
- package/dist/src/surface-paths.js +75 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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[];
|
package/dist/src/core/cursor.js
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
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 { 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
|
|
48
|
-
*
|
|
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
|
|
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,
|
|
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 (`*` → `%`;
|
|
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
|
-
|
|
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
|
|
38
|
-
return
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
116
|
+
const segs = storage.slice(prefix.length).split("/");
|
|
103
117
|
if (segs.length <= depth) {
|
|
104
|
-
byPath.set(
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
321
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
7
|
-
|
|
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:
|
|
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,
|
package/dist/src/index.d.ts
CHANGED
|
@@ -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:
|
package/dist/src/mcp/errors.js
CHANGED
|
@@ -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)
|
package/dist/src/mcp/graph.js
CHANGED
|
@@ -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 == …
|
|
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}
|
|
57
|
+
items.push(...userItems(trimmed, `_u${i}`));
|
|
56
58
|
});
|
|
57
|
-
return {
|
|
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
|
|
63
|
-
"
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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(
|
|
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.
|