@openparachute/vault 0.7.0-rc.2 → 0.7.0-rc.4
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/contract-typed-index.test.ts +92 -3
- package/core/src/indexed-fields.test.ts +30 -5
- package/core/src/indexed-fields.ts +24 -2
- package/core/src/mcp.ts +161 -61
- package/core/src/notes.ts +93 -9
- package/core/src/query-warnings.ts +35 -0
- package/core/src/search-query.test.ts +149 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +2 -1
- package/core/src/tag-schemas.ts +186 -4
- package/core/src/types.ts +13 -1
- package/package.json +1 -1
- package/src/contract-errors.test.ts +178 -12
- package/src/contract-search.test.ts +263 -31
- package/src/mcp-http.ts +122 -5
- package/src/mcp-list-tags-scope.test.ts +36 -0
- package/src/mcp-query-notes-search-scope.test.ts +136 -0
- package/src/mcp-tools.ts +48 -4
- package/src/routes.ts +378 -93
- package/src/tag-field-conflict-scope.test.ts +333 -0
- package/src/tag-scope.ts +66 -0
- package/src/ws-subscribe.test.ts +14 -0
package/core/src/notes.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import { getIndexedField, releaseField } from "./indexed-fields.js";
|
|
23
23
|
import { computeExpandedTagCounts, loadTagHierarchy, stripTagHash } from "./tag-hierarchy.js";
|
|
24
24
|
import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
|
|
25
|
+
import { buildLiteralSearchQuery, type SearchMode } from "./search-query.js";
|
|
25
26
|
|
|
26
27
|
let idCounter = 0;
|
|
27
28
|
|
|
@@ -283,6 +284,11 @@ export class TransitionConflictError extends Error {
|
|
|
283
284
|
*/
|
|
284
285
|
export class PathConflictError extends Error {
|
|
285
286
|
code = "PATH_CONFLICT" as const;
|
|
287
|
+
// Stable error_type (vault#554) — additive; mirrors the `error_type` REST
|
|
288
|
+
// has hardcoded in its json response since #126. Lets the generic MCP
|
|
289
|
+
// domain-error mapping (src/mcp-http.ts) pick this class up without a
|
|
290
|
+
// bespoke branch, the same way REST's catch already does explicitly.
|
|
291
|
+
error_type = "path_conflict" as const;
|
|
286
292
|
path: string;
|
|
287
293
|
|
|
288
294
|
constructor(path: string) {
|
|
@@ -305,6 +311,8 @@ export class PathConflictError extends Error {
|
|
|
305
311
|
*/
|
|
306
312
|
export class AmbiguousPathError extends Error {
|
|
307
313
|
code = "AMBIGUOUS_PATH" as const;
|
|
314
|
+
// Stable error_type (vault#554) — see PathConflictError's comment.
|
|
315
|
+
error_type = "ambiguous_path" as const;
|
|
308
316
|
path: string;
|
|
309
317
|
candidates: { id: string; extension: string }[];
|
|
310
318
|
|
|
@@ -345,6 +353,8 @@ export const EXTENSION_PATTERN = /^[a-z0-9]{1,16}$/;
|
|
|
345
353
|
|
|
346
354
|
export class ExtensionValidationError extends Error {
|
|
347
355
|
code = "INVALID_EXTENSION" as const;
|
|
356
|
+
// Stable error_type (vault#554) — see PathConflictError's comment.
|
|
357
|
+
error_type = "invalid_extension" as const;
|
|
348
358
|
extension: string;
|
|
349
359
|
reason: string;
|
|
350
360
|
|
|
@@ -1309,12 +1319,78 @@ export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
|
|
|
1309
1319
|
return { notes, next_cursor };
|
|
1310
1320
|
}
|
|
1311
1321
|
|
|
1322
|
+
/**
|
|
1323
|
+
* Turn a thrown FTS5 MATCH error into the structured `invalid_search_syntax`
|
|
1324
|
+
* shape (vault#551, WS2A item 2). ALWAYS structured, never a raw rethrow —
|
|
1325
|
+
* a raw `SQLiteError` escaping to the transport is an unstructured 500
|
|
1326
|
+
* (REST) / generic `isError` text (MCP), which is exactly the swallowed-
|
|
1327
|
+
* failure class this wave is closing.
|
|
1328
|
+
*
|
|
1329
|
+
* The two modes carry different hints:
|
|
1330
|
+
* - advanced: the caller passed raw FTS5 syntax that FTS5 rejected — tell
|
|
1331
|
+
* them to fix it or drop back to literal.
|
|
1332
|
+
* - literal: escaping + control-char sanitization
|
|
1333
|
+
* (`buildLiteralSearchQuery`) make every input syntactically valid
|
|
1334
|
+
* FTS5, so this is unreachable by construction — the belt-and-suspenders
|
|
1335
|
+
* structured error (rather than a raw rethrow) means that IF the
|
|
1336
|
+
* invariant is ever broken it still surfaces as an honest error, not a
|
|
1337
|
+
* 500. Signals a vault bug worth reporting.
|
|
1338
|
+
*/
|
|
1339
|
+
function searchSyntaxError(rawQuery: string, err: unknown, mode: SearchMode): QueryError {
|
|
1340
|
+
const causeMessage = err instanceof Error ? err.message : String(err);
|
|
1341
|
+
const hint =
|
|
1342
|
+
mode === "advanced"
|
|
1343
|
+
? `FTS5 rejected this as advanced query syntax (${causeMessage}). Fix the syntax, or omit search_mode:"advanced" for literal (punctuation-safe) search.`
|
|
1344
|
+
: `FTS5 rejected the escaped literal query (${causeMessage}) — this should be impossible after literal-mode escaping + control-char sanitization; please report it as a vault bug.`;
|
|
1345
|
+
return new QueryError(`invalid search syntax: ${causeMessage}`, "INVALID_QUERY", {
|
|
1346
|
+
error_type: "invalid_search_syntax",
|
|
1347
|
+
field: "search",
|
|
1348
|
+
got: rawQuery,
|
|
1349
|
+
hint,
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1312
1353
|
export function searchNotes(
|
|
1313
1354
|
db: Database,
|
|
1314
1355
|
query: string,
|
|
1315
|
-
opts?: { tags?: string[]; limit?: number },
|
|
1356
|
+
opts?: { tags?: string[]; limit?: number; mode?: SearchMode; sort?: "asc" | "desc" },
|
|
1316
1357
|
): Note[] {
|
|
1317
1358
|
const limit = typeof opts?.limit === "number" ? opts.limit : 50;
|
|
1359
|
+
// Literal-by-default (vault#551): escape the caller's text so FTS5's own
|
|
1360
|
+
// query syntax (hyphen = NOT, apostrophe/period = tokenizer breakage,
|
|
1361
|
+
// etc.) is treated as ordinary content, not syntax. `search_mode:
|
|
1362
|
+
// "advanced"` opts back into raw FTS5 query syntax — today's pre-#551
|
|
1363
|
+
// behavior, unchanged — for callers who want boolean/phrase/prefix
|
|
1364
|
+
// operators.
|
|
1365
|
+
const mode: SearchMode = opts?.mode ?? "literal";
|
|
1366
|
+
let ftsQuery: string;
|
|
1367
|
+
if (mode === "literal") {
|
|
1368
|
+
const built = buildLiteralSearchQuery(query);
|
|
1369
|
+
// "Only whitespace/quotes" (vault#551 edge case): the caller (store.ts /
|
|
1370
|
+
// the REST + MCP entry points) is expected to short-circuit this case
|
|
1371
|
+
// itself (with an `empty_search` warning) before ever reaching here —
|
|
1372
|
+
// this is a defensive fallback for any direct-core caller that skips
|
|
1373
|
+
// that check.
|
|
1374
|
+
if (built.isEmpty) return [];
|
|
1375
|
+
ftsQuery = built.query;
|
|
1376
|
+
} else {
|
|
1377
|
+
ftsQuery = query;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// `sort` honored under search (vault#551 WS2A item 3): default stays FTS5
|
|
1381
|
+
// relevance (`rank`, unchanged); an EXPLICIT `sort: "asc"|"desc"` switches
|
|
1382
|
+
// to `created_at` ordering. Checked against the literal string values (not
|
|
1383
|
+
// truthiness) so an absent `sort` can never accidentally match a branch.
|
|
1384
|
+
// `n.id ${direction}` is appended as a deterministic tiebreaker — same
|
|
1385
|
+
// rationale as `queryNotes` (two notes at the same created_at millisecond
|
|
1386
|
+
// would otherwise return in arbitrary/unstable order). `rank` needs no
|
|
1387
|
+
// tiebreaker (bm25 score is effectively unique per row).
|
|
1388
|
+
const orderBy =
|
|
1389
|
+
opts?.sort === "asc"
|
|
1390
|
+
? "n.created_at ASC, n.id ASC"
|
|
1391
|
+
: opts?.sort === "desc"
|
|
1392
|
+
? "n.created_at DESC, n.id DESC"
|
|
1393
|
+
: "rank";
|
|
1318
1394
|
|
|
1319
1395
|
if (opts?.tags && opts.tags.length > 0) {
|
|
1320
1396
|
// Canonical-bare-tag guard backstop (vault#XXX) for direct-core callers.
|
|
@@ -1334,12 +1410,19 @@ export function searchNotes(
|
|
|
1334
1410
|
JOIN notes_fts fts ON fts.rowid = n.rowid
|
|
1335
1411
|
WHERE notes_fts MATCH ?
|
|
1336
1412
|
AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
|
|
1337
|
-
ORDER BY
|
|
1413
|
+
ORDER BY ${orderBy}
|
|
1338
1414
|
LIMIT ?
|
|
1339
|
-
`).all(
|
|
1415
|
+
`).all(ftsQuery, ...searchTags, limit) as NoteRow[];
|
|
1340
1416
|
return notesWithTags(db, rows);
|
|
1341
|
-
} catch {
|
|
1342
|
-
|
|
1417
|
+
} catch (err) {
|
|
1418
|
+
// Surface EVERY FTS5 error structured, never a raw rethrow (vault#551):
|
|
1419
|
+
// advanced mode expects it (the caller passed raw syntax); literal
|
|
1420
|
+
// mode should never reach it (escaping + control-char sanitization
|
|
1421
|
+
// make the query valid) but if the invariant breaks we still want an
|
|
1422
|
+
// honest error, not an unstructured 500. A QueryError we threw
|
|
1423
|
+
// ourselves (empty-limit validation, etc.) is re-raised untouched.
|
|
1424
|
+
if (err instanceof QueryError) throw err;
|
|
1425
|
+
throw searchSyntaxError(query, err, mode);
|
|
1343
1426
|
}
|
|
1344
1427
|
}
|
|
1345
1428
|
}
|
|
@@ -1349,12 +1432,13 @@ export function searchNotes(
|
|
|
1349
1432
|
SELECT n.* FROM notes n
|
|
1350
1433
|
JOIN notes_fts fts ON fts.rowid = n.rowid
|
|
1351
1434
|
WHERE notes_fts MATCH ?
|
|
1352
|
-
ORDER BY
|
|
1435
|
+
ORDER BY ${orderBy}
|
|
1353
1436
|
LIMIT ?
|
|
1354
|
-
`).all(
|
|
1437
|
+
`).all(ftsQuery, limit) as NoteRow[];
|
|
1355
1438
|
return notesWithTags(db, rows);
|
|
1356
|
-
} catch {
|
|
1357
|
-
|
|
1439
|
+
} catch (err) {
|
|
1440
|
+
if (err instanceof QueryError) throw err;
|
|
1441
|
+
throw searchSyntaxError(query, err, mode);
|
|
1358
1442
|
}
|
|
1359
1443
|
}
|
|
1360
1444
|
|
|
@@ -182,3 +182,38 @@ export function collectUnknownTagWarnings(
|
|
|
182
182
|
}
|
|
183
183
|
return warnings;
|
|
184
184
|
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* `empty_search` warning (vault#551) — the `search` string carried no
|
|
188
|
+
* literal content: blank/whitespace-only, or (in literal mode, where a
|
|
189
|
+
* manually-typed `"` is ordinary content rather than syntax) nothing but
|
|
190
|
+
* quote characters. Rather than let this degenerate into an FTS5 syntax
|
|
191
|
+
* error or a confusing always-empty-but-syntactically-valid query, the
|
|
192
|
+
* search path short-circuits BEFORE ever calling FTS5 and reports this
|
|
193
|
+
* warning alongside an honest `[]`. See `core/src/search-query.ts`
|
|
194
|
+
* `buildLiteralSearchQuery`, which is the choke point that detects this.
|
|
195
|
+
*/
|
|
196
|
+
export function emptySearchWarning(): QueryWarning {
|
|
197
|
+
return {
|
|
198
|
+
code: "empty_search",
|
|
199
|
+
message:
|
|
200
|
+
"the search query has no literal content (blank, or only whitespace/quote characters) — returning no results without querying FTS5.",
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `ignored_param` warning (vault#551) — a query-shaping param was passed
|
|
206
|
+
* but has no effect given the rest of the request. First (and so far only)
|
|
207
|
+
* case: `search_mode` without `search` — the mode only shapes how `search`
|
|
208
|
+
* text is turned into an FTS5 query, so passing it alone almost always
|
|
209
|
+
* means the caller meant to pass `search` too. Generic over `param` /
|
|
210
|
+
* `reason` so a future ignored-param case can reuse the same shape instead
|
|
211
|
+
* of inventing a new warning code.
|
|
212
|
+
*/
|
|
213
|
+
export function ignoredParamWarning(param: string, reason: string): QueryWarning {
|
|
214
|
+
return {
|
|
215
|
+
code: "ignored_param",
|
|
216
|
+
message: `\`${param}\` has no effect: ${reason}`,
|
|
217
|
+
param,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -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
|
|
@@ -375,7 +376,7 @@ export class BunSqliteStore implements Store {
|
|
|
375
376
|
return { ...opts, _tagsExpanded: expanded } as QueryOpts;
|
|
376
377
|
}
|
|
377
378
|
|
|
378
|
-
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[]> {
|
|
379
380
|
// Canonical-bare-tag guard (vault#XXX): strip leading `#` from search tag
|
|
380
381
|
// filters before expansion, so `#manual` and `manual` resolve identically.
|
|
381
382
|
if (opts?.tags && opts.tags.length > 0) {
|
package/core/src/tag-schemas.ts
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { Database } from "bun:sqlite";
|
|
19
|
+
import { mapFieldType, validateFieldName } from "./indexed-fields.js";
|
|
19
20
|
|
|
20
21
|
// ---------------------------------------------------------------------------
|
|
21
22
|
// Types
|
|
@@ -280,17 +281,23 @@ export function deleteTagSchema(db: Database, tag: string): boolean {
|
|
|
280
281
|
* time"); dropping the inner-shape gate is consistent with that intent.
|
|
281
282
|
*/
|
|
282
283
|
export function validateRelationships(raw: unknown): Record<string, unknown> {
|
|
284
|
+
// `error_type` (vault#554) is attached directly on the thrown Error
|
|
285
|
+
// (duck-typed extra property, same pattern as QueryError's optional
|
|
286
|
+
// fields) rather than via a dedicated class — this is a validation
|
|
287
|
+
// leaf with one caller-facing shape, not a reusable domain error. Both
|
|
288
|
+
// REST (src/routes.ts, which already hardcodes this same string) and the
|
|
289
|
+
// generic MCP domain-error mapping (src/mcp-http.ts) key off it.
|
|
283
290
|
if (raw === null || raw === undefined) {
|
|
284
|
-
throw
|
|
291
|
+
throw relationshipsError("relationships: expected an object, got null/undefined");
|
|
285
292
|
}
|
|
286
293
|
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
287
|
-
throw
|
|
294
|
+
throw relationshipsError(
|
|
288
295
|
"relationships: expected an object mapping relationship name → value (got an array or primitive)",
|
|
289
296
|
);
|
|
290
297
|
}
|
|
291
298
|
for (const rel of Object.keys(raw as Record<string, unknown>)) {
|
|
292
299
|
if (!rel) {
|
|
293
|
-
throw
|
|
300
|
+
throw relationshipsError("relationships: keys must be non-empty strings");
|
|
294
301
|
}
|
|
295
302
|
}
|
|
296
303
|
// Round-trip through JSON to (a) confirm the payload is serializable —
|
|
@@ -300,11 +307,15 @@ export function validateRelationships(raw: unknown): Record<string, unknown> {
|
|
|
300
307
|
try {
|
|
301
308
|
serialized = JSON.stringify(raw);
|
|
302
309
|
} catch (err) {
|
|
303
|
-
throw
|
|
310
|
+
throw relationshipsError(`relationships: value must be JSON-serializable (${(err as Error).message})`);
|
|
304
311
|
}
|
|
305
312
|
return JSON.parse(serialized) as Record<string, unknown>;
|
|
306
313
|
}
|
|
307
314
|
|
|
315
|
+
function relationshipsError(message: string): Error {
|
|
316
|
+
return Object.assign(new Error(message), { error_type: "invalid_relationships" as const });
|
|
317
|
+
}
|
|
318
|
+
|
|
308
319
|
// ---------------------------------------------------------------------------
|
|
309
320
|
// Helpers
|
|
310
321
|
// ---------------------------------------------------------------------------
|
|
@@ -334,3 +345,174 @@ function jsonOrNull(value: unknown): string | null {
|
|
|
334
345
|
}
|
|
335
346
|
return JSON.stringify(value);
|
|
336
347
|
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Cross-tag field validation (vault#553 / #554) — update-tag messaging
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* A single field-declaration violation surfaced by {@link collectTagFieldViolations}.
|
|
355
|
+
* `reason` is a stable machine-checkable slug; `message` is the existing
|
|
356
|
+
* human-readable prose (unchanged wording, just no longer thrown on the
|
|
357
|
+
* first hit).
|
|
358
|
+
*/
|
|
359
|
+
export interface TagFieldViolation {
|
|
360
|
+
field: string;
|
|
361
|
+
reason: "type_conflict" | "indexed_flag_conflict" | "unsupported_indexed_type" | "invalid_field_name";
|
|
362
|
+
message: string;
|
|
363
|
+
/**
|
|
364
|
+
* The conflicting declarer tag — present on the cross-tag reasons
|
|
365
|
+
* (`type_conflict` / `indexed_flag_conflict`) only; the solo own-field
|
|
366
|
+
* reasons have no other party. Core populates it unconditionally
|
|
367
|
+
* (scope-unaware by architecture); the SERVER layers scrub it — and
|
|
368
|
+
* generalize `message` — when the declarer is outside a tag-scoped
|
|
369
|
+
* caller's allowlist (`scrubTagFieldViolationsByScope` in
|
|
370
|
+
* src/tag-scope.ts), so a scoped session learns THAT a conflict exists
|
|
371
|
+
* (the write is still rejected — integrity is scope-independent) but not
|
|
372
|
+
* WHICH out-of-scope tag it conflicts with or what that tag declares.
|
|
373
|
+
*/
|
|
374
|
+
other_tag?: string;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Thrown by `update-tag` (MCP tool + REST `PUT /api/tags/:name`) when one or
|
|
379
|
+
* more fields in the incoming `fields` payload conflict with another tag's
|
|
380
|
+
* declaration, or declare an unindexable/invalid indexed field. Carries
|
|
381
|
+
* EVERY violation found in the call (vault#553 settled complaint: the prior
|
|
382
|
+
* behavior threw on the FIRST offending field and silently never reported
|
|
383
|
+
* the rest — two testers independently assumed the other fields had landed).
|
|
384
|
+
* The message states explicitly that no changes were applied — the whole
|
|
385
|
+
* `upsertTagRecord` call is validated BEFORE any write, so a rejection here
|
|
386
|
+
* always leaves the tag record untouched (atomicity unchanged from before;
|
|
387
|
+
* this only improves what the caller is told).
|
|
388
|
+
*/
|
|
389
|
+
export class TagFieldConflictError extends Error {
|
|
390
|
+
code = "TAG_FIELD_CONFLICT" as const;
|
|
391
|
+
error_type = "tag_field_conflict" as const;
|
|
392
|
+
tag: string;
|
|
393
|
+
violations: TagFieldViolation[];
|
|
394
|
+
|
|
395
|
+
constructor(tag: string, violations: TagFieldViolation[]) {
|
|
396
|
+
const summary = violations.map((v) => `${v.field} (${v.reason}): ${v.message}`).join("; ");
|
|
397
|
+
super(
|
|
398
|
+
`tag_field_conflict: ${violations.length} field violation(s) for tag "${tag}" — no changes were applied. ${summary}`,
|
|
399
|
+
);
|
|
400
|
+
this.name = "TagFieldConflictError";
|
|
401
|
+
this.tag = tag;
|
|
402
|
+
this.violations = violations;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Validate the fields a caller is declaring/redeclaring for `tag` against
|
|
408
|
+
* every OTHER tag's schema — `type` and `indexed` must agree across all
|
|
409
|
+
* declarers (both are global per field). Returns every violation found —
|
|
410
|
+
* never throws — so the caller can report the complete set in one
|
|
411
|
+
* structured error (or none, when the array is empty).
|
|
412
|
+
*
|
|
413
|
+
* `incomingFields` is the set of fields THIS call is declaring (MCP:
|
|
414
|
+
* `params.fields`; REST: `body.fields`) — not the full merged field map.
|
|
415
|
+
* Fields the call doesn't touch were already valid (or already accepted)
|
|
416
|
+
* and are not re-validated here, matching the pre-#553 scope of the check.
|
|
417
|
+
*
|
|
418
|
+
* Two deliberate exclusions preserve pre-existing wire contracts
|
|
419
|
+
* (vault#554 — statuses/error_types on previously-working calls must not
|
|
420
|
+
* change):
|
|
421
|
+
*
|
|
422
|
+
* 1. The SEPARATE own-field checks (unsupported type for indexing,
|
|
423
|
+
* invalid identifier) are NOT included — REST's `PUT /api/tags/:name`
|
|
424
|
+
* has an established, tested single-violation `IndexedFieldError` →
|
|
425
|
+
* 400 `invalid_indexed_field` contract for those (vault#478). Use
|
|
426
|
+
* {@link collectTagFieldViolations} (own-field checks included) for a
|
|
427
|
+
* caller — MCP's `update-tag` tool — with no such prior contract.
|
|
428
|
+
* 2. The type-conflict check is SKIPPED for any field whose INCOMING spec
|
|
429
|
+
* is `indexed: true` (wire review, vault#554): a both-indexed cross-tag
|
|
430
|
+
* type conflict already errors on main via `store.upsertTagRecord` →
|
|
431
|
+
* `declareField`'s cross-declarer sqlite-type check (`IndexedFieldError`
|
|
432
|
+
* → 400 `invalid_indexed_field`), and reporting it here would flip that
|
|
433
|
+
* established 400 to a 422. Letting it fall through preserves the 400.
|
|
434
|
+
* No case is silently re-accepted by the skip: an incoming-indexed type
|
|
435
|
+
* conflict against an INDEXED declarer hits declareField's 400; against
|
|
436
|
+
* a NON-indexed declarer the indexed-FLAG conflict below still fires
|
|
437
|
+
* (the flags necessarily differ). What this function newly reports —
|
|
438
|
+
* both silent 200s on main — is (a) type conflicts on NON-indexed
|
|
439
|
+
* incoming fields and (b) indexed-flag conflicts.
|
|
440
|
+
*/
|
|
441
|
+
export function collectCrossTagFieldViolations(
|
|
442
|
+
db: Database,
|
|
443
|
+
tag: string,
|
|
444
|
+
incomingFields: Record<string, TagFieldSchema>,
|
|
445
|
+
): TagFieldViolation[] {
|
|
446
|
+
const violations: TagFieldViolation[] = [];
|
|
447
|
+
const otherSchemas = listTagSchemas(db).filter((s) => s.tag !== tag);
|
|
448
|
+
|
|
449
|
+
for (const [fieldName, spec] of Object.entries(incomingFields)) {
|
|
450
|
+
const incomingIndexed = spec.indexed === true;
|
|
451
|
+
for (const other of otherSchemas) {
|
|
452
|
+
const otherSpec = other.fields?.[fieldName];
|
|
453
|
+
if (!otherSpec) continue;
|
|
454
|
+
// See doc comment exclusion 2: incoming-indexed type conflicts keep
|
|
455
|
+
// their pre-existing declareField → 400 invalid_indexed_field path.
|
|
456
|
+
if (!incomingIndexed && otherSpec.type !== spec.type) {
|
|
457
|
+
violations.push({
|
|
458
|
+
field: fieldName,
|
|
459
|
+
reason: "type_conflict",
|
|
460
|
+
message: `field "${fieldName}" type conflict: tag "${tag}" declares "${spec.type}"; tag "${other.tag}" declares "${otherSpec.type}". Types must agree across all declarers.`,
|
|
461
|
+
other_tag: other.tag,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
if ((otherSpec.indexed === true) !== incomingIndexed) {
|
|
465
|
+
violations.push({
|
|
466
|
+
field: fieldName,
|
|
467
|
+
reason: "indexed_flag_conflict",
|
|
468
|
+
message: `field "${fieldName}" indexed-flag conflict: tag "${tag}" sets indexed=${incomingIndexed}; tag "${other.tag}" sets indexed=${otherSpec.indexed === true}. Must match across all declarers — change them atomically or not at all.`,
|
|
469
|
+
other_tag: other.tag,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return violations;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Full field-violation collection: {@link collectCrossTagFieldViolations}
|
|
479
|
+
* PLUS the own-field checks (unsupported type for indexing, invalid
|
|
480
|
+
* identifier). Used by the MCP `update-tag` tool, which — unlike REST — had
|
|
481
|
+
* no prior single-violation status-code contract to preserve for those two
|
|
482
|
+
* checks (its old inline loop threw an unstructured `Error` for them, same
|
|
483
|
+
* as everything else pre-#554); collecting everything here is a strict
|
|
484
|
+
* improvement. See {@link collectCrossTagFieldViolations}'s doc comment for
|
|
485
|
+
* why REST calls that narrower function directly instead of this one.
|
|
486
|
+
*/
|
|
487
|
+
export function collectTagFieldViolations(
|
|
488
|
+
db: Database,
|
|
489
|
+
tag: string,
|
|
490
|
+
incomingFields: Record<string, TagFieldSchema>,
|
|
491
|
+
): TagFieldViolation[] {
|
|
492
|
+
const violations = collectCrossTagFieldViolations(db, tag, incomingFields);
|
|
493
|
+
|
|
494
|
+
for (const [fieldName, spec] of Object.entries(incomingFields)) {
|
|
495
|
+
if (spec.indexed === true) {
|
|
496
|
+
const mapped = mapFieldType(spec.type);
|
|
497
|
+
if (!mapped) {
|
|
498
|
+
violations.push({
|
|
499
|
+
field: fieldName,
|
|
500
|
+
reason: "unsupported_indexed_type",
|
|
501
|
+
message: `field "${fieldName}" has unsupported type "${spec.type}" for indexing (supported: string, integer, boolean)`,
|
|
502
|
+
});
|
|
503
|
+
} else {
|
|
504
|
+
try {
|
|
505
|
+
validateFieldName(fieldName);
|
|
506
|
+
} catch (err) {
|
|
507
|
+
violations.push({
|
|
508
|
+
field: fieldName,
|
|
509
|
+
reason: "invalid_field_name",
|
|
510
|
+
message: (err as Error).message,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
return violations;
|
|
518
|
+
}
|
package/core/src/types.ts
CHANGED
|
@@ -2,6 +2,7 @@ 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
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";
|
|
7
8
|
import type { FindPathResult } from "./links.js";
|
|
@@ -314,7 +315,18 @@ export interface Store {
|
|
|
314
315
|
* agent loop can persist a single watermark and keep polling.
|
|
315
316
|
*/
|
|
316
317
|
queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage>;
|
|
317
|
-
|
|
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[]>;
|
|
318
330
|
|
|
319
331
|
// Tags
|
|
320
332
|
tagNote(noteId: string, tags: string[]): Promise<void>;
|