@openparachute/vault 0.6.5 → 0.7.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/src/contract-concurrency.test.ts +117 -0
- package/core/src/contract-taxonomy.test.ts +119 -0
- package/core/src/contract-typed-index.test.ts +106 -0
- package/core/src/cursor.ts +27 -9
- package/core/src/links.ts +63 -3
- package/core/src/mcp.ts +67 -11
- package/core/src/notes.ts +142 -28
- package/core/src/query-operators.ts +18 -1
- package/core/src/query-warnings.ts +184 -0
- package/core/src/store.ts +9 -2
- package/core/src/tag-hierarchy.ts +113 -0
- package/core/src/types.ts +19 -4
- package/package.json +1 -1
- package/src/contract-errors.test.ts +98 -0
- package/src/contract-honest-queries.test.ts +412 -0
- package/src/contract-search.test.ts +127 -0
- package/src/live-match.test.ts +17 -0
- package/src/live-match.ts +4 -1
- package/src/mcp-http.ts +19 -0
- package/src/mcp-list-tags-scope.test.ts +154 -0
- package/src/mcp-tools.ts +49 -8
- package/src/routes.ts +234 -17
- package/web/ui/dist/assets/{index-TJ-XN4OZ.js → index-B8pGeQam.js} +1 -1
- package/web/ui/dist/index.html +1 -1
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract suite — error taxonomy at the REST boundary (Wave 1 of the
|
|
3
|
+
* Reliability & Usability Program, umbrella #556). Encodes the 2026-07-09
|
|
4
|
+
* nine-persona deep test's WS5 findings (#554) as executable tests: PASSING
|
|
5
|
+
* tests lock in the structured-error precedents that already exist
|
|
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.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
|
|
13
|
+
import { Database } from "bun:sqlite";
|
|
14
|
+
import { BunStore } from "./vault-store.ts";
|
|
15
|
+
import { handleNotes } from "./routes.ts";
|
|
16
|
+
|
|
17
|
+
let db: Database;
|
|
18
|
+
let store: BunStore;
|
|
19
|
+
|
|
20
|
+
const BASE = "http://localhost/api";
|
|
21
|
+
|
|
22
|
+
function post(body: unknown): Promise<Response> {
|
|
23
|
+
return handleNotes(
|
|
24
|
+
new Request(`${BASE}/notes`, { method: "POST", body: JSON.stringify(body) }),
|
|
25
|
+
store,
|
|
26
|
+
"",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function patch(id: string, body: unknown): Promise<Response> {
|
|
31
|
+
return handleNotes(
|
|
32
|
+
new Request(`${BASE}/notes/${id}`, { method: "PATCH", body: JSON.stringify(body) }),
|
|
33
|
+
store,
|
|
34
|
+
`/${id}`,
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
db = new Database(":memory:");
|
|
40
|
+
store = new BunStore(db);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
db.close();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("contract: error taxonomy — passing (lock in existing structured precedents)", () => {
|
|
48
|
+
it("creating a note at a path that's already taken returns 409 path_conflict", async () => {
|
|
49
|
+
await store.createNote("first", { path: "taken" });
|
|
50
|
+
const res = await post({ content: "second", path: "taken" });
|
|
51
|
+
expect(res.status).toBe(409);
|
|
52
|
+
const body: any = await res.json();
|
|
53
|
+
expect(body.error_type).toBe("path_conflict");
|
|
54
|
+
expect(body.path).toBe("taken");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("updating with a stale if_updated_at returns 409 conflict carrying current_updated_at + your_updated_at", async () => {
|
|
58
|
+
const note = await store.createNote("original", { id: "n1" });
|
|
59
|
+
const res = await patch(note.id, { content: "changed", if_updated_at: "2020-01-01T00:00:00.000Z" });
|
|
60
|
+
expect(res.status).toBe(409);
|
|
61
|
+
const body: any = await res.json();
|
|
62
|
+
expect(body.error_type).toBe("conflict");
|
|
63
|
+
expect(body.current_updated_at).toBe(note.updatedAt);
|
|
64
|
+
expect(body.your_updated_at).toBe("2020-01-01T00:00:00.000Z");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("a strict schema violation on write returns 422 schema_validation naming every violation", async () => {
|
|
68
|
+
await store.upsertTagRecord("strict-status", {
|
|
69
|
+
fields: { status: { type: "string", enum: ["active", "archived"], strict: true } },
|
|
70
|
+
});
|
|
71
|
+
const res = await post({ content: "x", tags: ["strict-status"], metadata: { status: "bogus" } });
|
|
72
|
+
expect(res.status).toBe(422);
|
|
73
|
+
const body: any = await res.json();
|
|
74
|
+
expect(body.error_type).toBe("schema_validation");
|
|
75
|
+
expect(Array.isArray(body.violations)).toBe(true);
|
|
76
|
+
expect(body.violations.length).toBeGreaterThan(0);
|
|
77
|
+
expect(body.violations[0].field).toBe("status");
|
|
78
|
+
expect(body.violations[0].reason).toBe("enum_mismatch");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("updating a note without if_updated_at or force returns 428 precondition_required", async () => {
|
|
82
|
+
const note = await store.createNote("original", { id: "n1" });
|
|
83
|
+
const res = await patch(note.id, { content: "changed, no precondition" });
|
|
84
|
+
expect(res.status).toBe(428);
|
|
85
|
+
const body: any = await res.json();
|
|
86
|
+
expect(body.error_type).toBe("precondition_required");
|
|
87
|
+
expect(body.note_id).toBe(note.id);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
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
|
+
);
|
|
98
|
+
});
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract suite — honest queries at the REST + MCP boundary (Wave 1's
|
|
3
|
+
* findings, Wave 2's fixes — Reliability & Usability Program, umbrella
|
|
4
|
+
* #556). Encodes the 2026-07-09 nine-persona deep test's WS1 findings
|
|
5
|
+
* (#550) as executable tests: PASSING tests lock in behavior that is
|
|
6
|
+
* correct today; the #550 `test.todo` entries this file used to carry have
|
|
7
|
+
* all been flipped to real assertions against the WS1 fixes (warnings
|
|
8
|
+
* channel, structured invalids, cursor bootstrap, tags 404, expanded_count)
|
|
9
|
+
* — see #550 for the full write-up. Covers `src/routes.ts` (the REST
|
|
10
|
+
* surface) plus a handful of MCP-parity checks (`core/src/mcp.ts`) where
|
|
11
|
+
* the two surfaces share one implementation and a REST-only test wouldn't
|
|
12
|
+
* prove the MCP side actually got the fix.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
|
|
16
|
+
import { Database } from "bun:sqlite";
|
|
17
|
+
import { BunStore } from "./vault-store.ts";
|
|
18
|
+
import { handleNotes, handleTags, handleFindPath } from "./routes.ts";
|
|
19
|
+
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
20
|
+
|
|
21
|
+
let db: Database;
|
|
22
|
+
let store: BunStore;
|
|
23
|
+
|
|
24
|
+
const BASE = "http://localhost/api";
|
|
25
|
+
|
|
26
|
+
function getNotes(qs: string): Promise<Response> {
|
|
27
|
+
return handleNotes(new Request(`${BASE}/notes?${qs}`, { method: "GET" }), store, "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getTags(qs: string): Promise<Response> {
|
|
31
|
+
return handleTags(new Request(`${BASE}/tags?${qs}`, { method: "GET" }), store, "");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getTagByName(name: string): Promise<Response> {
|
|
35
|
+
return handleTags(new Request(`${BASE}/tags/${name}`, { method: "GET" }), store, `/${name}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function findPath(source: string, target: string): Promise<Response> {
|
|
39
|
+
return handleFindPath(new Request(`${BASE}/find-path?source=${source}&target=${target}`, { method: "GET" }), store);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Decode the `X-Parachute-Warnings` header (vault#550 — percent-encoded JSON; see `jsonWithWarnings` in routes.ts). */
|
|
43
|
+
function decodeWarningsHeader(res: Response): any[] | null {
|
|
44
|
+
const raw = res.headers.get("X-Parachute-Warnings");
|
|
45
|
+
if (!raw) return null;
|
|
46
|
+
return JSON.parse(decodeURIComponent(raw));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
db = new Database(":memory:");
|
|
51
|
+
store = new BunStore(db);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(() => {
|
|
55
|
+
db.close();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("contract: honest queries — passing (lock in current behavior)", () => {
|
|
59
|
+
it("querying a nonexistent tag returns 200 with an empty array, not an error", async () => {
|
|
60
|
+
const res = await getNotes("tag=zzznonexistenttag");
|
|
61
|
+
expect(res.status).toBe(200);
|
|
62
|
+
const body = await res.json();
|
|
63
|
+
// Shape may gain a `warnings` HEADER (vault#550, additive) — assert
|
|
64
|
+
// only status + array shape here so the header addition doesn't break
|
|
65
|
+
// this contract test. The header itself is exercised below.
|
|
66
|
+
expect(Array.isArray(body)).toBe(true);
|
|
67
|
+
expect(body).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("a metadata operator query on a non-indexed field errors loudly with FIELD_NOT_INDEXED (existing behavior)", async () => {
|
|
71
|
+
const res = await getNotes("meta[not_a_real_field][gt]=5");
|
|
72
|
+
expect(res.status).toBe(400);
|
|
73
|
+
const body: any = await res.json();
|
|
74
|
+
expect(body.code).toBe("FIELD_NOT_INDEXED");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("a `near` query against a nonexistent anchor note errors cleanly with 404, not a silent []", async () => {
|
|
78
|
+
const res = await getNotes("near[note_id]=does-not-exist");
|
|
79
|
+
expect(res.status).toBe(404);
|
|
80
|
+
const body: any = await res.json();
|
|
81
|
+
expect(body.error).toBeDefined();
|
|
82
|
+
expect(body.note_id).toBe("does-not-exist");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// The cursor-bootstrap fix (vault#550) means `cursor` is now keyed on
|
|
86
|
+
// PRESENCE, not truthiness — `?cursor=` (present, empty) now engages the
|
|
87
|
+
// envelope (see the "cursor bootstrap" test below). An OMITTED `cursor`
|
|
88
|
+
// param is a completely different thing — no pagination intent at all —
|
|
89
|
+
// and still returns today's flat array. This test pins that omission
|
|
90
|
+
// case specifically, distinct from bootstrap.
|
|
91
|
+
it("a cursor-less limit query (cursor param OMITTED entirely) returns a flat array", async () => {
|
|
92
|
+
await store.createNote("only note");
|
|
93
|
+
const res = await getNotes("limit=5");
|
|
94
|
+
expect(res.status).toBe(200);
|
|
95
|
+
const body = await res.json();
|
|
96
|
+
expect(Array.isArray(body)).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("contract: honest queries — structured invalids (#550)", () => {
|
|
101
|
+
it("limit=-1 returns a structured invalid_query error instead of silently meaning \"unlimited\"", async () => {
|
|
102
|
+
await store.createNote("a note");
|
|
103
|
+
const res = await getNotes("limit=-1");
|
|
104
|
+
expect(res.status).toBe(400);
|
|
105
|
+
const body: any = await res.json();
|
|
106
|
+
expect(body.error_type).toBe("invalid_query");
|
|
107
|
+
expect(body.field).toBe("limit");
|
|
108
|
+
expect(body.got).toBe("-1");
|
|
109
|
+
expect(typeof body.hint).toBe("string");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("limit=abc (non-numeric) returns invalid_query instead of silently falling back to the default", async () => {
|
|
113
|
+
const res = await getNotes("limit=abc");
|
|
114
|
+
expect(res.status).toBe(400);
|
|
115
|
+
const body: any = await res.json();
|
|
116
|
+
expect(body.error_type).toBe("invalid_query");
|
|
117
|
+
expect(body.field).toBe("limit");
|
|
118
|
+
expect(body.got).toBe("abc");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("offset=-1 (negative offset) returns a structured invalid_query error", async () => {
|
|
122
|
+
const res = await getNotes("offset=-1");
|
|
123
|
+
expect(res.status).toBe(400);
|
|
124
|
+
const body: any = await res.json();
|
|
125
|
+
expect(body.error_type).toBe("invalid_query");
|
|
126
|
+
expect(body.field).toBe("offset");
|
|
127
|
+
expect(body.got).toBe("-1");
|
|
128
|
+
expect(typeof body.hint).toBe("string");
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("an unparseable date value in a bracket date filter returns invalid_query instead of silently matching nothing or everything", async () => {
|
|
132
|
+
const res = await getNotes("meta[created_at][gte]=not-a-real-date");
|
|
133
|
+
expect(res.status).toBe(400);
|
|
134
|
+
const body: any = await res.json();
|
|
135
|
+
expect(body.error_type).toBe("invalid_query");
|
|
136
|
+
expect(body.got).toBe("not-a-real-date");
|
|
137
|
+
expect(typeof body.hint).toBe("string");
|
|
138
|
+
expect(body.hint.toLowerCase()).toContain("iso");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("a malformed cursor's error message states the bootstrap flow explicitly", async () => {
|
|
142
|
+
const res = await getNotes("cursor=not-a-valid-cursor!!!");
|
|
143
|
+
expect(res.status).toBe(400);
|
|
144
|
+
const body: any = await res.json();
|
|
145
|
+
expect(body.code).toBe("cursor_invalid");
|
|
146
|
+
// Must name the recovery flow, not just "this is broken" — the P1
|
|
147
|
+
// fix's whole point is that the bootstrap shape is DISCOVERABLE from
|
|
148
|
+
// the error, not just from documentation the caller may never read.
|
|
149
|
+
expect(body.error).toContain('cursor:""');
|
|
150
|
+
expect(body.error.toLowerCase()).toContain("next_cursor");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe("contract: honest queries — warnings channel (#550)", () => {
|
|
155
|
+
it("an unrecognized tag surfaces an unknown_tag warning (with did_you_mean) via the X-Parachute-Warnings header, bare-array body unchanged", async () => {
|
|
156
|
+
await store.createNote("a note", { tags: ["project"] });
|
|
157
|
+
const res = await getNotes("tag=projet"); // typo: missing the second "c"
|
|
158
|
+
expect(res.status).toBe(200);
|
|
159
|
+
const body = await res.json();
|
|
160
|
+
expect(Array.isArray(body)).toBe(true); // shape stays a bare array
|
|
161
|
+
expect(body).toEqual([]); // the typo'd tag genuinely matches nothing
|
|
162
|
+
|
|
163
|
+
const warnings = decodeWarningsHeader(res);
|
|
164
|
+
expect(warnings).not.toBeNull();
|
|
165
|
+
expect(warnings).toHaveLength(1);
|
|
166
|
+
expect(warnings![0].code).toBe("unknown_tag");
|
|
167
|
+
expect(warnings![0].tag).toBe("projet");
|
|
168
|
+
expect(warnings![0].did_you_mean).toBe("project");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("a tag that has notes via subtype expansion (a real descendant) does NOT warn, even though the literal tag has zero direct matches", async () => {
|
|
172
|
+
await store.upsertTagRecord("child", { parent_names: ["parent"] });
|
|
173
|
+
await store.createNote("a note", { tags: ["child"] });
|
|
174
|
+
const res = await getNotes("tag=parent");
|
|
175
|
+
expect(res.status).toBe(200);
|
|
176
|
+
const warnings = decodeWarningsHeader(res);
|
|
177
|
+
expect(warnings).toBeNull(); // "parent" resolves via expansion — not unknown
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("the flat date_field/date_from/date_to params (removed at 0.6.4) surface a removed_param warning per param, still silently ignored in the result set", async () => {
|
|
181
|
+
await store.createNote("in range", { created_at: "2020-06-01T00:00:00.000Z" });
|
|
182
|
+
await store.createNote("out of range", { created_at: "2099-01-01T00:00:00.000Z" });
|
|
183
|
+
const res = await getNotes("date_field=created_at&date_from=2020-01-01&date_to=2020-12-31");
|
|
184
|
+
expect(res.status).toBe(200);
|
|
185
|
+
const body: any[] = await res.json();
|
|
186
|
+
// Still unfiltered (existing behavior — bracket-style is the only
|
|
187
|
+
// supported date filter now; this locks in that the ignoring itself
|
|
188
|
+
// didn't change, only that it's no longer SILENT). Both notes come
|
|
189
|
+
// back even though `out of range` sits well outside the requested
|
|
190
|
+
// window — proof the flat params never touched the query.
|
|
191
|
+
expect(body.length).toBe(2);
|
|
192
|
+
|
|
193
|
+
const warnings = decodeWarningsHeader(res);
|
|
194
|
+
expect(warnings).not.toBeNull();
|
|
195
|
+
const codes = warnings!.map((w) => w.code);
|
|
196
|
+
expect(codes.filter((c) => c === "removed_param")).toHaveLength(3);
|
|
197
|
+
const params = warnings!.filter((w) => w.code === "removed_param").map((w) => w.param).sort();
|
|
198
|
+
expect(params).toEqual(["date_field", "date_from", "date_to"]);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("cursor mode carries warnings INLINE in the envelope (in addition to the header)", async () => {
|
|
202
|
+
const res = await getNotes("cursor=&tag=doesnotexist");
|
|
203
|
+
expect(res.status).toBe(200);
|
|
204
|
+
const body: any = await res.json();
|
|
205
|
+
expect(Array.isArray(body.notes)).toBe(true);
|
|
206
|
+
expect(typeof body.next_cursor).toBe("string");
|
|
207
|
+
expect(Array.isArray(body.warnings)).toBe(true);
|
|
208
|
+
expect(body.warnings[0].code).toBe("unknown_tag");
|
|
209
|
+
// header present too, same content
|
|
210
|
+
const headerWarnings = decodeWarningsHeader(res);
|
|
211
|
+
expect(headerWarnings).toEqual(body.warnings);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("format=graph carries warnings INLINE in the {nodes, edges} envelope (in addition to the header)", async () => {
|
|
215
|
+
await store.createNote("a note", { tags: ["real"] });
|
|
216
|
+
const res = await getNotes("format=graph&tag=doesnotexist");
|
|
217
|
+
expect(res.status).toBe(200);
|
|
218
|
+
const body: any = await res.json();
|
|
219
|
+
expect(Array.isArray(body.nodes)).toBe(true);
|
|
220
|
+
expect(Array.isArray(body.edges)).toBe(true);
|
|
221
|
+
expect(Array.isArray(body.warnings)).toBe(true);
|
|
222
|
+
expect(body.warnings[0].code).toBe("unknown_tag");
|
|
223
|
+
const headerWarnings = decodeWarningsHeader(res);
|
|
224
|
+
expect(headerWarnings).toEqual(body.warnings);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("format=graph with no warnings carries NO warnings key and no header (shape unchanged)", async () => {
|
|
228
|
+
await store.createNote("a note", { tags: ["real"] });
|
|
229
|
+
const res = await getNotes("format=graph&tag=real");
|
|
230
|
+
expect(res.status).toBe(200);
|
|
231
|
+
const body: any = await res.json();
|
|
232
|
+
expect(body.warnings).toBeUndefined();
|
|
233
|
+
expect(res.headers.get("X-Parachute-Warnings")).toBeNull();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("unknown_tag warnings are capped at 8 with a warnings_truncated marker (garbage tags array can't inflate the header unboundedly)", async () => {
|
|
237
|
+
const junkTags = Array.from({ length: 12 }, (_, i) => `zz-junk-tag-${i}`);
|
|
238
|
+
const res = await getNotes(`tag=${junkTags.join(",")}`);
|
|
239
|
+
expect(res.status).toBe(200);
|
|
240
|
+
const warnings = decodeWarningsHeader(res);
|
|
241
|
+
expect(warnings).not.toBeNull();
|
|
242
|
+
const unknown = warnings!.filter((w) => w.code === "unknown_tag");
|
|
243
|
+
const truncated = warnings!.filter((w) => w.code === "warnings_truncated");
|
|
244
|
+
expect(unknown).toHaveLength(8);
|
|
245
|
+
expect(truncated).toHaveLength(1);
|
|
246
|
+
expect(truncated[0].suppressed).toBe(4); // 12 junk tags − 8 reported
|
|
247
|
+
expect(truncated[0].limit).toBe(8);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
describe("contract: honest queries — cursor bootstrap (#550, the P1)", () => {
|
|
252
|
+
it("an empty cursor (`?cursor=`) engages the {notes, next_cursor} envelope on the VERY FIRST call, and the returned next_cursor sees only notes written after it", async () => {
|
|
253
|
+
const n1 = await store.createNote("existing note");
|
|
254
|
+
|
|
255
|
+
const first = await getNotes("cursor=");
|
|
256
|
+
expect(first.status).toBe(200);
|
|
257
|
+
const firstBody: any = await first.json();
|
|
258
|
+
expect(Array.isArray(firstBody)).toBe(false);
|
|
259
|
+
expect(Array.isArray(firstBody.notes)).toBe(true);
|
|
260
|
+
expect(firstBody.notes.map((n: any) => n.id)).toEqual([n1.id]);
|
|
261
|
+
expect(typeof firstBody.next_cursor).toBe("string");
|
|
262
|
+
expect(firstBody.next_cursor.length).toBeGreaterThan(0);
|
|
263
|
+
|
|
264
|
+
const n2 = await store.createNote("new note");
|
|
265
|
+
const second = await getNotes(`cursor=${encodeURIComponent(firstBody.next_cursor)}`);
|
|
266
|
+
expect(second.status).toBe(200);
|
|
267
|
+
const secondBody: any = await second.json();
|
|
268
|
+
expect(secondBody.notes.map((n: any) => n.id)).toEqual([n2.id]);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("MCP query-notes: cursor: \"\" likewise bootstraps the envelope on the first call", async () => {
|
|
272
|
+
const n1 = await store.createNote("existing note");
|
|
273
|
+
const tools = generateMcpTools(store);
|
|
274
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
275
|
+
|
|
276
|
+
const first = await queryNotes.execute({ cursor: "" }) as any;
|
|
277
|
+
expect(Array.isArray(first)).toBe(false);
|
|
278
|
+
expect(Array.isArray(first.notes)).toBe(true);
|
|
279
|
+
expect(first.notes.map((n: any) => n.id)).toEqual([n1.id]);
|
|
280
|
+
expect(typeof first.next_cursor).toBe("string");
|
|
281
|
+
|
|
282
|
+
const n2 = await store.createNote("new note");
|
|
283
|
+
const second = await queryNotes.execute({ cursor: first.next_cursor }) as any;
|
|
284
|
+
expect(second.notes.map((n: any) => n.id)).toEqual([n2.id]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("omitting `cursor` entirely (MCP) is NOT the same as bootstrapping — no envelope, no next_cursor", async () => {
|
|
288
|
+
await store.createNote("only note");
|
|
289
|
+
const tools = generateMcpTools(store);
|
|
290
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
291
|
+
const result = await queryNotes.execute({}) as any;
|
|
292
|
+
expect(Array.isArray(result)).toBe(true);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("contract: honest queries — tags 404 (#550)", () => {
|
|
297
|
+
it("GET /api/tags/{nonexistent} returns 404 tag_not_found instead of a synthesized all-null 200", async () => {
|
|
298
|
+
const res = await getTagByName("zzznonexistenttag");
|
|
299
|
+
expect(res.status).toBe(404);
|
|
300
|
+
const body: any = await res.json();
|
|
301
|
+
expect(body.error_type).toBe("tag_not_found");
|
|
302
|
+
expect(body.tag).toBe("zzznonexistenttag");
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("GET /api/tags?tag=<nonexistent> returns the same 404 tag_not_found shape, with did_you_mean when a close match exists", async () => {
|
|
306
|
+
await store.upsertTagRecord("project", {});
|
|
307
|
+
const res = await getTags("tag=projcet"); // transposed letters
|
|
308
|
+
expect(res.status).toBe(404);
|
|
309
|
+
const body: any = await res.json();
|
|
310
|
+
expect(body.error_type).toBe("tag_not_found");
|
|
311
|
+
expect(body.tag).toBe("projcet");
|
|
312
|
+
expect(body.did_you_mean).toBe("project");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("a tag with an identity row but zero notes is still a real 200 (not 404) — an identity row alone is enough", async () => {
|
|
316
|
+
await store.upsertTagRecord("declared-but-unused", { description: "reserved for later" });
|
|
317
|
+
const res = await getTagByName("declared-but-unused");
|
|
318
|
+
expect(res.status).toBe(200);
|
|
319
|
+
const body: any = await res.json();
|
|
320
|
+
expect(body.count).toBe(0);
|
|
321
|
+
expect(body.expanded_count).toBe(0);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("MCP list-tags with a nonexistent tag param returns a structured tag_not_found object (not a thrown error, not a synthesized 200)", async () => {
|
|
325
|
+
const tools = generateMcpTools(store);
|
|
326
|
+
const listTags = tools.find((t) => t.name === "list-tags")!;
|
|
327
|
+
const result = await listTags.execute({ tag: "zzznonexistenttag" }) as any;
|
|
328
|
+
expect(result.error_type).toBe("tag_not_found");
|
|
329
|
+
expect(result.tag).toBe("zzznonexistenttag");
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
describe("contract: honest queries — expanded_count (#550)", () => {
|
|
334
|
+
it("list-tags reports expanded_count reflecting the subtype rollup, while count stays the literal per-tag number", async () => {
|
|
335
|
+
// parent/child fixture: every note is tagged with the CHILD only, so
|
|
336
|
+
// the literal `count` on "parent" is 0 even though every one of those
|
|
337
|
+
// notes IS conceptually a "parent" via the declared hierarchy.
|
|
338
|
+
await store.upsertTagRecord("parent", {});
|
|
339
|
+
await store.upsertTagRecord("child", { parent_names: ["parent"] });
|
|
340
|
+
await store.createNote("n1", { tags: ["child"] });
|
|
341
|
+
await store.createNote("n2", { tags: ["child"] });
|
|
342
|
+
await store.createNote("n3", { tags: ["parent"] }); // also directly tagged
|
|
343
|
+
|
|
344
|
+
const res = await getTags("");
|
|
345
|
+
expect(res.status).toBe(200);
|
|
346
|
+
const body: any[] = await res.json();
|
|
347
|
+
const parentRow = body.find((t) => t.name === "parent")!;
|
|
348
|
+
const childRow = body.find((t) => t.name === "child")!;
|
|
349
|
+
|
|
350
|
+
expect(parentRow.count).toBe(1); // only n3 carries the literal "parent" tag
|
|
351
|
+
expect(parentRow.expanded_count).toBe(3); // n1, n2 (via child) + n3
|
|
352
|
+
expect(childRow.count).toBe(2);
|
|
353
|
+
expect(childRow.expanded_count).toBe(2); // "child" has no further descendants
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("GET /api/tags/{name} single-tag detail also carries expanded_count", async () => {
|
|
357
|
+
await store.upsertTagRecord("parent2", {});
|
|
358
|
+
await store.upsertTagRecord("child2", { parent_names: ["parent2"] });
|
|
359
|
+
await store.createNote("n1", { tags: ["child2"] });
|
|
360
|
+
|
|
361
|
+
const res = await getTagByName("parent2");
|
|
362
|
+
const body: any = await res.json();
|
|
363
|
+
expect(body.count).toBe(0);
|
|
364
|
+
expect(body.expanded_count).toBe(1);
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
describe("contract: honest queries — find-path hydration (#550, additive)", () => {
|
|
369
|
+
it("REST find-path hydrates `nodes` (id+path) and `edges` (source/target/relationship+paths) alongside the original path/relationships shape", async () => {
|
|
370
|
+
const a = await store.createNote("A", { id: "a", path: "People/Alice" });
|
|
371
|
+
const b = await store.createNote("B", { id: "b" }); // no path set
|
|
372
|
+
const c = await store.createNote("C", { id: "c", path: "Projects/X" });
|
|
373
|
+
await store.createLink("a", "b", "mentions");
|
|
374
|
+
await store.createLink("b", "c", "related-to");
|
|
375
|
+
|
|
376
|
+
const res = await findPath("a", "c");
|
|
377
|
+
expect(res.status).toBe(200);
|
|
378
|
+
const body: any = await res.json();
|
|
379
|
+
|
|
380
|
+
// Original shape, byte-identical — back-compat.
|
|
381
|
+
expect(body.path).toEqual(["a", "b", "c"]);
|
|
382
|
+
expect(body.relationships).toEqual(["mentions", "related-to"]);
|
|
383
|
+
|
|
384
|
+
// Additive hydration.
|
|
385
|
+
expect(body.nodes).toEqual([
|
|
386
|
+
{ id: "a", path: "People/Alice" },
|
|
387
|
+
{ id: "b", path: null },
|
|
388
|
+
{ id: "c", path: "Projects/X" },
|
|
389
|
+
]);
|
|
390
|
+
expect(body.edges).toEqual([
|
|
391
|
+
{ source: "a", target: "b", relationship: "mentions", sourcePath: "People/Alice", targetPath: null },
|
|
392
|
+
{ source: "b", target: "c", relationship: "related-to", sourcePath: null, targetPath: "Projects/X" },
|
|
393
|
+
]);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("MCP find-path hydrates the same nodes/edges shape", async () => {
|
|
397
|
+
await store.createNote("A", { id: "a", path: "People/Alice" });
|
|
398
|
+
await store.createNote("B", { id: "b", path: "Projects/X" });
|
|
399
|
+
await store.createLink("a", "b", "mentions");
|
|
400
|
+
|
|
401
|
+
const tools = generateMcpTools(store);
|
|
402
|
+
const findPathTool = tools.find((t) => t.name === "find-path")!;
|
|
403
|
+
const result = await findPathTool.execute({ source: "a", target: "b" }) as any;
|
|
404
|
+
expect(result.nodes).toEqual([
|
|
405
|
+
{ id: "a", path: "People/Alice" },
|
|
406
|
+
{ id: "b", path: "Projects/X" },
|
|
407
|
+
]);
|
|
408
|
+
expect(result.edges).toEqual([
|
|
409
|
+
{ source: "a", target: "b", relationship: "mentions", sourcePath: "People/Alice", targetPath: "Projects/X" },
|
|
410
|
+
]);
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* Ground truth for every assertion here was re-verified live against this
|
|
10
|
+
* repo's FTS5 search path (`core/src/notes.ts` searchNotes → REST `GET
|
|
11
|
+
* /notes?search=`) before writing — see the PR description for the probe
|
|
12
|
+
* transcript.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
|
|
16
|
+
import { Database } from "bun:sqlite";
|
|
17
|
+
import { BunStore } from "./vault-store.ts";
|
|
18
|
+
import { handleNotes } from "./routes.ts";
|
|
19
|
+
|
|
20
|
+
let db: Database;
|
|
21
|
+
let store: BunStore;
|
|
22
|
+
|
|
23
|
+
const BASE = "http://localhost/api";
|
|
24
|
+
|
|
25
|
+
function search(qs: string): Promise<Response> {
|
|
26
|
+
return handleNotes(new Request(`${BASE}/notes?${qs}`, { method: "GET" }), store, "");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Planted corpus — each note exists to exercise exactly one FTS5 quirk. */
|
|
30
|
+
const NOTES = {
|
|
31
|
+
hyphenPhrase: "The rollout had an eleven-day capping delay before it shipped.",
|
|
32
|
+
contraction: "She said she didn't know about the capping delay.",
|
|
33
|
+
decimal: "The measurement came out to 18.6 percent this quarter.",
|
|
34
|
+
bothWords: "A plain keyword note about widgets and gadgets.",
|
|
35
|
+
widgetsOnly: "Only widgets here, no other word.",
|
|
36
|
+
gadgetsOnly: "Gadgets alone, nothing else notable.",
|
|
37
|
+
filler1: "Quarterly report drafted for the finance team.",
|
|
38
|
+
filler2: "Meeting notes from the Tuesday standup.",
|
|
39
|
+
filler3: "Grocery list: eggs, bread, oat milk.",
|
|
40
|
+
filler4: "Project plan for the Q3 roadmap.",
|
|
41
|
+
filler5: "Weekly retro: wins and blockers for the sprint.",
|
|
42
|
+
filler6: "Random thoughts on the trip itinerary.",
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
db = new Database(":memory:");
|
|
47
|
+
store = new BunStore(db);
|
|
48
|
+
for (const content of Object.values(NOTES)) {
|
|
49
|
+
await store.createNote(content);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(() => {
|
|
54
|
+
db.close();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
async function bodyOf(res: Response): Promise<any> {
|
|
58
|
+
return res.json();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("contract: search — passing (lock in current behavior)", () => {
|
|
62
|
+
it("plain keyword search finds the matching notes", async () => {
|
|
63
|
+
const res = await search("search=widgets&include_content=true");
|
|
64
|
+
expect(res.status).toBe(200);
|
|
65
|
+
const body = await bodyOf(res);
|
|
66
|
+
const contents = new Set(body.map((n: any) => n.content));
|
|
67
|
+
expect(contents.has(NOTES.bothWords)).toBe(true);
|
|
68
|
+
expect(contents.has(NOTES.widgetsOnly)).toBe(true);
|
|
69
|
+
expect(contents.has(NOTES.gadgetsOnly)).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("two-word unquoted search is implicit AND — only the note containing BOTH terms matches", async () => {
|
|
73
|
+
const res = await search("search=widgets+gadgets&include_content=true");
|
|
74
|
+
expect(res.status).toBe(200);
|
|
75
|
+
const body = await bodyOf(res);
|
|
76
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.bothWords]);
|
|
77
|
+
});
|
|
78
|
+
|
|
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`);
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
const body = await bodyOf(res);
|
|
83
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.hyphenPhrase]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('quoted decimal literal finds it: "18.6"', async () => {
|
|
87
|
+
const res = await search(`search=${encodeURIComponent('"18.6"')}&include_content=true`);
|
|
88
|
+
expect(res.status).toBe(200);
|
|
89
|
+
const body = await bodyOf(res);
|
|
90
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.decimal]);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('quoted contraction finds it: "didn\'t"', async () => {
|
|
94
|
+
const res = await search(`search=${encodeURIComponent(`"didn't"`)}&include_content=true`);
|
|
95
|
+
expect(res.status).toBe(200);
|
|
96
|
+
const body = await bodyOf(res);
|
|
97
|
+
expect(body.map((n: any) => n.content)).toEqual([NOTES.contraction]);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("unknown-word search returns []", async () => {
|
|
101
|
+
const res = await search("search=zzzznonexistentword&include_content=true");
|
|
102
|
+
expect(res.status).toBe(200);
|
|
103
|
+
const body = await bodyOf(res);
|
|
104
|
+
expect(body).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
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
|
+
);
|
|
127
|
+
});
|
package/src/live-match.test.ts
CHANGED
|
@@ -196,3 +196,20 @@ describe("live-match — predicate parity with the query engine", () => {
|
|
|
196
196
|
await assertParity({ tags: ["t"], metadata: { kind: "note" } });
|
|
197
197
|
});
|
|
198
198
|
});
|
|
199
|
+
|
|
200
|
+
describe("live-match — unsupportedSubscriptionReason cursor guard (vault#550)", () => {
|
|
201
|
+
it("rejects a bootstrap empty-string cursor (presence, not truthiness)", async () => {
|
|
202
|
+
const { unsupportedSubscriptionReason } = await import("./live-match.ts");
|
|
203
|
+
// `cursor: ""` is cursor INTENT (the vault#550 bootstrap call) — a live
|
|
204
|
+
// subscription can't paginate, so it must be rejected the same as a
|
|
205
|
+
// real cursor, not silently treated as "no cursor requested."
|
|
206
|
+
expect(unsupportedSubscriptionReason({ cursor: "" } as QueryOpts)).toContain("cursor");
|
|
207
|
+
expect(unsupportedSubscriptionReason({ cursor: "eyJxaC" } as QueryOpts)).toContain("cursor");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("an omitted/undefined cursor does not reject", async () => {
|
|
211
|
+
const { unsupportedSubscriptionReason } = await import("./live-match.ts");
|
|
212
|
+
expect(unsupportedSubscriptionReason({ cursor: undefined } as QueryOpts)).toBeNull();
|
|
213
|
+
expect(unsupportedSubscriptionReason({} as QueryOpts)).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
});
|
package/src/live-match.ts
CHANGED
|
@@ -76,7 +76,10 @@ export function unsupportedSubscriptionReason(opts: QueryOpts): string | null {
|
|
|
76
76
|
// subscribe route detects them from raw query params. These guards catch the
|
|
77
77
|
// remaining shapes that the matcher can't faithfully evaluate against a
|
|
78
78
|
// single in-hand note (so snapshot and live would disagree).
|
|
79
|
-
|
|
79
|
+
// Presence, not truthiness (vault#550) — a bootstrap `cursor: ""` is
|
|
80
|
+
// still cursor intent and must still be rejected here, not silently
|
|
81
|
+
// treated as "no cursor requested."
|
|
82
|
+
if (opts.cursor !== undefined) {
|
|
80
83
|
return "cursor pagination is not supported for live subscriptions";
|
|
81
84
|
}
|
|
82
85
|
if (opts.hasLinks !== undefined) {
|