@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
|
@@ -4,15 +4,16 @@
|
|
|
4
4
|
* nine-persona deep test's WS5 findings (#554) as executable tests: PASSING
|
|
5
5
|
* tests lock in the structured-error precedents that already exist
|
|
6
6
|
* (`path_conflict`, `conflict` with current/expected timestamps,
|
|
7
|
-
* `precondition_required`, `schema_validation`)
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* `precondition_required`, `schema_validation`). The Wave 4 (#554) error
|
|
8
|
+
* taxonomy sweep flipped the original `test.todo` entries into real
|
|
9
|
+
* assertions below — see that describe block.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { describe, it,
|
|
12
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
13
13
|
import { Database } from "bun:sqlite";
|
|
14
14
|
import { BunStore } from "./vault-store.ts";
|
|
15
|
-
import { handleNotes } from "./routes.ts";
|
|
15
|
+
import { handleNotes, handleTags } from "./routes.ts";
|
|
16
|
+
import { generateMcpTools, PreconditionRequiredError } from "../core/src/mcp.ts";
|
|
16
17
|
|
|
17
18
|
let db: Database;
|
|
18
19
|
let store: BunStore;
|
|
@@ -52,6 +53,7 @@ describe("contract: error taxonomy — passing (lock in existing structured prec
|
|
|
52
53
|
const body: any = await res.json();
|
|
53
54
|
expect(body.error_type).toBe("path_conflict");
|
|
54
55
|
expect(body.path).toBe("taken");
|
|
56
|
+
expect(typeof body.hint).toBe("string");
|
|
55
57
|
});
|
|
56
58
|
|
|
57
59
|
it("updating with a stale if_updated_at returns 409 conflict carrying current_updated_at + your_updated_at", async () => {
|
|
@@ -62,6 +64,7 @@ describe("contract: error taxonomy — passing (lock in existing structured prec
|
|
|
62
64
|
expect(body.error_type).toBe("conflict");
|
|
63
65
|
expect(body.current_updated_at).toBe(note.updatedAt);
|
|
64
66
|
expect(body.your_updated_at).toBe("2020-01-01T00:00:00.000Z");
|
|
67
|
+
expect(typeof body.hint).toBe("string");
|
|
65
68
|
});
|
|
66
69
|
|
|
67
70
|
it("a strict schema violation on write returns 422 schema_validation naming every violation", async () => {
|
|
@@ -76,6 +79,7 @@ describe("contract: error taxonomy — passing (lock in existing structured prec
|
|
|
76
79
|
expect(body.violations.length).toBeGreaterThan(0);
|
|
77
80
|
expect(body.violations[0].field).toBe("status");
|
|
78
81
|
expect(body.violations[0].reason).toBe("enum_mismatch");
|
|
82
|
+
expect(typeof body.hint).toBe("string");
|
|
79
83
|
});
|
|
80
84
|
|
|
81
85
|
it("updating a note without if_updated_at or force returns 428 precondition_required", async () => {
|
|
@@ -85,14 +89,176 @@ describe("contract: error taxonomy — passing (lock in existing structured prec
|
|
|
85
89
|
const body: any = await res.json();
|
|
86
90
|
expect(body.error_type).toBe("precondition_required");
|
|
87
91
|
expect(body.note_id).toBe(note.id);
|
|
92
|
+
expect(typeof body.hint).toBe("string");
|
|
88
93
|
});
|
|
89
94
|
});
|
|
90
95
|
|
|
91
|
-
describe("contract: error taxonomy —
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
describe("contract: error taxonomy — #554 (flipped from todo)", () => {
|
|
97
|
+
it("every error response on the PATCH content_edit branch carries a structured {error_type, hint} pair — previously bare {error, message} strings with no error_type at all", async () => {
|
|
98
|
+
const note = await store.createNote("the quick brown fox jumps over the fox", { id: "n1" });
|
|
99
|
+
|
|
100
|
+
// mutually_exclusive — content + append in the same call.
|
|
101
|
+
const mutEx = await patch(note.id, { content: "x", append: "y", force: true });
|
|
102
|
+
expect(mutEx.status).toBe(400);
|
|
103
|
+
const mutExBody: any = await mutEx.json();
|
|
104
|
+
expect(mutExBody.error_type).toBe("mutually_exclusive");
|
|
105
|
+
expect(typeof mutExBody.hint).toBe("string");
|
|
106
|
+
|
|
107
|
+
// invalid_content_edit — content_edit missing new_text.
|
|
108
|
+
const badShape = await patch(note.id, { content_edit: { old_text: "fox" }, force: true });
|
|
109
|
+
expect(badShape.status).toBe(400);
|
|
110
|
+
const badShapeBody: any = await badShape.json();
|
|
111
|
+
expect(badShapeBody.error_type).toBe("invalid_content_edit");
|
|
112
|
+
expect(badShapeBody.field).toBe("content_edit");
|
|
113
|
+
expect(typeof badShapeBody.hint).toBe("string");
|
|
114
|
+
|
|
115
|
+
// content_edit_not_found — old_text absent from the note's content.
|
|
116
|
+
const notFound = await patch(note.id, {
|
|
117
|
+
content_edit: { old_text: "giraffe", new_text: "zebra" },
|
|
118
|
+
force: true,
|
|
119
|
+
});
|
|
120
|
+
expect(notFound.status).toBe(422);
|
|
121
|
+
const notFoundBody: any = await notFound.json();
|
|
122
|
+
expect(notFoundBody.error_type).toBe("content_edit_not_found");
|
|
123
|
+
expect(notFoundBody.field).toBe("content_edit.old_text");
|
|
124
|
+
expect(typeof notFoundBody.hint).toBe("string");
|
|
125
|
+
|
|
126
|
+
// content_edit_ambiguous — old_text ("fox") matches twice in the seed content.
|
|
127
|
+
const ambiguous = await patch(note.id, {
|
|
128
|
+
content_edit: { old_text: "fox", new_text: "wolf" },
|
|
129
|
+
force: true,
|
|
130
|
+
});
|
|
131
|
+
expect(ambiguous.status).toBe(409);
|
|
132
|
+
const ambiguousBody: any = await ambiguous.json();
|
|
133
|
+
expect(ambiguousBody.error_type).toBe("content_edit_ambiguous");
|
|
134
|
+
expect(ambiguousBody.field).toBe("content_edit.old_text");
|
|
135
|
+
expect(typeof ambiguousBody.hint).toBe("string");
|
|
136
|
+
|
|
137
|
+
// invalid_state_transition — state_transition.field is an empty string.
|
|
138
|
+
const badTransition = await patch(note.id, { state_transition: { field: "", from: "a", to: "b" } });
|
|
139
|
+
expect(badTransition.status).toBe(400);
|
|
140
|
+
const badTransitionBody: any = await badTransition.json();
|
|
141
|
+
expect(badTransitionBody.error_type).toBe("invalid_state_transition");
|
|
142
|
+
expect(badTransitionBody.field).toBe("state_transition.field");
|
|
143
|
+
expect(typeof badTransitionBody.hint).toBe("string");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("batch update-note honors a top-level `force` applied per-item as a DEFAULT, and an item's own value still wins", async () => {
|
|
147
|
+
const a = await store.createNote("a", { id: "a1" });
|
|
148
|
+
const b = await store.createNote("b", { id: "b1" });
|
|
149
|
+
const tools = generateMcpTools(store);
|
|
150
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
151
|
+
|
|
152
|
+
// Top-level force:true, item omits its own force/if_updated_at — the
|
|
153
|
+
// default applies and the write succeeds instead of throwing
|
|
154
|
+
// precondition_required. Before the fix, `items = batch ?? [params]`
|
|
155
|
+
// never merged the top-level field in, so this ALWAYS threw.
|
|
156
|
+
const result: any = await updateNote.execute({
|
|
157
|
+
force: true,
|
|
158
|
+
notes: [{ id: a.id, content: "a-updated" }],
|
|
159
|
+
});
|
|
160
|
+
expect(result[0].content).toBe("a-updated");
|
|
161
|
+
|
|
162
|
+
// Item-level values still win over the top-level default: this item
|
|
163
|
+
// explicitly sets `force: false` (overriding the batch default) and
|
|
164
|
+
// supplies no `if_updated_at` of its own, so the precondition gate
|
|
165
|
+
// still fires for it.
|
|
166
|
+
let caught: unknown;
|
|
167
|
+
try {
|
|
168
|
+
await updateNote.execute({
|
|
169
|
+
force: true,
|
|
170
|
+
notes: [{ id: b.id, content: "b-updated", force: false }],
|
|
171
|
+
});
|
|
172
|
+
} catch (e) {
|
|
173
|
+
caught = e;
|
|
174
|
+
}
|
|
175
|
+
expect(caught).toBeInstanceOf(PreconditionRequiredError);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("REST PUT /api/tags/:name reports ALL cross-tag field violations in one call and states no changes were applied (#553 messaging, mirrors the MCP tool)", async () => {
|
|
179
|
+
// Tag "a" declares two fields; tag "b" redeclares BOTH with conflicting
|
|
180
|
+
// specs in the SAME PUT — a NON-indexed type conflict on "x" and an
|
|
181
|
+
// indexed-flag conflict on "y" (both were silent 200s on main; the
|
|
182
|
+
// both-indexed type-conflict case deliberately keeps its pre-existing
|
|
183
|
+
// 400 path — see the regression test below). Before this fix REST had
|
|
184
|
+
// no cross-tag pre-check at all here (a gap distinct from the MCP
|
|
185
|
+
// tool's old first-field-only throw); now both surfaces report
|
|
186
|
+
// identically.
|
|
187
|
+
await store.upsertTagRecord("a", {
|
|
188
|
+
fields: {
|
|
189
|
+
x: { type: "string" },
|
|
190
|
+
y: { type: "boolean", indexed: true },
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const req = new Request("http://localhost/api/tags/b", {
|
|
195
|
+
method: "PUT",
|
|
196
|
+
body: JSON.stringify({
|
|
197
|
+
fields: {
|
|
198
|
+
x: { type: "integer" },
|
|
199
|
+
y: { type: "boolean", indexed: false },
|
|
200
|
+
},
|
|
201
|
+
}),
|
|
202
|
+
});
|
|
203
|
+
const res = await handleTags(req, store, "/b");
|
|
204
|
+
expect(res.status).toBe(422);
|
|
205
|
+
const body: any = await res.json();
|
|
206
|
+
expect(body.error_type).toBe("tag_field_conflict");
|
|
207
|
+
expect(body.violations).toHaveLength(2);
|
|
208
|
+
const byField = new Map(body.violations.map((v: any) => [v.field, v.reason]));
|
|
209
|
+
expect(byField.get("x")).toBe("type_conflict");
|
|
210
|
+
expect(byField.get("y")).toBe("indexed_flag_conflict");
|
|
211
|
+
expect(body.message).toContain("no changes were applied");
|
|
212
|
+
// Full detail for an unscoped caller — the conflicting declarer is named.
|
|
213
|
+
expect(body.violations[0].other_tag).toBe("a");
|
|
214
|
+
|
|
215
|
+
// Nothing partially landed.
|
|
216
|
+
const bRecord = await store.getTagRecord("b");
|
|
217
|
+
expect(bRecord?.fields ?? null).toBeFalsy();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("REST PUT /api/tags/:name still returns 400 invalid_indexed_field for a solo bad field name (vault#478 contract unchanged)", async () => {
|
|
221
|
+
// No cross-tag conflict here — "meeting-type" is invalid on its OWN
|
|
222
|
+
// (kebab-case). This must stay on the pre-existing 400/invalid_indexed_field
|
|
223
|
+
// path, NOT the new 422/tag_field_conflict path — the cross-tag pre-check
|
|
224
|
+
// is deliberately scoped to type/indexed-flag agreement only (see
|
|
225
|
+
// `collectCrossTagFieldViolations`'s doc comment in core/src/tag-schemas.ts).
|
|
226
|
+
const req = new Request("http://localhost/api/tags/meeting", {
|
|
227
|
+
method: "PUT",
|
|
228
|
+
body: JSON.stringify({ fields: { "meeting-type": { type: "string", indexed: true } } }),
|
|
229
|
+
});
|
|
230
|
+
const res = await handleTags(req, store, "/meeting");
|
|
231
|
+
expect(res.status).toBe(400);
|
|
232
|
+
const body: any = await res.json();
|
|
233
|
+
expect(body.error_type).toBe("invalid_indexed_field");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("REST PUT /api/tags/:name: a BOTH-INDEXED cross-tag type conflict stays 400 invalid_indexed_field, NOT 422 (wire-contract floor — pre-existing declareField behavior)", async () => {
|
|
237
|
+
// Exact regression from the wire review: tag "a" declares x
|
|
238
|
+
// string+indexed; tag "b" PUTs x integer+indexed. On main this
|
|
239
|
+
// returned 400 invalid_indexed_field (declareField's cross-declarer
|
|
240
|
+
// sqlite-type check inside store.upsertTagRecord); the vault#554
|
|
241
|
+
// pre-check must NOT intercept it as a 422 — statuses/error_types on
|
|
242
|
+
// previously-working calls are wire contract. See
|
|
243
|
+
// `collectCrossTagFieldViolations`'s doc-comment exclusion 2.
|
|
244
|
+
await store.upsertTagRecord("a", {
|
|
245
|
+
fields: { x: { type: "string", indexed: true } },
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const req = new Request("http://localhost/api/tags/b", {
|
|
249
|
+
method: "PUT",
|
|
250
|
+
body: JSON.stringify({ fields: { x: { type: "integer", indexed: true } } }),
|
|
251
|
+
});
|
|
252
|
+
const res = await handleTags(req, store, "/b");
|
|
253
|
+
expect(res.status).toBe(400);
|
|
254
|
+
const body: any = await res.json();
|
|
255
|
+
expect(body.error_type).toBe("invalid_indexed_field");
|
|
256
|
+
// Unscoped caller: declareField's full message (naming the declarer)
|
|
257
|
+
// is preserved verbatim.
|
|
258
|
+
expect(body.error).toContain("tag(s) [a]");
|
|
259
|
+
|
|
260
|
+
// Nothing persisted — the store's transaction rolled back.
|
|
261
|
+
const bRecord = await store.getTagRecord("b");
|
|
262
|
+
expect(bRecord?.fields ?? null).toBeFalsy();
|
|
263
|
+
});
|
|
98
264
|
});
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Contract suite — search (Wave 1
|
|
3
|
-
* umbrella #556). Encodes the 2026-07-09
|
|
4
|
-
*
|
|
5
|
-
* that is correct today; `test.todo`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* Contract suite — search (Wave 1's findings, Wave 3's fix — Reliability &
|
|
3
|
+
* Usability Program, umbrella #556, WS2A #551). Encodes the 2026-07-09
|
|
4
|
+
* nine-persona deep test's search findings as executable tests: PASSING
|
|
5
|
+
* tests lock in behavior that is correct today; the #551 `test.todo`
|
|
6
|
+
* entries this file used to carry have all been flipped to real assertions
|
|
7
|
+
* against the literal-by-default fix (escaping, `search_mode: "advanced"`,
|
|
8
|
+
* honored `sort`, structured `invalid_search_syntax`) — see #551 for the
|
|
9
|
+
* full write-up. Covers `src/routes.ts` (the REST surface) plus a handful
|
|
10
|
+
* of MCP-parity checks (`core/src/mcp.ts`) where the two surfaces share one
|
|
11
|
+
* implementation (`core/src/notes.ts` `searchNotes` + `core/src/search-
|
|
12
|
+
* query.ts`) and a REST-only test wouldn't prove the MCP side actually got
|
|
13
|
+
* the fix — mirrors the pattern in `contract-honest-queries.test.ts`.
|
|
8
14
|
*
|
|
9
15
|
* Ground truth for every assertion here was re-verified live against this
|
|
10
16
|
* repo's FTS5 search path (`core/src/notes.ts` searchNotes → REST `GET
|
|
@@ -16,6 +22,7 @@ import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
|
|
|
16
22
|
import { Database } from "bun:sqlite";
|
|
17
23
|
import { BunStore } from "./vault-store.ts";
|
|
18
24
|
import { handleNotes } from "./routes.ts";
|
|
25
|
+
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
19
26
|
|
|
20
27
|
let db: Database;
|
|
21
28
|
let store: BunStore;
|
|
@@ -26,6 +33,13 @@ function search(qs: string): Promise<Response> {
|
|
|
26
33
|
return handleNotes(new Request(`${BASE}/notes?${qs}`, { method: "GET" }), store, "");
|
|
27
34
|
}
|
|
28
35
|
|
|
36
|
+
/** Decode the `X-Parachute-Warnings` header (vault#550 — percent-encoded JSON; see `jsonWithWarnings` in routes.ts). */
|
|
37
|
+
function decodeWarningsHeader(res: Response): any[] | null {
|
|
38
|
+
const raw = res.headers.get("X-Parachute-Warnings");
|
|
39
|
+
if (!raw) return null;
|
|
40
|
+
return JSON.parse(decodeURIComponent(raw));
|
|
41
|
+
}
|
|
42
|
+
|
|
29
43
|
/** Planted corpus — each note exists to exercise exactly one FTS5 quirk. */
|
|
30
44
|
const NOTES = {
|
|
31
45
|
hyphenPhrase: "The rollout had an eleven-day capping delay before it shipped.",
|
|
@@ -76,22 +90,36 @@ describe("contract: search — passing (lock in current behavior)", () => {
|
|
|
76
90
|
expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
|
|
77
91
|
});
|
|
78
92
|
|
|
79
|
-
|
|
80
|
-
|
|
93
|
+
// The next three tests manually quote the search string, expecting raw
|
|
94
|
+
// FTS5 phrase syntax to be honored. That's advanced-mode behavior as of
|
|
95
|
+
// #551 (literal-by-default escapes a caller's own `"` characters as
|
|
96
|
+
// ordinary content instead of honoring them as phrase syntax) — updated
|
|
97
|
+
// to pass `search_mode: "advanced"` explicitly. (Note for reviewers:
|
|
98
|
+
// empirically the bare literal-mode escaping of these SAME manually-
|
|
99
|
+
// quoted strings still resolves to the identical result set for this
|
|
100
|
+
// corpus — FTS5's tokenizer strips the embedded `"` characters the same
|
|
101
|
+
// way it strips other punctuation — but the semantic guarantee these
|
|
102
|
+
// tests were written to pin (phrase ADJACENCY honored as syntax) only
|
|
103
|
+
// holds under advanced mode; literal mode no longer enforces adjacency
|
|
104
|
+
// at all, it just happens to agree here because no other note contains
|
|
105
|
+
// a subset of the same words. See #551 test coverage for the literal-
|
|
106
|
+
// mode equivalents of these same strings, unquoted, further down.)
|
|
107
|
+
it('quoted phrase finds hyphenated phrase text: "eleven-day capping delay" (search_mode: "advanced")', async () => {
|
|
108
|
+
const res = await search(`search=${encodeURIComponent('"eleven-day capping delay"')}&search_mode=advanced&include_content=true`);
|
|
81
109
|
expect(res.status).toBe(200);
|
|
82
110
|
const body = await bodyOf(res);
|
|
83
111
|
expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
|
|
84
112
|
});
|
|
85
113
|
|
|
86
|
-
it('quoted decimal literal finds it: "18.6"', async () => {
|
|
87
|
-
const res = await search(`search=${encodeURIComponent('"18.6"')}&include_content=true`);
|
|
114
|
+
it('quoted decimal literal finds it: "18.6" (search_mode: "advanced")', async () => {
|
|
115
|
+
const res = await search(`search=${encodeURIComponent('"18.6"')}&search_mode=advanced&include_content=true`);
|
|
88
116
|
expect(res.status).toBe(200);
|
|
89
117
|
const body = await bodyOf(res);
|
|
90
118
|
expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
|
|
91
119
|
});
|
|
92
120
|
|
|
93
|
-
it('quoted contraction finds it: "didn\'t"', async () => {
|
|
94
|
-
const res = await search(`search=${encodeURIComponent(`"didn't"`)}&include_content=true`);
|
|
121
|
+
it('quoted contraction finds it: "didn\'t" (search_mode: "advanced")', async () => {
|
|
122
|
+
const res = await search(`search=${encodeURIComponent(`"didn't"`)}&search_mode=advanced&include_content=true`);
|
|
95
123
|
expect(res.status).toBe(200);
|
|
96
124
|
const body = await bodyOf(res);
|
|
97
125
|
expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
|
|
@@ -105,23 +133,227 @@ describe("contract: search — passing (lock in current behavior)", () => {
|
|
|
105
133
|
});
|
|
106
134
|
});
|
|
107
135
|
|
|
108
|
-
describe(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
describe('contract: search — literal-by-default (#551, flipped from todo)', () => {
|
|
137
|
+
it(`unquoted search "didn't" finds the contraction content (literal-by-default — the bare apostrophe used to split into two AND'd tokens and return [])`, async () => {
|
|
138
|
+
const res = await search(`search=${encodeURIComponent("didn't")}&include_content=true`);
|
|
139
|
+
expect(res.status).toBe(200);
|
|
140
|
+
const body = await bodyOf(res);
|
|
141
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it(`unquoted search "eleven-day capping delay" finds the hyphenated-phrase note (literal-by-default — the bare hyphenated word used to tokenize into a NOT-operator and return [])`, async () => {
|
|
145
|
+
const res = await search(`search=${encodeURIComponent("eleven-day capping delay")}&include_content=true`);
|
|
146
|
+
expect(res.status).toBe(200);
|
|
147
|
+
const body = await bodyOf(res);
|
|
148
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it(`unquoted search "18.6" finds the decimal note (literal-by-default — the bare decimal used to break the FTS5 parse and return [])`, async () => {
|
|
152
|
+
const res = await search(`search=${encodeURIComponent("18.6")}&include_content=true`);
|
|
153
|
+
expect(res.status).toBe(200);
|
|
154
|
+
const body = await bodyOf(res);
|
|
155
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('search_mode:"advanced" preserves raw FTS5 query syntax (boolean AND/NOT, prefix *)', async () => {
|
|
159
|
+
const and = await bodyOf(
|
|
160
|
+
await search(`search=${encodeURIComponent("widgets AND gadgets")}&search_mode=advanced&include_content=true`),
|
|
161
|
+
);
|
|
162
|
+
expect(and.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
|
|
163
|
+
|
|
164
|
+
const not = await bodyOf(
|
|
165
|
+
await search(`search=${encodeURIComponent("widgets NOT gadgets")}&search_mode=advanced&include_content=true`),
|
|
166
|
+
);
|
|
167
|
+
expect(not.map((n: any) => n.content)).toEqual([NOTES.widgetsOnly]);
|
|
168
|
+
|
|
169
|
+
const prefix = await bodyOf(
|
|
170
|
+
await search(`search=${encodeURIComponent("wid*")}&search_mode=advanced&include_content=true`),
|
|
171
|
+
);
|
|
172
|
+
const prefixContents = new Set(prefix.map((n: any) => n.content));
|
|
173
|
+
expect(prefixContents.has(NOTES.widgetsOnly)).toBe(true);
|
|
174
|
+
expect(prefixContents.has(NOTES.bothWords)).toBe(true);
|
|
175
|
+
expect(prefixContents.has(NOTES.gadgetsOnly)).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('sort:"asc"/"desc" changes result order under search (previously silently ignored — FTS5 rank order won regardless of the sort param)', async () => {
|
|
179
|
+
await store.createNote("sortprobe alpha content", { created_at: "2020-01-01T00:00:00.000Z" });
|
|
180
|
+
await store.createNote("sortprobe beta content", { created_at: "2022-06-15T00:00:00.000Z" });
|
|
181
|
+
await store.createNote("sortprobe gamma content", { created_at: "2024-12-31T00:00:00.000Z" });
|
|
182
|
+
|
|
183
|
+
const asc = await bodyOf(await search("search=sortprobe&sort=asc&include_content=true"));
|
|
184
|
+
expect(asc.map((n: any) => n.content)).toEqual([
|
|
185
|
+
"sortprobe alpha content",
|
|
186
|
+
"sortprobe beta content",
|
|
187
|
+
"sortprobe gamma content",
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const desc = await bodyOf(await search("search=sortprobe&sort=desc&include_content=true"));
|
|
191
|
+
expect(desc.map((n: any) => n.content)).toEqual([
|
|
192
|
+
"sortprobe gamma content",
|
|
193
|
+
"sortprobe beta content",
|
|
194
|
+
"sortprobe alpha content",
|
|
195
|
+
]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("malformed FTS syntax under search_mode:\"advanced\" yields a structured 400 invalid_search_syntax error, not a silently swallowed []", async () => {
|
|
199
|
+
const res = await search(`search=${encodeURIComponent('"unbalanced')}&search_mode=advanced`);
|
|
200
|
+
expect(res.status).toBe(400);
|
|
201
|
+
const body = await bodyOf(res);
|
|
202
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
203
|
+
expect(body.error_type).toBe("invalid_search_syntax");
|
|
204
|
+
expect(body.field).toBe("search");
|
|
205
|
+
expect(body.got).toBe('"unbalanced');
|
|
206
|
+
expect(typeof body.hint).toBe("string");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("the SAME malformed string in literal mode (default) returns an honest result, not an error — escaping makes it syntactically valid FTS5", async () => {
|
|
210
|
+
const res = await search(`search=${encodeURIComponent('"unbalanced')}`);
|
|
211
|
+
expect(res.status).toBe(200);
|
|
212
|
+
const body = await bodyOf(res);
|
|
213
|
+
expect(Array.isArray(body)).toBe(true);
|
|
214
|
+
expect(body).toEqual([]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// Review fold: a NUL byte inside a literal-mode token used to crash FTS5's
|
|
218
|
+
// C-string parser (`SQLiteError: unterminated string`) → raw rethrow →
|
|
219
|
+
// unstructured 500. Sanitization (control chars → separators) makes it a
|
|
220
|
+
// normal search. `%00` is the URL-encoding of a NUL byte.
|
|
221
|
+
it("search=hello%00world (NUL byte in a token) does NOT 500 — literal mode cannot throw", async () => {
|
|
222
|
+
await store.createNote("hello world reachable across the NUL split", { tags: ["nul"] });
|
|
223
|
+
const res = await search("search=hello%00world&include_content=true");
|
|
224
|
+
expect(res.status).toBe(200);
|
|
225
|
+
const body = await bodyOf(res);
|
|
226
|
+
expect(Array.isArray(body)).toBe(true);
|
|
227
|
+
// The NUL split the token into "hello" AND "world" — both searchable —
|
|
228
|
+
// so the planted note (containing both words) is found.
|
|
229
|
+
expect(body.some((n: any) => n.content.includes("hello world reachable"))).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("a NUL-only search short-circuits to [] with an empty_search warning, no 500", async () => {
|
|
233
|
+
const res = await search("search=%00");
|
|
234
|
+
expect(res.status).toBe(200);
|
|
235
|
+
const body = await bodyOf(res);
|
|
236
|
+
expect(body).toEqual([]);
|
|
237
|
+
const warnings = decodeWarningsHeader(res);
|
|
238
|
+
expect(warnings).not.toBeNull();
|
|
239
|
+
expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("sort under search is STABLE at identical created_at (deterministic n.id tiebreaker, both directions)", async () => {
|
|
243
|
+
// Two notes at the SAME millisecond — without the id tiebreaker their
|
|
244
|
+
// relative order would be arbitrary/unstable (review nit).
|
|
245
|
+
const ts = "2023-03-03T03:03:03.030Z";
|
|
246
|
+
await store.createNote("tiebreak content one", { id: "tie-aaa", created_at: ts });
|
|
247
|
+
await store.createNote("tiebreak content two", { id: "tie-bbb", created_at: ts });
|
|
248
|
+
|
|
249
|
+
const asc = await bodyOf(await search("search=tiebreak&sort=asc&include_content=true"));
|
|
250
|
+
const ascIds = asc.map((n: any) => n.id);
|
|
251
|
+
// ASC id tiebreaker → tie-aaa before tie-bbb.
|
|
252
|
+
expect(ascIds).toEqual(["tie-aaa", "tie-bbb"]);
|
|
253
|
+
|
|
254
|
+
const desc = await bodyOf(await search("search=tiebreak&sort=desc&include_content=true"));
|
|
255
|
+
const descIds = desc.map((n: any) => n.id);
|
|
256
|
+
// DESC id tiebreaker → tie-bbb before tie-aaa (the exact reverse).
|
|
257
|
+
expect(descIds).toEqual(["tie-bbb", "tie-aaa"]);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
describe("contract: search — escaping edge cases, tag-scope, warnings (#551)", () => {
|
|
262
|
+
it("a double space between tokens still finds the note containing both (whitespace runs collapse)", async () => {
|
|
263
|
+
const res = await search(`search=${encodeURIComponent("widgets gadgets")}&include_content=true`);
|
|
264
|
+
expect(res.status).toBe(200);
|
|
265
|
+
const body = await bodyOf(res);
|
|
266
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("whitespace-only search short-circuits to [] with an empty_search warning, no FTS5 call/error", async () => {
|
|
270
|
+
const res = await search(`search=${encodeURIComponent(" ")}`);
|
|
271
|
+
expect(res.status).toBe(200);
|
|
272
|
+
const body = await bodyOf(res);
|
|
273
|
+
expect(body).toEqual([]);
|
|
274
|
+
const warnings = decodeWarningsHeader(res);
|
|
275
|
+
expect(warnings).not.toBeNull();
|
|
276
|
+
expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('a quotes-only search (e.g. a lone `"`) short-circuits to [] with an empty_search warning', async () => {
|
|
280
|
+
const res = await search(`search=${encodeURIComponent('"')}`);
|
|
281
|
+
expect(res.status).toBe(200);
|
|
282
|
+
const body = await bodyOf(res);
|
|
283
|
+
expect(body).toEqual([]);
|
|
284
|
+
const warnings = decodeWarningsHeader(res);
|
|
285
|
+
expect(warnings).not.toBeNull();
|
|
286
|
+
expect(warnings!.some((w: any) => w.code === "empty_search")).toBe(true);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("tag-filtered search also gets literal escaping (the tag-scoped FTS prepared statement)", async () => {
|
|
290
|
+
await store.createNote(NOTES.hyphenPhrase, { tags: ["probe"] });
|
|
291
|
+
const res = await search(
|
|
292
|
+
`search=${encodeURIComponent("eleven-day capping delay")}&tag=probe&include_content=true`,
|
|
293
|
+
);
|
|
294
|
+
expect(res.status).toBe(200);
|
|
295
|
+
const body = await bodyOf(res);
|
|
296
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
|
|
297
|
+
expect(body[0].tags).toContain("probe");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("search_mode without search is a no-op that surfaces an ignored_param warning (structured-query path)", async () => {
|
|
301
|
+
const res = await search("search_mode=advanced");
|
|
302
|
+
expect(res.status).toBe(200);
|
|
303
|
+
const warnings = decodeWarningsHeader(res);
|
|
304
|
+
expect(warnings).not.toBeNull();
|
|
305
|
+
expect(warnings!.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it("an unrecognized search_mode value is a structured 400 invalid_query", async () => {
|
|
309
|
+
const res = await search("search=widgets&search_mode=bogus");
|
|
310
|
+
expect(res.status).toBe(400);
|
|
311
|
+
const body = await bodyOf(res);
|
|
312
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
313
|
+
expect(body.error_type).toBe("invalid_query");
|
|
314
|
+
expect(body.field).toBe("search_mode");
|
|
315
|
+
expect(body.got).toBe("bogus");
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe("contract: search — MCP parity (#551)", () => {
|
|
320
|
+
it("query-notes: literal-by-default finds punctuation content the same as REST", async () => {
|
|
321
|
+
const tools = generateMcpTools(store);
|
|
322
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
323
|
+
const result = (await query.execute({ search: "didn't", include_content: true })) as any[];
|
|
324
|
+
expect(result.map((n: any) => n.content)).toEqual([NOTES.contraction]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("query-notes: search_mode validation rejects an unknown value", async () => {
|
|
328
|
+
const tools = generateMcpTools(store);
|
|
329
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
330
|
+
await expect(query.execute({ search: "widgets", search_mode: "bogus" })).rejects.toThrow(/search_mode/);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("query-notes: search_mode without search surfaces an ignored_param warning", async () => {
|
|
334
|
+
const tools = generateMcpTools(store);
|
|
335
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
336
|
+
const result = (await query.execute({ search_mode: "advanced" })) as any;
|
|
337
|
+
expect(result.warnings).toBeDefined();
|
|
338
|
+
expect(result.warnings.some((w: any) => w.code === "ignored_param" && w.param === "search_mode")).toBe(true);
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("query-notes: search_mode:\"advanced\" + malformed syntax throws a structured invalid_search_syntax error", async () => {
|
|
342
|
+
const tools = generateMcpTools(store);
|
|
343
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
344
|
+
await expect(
|
|
345
|
+
query.execute({ search: '"unbalanced', search_mode: "advanced" }),
|
|
346
|
+
).rejects.toMatchObject({ error_type: "invalid_search_syntax" });
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
it("query-notes: sort:\"asc\"/\"desc\" changes order under search (parity with REST)", async () => {
|
|
350
|
+
await store.createNote("mcpsortprobe alpha", { created_at: "2020-01-01T00:00:00.000Z" });
|
|
351
|
+
await store.createNote("mcpsortprobe beta", { created_at: "2024-01-01T00:00:00.000Z" });
|
|
352
|
+
const tools = generateMcpTools(store);
|
|
353
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
354
|
+
const asc = (await query.execute({ search: "mcpsortprobe", sort: "asc", include_content: true })) as any[];
|
|
355
|
+
expect(asc.map((n: any) => n.content)).toEqual(["mcpsortprobe alpha", "mcpsortprobe beta"]);
|
|
356
|
+
const desc = (await query.execute({ search: "mcpsortprobe", sort: "desc", include_content: true })) as any[];
|
|
357
|
+
expect(desc.map((n: any) => n.content)).toEqual(["mcpsortprobe beta", "mcpsortprobe alpha"]);
|
|
358
|
+
});
|
|
127
359
|
});
|