@openparachute/vault 0.7.0-rc.1 → 0.7.0-rc.3
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/core/src/cursor.ts +27 -9
- package/core/src/links.ts +63 -3
- package/core/src/mcp.ts +155 -23
- package/core/src/notes.ts +226 -37
- package/core/src/query-operators.ts +18 -1
- package/core/src/query-warnings.ts +219 -0
- package/core/src/search-query.test.ts +149 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +11 -3
- package/core/src/tag-hierarchy.ts +113 -0
- package/core/src/types.ts +32 -5
- package/package.json +1 -1
- package/src/contract-honest-queries.test.ts +350 -38
- package/src/contract-search.test.ts +263 -31
- package/src/live-match.test.ts +17 -0
- package/src/live-match.ts +4 -1
- package/src/mcp-http.ts +32 -0
- package/src/mcp-list-tags-scope.test.ts +154 -0
- package/src/mcp-tools.ts +49 -8
- package/src/routes.ts +327 -23
- package/src/ws-subscribe.test.ts +14 -0
- package/web/ui/dist/assets/{index-TJ-XN4OZ.js → index-B8pGeQam.js} +1 -1
- package/web/ui/dist/index.html +1 -1
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
SEARCH_MODES,
|
|
4
|
+
escapeFtsToken,
|
|
5
|
+
buildLiteralSearchQuery,
|
|
6
|
+
isValidSearchMode,
|
|
7
|
+
} from "./search-query.js";
|
|
8
|
+
|
|
9
|
+
describe("escapeFtsToken", () => {
|
|
10
|
+
it("wraps a plain token in quotes", () => {
|
|
11
|
+
expect(escapeFtsToken("widgets")).toBe(`"widgets"`);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("doubles an embedded quote", () => {
|
|
15
|
+
expect(escapeFtsToken(`say"hi`)).toBe(`"say""hi"`);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("doubles multiple embedded quotes", () => {
|
|
19
|
+
expect(escapeFtsToken(`"eleven-day`)).toBe(`"""eleven-day"`);
|
|
20
|
+
expect(escapeFtsToken(`delay"`)).toBe(`"delay"""`);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("preserves punctuation that isn't a quote (hyphen, period, apostrophe)", () => {
|
|
24
|
+
expect(escapeFtsToken("eleven-day")).toBe(`"eleven-day"`);
|
|
25
|
+
expect(escapeFtsToken("18.6")).toBe(`"18.6"`);
|
|
26
|
+
expect(escapeFtsToken("didn't")).toBe(`"didn't"`);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("buildLiteralSearchQuery", () => {
|
|
31
|
+
it("wraps a single token", () => {
|
|
32
|
+
const r = buildLiteralSearchQuery("widgets");
|
|
33
|
+
expect(r.isEmpty).toBe(false);
|
|
34
|
+
expect(r.query).toBe(`"widgets"`);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("phrase-quotes each whitespace-delimited token and ANDs them (vault#551 root fix)", () => {
|
|
38
|
+
const r = buildLiteralSearchQuery("eleven-day capping delay");
|
|
39
|
+
expect(r.isEmpty).toBe(false);
|
|
40
|
+
expect(r.query).toBe(`"eleven-day" "capping" "delay"`);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("escapes an apostrophe as literal content, not syntax", () => {
|
|
44
|
+
const r = buildLiteralSearchQuery("didn't");
|
|
45
|
+
expect(r.isEmpty).toBe(false);
|
|
46
|
+
expect(r.query).toBe(`"didn't"`);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("escapes a decimal point as literal content, not syntax", () => {
|
|
50
|
+
const r = buildLiteralSearchQuery("18.6");
|
|
51
|
+
expect(r.isEmpty).toBe(false);
|
|
52
|
+
expect(r.query).toBe(`"18.6"`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("collapses runs of whitespace between tokens", () => {
|
|
56
|
+
const r = buildLiteralSearchQuery("widgets gadgets");
|
|
57
|
+
expect(r.query).toBe(`"widgets" "gadgets"`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("trims leading/trailing whitespace", () => {
|
|
61
|
+
const r = buildLiteralSearchQuery(" widgets ");
|
|
62
|
+
expect(r.query).toBe(`"widgets"`);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("escapes a manually-quoted phrase's embedded quote characters as content", () => {
|
|
66
|
+
// The user typed literal quote marks expecting phrase syntax; in
|
|
67
|
+
// literal mode those quote characters are just more content that gets
|
|
68
|
+
// escaped like anything else (vault#551 — this is CORRECT, not a bug:
|
|
69
|
+
// raw phrase/boolean/prefix syntax now requires search_mode:"advanced").
|
|
70
|
+
const r = buildLiteralSearchQuery(`"eleven-day capping delay"`);
|
|
71
|
+
expect(r.isEmpty).toBe(false);
|
|
72
|
+
expect(r.query).toBe(`"""eleven-day" "capping" "delay"""`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("treats a blank string as empty", () => {
|
|
76
|
+
expect(buildLiteralSearchQuery("").isEmpty).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("treats a whitespace-only string as empty", () => {
|
|
80
|
+
expect(buildLiteralSearchQuery(" ").isEmpty).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("treats a lone quote character as empty", () => {
|
|
84
|
+
expect(buildLiteralSearchQuery(`"`).isEmpty).toBe(true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("treats a quotes-and-whitespace-only string as empty", () => {
|
|
88
|
+
expect(buildLiteralSearchQuery(`" " "`).isEmpty).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("an empty-flagged result never carries a query string callers should use", () => {
|
|
92
|
+
const r = buildLiteralSearchQuery(" ");
|
|
93
|
+
expect(r.isEmpty).toBe(true);
|
|
94
|
+
expect(r.query).toBe("");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Control-character sanitization (vault#551 review fold) — a NUL byte
|
|
98
|
+
// (or any C0/DEL control) inside a token would otherwise crash FTS5's
|
|
99
|
+
// C-string parser with a raw `unterminated string` error, breaking the
|
|
100
|
+
// "literal mode cannot throw" guarantee.
|
|
101
|
+
it("splits a token on an embedded NUL byte — both halves stay searchable", () => {
|
|
102
|
+
const r = buildLiteralSearchQuery("hello\x00world");
|
|
103
|
+
expect(r.isEmpty).toBe(false);
|
|
104
|
+
expect(r.query).toBe(`"hello" "world"`);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("treats a NUL-only string as empty (short-circuits, never reaches FTS5)", () => {
|
|
108
|
+
const r = buildLiteralSearchQuery("\x00");
|
|
109
|
+
expect(r.isEmpty).toBe(true);
|
|
110
|
+
expect(r.query).toBe("");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("collapses a run of NUL/control bytes between tokens like whitespace", () => {
|
|
114
|
+
const r = buildLiteralSearchQuery("hello\x00\x00world");
|
|
115
|
+
expect(r.query).toBe(`"hello" "world"`);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("sanitizes the other C0 controls and DEL (0x01-0x1F, 0x7F) as separators too", () => {
|
|
119
|
+
expect(buildLiteralSearchQuery("a\x01\x02b").query).toBe(`"a" "b"`);
|
|
120
|
+
expect(buildLiteralSearchQuery("a\x1fb").query).toBe(`"a" "b"`);
|
|
121
|
+
expect(buildLiteralSearchQuery("a\x7fb").query).toBe(`"a" "b"`);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("a control byte adjacent to a real token still yields the clean token", () => {
|
|
125
|
+
expect(buildLiteralSearchQuery("\x00hello\x00").query).toBe(`"hello"`);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("isValidSearchMode / SEARCH_MODES", () => {
|
|
130
|
+
it("SEARCH_MODES is exactly literal + advanced", () => {
|
|
131
|
+
expect(SEARCH_MODES).toEqual(["literal", "advanced"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("accepts literal and advanced", () => {
|
|
135
|
+
expect(isValidSearchMode("literal")).toBe(true);
|
|
136
|
+
expect(isValidSearchMode("advanced")).toBe(true);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("rejects an unknown string", () => {
|
|
140
|
+
expect(isValidSearchMode("bogus")).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("rejects non-string values", () => {
|
|
144
|
+
expect(isValidSearchMode(123)).toBe(false);
|
|
145
|
+
expect(isValidSearchMode(null)).toBe(false);
|
|
146
|
+
expect(isValidSearchMode(undefined)).toBe(false);
|
|
147
|
+
expect(isValidSearchMode(["literal"])).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
Binary file
|
package/core/src/store.ts
CHANGED
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
countConformanceViolations,
|
|
35
35
|
type ConformanceReport,
|
|
36
36
|
} from "./conformance.js";
|
|
37
|
+
import type { SearchMode } from "./search-query.js";
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* bun:sqlite-backed Store implementation. Internally everything is
|
|
@@ -70,8 +71,15 @@ export class BunSqliteStore implements Store {
|
|
|
70
71
|
* boot or after an invalidation does the scan; subsequent calls hit the
|
|
71
72
|
* cache. Returns the same object until invalidated, so callers can rely
|
|
72
73
|
* on identity for memoizing per-tag descendant sets.
|
|
74
|
+
*
|
|
75
|
+
* Public (vault#550 fold): the query-warnings collector
|
|
76
|
+
* (`core/src/query-warnings.ts:collectUnknownTagWarnings`) runs on every
|
|
77
|
+
* tag-filtered structured query — threading this cached hierarchy in
|
|
78
|
+
* (instead of a fresh `loadTagHierarchy` per request) keeps the
|
|
79
|
+
* common all-tags-known case at ~zero extra cost. Treat the returned
|
|
80
|
+
* object as READ-ONLY — it's the shared cache, invalidated by writers.
|
|
73
81
|
*/
|
|
74
|
-
|
|
82
|
+
getTagHierarchy(): TagHierarchy {
|
|
75
83
|
if (!this._tagHierarchy) this._tagHierarchy = loadTagHierarchy(this.db);
|
|
76
84
|
return this._tagHierarchy;
|
|
77
85
|
}
|
|
@@ -368,7 +376,7 @@ export class BunSqliteStore implements Store {
|
|
|
368
376
|
return { ...opts, _tagsExpanded: expanded } as QueryOpts;
|
|
369
377
|
}
|
|
370
378
|
|
|
371
|
-
async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]> {
|
|
379
|
+
async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode; mode?: SearchMode; sort?: "asc" | "desc" }): Promise<Note[]> {
|
|
372
380
|
// Canonical-bare-tag guard (vault#XXX): strip leading `#` from search tag
|
|
373
381
|
// filters before expansion, so `#manual` and `manual` resolve identically.
|
|
374
382
|
if (opts?.tags && opts.tags.length > 0) {
|
|
@@ -445,7 +453,7 @@ export class BunSqliteStore implements Store {
|
|
|
445
453
|
return expanded;
|
|
446
454
|
}
|
|
447
455
|
|
|
448
|
-
async listTags(): Promise<{ name: string; count: number }[]> {
|
|
456
|
+
async listTags(): Promise<{ name: string; count: number; expanded_count: number }[]> {
|
|
449
457
|
return noteOps.listTags(this.db);
|
|
450
458
|
}
|
|
451
459
|
|
|
@@ -249,6 +249,119 @@ export function getTagExpansion(
|
|
|
249
249
|
}
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Suggest the closest EXISTING tag name to an unmatched input — the
|
|
254
|
+
* `did_you_mean` hint on `unknown_tag` warnings (vault#550) and
|
|
255
|
+
* `tag_not_found` errors. Candidates are scored, lower is better:
|
|
256
|
+
*
|
|
257
|
+
* 0. case-only difference (`Voice` vs `voice`)
|
|
258
|
+
* 1. a prefix relationship either direction (`voice` / `voice-memo` —
|
|
259
|
+
* catches plural/singular drift and namespace-child typos)
|
|
260
|
+
* 2+dist. Levenshtein edit distance, when within a length-scaled budget
|
|
261
|
+
*
|
|
262
|
+
* Returns the single best match, or `undefined` when nothing is close
|
|
263
|
+
* enough to be worth suggesting — a genuinely novel tag name shouldn't get
|
|
264
|
+
* a noisy "did you mean" pointing at an unrelated tag.
|
|
265
|
+
*/
|
|
266
|
+
export function suggestSimilarTag(
|
|
267
|
+
candidates: Iterable<string>,
|
|
268
|
+
input: string,
|
|
269
|
+
): string | undefined {
|
|
270
|
+
const lower = input.toLowerCase();
|
|
271
|
+
let best: string | undefined;
|
|
272
|
+
let bestScore = Infinity;
|
|
273
|
+
for (const candidate of candidates) {
|
|
274
|
+
if (candidate === input) continue;
|
|
275
|
+
const candLower = candidate.toLowerCase();
|
|
276
|
+
let score: number | null = null;
|
|
277
|
+
if (candLower === lower) {
|
|
278
|
+
score = 0;
|
|
279
|
+
} else if (lower.length >= 2 && candLower.length >= 2 && (candLower.startsWith(lower) || lower.startsWith(candLower))) {
|
|
280
|
+
score = 1;
|
|
281
|
+
} else {
|
|
282
|
+
const dist = levenshtein(lower, candLower);
|
|
283
|
+
const budget = Math.max(2, Math.ceil(Math.min(lower.length, candLower.length) * 0.34));
|
|
284
|
+
if (dist <= budget) score = 2 + dist;
|
|
285
|
+
}
|
|
286
|
+
if (score !== null && score < bestScore) {
|
|
287
|
+
bestScore = score;
|
|
288
|
+
best = candidate;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return best;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Classic Levenshtein edit distance, O(m·n) time / O(n) space. */
|
|
295
|
+
function levenshtein(a: string, b: string): number {
|
|
296
|
+
const m = a.length;
|
|
297
|
+
const n = b.length;
|
|
298
|
+
if (m === 0) return n;
|
|
299
|
+
if (n === 0) return m;
|
|
300
|
+
let prev = new Array<number>(n + 1);
|
|
301
|
+
let curr = new Array<number>(n + 1);
|
|
302
|
+
for (let j = 0; j <= n; j++) prev[j] = j;
|
|
303
|
+
for (let i = 1; i <= m; i++) {
|
|
304
|
+
curr[0] = i;
|
|
305
|
+
for (let j = 1; j <= n; j++) {
|
|
306
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
307
|
+
curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
|
|
308
|
+
}
|
|
309
|
+
[prev, curr] = [curr, prev];
|
|
310
|
+
}
|
|
311
|
+
return prev[n]!;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Compute, for every declared tag, the number of DISTINCT notes matching
|
|
316
|
+
* that tag OR any transitive descendant under the subtypes axis (vault#550
|
|
317
|
+
* `expanded_count` — the rollup a parent tag whose notes are all tagged
|
|
318
|
+
* with a CHILD tag needs to report a non-zero count).
|
|
319
|
+
*
|
|
320
|
+
* One query fetches every `(tag_name, note_id)` pairing from `note_tags`;
|
|
321
|
+
* the fan-out to ancestors is pure in-memory set work over the (memoized)
|
|
322
|
+
* hierarchy — not one query per tag, so this stays cheap regardless of how
|
|
323
|
+
* many tags the vault declares. `getTagDescendants` is memoized per tag on
|
|
324
|
+
* `h`, so the ancestor-closure build below reuses that cache instead of
|
|
325
|
+
* re-walking the graph per candidate.
|
|
326
|
+
*/
|
|
327
|
+
export function computeExpandedTagCounts(
|
|
328
|
+
db: Database,
|
|
329
|
+
h: TagHierarchy,
|
|
330
|
+
): Map<string, number> {
|
|
331
|
+
const rows = db.prepare(`SELECT tag_name, note_id FROM note_tags`).all() as
|
|
332
|
+
{ tag_name: string; note_id: string }[];
|
|
333
|
+
|
|
334
|
+
const expandedNotes = new Map<string, Set<string>>();
|
|
335
|
+
const ancestorsCache = new Map<string, string[]>();
|
|
336
|
+
|
|
337
|
+
function ancestorsOrSelf(x: string): string[] {
|
|
338
|
+
const cached = ancestorsCache.get(x);
|
|
339
|
+
if (cached) return cached;
|
|
340
|
+
const result: string[] = [];
|
|
341
|
+
for (const t of h.allTags) {
|
|
342
|
+
if (getTagDescendants(h, t).has(x)) result.push(t);
|
|
343
|
+
}
|
|
344
|
+
if (!result.includes(x)) result.push(x);
|
|
345
|
+
ancestorsCache.set(x, result);
|
|
346
|
+
return result;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const row of rows) {
|
|
350
|
+
for (const ancestor of ancestorsOrSelf(row.tag_name)) {
|
|
351
|
+
let set = expandedNotes.get(ancestor);
|
|
352
|
+
if (!set) {
|
|
353
|
+
set = new Set();
|
|
354
|
+
expandedNotes.set(ancestor, set);
|
|
355
|
+
}
|
|
356
|
+
set.add(row.note_id);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const counts = new Map<string, number>();
|
|
361
|
+
for (const [tag, set] of expandedNotes) counts.set(tag, set.size);
|
|
362
|
+
return counts;
|
|
363
|
+
}
|
|
364
|
+
|
|
252
365
|
/**
|
|
253
366
|
* Detect cycles in the declared hierarchy. Returns the list of tags
|
|
254
367
|
* reachable from themselves via parent declarations. Used by
|
package/core/src/types.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import type { Database } from "bun:sqlite";
|
|
2
2
|
import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
3
3
|
import type { PrunedField } from "./indexed-fields.js";
|
|
4
|
-
import type { TagExpandMode } from "./tag-hierarchy.js";
|
|
4
|
+
import type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
|
|
5
|
+
import type { SearchMode } from "./search-query.js";
|
|
5
6
|
import type { ValidationStatus } from "./schema-defaults.js";
|
|
6
7
|
import type { ConformanceReport } from "./conformance.js";
|
|
8
|
+
import type { FindPathResult } from "./links.js";
|
|
7
9
|
|
|
8
10
|
// ---- Re-exports ----
|
|
9
11
|
|
|
10
12
|
export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
11
13
|
export type { PrunedField } from "./indexed-fields.js";
|
|
12
|
-
export type { TagExpandMode } from "./tag-hierarchy.js";
|
|
14
|
+
export type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
|
|
13
15
|
export type { ConformanceReport } from "./conformance.js";
|
|
16
|
+
export type { FindPathResult } from "./links.js";
|
|
14
17
|
|
|
15
18
|
// ---- Note ----
|
|
16
19
|
|
|
@@ -312,7 +315,18 @@ export interface Store {
|
|
|
312
315
|
* agent loop can persist a single watermark and keep polling.
|
|
313
316
|
*/
|
|
314
317
|
queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage>;
|
|
315
|
-
|
|
318
|
+
/**
|
|
319
|
+
* `mode` (vault#551 — literal-by-default): "literal" (default) escapes +
|
|
320
|
+
* phrase-quotes the query so FTS5 punctuation syntax (hyphen = NOT, an
|
|
321
|
+
* apostrophe/period breaking the parse, ...) is treated as ordinary
|
|
322
|
+
* content. "advanced" passes `query` through to FTS5 raw (pre-#551
|
|
323
|
+
* behavior) for callers who want boolean/phrase/prefix operators — a
|
|
324
|
+
* syntax error in that mode throws (`error_type: "invalid_search_syntax"`)
|
|
325
|
+
* rather than silently returning `[]`. `sort` (vault#551): omitted stays
|
|
326
|
+
* FTS5 relevance ranking (default); an explicit "asc"/"desc" switches to
|
|
327
|
+
* `created_at` ordering. See `core/src/search-query.ts`.
|
|
328
|
+
*/
|
|
329
|
+
searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode; mode?: SearchMode; sort?: "asc" | "desc" }): Promise<Note[]>;
|
|
316
330
|
|
|
317
331
|
// Tags
|
|
318
332
|
tagNote(noteId: string, tags: string[]): Promise<void>;
|
|
@@ -336,7 +350,20 @@ export interface Store {
|
|
|
336
350
|
* IDENTICAL expansion the snapshot query engine uses for the same `expand`.
|
|
337
351
|
*/
|
|
338
352
|
expandTags(tags: string[], mode?: TagExpandMode): Promise<Set<string>>;
|
|
339
|
-
|
|
353
|
+
/**
|
|
354
|
+
* The store's cached tag hierarchy (invalidated on tag/parent_names
|
|
355
|
+
* writes). Sync, like `db` and `transaction`. Exposed (vault#550 fold)
|
|
356
|
+
* so per-query consumers — the `unknown_tag` warning collector — reuse
|
|
357
|
+
* the cache instead of re-scanning the `tags` table per request. Treat
|
|
358
|
+
* the returned object as READ-ONLY shared state.
|
|
359
|
+
*/
|
|
360
|
+
getTagHierarchy(): TagHierarchy;
|
|
361
|
+
/**
|
|
362
|
+
* `expanded_count` (vault#550): distinct notes matching the tag OR any
|
|
363
|
+
* transitive descendant under the DEFAULT (subtypes) expansion axis,
|
|
364
|
+
* alongside the literal `count`. See `core/src/tag-hierarchy.ts:computeExpandedTagCounts`.
|
|
365
|
+
*/
|
|
366
|
+
listTags(): Promise<{ name: string; count: number; expanded_count: number }[]>;
|
|
340
367
|
deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
|
|
341
368
|
renameTag(
|
|
342
369
|
oldName: string,
|
|
@@ -375,7 +402,7 @@ export interface Store {
|
|
|
375
402
|
|
|
376
403
|
// Deeper link queries
|
|
377
404
|
traverseLinks(noteId: string, opts?: { max_depth?: number; relationship?: string }): Promise<{ noteId: string; depth: number; relationship: string; direction: "outbound" | "inbound" }[]>;
|
|
378
|
-
findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): Promise<
|
|
405
|
+
findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): Promise<FindPathResult | null>;
|
|
379
406
|
|
|
380
407
|
// Tag schemas — schema-only facade (description + fields). Back-compat
|
|
381
408
|
// surface for v13-and-earlier callers; reads/writes route through the
|