@openparachute/vault 0.6.5 → 0.7.0-rc.1

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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Contract suite — concurrency + write-path strengths (Wave 1 of the
3
+ * Reliability & Usability Program, umbrella #556). Encodes the 2026-07-09
4
+ * nine-persona deep test's verdict that "the storage/concurrency core is
5
+ * trustworthy — zero server errors, zero corruption, zero lost writes across
6
+ * ~100 hostile operations and a live two-writer session" (#556, #555 work
7
+ * item "document the strong contracts the deep test verified but that are
8
+ * currently unwritten selling points"). Every test here is a PASSING lock-in
9
+ * — there is no known-broken behavior in this file, only undocumented
10
+ * correctness worth pinning as contract before later waves touch adjacent
11
+ * code paths.
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach } from "bun:test";
15
+ import { Database } from "bun:sqlite";
16
+ import { SqliteStore } from "./store.js";
17
+ import { ConflictError, PathConflictError } from "./notes.js";
18
+
19
+ let store: SqliteStore;
20
+ let db: Database;
21
+
22
+ beforeEach(() => {
23
+ db = new Database(":memory:");
24
+ store = new SqliteStore(db);
25
+ });
26
+
27
+ describe("contract: concurrency — passing (lock in verified strengths)", () => {
28
+ it("sequential interleaved appends from two writers preserve call order and lose nothing (25-line pattern)", async () => {
29
+ const note = await store.createNote("", { id: "log" });
30
+
31
+ // Simulate two independent writers interleaving 25 appends each.
32
+ // Sequential (not truly parallel) — the contract under test is that
33
+ // SQL-level `content = content || ?` concatenation never drops or
34
+ // reorders a write relative to the order calls actually landed in,
35
+ // which is exactly what a real two-writer race also guarantees
36
+ // (see core.test.ts's "update-note append is atomic under concurrent
37
+ // calls" for the Promise.all interleaving twin of this test).
38
+ const expectedLines: string[] = [];
39
+ for (let i = 1; i <= 25; i++) {
40
+ const lineA = `writer-A line ${i}\n`;
41
+ await store.updateNote(note.id, { append: lineA });
42
+ expectedLines.push(lineA);
43
+
44
+ const lineB = `writer-B line ${i}\n`;
45
+ await store.updateNote(note.id, { append: lineB });
46
+ expectedLines.push(lineB);
47
+ }
48
+
49
+ const final = await store.getNote(note.id);
50
+ expect(final!.content).toBe(expectedLines.join(""));
51
+ // 50 appends, none lost: every planted line is present exactly once.
52
+ expect(final!.content.match(/writer-A line/g)?.length).toBe(25);
53
+ expect(final!.content.match(/writer-B line/g)?.length).toBe(25);
54
+ });
55
+
56
+ it("two updates racing on the same if_updated_at token: exactly one succeeds, the other gets a ConflictError", async () => {
57
+ const note = await store.createNote("v0", { id: "n1" });
58
+ const token = note.updatedAt!;
59
+
60
+ const first = await store.updateNote(note.id, { content: "v1 (writer A)", if_updated_at: token });
61
+ expect(first.content).toBe("v1 (writer A)");
62
+
63
+ let err: any;
64
+ try {
65
+ // Writer B read the note before writer A's update landed and is still
66
+ // holding the stale token — this must conflict, not silently overwrite.
67
+ await store.updateNote(note.id, { content: "v1 (writer B)", if_updated_at: token });
68
+ } catch (e) {
69
+ err = e;
70
+ }
71
+ expect(err).toBeInstanceOf(ConflictError);
72
+ expect(err.note_id).toBe(note.id);
73
+ expect(err.expected_updated_at).toBe(token);
74
+ expect(err.current_updated_at).toBe(first.updatedAt);
75
+
76
+ // Writer A's write is the one that stuck.
77
+ const final = await store.getNote(note.id);
78
+ expect(final!.content).toBe("v1 (writer A)");
79
+ });
80
+
81
+ it("creating a note at an already-taken path throws PathConflictError — the prior note is untouched", async () => {
82
+ const original = await store.createNote("original owner", { path: "shared/path" });
83
+
84
+ let err: any;
85
+ try {
86
+ await store.createNote("racing creator", { path: "shared/path" });
87
+ } catch (e) {
88
+ err = e;
89
+ }
90
+ expect(err).toBeInstanceOf(PathConflictError);
91
+ expect(err.path).toBe("shared/path");
92
+
93
+ const survivor = await store.getNote(original.id);
94
+ expect(survivor!.content).toBe("original owner");
95
+ });
96
+
97
+ it("a metadata update merges rather than replaces — untouched top-level keys survive a partial patch", async () => {
98
+ const note = await store.createNote("has metadata", {
99
+ metadata: { keep: "me", also_keep: 42, drop_me: "bye" },
100
+ });
101
+
102
+ // mergeMetadata is the pure merge primitive the MCP/REST write paths
103
+ // call before handing the merged object to store.updateNote (the
104
+ // Store/engine-level updateNote itself replaces `metadata` wholesale —
105
+ // merge is a caller responsibility, exercised here directly).
106
+ const { mergeMetadata } = await import("./notes.js");
107
+ const merged = mergeMetadata(note.metadata as Record<string, unknown>, {
108
+ also_keep: 43,
109
+ drop_me: null,
110
+ new_key: "hello",
111
+ });
112
+ await store.updateNote(note.id, { metadata: merged, if_updated_at: note.updatedAt });
113
+
114
+ const after = await store.getNote(note.id);
115
+ expect(after!.metadata).toEqual({ keep: "me", also_keep: 43, new_key: "hello" });
116
+ });
117
+ });
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Contract suite — taxonomy integrity (Wave 1 of the Reliability & Usability
3
+ * Program, umbrella #556). Encodes the 2026-07-09 nine-persona deep test's
4
+ * WS3 findings (#552) as executable tests: PASSING tests lock in behavior
5
+ * that is correct today (the tag-expand axis, `delete-tag`'s "untag, don't
6
+ * delete notes" contract, and `renameTag`'s full cascade — note_tags,
7
+ * `parent_names` references in OTHER tags, and note body content); `test.todo`
8
+ * entries describe the target behavior for confirmed-broken cases, to be
9
+ * flipped to real assertions in a later wave. See #552 for the full write-up.
10
+ */
11
+
12
+ import { describe, it, test, expect, beforeEach } from "bun:test";
13
+ import { Database } from "bun:sqlite";
14
+ import { SqliteStore } from "./store.js";
15
+
16
+ let store: SqliteStore;
17
+ let db: Database;
18
+
19
+ beforeEach(() => {
20
+ db = new Database(":memory:");
21
+ store = new SqliteStore(db);
22
+ });
23
+
24
+ const idsOf = (notes: { id: string }[]) => new Set(notes.map((n) => n.id));
25
+
26
+ describe("contract: taxonomy — passing (lock in current behavior)", () => {
27
+ it("expand axis 4-way discrimination: subtypes/namespace/both/exact each answer a distinct question", async () => {
28
+ // Two-axis corpus (mirrors tag-expand-axis.test.ts): `person` is a
29
+ // declared subtype of `entity` (parent_names) but not name-prefixed;
30
+ // `entity/archived` is name-prefixed under `entity` but NOT a declared
31
+ // subtype; `entity/person` is both.
32
+ await store.upsertTagRecord("entity", { description: "an entity" });
33
+ await store.upsertTagRecord("person", { parent_names: ["entity"] });
34
+ await store.upsertTagRecord("entity/archived", {});
35
+ await store.upsertTagRecord("entity/person", { parent_names: ["entity"] });
36
+
37
+ const nEntity = await store.createNote("literal entity", { tags: ["entity"] });
38
+ const nPerson = await store.createNote("a person (subtype)", { tags: ["person"] });
39
+ const nArchived = await store.createNote("filed under entity/", { tags: ["entity/archived"] });
40
+ const nBoth = await store.createNote("subtype AND filed", { tags: ["entity/person"] });
41
+
42
+ const subtypes = await store.queryNotes({ tags: ["entity"], expand: "subtypes" });
43
+ expect(idsOf(subtypes)).toEqual(new Set([nEntity.id, nPerson.id, nBoth.id]));
44
+
45
+ const namespace = await store.queryNotes({ tags: ["entity"], expand: "namespace" });
46
+ expect(idsOf(namespace)).toEqual(new Set([nEntity.id, nArchived.id, nBoth.id]));
47
+
48
+ const both = await store.queryNotes({ tags: ["entity"], expand: "both" });
49
+ expect(idsOf(both)).toEqual(new Set([nEntity.id, nPerson.id, nArchived.id, nBoth.id]));
50
+
51
+ const exact = await store.queryNotes({ tags: ["entity"], expand: "exact" });
52
+ expect(idsOf(exact)).toEqual(new Set([nEntity.id]));
53
+ });
54
+
55
+ it("delete-tag untags notes without deleting the notes themselves", async () => {
56
+ const note = await store.createNote("keep me", { tags: ["temp", "keeper"] });
57
+ const result = await store.deleteTag("temp");
58
+ expect(result).toEqual({ deleted: true, notes_untagged: 1 });
59
+
60
+ const survivor = await store.getNote(note.id);
61
+ expect(survivor).not.toBeNull();
62
+ expect(survivor!.content).toBe("keep me");
63
+ expect(survivor!.tags).toEqual(["keeper"]);
64
+ expect(survivor!.tags).not.toContain("temp");
65
+ });
66
+
67
+ it("renameTag cascades note_tags, other tags' parent_names, AND note body content in one atomic call", async () => {
68
+ // note_tags surface: a note directly tagged `proj`.
69
+ const tagged = await store.createNote("owns the proj tag", { tags: ["proj"] });
70
+ // parent_names surface: another tag declares `proj` as a parent.
71
+ await store.upsertTagRecord("proj", { description: "root project tag" });
72
+ await store.upsertTagRecord("special", { parent_names: ["proj"] });
73
+ // note-body surface: a note mentions `#proj` inline (not as a real tag).
74
+ const mentioning = await store.createNote("kickoff notes, see #proj for context", {});
75
+
76
+ const result = await store.renameTag("proj", "initiative");
77
+ expect("error" in result).toBe(false);
78
+ if ("error" in result) throw new Error("unreachable");
79
+ expect(result.renamed).toBeGreaterThanOrEqual(1);
80
+ expect(result.parent_refs_updated).toBeGreaterThanOrEqual(1);
81
+ expect(result.notes_rewritten).toBeGreaterThanOrEqual(1);
82
+
83
+ // 1. note_tags cascade.
84
+ const taggedAfter = await store.getNote(tagged.id);
85
+ expect(taggedAfter!.tags).toContain("initiative");
86
+ expect(taggedAfter!.tags).not.toContain("proj");
87
+
88
+ // 2. OTHER tags' parent_names cascade.
89
+ const specialAfter = await store.getTagRecord("special");
90
+ expect(specialAfter?.parent_names).toEqual(["initiative"]);
91
+
92
+ // 3. Note body content cascade.
93
+ const mentioningAfter = await store.getNote(mentioning.id);
94
+ expect(mentioningAfter!.content).toContain("#initiative");
95
+ expect(mentioningAfter!.content).not.toContain("#proj");
96
+
97
+ // The old tag name is fully retired — no longer a live tag row.
98
+ const oldRecord = await store.getTagRecord("proj");
99
+ expect(oldRecord).toBeNull();
100
+ });
101
+ });
102
+
103
+ describe("contract: taxonomy — todo (#552)", () => {
104
+ test.todo(
105
+ "#552: deleting a tag still referenced in another tag's parent_names errors unless cascade/detach is passed (today: store.deleteTag succeeds unconditionally and leaves the referencing tag's parent_names pointing at a now-nonexistent tag name — verified with a fresh parent/child fixture)",
106
+ );
107
+ test.todo(
108
+ "#552: a parent_names cycle (A declares parent B, then B declares parent A) is rejected at write time (today: store.upsertTagRecord accepts both writes with no cycle check — traversal elsewhere is cycle-safe, but the write itself is not honest about creating one)",
109
+ );
110
+ test.todo(
111
+ "#552: rename-tag is exposed as an MCP tool, not just a REST endpoint (today: POST /api/tags/:name/rename exists and store.renameTag is wired, but generateMcpTools has no rename-tag tool — an MCP-only agent cannot rename a tag)",
112
+ );
113
+ test.todo(
114
+ "#552: merge-tags is exposed as an MCP tool, not just a REST endpoint (today: POST /api/tags/merge exists and store.mergeTags is wired, but generateMcpTools has no merge-tags tool)",
115
+ );
116
+ test.todo(
117
+ "#552: a `vault doctor` integrity scan reports dangling parent_names references (today: no doctor/scan surface inspects parent_names for referential integrity — the delete-tag gap above goes undetected)",
118
+ );
119
+ });
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Contract suite — typed indexes (Wave 1 of the Reliability & Usability
3
+ * Program, umbrella #556). Encodes the 2026-07-09 nine-persona deep test's
4
+ * WS4 findings (#553) as executable tests: PASSING tests lock in behavior
5
+ * that is correct today (well-typed integer-indexed range queries, the
6
+ * merge-patch metadata contract, and the state_transition compare-and-set
7
+ * conflict); `test.todo` entries describe the target behavior for
8
+ * confirmed-broken cases, to be flipped to real assertions in a later wave.
9
+ * See #553 for the full write-up.
10
+ *
11
+ * The TEXT-poisoning todo below was reproduced live before writing: declaring
12
+ * an indexed integer field and writing `metadata.n = "four"` succeeds with
13
+ * only an advisory `type_mismatch` warning, and the resulting row's generated
14
+ * column carries the raw TEXT value under SQLite's INTEGER-affinity
15
+ * coercion — which then sorts ABOVE every real integer (SQLite type
16
+ * ordering: NULL < INTEGER/REAL < TEXT < BLOB), so `{n: {gt: 100}}`
17
+ * incorrectly matches the poisoned row alongside genuine matches.
18
+ */
19
+
20
+ import { describe, it, test, expect, beforeEach } from "bun:test";
21
+ import { Database } from "bun:sqlite";
22
+ import { SqliteStore } from "./store.js";
23
+ import { generateMcpTools } from "./mcp.js";
24
+ import { TransitionConflictError } from "./notes.js";
25
+
26
+ let store: SqliteStore;
27
+ let db: Database;
28
+
29
+ beforeEach(() => {
30
+ db = new Database(":memory:");
31
+ store = new SqliteStore(db);
32
+ });
33
+
34
+ describe("contract: typed indexes — passing (lock in current behavior)", () => {
35
+ it("an integer-indexed field with well-typed integer values answers gte/lt correctly", async () => {
36
+ await store.upsertTagRecord("metric", { fields: { n: { type: "integer", indexed: true } } });
37
+ const low = await store.createNote("n=5", { tags: ["metric"], metadata: { n: 5 } });
38
+ const mid = await store.createNote("n=50", { tags: ["metric"], metadata: { n: 50 } });
39
+ const high = await store.createNote("n=200", { tags: ["metric"], metadata: { n: 200 } });
40
+
41
+ const gte50 = await store.queryNotes({ tags: ["metric"], metadata: { n: { gte: 50 } } });
42
+ expect(new Set(gte50.map((n) => n.id))).toEqual(new Set([mid.id, high.id]));
43
+
44
+ const lt50 = await store.queryNotes({ tags: ["metric"], metadata: { n: { lt: 50 } } });
45
+ expect(new Set(lt50.map((n) => n.id))).toEqual(new Set([low.id]));
46
+ });
47
+
48
+ it("merge-patch metadata preserves untouched fields, updates named fields, and RFC-7386-deletes null fields", async () => {
49
+ const note = await store.createNote("has metadata", {
50
+ metadata: { a: 1, b: 2, c: 3 },
51
+ });
52
+ const tools = generateMcpTools(store);
53
+ const updateNote = tools.find((t) => t.name === "update-note")!;
54
+
55
+ const result: any = await updateNote.execute({
56
+ id: note.id,
57
+ metadata: { b: 20, c: null },
58
+ force: true,
59
+ });
60
+
61
+ expect(result.metadata).toEqual({ a: 1, b: 20 });
62
+ });
63
+
64
+ it("a state_transition compare-and-set conflicts when the note has already transitioned away from `from`", async () => {
65
+ const note = await store.createNote("workflow item", { metadata: { status: "draft" } });
66
+
67
+ const first = await store.updateNote(note.id, {
68
+ state_transition: { field: "status", from: "draft", to: "published" },
69
+ });
70
+ expect((first.metadata as any).status).toBe("published");
71
+
72
+ let err: any;
73
+ try {
74
+ // Same `from: "draft"` precondition — but the note is now "published",
75
+ // so this must conflict rather than silently no-op or overwrite.
76
+ await store.updateNote(note.id, {
77
+ state_transition: { field: "status", from: "draft", to: "archived" },
78
+ });
79
+ } catch (e) {
80
+ err = e;
81
+ }
82
+ expect(err).toBeInstanceOf(TransitionConflictError);
83
+ expect(err.field).toBe("status");
84
+ expect(err.expected_from).toBe("draft");
85
+ expect(err.current).toBe("published");
86
+
87
+ // The note's status is untouched by the rejected transition.
88
+ const after = await store.getNote(note.id);
89
+ expect((after!.metadata as any).status).toBe("published");
90
+ });
91
+ });
92
+
93
+ describe("contract: typed indexes — todo (#553)", () => {
94
+ test.todo(
95
+ `#553: a write of a TEXT value to an indexed integer field is REJECTED (today: it is accepted with only an advisory type_mismatch warning, and the poisoned row then silently matches {gt: 100}-style range queries via SQLite's TEXT-sorts-above-INTEGER type-affinity ordering — reproduced live: metadata.n = "four" on an indexed integer field succeeds, and query {n: {gt: 100}} incorrectly returns it)`,
96
+ );
97
+ test.todo(
98
+ `#553: an unset enum field stays ABSENT — no first-value backfill — so exists:false correctly matches a note that never set the field (today: applySchemaDefaults in core/src/mcp.ts backfills the schema's first enum value onto every note that gains the tag, so "never set" is indistinguishable from "explicitly set to the default" and exists:false never matches)`,
99
+ );
100
+ test.todo(
101
+ `#553: update-tag reports ALL invalid fields in one call AND states explicitly that no changes were applied (today: the cross-tag type/indexed-flag validation loop in core/src/mcp.ts throws on the FIRST offending field and never reports the rest, and the thrown Error's message doesn't say whether the tag's other, valid fields were partially applied — reproduced live: declaring two invalid fields in one update-tag call surfaces only the first field's error)`,
102
+ );
103
+ test.todo(
104
+ `#553: the indexed-field type list is honest about what's actually indexable (core/src/mcp.ts's update-tag field-type description advertises "string, boolean, integer, number, array, object" but indexed-fields.ts's TYPE_MAP only supports string/integer/boolean — "number" and the container types are accepted as declared but silently un-indexable)`,
105
+ );
106
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.5",
3
+ "version": "0.7.0-rc.1",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -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,100 @@
1
+ /**
2
+ * Contract suite — honest queries 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 WS1 findings (#550) as executable tests: PASSING
5
+ * tests lock in behavior that is correct today; `test.todo` entries describe
6
+ * the target behavior for confirmed-broken cases, to be flipped to real
7
+ * assertions in a later wave. See #550 for the full write-up — this file
8
+ * covers the `src/routes.ts` REST surface specifically (the cursor-bootstrap
9
+ * gap lives at routes.ts:1102/1106, the tags/{name} 404 gap at the GET /tags
10
+ * handler in `handleTags`).
11
+ */
12
+
13
+ import { describe, it, test, expect, beforeEach, afterEach } from "bun:test";
14
+ import { Database } from "bun:sqlite";
15
+ import { BunStore } from "./vault-store.ts";
16
+ import { handleNotes, handleTags } from "./routes.ts";
17
+
18
+ let db: Database;
19
+ let store: BunStore;
20
+
21
+ const BASE = "http://localhost/api";
22
+
23
+ function getNotes(qs: string): Promise<Response> {
24
+ return handleNotes(new Request(`${BASE}/notes?${qs}`, { method: "GET" }), store, "");
25
+ }
26
+
27
+ function getTags(qs: string): Promise<Response> {
28
+ return handleTags(new Request(`${BASE}/tags?${qs}`, { method: "GET" }), store, "");
29
+ }
30
+
31
+ beforeEach(() => {
32
+ db = new Database(":memory:");
33
+ store = new BunStore(db);
34
+ });
35
+
36
+ afterEach(() => {
37
+ db.close();
38
+ });
39
+
40
+ describe("contract: honest queries — passing (lock in current behavior)", () => {
41
+ it("querying a nonexistent tag returns 200 with an empty array, not an error", async () => {
42
+ const res = await getNotes("tag=zzznonexistenttag");
43
+ expect(res.status).toBe(200);
44
+ const body = await res.json();
45
+ // Shape may gain a `warnings` field later (#550) — assert only status +
46
+ // array shape now so that addition doesn't break this contract test.
47
+ expect(Array.isArray(body)).toBe(true);
48
+ expect(body).toEqual([]);
49
+ });
50
+
51
+ it("a metadata operator query on a non-indexed field errors loudly with FIELD_NOT_INDEXED (existing behavior)", async () => {
52
+ const res = await getNotes("meta[not_a_real_field][gt]=5");
53
+ expect(res.status).toBe(400);
54
+ const body: any = await res.json();
55
+ expect(body.code).toBe("FIELD_NOT_INDEXED");
56
+ });
57
+
58
+ it("a `near` query against a nonexistent anchor note errors cleanly with 404, not a silent []", async () => {
59
+ const res = await getNotes("near[note_id]=does-not-exist");
60
+ expect(res.status).toBe(404);
61
+ const body: any = await res.json();
62
+ expect(body.error).toBeDefined();
63
+ expect(body.note_id).toBe("does-not-exist");
64
+ });
65
+
66
+ // removed in W2 — once #550's cursor-bootstrap fix lands, an omitted
67
+ // `cursor` param will (per the documented intent) still return a flat
68
+ // array for callers who never opt into cursor pagination, so this
69
+ // specific assertion should survive; the paired todo below is the one
70
+ // that flips when the bootstrap gap closes. Kept both here so the fix
71
+ // is a conscious, reviewed change rather than an incidental snapshot.
72
+ it("a cursor-less limit query returns a flat array (today's shape) — removed in W2", async () => {
73
+ await store.createNote("only note");
74
+ const res = await getNotes("limit=5");
75
+ expect(res.status).toBe(200);
76
+ const body = await res.json();
77
+ expect(Array.isArray(body)).toBe(true);
78
+ });
79
+ });
80
+
81
+ describe("contract: honest queries — todo (#550)", () => {
82
+ test.todo(
83
+ "#550: limit=-1 returns a structured error instead of silently meaning \"unlimited\" (today: SQLite's negative-LIMIT-means-no-limit semantics leak straight through to the caller)",
84
+ );
85
+ test.todo(
86
+ "#550: query responses carry a warnings: [] channel, populated with a did_you_mean suggestion for an unknown tag name",
87
+ );
88
+ test.todo(
89
+ "#550: an invalid date_filter value (unparseable date string) returns a structured error instead of silently matching nothing or everything",
90
+ );
91
+ test.todo(
92
+ "#550: GET /api/tags/{nonexistent} returns 404 instead of 200 with an all-null synthesized record",
93
+ );
94
+ test.todo(
95
+ "#550: list-tags reports expanded_count (rollup through parent_names descendants) alongside the literal per-tag count — today a parent tag with only child-tagged notes reports count: 0",
96
+ );
97
+ test.todo(
98
+ "#550: cursor bootstrap — a first call that expresses cursor intent (no `cursor` param yet, but pagination is desired) returns a {notes, next_cursor} envelope, and a second call passing that cursor back sees only notes written since the first call (today: routes.ts only wraps the response in {notes, next_cursor} when a cursor param is ALREADY present, so the very first call can never obtain one — core/src/mcp.ts:331 documents a bootstrap flow that is not actually reachable)",
99
+ );
100
+ });
@@ -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
+ });