@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,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/core/src/cursor.ts
CHANGED
|
@@ -94,41 +94,59 @@ export function encodeCursor(payload: CursorPayload): string {
|
|
|
94
94
|
return Buffer.from(json, "utf8").toString("base64url");
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Trailing hint appended to every `cursor_invalid` message (vault#550) so a
|
|
99
|
+
* caller who passed a garbage cursor — or never understood the bootstrap
|
|
100
|
+
* shape in the first place — gets the actual recovery flow, not just "this
|
|
101
|
+
* string is broken." The bootstrap flow: an EMPTY string (`cursor: ""` /
|
|
102
|
+
* `?cursor=`) opts into cursor mode without a watermark yet; the response's
|
|
103
|
+
* `next_cursor` is what you pass on every call after that.
|
|
104
|
+
*/
|
|
105
|
+
const CURSOR_BOOTSTRAP_HINT =
|
|
106
|
+
'first call: pass cursor:"" (or omit `cursor` entirely for a plain, non-paginated list); the response carries `next_cursor` — pass that back on each subsequent call to resume from the watermark.';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Decode a cursor string. Throws `CursorError` on any structural problem.
|
|
110
|
+
* An EMPTY string is deliberately NOT accepted here — callers (queryNotes /
|
|
111
|
+
* queryNotesPaged) must special-case `opts.cursor === ""` as "cursor mode,
|
|
112
|
+
* no watermark yet" and skip calling this at all (vault#550 bootstrap fix).
|
|
113
|
+
* Any caller that reaches this function with an empty string gets the same
|
|
114
|
+
* loud `cursor_invalid` as any other malformed cursor.
|
|
115
|
+
*/
|
|
98
116
|
export function decodeCursor(cursor: string): CursorPayload {
|
|
99
117
|
if (typeof cursor !== "string" || cursor.length === 0) {
|
|
100
|
-
throw new CursorError(
|
|
118
|
+
throw new CursorError(`cursor must be a non-empty string — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
101
119
|
}
|
|
102
120
|
let json: string;
|
|
103
121
|
try {
|
|
104
122
|
json = Buffer.from(cursor, "base64url").toString("utf8");
|
|
105
123
|
} catch {
|
|
106
|
-
throw new CursorError(
|
|
124
|
+
throw new CursorError(`cursor is not valid base64url — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
107
125
|
}
|
|
108
126
|
let parsed: unknown;
|
|
109
127
|
try {
|
|
110
128
|
parsed = JSON.parse(json);
|
|
111
129
|
} catch {
|
|
112
|
-
throw new CursorError(
|
|
130
|
+
throw new CursorError(`cursor payload is not valid JSON — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
113
131
|
}
|
|
114
132
|
if (!parsed || typeof parsed !== "object") {
|
|
115
|
-
throw new CursorError(
|
|
133
|
+
throw new CursorError(`cursor payload must be an object — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
116
134
|
}
|
|
117
135
|
const p = parsed as Record<string, unknown>;
|
|
118
136
|
if (typeof p.v !== "number" || p.v !== CURSOR_VERSION) {
|
|
119
137
|
throw new CursorError(
|
|
120
|
-
`cursor schema version mismatch (expected ${CURSOR_VERSION}, got ${String(p.v)})`,
|
|
138
|
+
`cursor schema version mismatch (expected ${CURSOR_VERSION}, got ${String(p.v)}) — ${CURSOR_BOOTSTRAP_HINT}`,
|
|
121
139
|
"cursor_invalid",
|
|
122
140
|
);
|
|
123
141
|
}
|
|
124
142
|
if (typeof p.last_updated_at !== "number" || !Number.isFinite(p.last_updated_at)) {
|
|
125
|
-
throw new CursorError(
|
|
143
|
+
throw new CursorError(`cursor.last_updated_at must be a finite number — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
126
144
|
}
|
|
127
145
|
if (typeof p.last_id !== "string") {
|
|
128
|
-
throw new CursorError(
|
|
146
|
+
throw new CursorError(`cursor.last_id must be a string — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
129
147
|
}
|
|
130
148
|
if (typeof p.query_hash !== "string" || p.query_hash.length === 0) {
|
|
131
|
-
throw new CursorError(
|
|
149
|
+
throw new CursorError(`cursor.query_hash must be a non-empty string — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
132
150
|
}
|
|
133
151
|
return {
|
|
134
152
|
v: p.v,
|
package/core/src/links.ts
CHANGED
|
@@ -404,6 +404,66 @@ export function traverseLinks(
|
|
|
404
404
|
return results;
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
+
/** Hydrated find-path result — see `hydratePathResult` for field semantics. */
|
|
408
|
+
export interface FindPathResult {
|
|
409
|
+
/** Note IDs, source → target (unchanged shape — back-compat). */
|
|
410
|
+
path: string[];
|
|
411
|
+
/** relationships[i] is the edge connecting path[i] to path[i+1] (unchanged shape). */
|
|
412
|
+
relationships: string[];
|
|
413
|
+
/**
|
|
414
|
+
* Hydrated companion to `path[]` (vault#550, additive): one entry per
|
|
415
|
+
* node, in the same order, carrying the note's `path` field alongside
|
|
416
|
+
* its `id` — so a caller doesn't have to round-trip each id through a
|
|
417
|
+
* separate note fetch just to render a human-legible chain.
|
|
418
|
+
*/
|
|
419
|
+
nodes: { id: string; path: string | null }[];
|
|
420
|
+
/**
|
|
421
|
+
* Hydrated companion to `relationships[]` (vault#550, additive): each
|
|
422
|
+
* hop as a self-contained `{source, target, relationship}` edge plus
|
|
423
|
+
* both endpoints' note `path`, so a graph-rendering client can draw the
|
|
424
|
+
* chain without cross-referencing `nodes`.
|
|
425
|
+
*/
|
|
426
|
+
edges: {
|
|
427
|
+
source: string;
|
|
428
|
+
target: string;
|
|
429
|
+
relationship: string;
|
|
430
|
+
sourcePath: string | null;
|
|
431
|
+
targetPath: string | null;
|
|
432
|
+
}[];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Hydrate a raw BFS result (`path` ids + `relationships`) with each node's
|
|
437
|
+
* note `path` field, in ONE batched query (not one fetch per hop). `path`
|
|
438
|
+
* and `relationships` pass through byte-identical to before this existed —
|
|
439
|
+
* `nodes`/`edges` are pure additions.
|
|
440
|
+
*/
|
|
441
|
+
function hydratePathResult(db: Database, path: string[], relationships: string[]): FindPathResult {
|
|
442
|
+
const pathById = new Map<string, string | null>();
|
|
443
|
+
for (const chunk of chunkForInClause(path)) {
|
|
444
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
445
|
+
const rows = db.prepare(`SELECT id, path FROM notes WHERE id IN (${placeholders})`).all(...chunk) as
|
|
446
|
+
{ id: string; path: string | null }[];
|
|
447
|
+
for (const row of rows) pathById.set(row.id, row.path);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const nodes = path.map((id) => ({ id, path: pathById.get(id) ?? null }));
|
|
451
|
+
const edges: FindPathResult["edges"] = [];
|
|
452
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
453
|
+
const source = path[i]!;
|
|
454
|
+
const target = path[i + 1]!;
|
|
455
|
+
edges.push({
|
|
456
|
+
source,
|
|
457
|
+
target,
|
|
458
|
+
relationship: relationships[i] ?? "",
|
|
459
|
+
sourcePath: pathById.get(source) ?? null,
|
|
460
|
+
targetPath: pathById.get(target) ?? null,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { path, relationships, nodes, edges };
|
|
465
|
+
}
|
|
466
|
+
|
|
407
467
|
/**
|
|
408
468
|
* Find a path between two notes in the link graph.
|
|
409
469
|
* Returns the sequence of note IDs from source to target, or null if no path exists.
|
|
@@ -413,11 +473,11 @@ export function findPath(
|
|
|
413
473
|
sourceId: string,
|
|
414
474
|
targetId: string,
|
|
415
475
|
opts?: { max_depth?: number },
|
|
416
|
-
):
|
|
476
|
+
): FindPathResult | null {
|
|
417
477
|
const maxDepth = opts?.max_depth ?? 5;
|
|
418
478
|
|
|
419
479
|
if (sourceId === targetId) {
|
|
420
|
-
return
|
|
480
|
+
return hydratePathResult(db, [sourceId], []);
|
|
421
481
|
}
|
|
422
482
|
|
|
423
483
|
// BFS from source
|
|
@@ -460,7 +520,7 @@ export function findPath(
|
|
|
460
520
|
current = entry.parent;
|
|
461
521
|
}
|
|
462
522
|
path.unshift(sourceId);
|
|
463
|
-
return
|
|
523
|
+
return hydratePathResult(db, path, relationships);
|
|
464
524
|
}
|
|
465
525
|
}
|
|
466
526
|
}
|
package/core/src/mcp.ts
CHANGED
|
@@ -4,7 +4,8 @@ import { transactionAsync } from "./txn.js";
|
|
|
4
4
|
import * as noteOps from "./notes.js";
|
|
5
5
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
6
6
|
import { QueryError } from "./query-operators.js";
|
|
7
|
-
import { TAG_EXPAND_MODES, stripTagHash, type TagExpandMode } from "./tag-hierarchy.js";
|
|
7
|
+
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
|
|
8
|
+
import { collectUnknownTagWarnings, type QueryWarning } from "./query-warnings.js";
|
|
8
9
|
import * as linkOps from "./links.js";
|
|
9
10
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
10
11
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
@@ -237,7 +238,12 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
237
238
|
|
|
238
239
|
Large notes: pass \`content_offset\` / \`content_length\` (UTF-8 bytes) for a bounded read of note content — the response carries the slice plus \`content_total_length\` and \`content_next_offset\` (null when complete). Loop, feeding \`content_next_offset\` back as \`content_offset\`, to read a note too large for one response.
|
|
239
240
|
|
|
240
|
-
Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded
|
|
241
|
+
Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.
|
|
242
|
+
|
|
243
|
+
Response shape (vault#550 — three variants, pick by what you passed):
|
|
244
|
+
- Default (no \`cursor\`, no warnings): a bare array of notes.
|
|
245
|
+
- Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
|
|
246
|
+
- Warnings present (e.g. an unrecognized \`tag\`) and NOT in cursor mode: \`{notes: [...], warnings: [...]}\`. Cursor mode + warnings compose: \`{notes, next_cursor, warnings}\`. Absent \`warnings\` key means nothing to flag — don't assume its presence either way.`,
|
|
241
247
|
inputSchema: {
|
|
242
248
|
type: "object",
|
|
243
249
|
properties: {
|
|
@@ -328,7 +334,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
328
334
|
cursor: {
|
|
329
335
|
type: "string",
|
|
330
336
|
description:
|
|
331
|
-
"Opaque cursor for 'since last checked' agent loops (vault#313).
|
|
337
|
+
"Opaque cursor for 'since last checked' agent loops (vault#313). Bootstrap flow (vault#550): FIRST call passes `cursor: \"\"` (empty string) — this opts into cursor mode with no watermark yet and the response comes back as `{notes, next_cursor}`. Persist `next_cursor` and pass it back verbatim as `cursor` on every SUBSEQUENT call to receive only notes created or updated since the prior page. Omitting `cursor` entirely (not passing the key at all) is a DIFFERENT thing — a plain one-shot list with no cursor envelope and no way to resume; use that when you don't want pagination at all. The cursor binds to the query's filters (tag, path, metadata, etc.); changing them between calls returns a structured `cursor_query_mismatch` error, and a malformed/expired cursor returns `cursor_invalid` naming the bootstrap flow again. Pagination via cursor orders results by `updated_at ASC` and is mutually exclusive with `order_by` and `sort: \"desc\"`.",
|
|
332
338
|
},
|
|
333
339
|
include_content: { type: "boolean", description: "Include note content (default: true for single, false for list)" },
|
|
334
340
|
content_offset: {
|
|
@@ -466,7 +472,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
466
472
|
// neighborhood every call to be cursor-stable; we punt for now).
|
|
467
473
|
// Both surface as INVALID_QUERY rather than silently returning
|
|
468
474
|
// wrong rows.
|
|
469
|
-
|
|
475
|
+
// Presence, not truthiness (vault#550 bootstrap fix) — `cursor: ""`
|
|
476
|
+
// is the bootstrap call ("I want to paginate, no watermark yet") and
|
|
477
|
+
// must still engage cursor mode. Before this fix `"".length > 0` was
|
|
478
|
+
// false, so the very first call could never obtain a `next_cursor`.
|
|
479
|
+
const cursorMode = typeof params.cursor === "string";
|
|
470
480
|
if (cursorMode && params.search) {
|
|
471
481
|
throw new QueryError(
|
|
472
482
|
`cursor is incompatible with full-text search — FTS has its own ordering. Use date_filter on updated_at for since-last-checked search.`,
|
|
@@ -495,6 +505,14 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
495
505
|
// --- Full-text search ---
|
|
496
506
|
let results: Note[];
|
|
497
507
|
let nextCursor: string | null = null;
|
|
508
|
+
// Warnings channel (vault#550) — structured-query only for this
|
|
509
|
+
// wave; `search=` is out of scope (see #551). Scope-unaware by
|
|
510
|
+
// design (see `core/src/query-warnings.ts` doc comment) — a
|
|
511
|
+
// tag-scoped MCP session gets these stripped by the
|
|
512
|
+
// `applyTagScopeWrappers` query-notes wrapper in `src/mcp-tools.ts`
|
|
513
|
+
// before the result reaches the caller, so an out-of-scope tag name
|
|
514
|
+
// never leaks via `did_you_mean`.
|
|
515
|
+
let queryWarnings: QueryWarning[] = [];
|
|
498
516
|
if (params.search) {
|
|
499
517
|
// Normalize tag param
|
|
500
518
|
const tags = normalizeTags(params.tag);
|
|
@@ -556,6 +574,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
556
574
|
offset: params.offset as number | undefined,
|
|
557
575
|
cursor: cursorMode ? (params.cursor as string) : undefined,
|
|
558
576
|
};
|
|
577
|
+
queryWarnings = collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy());
|
|
559
578
|
if (cursorMode) {
|
|
560
579
|
const page = await store.queryNotesPaged(queryOpts);
|
|
561
580
|
results = page.notes;
|
|
@@ -637,13 +656,29 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
637
656
|
}
|
|
638
657
|
// Cursor mode wraps the list in `{notes, next_cursor}` so callers can
|
|
639
658
|
// chain calls without tracking a watermark client-side. Legacy
|
|
640
|
-
// callers (no `cursor` param) still get the flat
|
|
641
|
-
|
|
642
|
-
|
|
659
|
+
// callers (no `cursor` param, no warnings) still get the flat
|
|
660
|
+
// array (vault#550 — warnings channel is additive, never forces
|
|
661
|
+
// the envelope on its own outside cursor mode... except when
|
|
662
|
+
// there ARE warnings, in which case the envelope is the only way
|
|
663
|
+
// to attach them).
|
|
664
|
+
if (cursorMode) {
|
|
665
|
+
return {
|
|
666
|
+
notes: enrichedOut,
|
|
667
|
+
next_cursor: nextCursor,
|
|
668
|
+
...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
return queryWarnings.length > 0 ? { notes: enrichedOut, warnings: queryWarnings } : enrichedOut;
|
|
643
672
|
}
|
|
644
673
|
|
|
645
|
-
if (cursorMode)
|
|
646
|
-
|
|
674
|
+
if (cursorMode) {
|
|
675
|
+
return {
|
|
676
|
+
notes: output,
|
|
677
|
+
next_cursor: nextCursor,
|
|
678
|
+
...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}),
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
return queryWarnings.length > 0 ? { notes: output, warnings: queryWarnings } : output;
|
|
647
682
|
},
|
|
648
683
|
},
|
|
649
684
|
|
|
@@ -1353,7 +1388,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1353
1388
|
{
|
|
1354
1389
|
name: "list-tags",
|
|
1355
1390
|
requiredVerb: "read",
|
|
1356
|
-
description: `List tags with usage counts. Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps). Pass \`include_schema: true\` to include the full record for every tag.`,
|
|
1391
|
+
description: `List tags with usage counts. Each row carries \`count\` (notes carrying the EXACT tag) and \`expanded_count\` (vault#550 — distinct notes matching the tag OR any transitive descendant under the default subtypes expansion; use this to see a parent tag's true rollup when its notes are actually tagged with a more specific child). Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps) — errors with \`error_type: "tag_not_found"\` (plus a \`did_you_mean\` hint when a close match exists) if the tag has no identity row and no notes. Pass \`include_schema: true\` to include the full record for every tag.`,
|
|
1357
1392
|
inputSchema: {
|
|
1358
1393
|
type: "object",
|
|
1359
1394
|
properties: {
|
|
@@ -1368,9 +1403,30 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1368
1403
|
const allTags = noteOps.listTags(db);
|
|
1369
1404
|
const found = allTags.find((t) => t.name === singleTag);
|
|
1370
1405
|
const record = tagSchemaOps.getTagRecord(db, singleTag);
|
|
1406
|
+
// vault#550 — a tag with no identity row AND no note carrying it
|
|
1407
|
+
// isn't a legitimate (if empty) tag, it's a typo or a tag from a
|
|
1408
|
+
// different vault. Return a structured miss instead of a
|
|
1409
|
+
// synthesized all-null 200. `did_you_mean` searches the full
|
|
1410
|
+
// vault-wide tag catalog — core is scope-unaware by architecture.
|
|
1411
|
+
// Tag-scope enforcement lives in the server layer's list-tags
|
|
1412
|
+
// wrapper (src/mcp-tools.ts:applyTagScopeWrappers): a scoped
|
|
1413
|
+
// session's out-of-scope `tag` param short-circuits to
|
|
1414
|
+
// tag_not_found BEFORE this executes, and an in-scope miss gets
|
|
1415
|
+
// its `did_you_mean` dropped unless the suggestion is also
|
|
1416
|
+
// in-scope.
|
|
1417
|
+
if (!found && !record) {
|
|
1418
|
+
const suggestion = suggestSimilarTag(allTags.map((t) => t.name), singleTag);
|
|
1419
|
+
return {
|
|
1420
|
+
error: "Tag not found",
|
|
1421
|
+
error_type: "tag_not_found",
|
|
1422
|
+
tag: singleTag,
|
|
1423
|
+
...(suggestion ? { did_you_mean: suggestion } : {}),
|
|
1424
|
+
};
|
|
1425
|
+
}
|
|
1371
1426
|
return {
|
|
1372
1427
|
name: singleTag,
|
|
1373
1428
|
count: found?.count ?? 0,
|
|
1429
|
+
expanded_count: found?.expanded_count ?? 0,
|
|
1374
1430
|
description: record?.description ?? null,
|
|
1375
1431
|
fields: record?.fields ?? null,
|
|
1376
1432
|
relationships: record?.relationships ?? null,
|
|
@@ -1589,7 +1645,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1589
1645
|
{
|
|
1590
1646
|
name: "find-path",
|
|
1591
1647
|
requiredVerb: "read",
|
|
1592
|
-
description: "Find the shortest path between two notes in the link graph. Accepts IDs or paths. Returns
|
|
1648
|
+
description: "Find the shortest path between two notes in the link graph. Accepts IDs or paths. Returns null if no path exists, else `{path, relationships, nodes, edges}`: `path` (note IDs, source→target) and `relationships` (relationships[i] connects path[i] to path[i+1]) are the original id-only shape; `nodes` (vault#550, additive) hydrates each id in `path` with the note's own `path` field — `[{id, path}]` in the same order; `edges` (additive) is the self-contained hop list — `[{source, target, relationship, sourcePath, targetPath}]` — for rendering the chain without cross-referencing `nodes`.",
|
|
1593
1649
|
inputSchema: {
|
|
1594
1650
|
type: "object",
|
|
1595
1651
|
properties: {
|