@openparachute/vault 0.7.0-rc.1 → 0.7.0-rc.2
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 +67 -11
- package/core/src/notes.ts +142 -28
- package/core/src/query-operators.ts +18 -1
- package/core/src/query-warnings.ts +184 -0
- package/core/src/store.ts +9 -2
- package/core/src/tag-hierarchy.ts +113 -0
- package/core/src/types.ts +19 -4
- package/package.json +1 -1
- package/src/contract-honest-queries.test.ts +350 -38
- package/src/live-match.test.ts +17 -0
- package/src/live-match.ts +4 -1
- package/src/mcp-http.ts +19 -0
- package/src/mcp-list-tags-scope.test.ts +154 -0
- package/src/mcp-tools.ts +49 -8
- package/src/routes.ts +234 -17
- 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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `warnings: []` channel (vault#550 — Reliability & Usability Program
|
|
3
|
+
* WS1, "honest queries"). Ratified principle: if the vault can still answer
|
|
4
|
+
* the question asked, answer it and attach a warning; if it would answer a
|
|
5
|
+
* DIFFERENT question, return a structured named error. Silence is never the
|
|
6
|
+
* third option.
|
|
7
|
+
*
|
|
8
|
+
* This module owns the ONE validated choke point for `unknown_tag`
|
|
9
|
+
* warnings — both the REST structured-query path (`src/routes.ts`) and the
|
|
10
|
+
* MCP `query-notes` tool call into `collectUnknownTagWarnings` so the two
|
|
11
|
+
* surfaces can't drift. REST-only warnings (`removed_param` — a REST query
|
|
12
|
+
* string concept with no MCP analog) stay in `src/routes.ts`.
|
|
13
|
+
*
|
|
14
|
+
* Tag-scope note (security review): this module is deliberately
|
|
15
|
+
* SCOPE-UNAWARE — `collectUnknownTagWarnings` always resolves against the
|
|
16
|
+
* full vault-wide tag catalog, exactly like `core/src/notes.ts:queryNotes`
|
|
17
|
+
* itself. Callers on a tag-scoped session MUST NOT surface these warnings
|
|
18
|
+
* (or must first narrow the candidate pool) — `did_you_mean` naming an
|
|
19
|
+
* out-of-scope tag would leak its existence across the scope boundary,
|
|
20
|
+
* which this codebase treats as a hard "no leak" invariant elsewhere (see
|
|
21
|
+
* `docs/contracts/tag-scoped-tokens.md`). `src/routes.ts` skips this call
|
|
22
|
+
* entirely for scoped sessions; `src/mcp-tools.ts`'s `query-notes` wrapper
|
|
23
|
+
* strips any `warnings` core computed before returning to a scoped caller.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { Database } from "bun:sqlite";
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_TAG_EXPAND_MODE,
|
|
29
|
+
getTagExpansion,
|
|
30
|
+
loadTagHierarchy,
|
|
31
|
+
stripTagHash,
|
|
32
|
+
suggestSimilarTag,
|
|
33
|
+
type TagExpandMode,
|
|
34
|
+
type TagHierarchy,
|
|
35
|
+
} from "./tag-hierarchy.js";
|
|
36
|
+
import { chunkForInClause } from "./sql-in.js";
|
|
37
|
+
|
|
38
|
+
export interface QueryWarning {
|
|
39
|
+
code: string;
|
|
40
|
+
message: string;
|
|
41
|
+
[key: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Cap on `unknown_tag` warnings per query (vault#550 fold). A caller
|
|
46
|
+
* passing a garbage `tags` array (hundreds of junk names) would otherwise
|
|
47
|
+
* inflate the response — and the REST `X-Parachute-Warnings` header —
|
|
48
|
+
* unboundedly. Past the cap, a single `warnings_truncated` entry reports
|
|
49
|
+
* how many were suppressed.
|
|
50
|
+
*/
|
|
51
|
+
export const MAX_UNKNOWN_TAG_WARNINGS = 8;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Membership counts for a specific set of candidate tag names — NOT a
|
|
55
|
+
* full `listTags()` vault-wide scan. `listTags` additionally computes
|
|
56
|
+
* `expanded_count` (a full `note_tags` pass over EVERY tag, vault#550),
|
|
57
|
+
* which this function deliberately avoids: `collectUnknownTagWarnings` runs
|
|
58
|
+
* on every tag-filtered structured query (a hot path), so paying for a
|
|
59
|
+
* vault-wide rollup just to answer "does tag X have ANY notes" would be a
|
|
60
|
+
* real perf regression. This scopes the `note_tags` scan to only the
|
|
61
|
+
* (typically small) set of names actually reachable from the query's own
|
|
62
|
+
* tag filter and its expansion.
|
|
63
|
+
*/
|
|
64
|
+
function countMembership(db: Database, names: ReadonlySet<string>): Map<string, number> {
|
|
65
|
+
const counts = new Map<string, number>();
|
|
66
|
+
if (names.size === 0) return counts;
|
|
67
|
+
for (const chunk of chunkForInClause([...names])) {
|
|
68
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
69
|
+
const rows = db.prepare(
|
|
70
|
+
`SELECT tag_name, COUNT(*) as c FROM note_tags WHERE tag_name IN (${placeholders}) GROUP BY tag_name`,
|
|
71
|
+
).all(...chunk) as { tag_name: string; c: number }[];
|
|
72
|
+
for (const row of rows) counts.set(row.tag_name, row.c);
|
|
73
|
+
}
|
|
74
|
+
return counts;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Warn on each literal tag name in a `tags` filter that can contribute
|
|
79
|
+
* NOTHING to the result set no matter what — the caller almost certainly
|
|
80
|
+
* mistyped it, or is thinking of a different vault. A tag survives (no
|
|
81
|
+
* warning) if ANY of the following holds:
|
|
82
|
+
*
|
|
83
|
+
* - it has its own identity row (a `tags` table entry — created via
|
|
84
|
+
* `update-tag` or implicitly by tagging a note), OR
|
|
85
|
+
* - at least one note literally carries that tag, OR
|
|
86
|
+
* - its mode-aware expansion (subtypes/namespace/both/exact — whatever
|
|
87
|
+
* the query's `expand` axis resolves to) contains at least one tag
|
|
88
|
+
* name that itself has notes.
|
|
89
|
+
*
|
|
90
|
+
* `did_you_mean` names the closest existing tag (case variant, prefix
|
|
91
|
+
* relationship, or small edit distance) when one exists — see
|
|
92
|
+
* `suggestSimilarTag`.
|
|
93
|
+
*
|
|
94
|
+
* Scoped to the POSITIVE `tags` filter only — `excludeTags` is not
|
|
95
|
+
* checked (excluding a nonexistent tag is a harmless no-op, not a sign of
|
|
96
|
+
* a mistaken query) and this does not run under `search=` (out of scope
|
|
97
|
+
* for this wave — see #551).
|
|
98
|
+
*
|
|
99
|
+
* Perf shape (vault#550 fold): this runs on EVERY tag-filtered structured
|
|
100
|
+
* query, so the common all-tags-known case must cost ~nothing. Pass the
|
|
101
|
+
* store's cached `hierarchy` (`Store.getTagHierarchy()` — invalidated on
|
|
102
|
+
* tag writes) so no fresh `tags`-table scan happens per request; the
|
|
103
|
+
* `note_tags` membership query below only runs for input tags MISSING
|
|
104
|
+
* from the identity set (`h.allTags`), which for a well-formed query is
|
|
105
|
+
* none — a tag with an identity row can never warn (`hasIdentity`
|
|
106
|
+
* short-circuits), so there's nothing to look up. The `hierarchy` param
|
|
107
|
+
* stays optional for direct-core callers/tests (falls back to a fresh
|
|
108
|
+
* load).
|
|
109
|
+
*/
|
|
110
|
+
export function collectUnknownTagWarnings(
|
|
111
|
+
db: Database,
|
|
112
|
+
tags: string[] | undefined,
|
|
113
|
+
expandMode: TagExpandMode | undefined,
|
|
114
|
+
hierarchy?: TagHierarchy,
|
|
115
|
+
): QueryWarning[] {
|
|
116
|
+
if (!tags || tags.length === 0) return [];
|
|
117
|
+
|
|
118
|
+
const h = hierarchy ?? loadTagHierarchy(db);
|
|
119
|
+
const mode = expandMode ?? DEFAULT_TAG_EXPAND_MODE;
|
|
120
|
+
|
|
121
|
+
// Fast path: dedupe inputs and drop every tag with an identity row —
|
|
122
|
+
// those can never warn. For a well-formed query this empties the list
|
|
123
|
+
// and we return without touching the DB at all.
|
|
124
|
+
const suspects: string[] = [];
|
|
125
|
+
const seen = new Set<string>();
|
|
126
|
+
for (const raw of tags) {
|
|
127
|
+
const tag = stripTagHash(raw);
|
|
128
|
+
if (tag === "" || seen.has(tag)) continue;
|
|
129
|
+
seen.add(tag);
|
|
130
|
+
if (h.allTags.has(tag)) continue; // identity row → never unknown
|
|
131
|
+
suspects.push(tag);
|
|
132
|
+
}
|
|
133
|
+
if (suspects.length === 0) return [];
|
|
134
|
+
|
|
135
|
+
// Slow path (only for identity-less names): pre-compute each suspect's
|
|
136
|
+
// expansion set (memoized on `h.descendantsCache` — safe to share with
|
|
137
|
+
// the store's cache, entries are pure derived state) so the membership
|
|
138
|
+
// check is ONE batched IN-list query over the union, not one per tag.
|
|
139
|
+
// An identity-less tag can still be "known" two ways: a `note_tags` row
|
|
140
|
+
// exists without a `tags` row (not produced by current writers, but
|
|
141
|
+
// contract-tolerated), or children declared it a parent (`childrenOf`
|
|
142
|
+
// edges exist for undeclared parents) and a descendant has notes.
|
|
143
|
+
const expansions = new Map<string, Set<string>>();
|
|
144
|
+
for (const tag of suspects) expansions.set(tag, getTagExpansion(h, tag, mode));
|
|
145
|
+
const candidateNames = new Set<string>();
|
|
146
|
+
for (const set of expansions.values()) for (const name of set) candidateNames.add(name);
|
|
147
|
+
const counts = countMembership(db, candidateNames);
|
|
148
|
+
|
|
149
|
+
const warnings: QueryWarning[] = [];
|
|
150
|
+
let suppressed = 0;
|
|
151
|
+
for (const tag of suspects) {
|
|
152
|
+
const hasOwnMembership = (counts.get(tag) ?? 0) > 0;
|
|
153
|
+
let hasExpansionMembers = false;
|
|
154
|
+
for (const t of expansions.get(tag)!) {
|
|
155
|
+
if ((counts.get(t) ?? 0) > 0) {
|
|
156
|
+
hasExpansionMembers = true;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!hasOwnMembership && !hasExpansionMembers) {
|
|
162
|
+
if (warnings.length >= MAX_UNKNOWN_TAG_WARNINGS) {
|
|
163
|
+
suppressed++;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const suggestion = suggestSimilarTag(h.allTags, tag);
|
|
167
|
+
warnings.push({
|
|
168
|
+
code: "unknown_tag",
|
|
169
|
+
message: `tag "${tag}" has no identity row and no notes match it (directly or via expansion) — check spelling, or create it first with update-tag`,
|
|
170
|
+
tag,
|
|
171
|
+
...(suggestion ? { did_you_mean: suggestion } : {}),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (suppressed > 0) {
|
|
176
|
+
warnings.push({
|
|
177
|
+
code: "warnings_truncated",
|
|
178
|
+
message: `${suppressed} additional unknown_tag warning(s) suppressed — at most ${MAX_UNKNOWN_TAG_WARNINGS} are reported per query.`,
|
|
179
|
+
suppressed,
|
|
180
|
+
limit: MAX_UNKNOWN_TAG_WARNINGS,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
return warnings;
|
|
184
|
+
}
|
package/core/src/store.ts
CHANGED
|
@@ -70,8 +70,15 @@ export class BunSqliteStore implements Store {
|
|
|
70
70
|
* boot or after an invalidation does the scan; subsequent calls hit the
|
|
71
71
|
* cache. Returns the same object until invalidated, so callers can rely
|
|
72
72
|
* on identity for memoizing per-tag descendant sets.
|
|
73
|
+
*
|
|
74
|
+
* Public (vault#550 fold): the query-warnings collector
|
|
75
|
+
* (`core/src/query-warnings.ts:collectUnknownTagWarnings`) runs on every
|
|
76
|
+
* tag-filtered structured query — threading this cached hierarchy in
|
|
77
|
+
* (instead of a fresh `loadTagHierarchy` per request) keeps the
|
|
78
|
+
* common all-tags-known case at ~zero extra cost. Treat the returned
|
|
79
|
+
* object as READ-ONLY — it's the shared cache, invalidated by writers.
|
|
73
80
|
*/
|
|
74
|
-
|
|
81
|
+
getTagHierarchy(): TagHierarchy {
|
|
75
82
|
if (!this._tagHierarchy) this._tagHierarchy = loadTagHierarchy(this.db);
|
|
76
83
|
return this._tagHierarchy;
|
|
77
84
|
}
|
|
@@ -445,7 +452,7 @@ export class BunSqliteStore implements Store {
|
|
|
445
452
|
return expanded;
|
|
446
453
|
}
|
|
447
454
|
|
|
448
|
-
async listTags(): Promise<{ name: string; count: number }[]> {
|
|
455
|
+
async listTags(): Promise<{ name: string; count: number; expanded_count: number }[]> {
|
|
449
456
|
return noteOps.listTags(this.db);
|
|
450
457
|
}
|
|
451
458
|
|
|
@@ -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,18 @@
|
|
|
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
5
|
import type { ValidationStatus } from "./schema-defaults.js";
|
|
6
6
|
import type { ConformanceReport } from "./conformance.js";
|
|
7
|
+
import type { FindPathResult } from "./links.js";
|
|
7
8
|
|
|
8
9
|
// ---- Re-exports ----
|
|
9
10
|
|
|
10
11
|
export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
|
|
11
12
|
export type { PrunedField } from "./indexed-fields.js";
|
|
12
|
-
export type { TagExpandMode } from "./tag-hierarchy.js";
|
|
13
|
+
export type { TagExpandMode, TagHierarchy } from "./tag-hierarchy.js";
|
|
13
14
|
export type { ConformanceReport } from "./conformance.js";
|
|
15
|
+
export type { FindPathResult } from "./links.js";
|
|
14
16
|
|
|
15
17
|
// ---- Note ----
|
|
16
18
|
|
|
@@ -336,7 +338,20 @@ export interface Store {
|
|
|
336
338
|
* IDENTICAL expansion the snapshot query engine uses for the same `expand`.
|
|
337
339
|
*/
|
|
338
340
|
expandTags(tags: string[], mode?: TagExpandMode): Promise<Set<string>>;
|
|
339
|
-
|
|
341
|
+
/**
|
|
342
|
+
* The store's cached tag hierarchy (invalidated on tag/parent_names
|
|
343
|
+
* writes). Sync, like `db` and `transaction`. Exposed (vault#550 fold)
|
|
344
|
+
* so per-query consumers — the `unknown_tag` warning collector — reuse
|
|
345
|
+
* the cache instead of re-scanning the `tags` table per request. Treat
|
|
346
|
+
* the returned object as READ-ONLY shared state.
|
|
347
|
+
*/
|
|
348
|
+
getTagHierarchy(): TagHierarchy;
|
|
349
|
+
/**
|
|
350
|
+
* `expanded_count` (vault#550): distinct notes matching the tag OR any
|
|
351
|
+
* transitive descendant under the DEFAULT (subtypes) expansion axis,
|
|
352
|
+
* alongside the literal `count`. See `core/src/tag-hierarchy.ts:computeExpandedTagCounts`.
|
|
353
|
+
*/
|
|
354
|
+
listTags(): Promise<{ name: string; count: number; expanded_count: number }[]>;
|
|
340
355
|
deleteTag(name: string): Promise<{ deleted: boolean; notes_untagged: number }>;
|
|
341
356
|
renameTag(
|
|
342
357
|
oldName: string,
|
|
@@ -375,7 +390,7 @@ export interface Store {
|
|
|
375
390
|
|
|
376
391
|
// Deeper link queries
|
|
377
392
|
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<
|
|
393
|
+
findPath(sourceId: string, targetId: string, opts?: { max_depth?: number }): Promise<FindPathResult | null>;
|
|
379
394
|
|
|
380
395
|
// Tag schemas — schema-only facade (description + fields). Back-compat
|
|
381
396
|
// surface for v13-and-earlier callers; reads/writes route through the
|