@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.
@@ -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`); `test.todo` entries
8
- * describe the target behavior for confirmed-broken cases, to be flipped to
9
- * real assertions in a later wave. See #554 for the full write-up.
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, test, expect, beforeEach, afterEach } from "bun:test";
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 — todo (#554)", () => {
92
- test.todo(
93
- "#554: every error response carries a structured {error_type, hint} pair — today error_type exists on many paths but no response carries a `hint` field, and some error paths (e.g. bad_request/ambiguous/unprocessable_content in the PATCH content_edit branch) are still bare {error, message} strings with no error_type at all",
94
- );
95
- test.todo(
96
- "#554: batch update-note honors a top-level `force` (or `if_updated_at`) applied per-item today the batch entry point does `items = batch ?? [params]`, so a top-level force:true on the request never reaches the per-item precondition check (core/src/mcp.ts ~line 1114 reads item.force only) and each item without its OWN force/if_updated_at still throws precondition_required",
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 of the Reliability & Usability Program,
3
- * umbrella #556). Encodes the 2026-07-09 nine-persona deep test's search
4
- * findings (WS2, #551) as executable tests: PASSING tests lock in behavior
5
- * that is correct today; `test.todo` entries describe the target behavior
6
- * for confirmed-broken cases, to be flipped to real assertions in a later
7
- * wave. See #551 for the full write-up.
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
- it('quoted phrase finds hyphenated phrase text: "eleven-day capping delay"', async () => {
80
- const res = await search(`search=${encodeURIComponent('"eleven-day capping delay"')}&include_content=true`);
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("contract: search — todo (#551, literal-by-default + recall + ranking)", () => {
109
- test.todo(
110
- `#551: unquoted search: "didn't" finds the contraction content (literal-by-default — today the bare apostrophe splits into two AND'd tokens and returns [])`,
111
- );
112
- test.todo(
113
- `#551: unquoted search: "eleven-day capping delay" finds the hyphenated-phrase note (literal-by-default — today the bare hyphenated word tokenizes into separate AND'd terms and returns [])`,
114
- );
115
- test.todo(
116
- `#551: unquoted search: "18.6" finds the decimal note (literal-by-default — today the bare decimal tokenizes into separate AND'd terms and returns [])`,
117
- );
118
- test.todo(
119
- `#551: search_mode:"advanced" preserves raw FTS5 query syntax (AND/OR/NOT, phrase, prefix) once literal-by-default escaping ships as the new default`,
120
- );
121
- test.todo(
122
- `#551: sort:"asc"/"desc" changes result order under search (today silently ignored — FTS5 rank order wins regardless of the sort param)`,
123
- );
124
- test.todo(
125
- `#551: malformed FTS syntax (e.g. an unbalanced quote) yields a structured warning or error, not a silently swallowed [] (today searchNotes catches every FTS5 syntax error and returns [])`,
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
  });