@openparachute/vault 0.1.0 → 0.2.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.
- package/CHANGELOG.md +87 -0
- package/CLAUDE.md +2 -2
- package/README.md +289 -44
- package/core/src/core.test.ts +802 -346
- package/core/src/expand.ts +140 -0
- package/core/src/hooks.test.ts +27 -27
- package/core/src/hooks.ts +1 -1
- package/core/src/mcp.ts +102 -39
- package/core/src/notes.ts +82 -4
- package/core/src/obsidian.test.ts +11 -11
- package/core/src/paths.test.ts +46 -46
- package/core/src/schema.ts +18 -2
- package/core/src/store.ts +51 -51
- package/core/src/types.ts +29 -29
- package/core/src/wikilinks.test.ts +61 -61
- package/docs/HTTP_API.md +4 -2
- package/package.json +1 -1
- package/src/auth.test.ts +319 -0
- package/src/backup-launchd.test.ts +90 -0
- package/src/backup-launchd.ts +169 -0
- package/src/backup.test.ts +715 -0
- package/src/backup.ts +699 -0
- package/src/cli.ts +923 -31
- package/src/config.test.ts +173 -0
- package/src/config.ts +345 -15
- package/src/daemon.ts +136 -0
- package/src/doctor.test.ts +356 -0
- package/src/health.test.ts +201 -0
- package/src/health.ts +115 -0
- package/src/launchd.test.ts +91 -0
- package/src/launchd.ts +37 -40
- package/src/mcp-http.ts +1 -1
- package/src/mcp-tools.ts +7 -9
- package/src/oauth.test.ts +289 -8
- package/src/oauth.ts +66 -13
- package/src/published.test.ts +21 -21
- package/src/routes.ts +152 -70
- package/src/routing.test.ts +478 -0
- package/src/routing.ts +413 -0
- package/src/server.ts +7 -278
- package/src/systemd.test.ts +15 -0
- package/src/systemd.ts +18 -11
- package/src/triggers.test.ts +7 -7
- package/src/triggers.ts +6 -6
- package/src/vault-store.ts +20 -3
- package/src/vault.test.ts +356 -262
- package/.claude/settings.local.json +0 -31
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- package/web/vite.config.ts +0 -15
package/core/src/core.test.ts
CHANGED
|
@@ -13,150 +13,150 @@ beforeEach(() => {
|
|
|
13
13
|
|
|
14
14
|
// ---- Notes CRUD ----
|
|
15
15
|
|
|
16
|
-
describe("notes", () => {
|
|
17
|
-
it("creates a note", () => {
|
|
18
|
-
const note = store.createNote("Morning walk");
|
|
16
|
+
describe("notes", async () => {
|
|
17
|
+
it("creates a note", async () => {
|
|
18
|
+
const note = await store.createNote("Morning walk");
|
|
19
19
|
expect(note.content).toBe("Morning walk");
|
|
20
20
|
expect(note.id).toBeTruthy();
|
|
21
21
|
expect(note.createdAt).toBeTruthy();
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
-
it("creates a note with custom id", () => {
|
|
25
|
-
const note = store.createNote("Test", { id: "custom-id" });
|
|
24
|
+
it("creates a note with custom id", async () => {
|
|
25
|
+
const note = await store.createNote("Test", { id: "custom-id" });
|
|
26
26
|
expect(note.id).toBe("custom-id");
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
it("creates a note with path", () => {
|
|
30
|
-
const note = store.createNote("# Grocery List", { path: "Grocery List" });
|
|
29
|
+
it("creates a note with path", async () => {
|
|
30
|
+
const note = await store.createNote("# Grocery List", { path: "Grocery List" });
|
|
31
31
|
expect(note.path).toBe("Grocery List");
|
|
32
32
|
});
|
|
33
33
|
|
|
34
|
-
it("creates a note with tags", () => {
|
|
35
|
-
const note = store.createNote("Voice memo", { tags: ["daily", "voice"] });
|
|
34
|
+
it("creates a note with tags", async () => {
|
|
35
|
+
const note = await store.createNote("Voice memo", { tags: ["daily", "voice"] });
|
|
36
36
|
expect(note.tags).toContain("daily");
|
|
37
37
|
expect(note.tags).toContain("voice");
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
it("gets a note by id", () => {
|
|
41
|
-
const created = store.createNote("Test");
|
|
42
|
-
const found = store.getNote(created.id);
|
|
40
|
+
it("gets a note by id", async () => {
|
|
41
|
+
const created = await store.createNote("Test");
|
|
42
|
+
const found = await store.getNote(created.id);
|
|
43
43
|
expect(found).toBeTruthy();
|
|
44
44
|
expect(found!.id).toBe(created.id);
|
|
45
45
|
expect(found!.content).toBe("Test");
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
it("returns null for missing note", () => {
|
|
49
|
-
expect(store.getNote("nonexistent")).toBeNull();
|
|
48
|
+
it("returns null for missing note", async () => {
|
|
49
|
+
expect(await store.getNote("nonexistent")).toBeNull();
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
it("updates note content", () => {
|
|
53
|
-
const note = store.createNote("Original");
|
|
54
|
-
const updated = store.updateNote(note.id, { content: "Updated" });
|
|
52
|
+
it("updates note content", async () => {
|
|
53
|
+
const note = await store.createNote("Original");
|
|
54
|
+
const updated = await store.updateNote(note.id, { content: "Updated" });
|
|
55
55
|
expect(updated.content).toBe("Updated");
|
|
56
56
|
expect(updated.updatedAt).toBeTruthy();
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
it("updates note path", () => {
|
|
60
|
-
const note = store.createNote("Test");
|
|
61
|
-
const updated = store.updateNote(note.id, { path: "Notes/Test" });
|
|
59
|
+
it("updates note path", async () => {
|
|
60
|
+
const note = await store.createNote("Test");
|
|
61
|
+
const updated = await store.updateNote(note.id, { path: "Notes/Test" });
|
|
62
62
|
expect(updated.path).toBe("Notes/Test");
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
it("updates created_at", () => {
|
|
66
|
-
const note = store.createNote("Test");
|
|
65
|
+
it("updates created_at", async () => {
|
|
66
|
+
const note = await store.createNote("Test");
|
|
67
67
|
const newDate = "2025-01-15T12:00:00.000Z";
|
|
68
|
-
const updated = store.updateNote(note.id, { created_at: newDate });
|
|
68
|
+
const updated = await store.updateNote(note.id, { created_at: newDate });
|
|
69
69
|
expect(updated.createdAt).toBe(newDate);
|
|
70
70
|
expect(updated.content).toBe("Test"); // content unchanged
|
|
71
71
|
expect(updated.updatedAt).not.toBe(note.updatedAt); // updated_at bumped
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
-
it("updates metadata and created_at together", () => {
|
|
75
|
-
const note = store.createNote("Test");
|
|
74
|
+
it("updates metadata and created_at together", async () => {
|
|
75
|
+
const note = await store.createNote("Test");
|
|
76
76
|
const newDate = "2025-06-30T23:59:59.000Z";
|
|
77
77
|
const meta = { source: "import", version: 2 };
|
|
78
|
-
const updated = store.updateNote(note.id, { metadata: meta, created_at: newDate });
|
|
78
|
+
const updated = await store.updateNote(note.id, { metadata: meta, created_at: newDate });
|
|
79
79
|
expect(updated.createdAt).toBe(newDate);
|
|
80
80
|
expect(updated.metadata).toEqual(meta);
|
|
81
81
|
expect(updated.content).toBe("Test");
|
|
82
82
|
});
|
|
83
83
|
|
|
84
|
-
it("leaves created_at unchanged when not provided", () => {
|
|
85
|
-
const note = store.createNote("Test");
|
|
86
|
-
const updated = store.updateNote(note.id, { content: "Updated" });
|
|
84
|
+
it("leaves created_at unchanged when not provided", async () => {
|
|
85
|
+
const note = await store.createNote("Test");
|
|
86
|
+
const updated = await store.updateNote(note.id, { content: "Updated" });
|
|
87
87
|
expect(updated.createdAt).toBe(note.createdAt);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
|
-
it("deletes a note", () => {
|
|
91
|
-
const note = store.createNote("Delete me");
|
|
92
|
-
store.deleteNote(note.id);
|
|
93
|
-
expect(store.getNote(note.id)).toBeNull();
|
|
90
|
+
it("deletes a note", async () => {
|
|
91
|
+
const note = await store.createNote("Delete me");
|
|
92
|
+
await store.deleteNote(note.id);
|
|
93
|
+
expect(await store.getNote(note.id)).toBeNull();
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
it("cascade deletes tags and links", () => {
|
|
97
|
-
store.createNote("A", { id: "a", tags: ["daily"] });
|
|
98
|
-
store.createNote("B", { id: "b" });
|
|
99
|
-
store.createLink("a", "b", "mentions");
|
|
96
|
+
it("cascade deletes tags and links", async () => {
|
|
97
|
+
await store.createNote("A", { id: "a", tags: ["daily"] });
|
|
98
|
+
await store.createNote("B", { id: "b" });
|
|
99
|
+
await store.createLink("a", "b", "mentions");
|
|
100
100
|
|
|
101
|
-
store.deleteNote("a");
|
|
102
|
-
expect(store.getLinks("b")).toHaveLength(0);
|
|
101
|
+
await store.deleteNote("a");
|
|
102
|
+
expect(await store.getLinks("b")).toHaveLength(0);
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
105
|
|
|
106
106
|
// ---- Tags ----
|
|
107
107
|
|
|
108
|
-
describe("tags", () => {
|
|
109
|
-
it("starts with no tags", () => {
|
|
110
|
-
const tags = store.listTags();
|
|
108
|
+
describe("tags", async () => {
|
|
109
|
+
it("starts with no tags", async () => {
|
|
110
|
+
const tags = await store.listTags();
|
|
111
111
|
expect(tags).toHaveLength(0);
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
it("tags a note", () => {
|
|
115
|
-
const note = store.createNote("Test");
|
|
116
|
-
store.tagNote(note.id, ["daily", "voice"]);
|
|
117
|
-
const found = store.getNote(note.id);
|
|
114
|
+
it("tags a note", async () => {
|
|
115
|
+
const note = await store.createNote("Test");
|
|
116
|
+
await store.tagNote(note.id, ["daily", "voice"]);
|
|
117
|
+
const found = await store.getNote(note.id);
|
|
118
118
|
expect(found!.tags).toContain("daily");
|
|
119
119
|
expect(found!.tags).toContain("voice");
|
|
120
120
|
});
|
|
121
121
|
|
|
122
|
-
it("untags a note", () => {
|
|
123
|
-
const note = store.createNote("Test", { tags: ["daily", "voice"] });
|
|
124
|
-
store.untagNote(note.id, ["voice"]);
|
|
125
|
-
const found = store.getNote(note.id);
|
|
122
|
+
it("untags a note", async () => {
|
|
123
|
+
const note = await store.createNote("Test", { tags: ["daily", "voice"] });
|
|
124
|
+
await store.untagNote(note.id, ["voice"]);
|
|
125
|
+
const found = await store.getNote(note.id);
|
|
126
126
|
expect(found!.tags).toContain("daily");
|
|
127
127
|
expect(found!.tags).not.toContain("voice");
|
|
128
128
|
});
|
|
129
129
|
|
|
130
|
-
it("creates tags automatically", () => {
|
|
131
|
-
const note = store.createNote("Test");
|
|
132
|
-
store.tagNote(note.id, ["custom-tag"]);
|
|
133
|
-
const tags = store.listTags();
|
|
130
|
+
it("creates tags automatically", async () => {
|
|
131
|
+
const note = await store.createNote("Test");
|
|
132
|
+
await store.tagNote(note.id, ["custom-tag"]);
|
|
133
|
+
const tags = await store.listTags();
|
|
134
134
|
expect(tags.some((t) => t.name === "custom-tag")).toBe(true);
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
it("counts tag usage", () => {
|
|
138
|
-
store.createNote("A", { tags: ["daily"] });
|
|
139
|
-
store.createNote("B", { tags: ["daily"] });
|
|
140
|
-
store.createNote("C", { tags: ["doc"] });
|
|
137
|
+
it("counts tag usage", async () => {
|
|
138
|
+
await store.createNote("A", { tags: ["daily"] });
|
|
139
|
+
await store.createNote("B", { tags: ["daily"] });
|
|
140
|
+
await store.createNote("C", { tags: ["doc"] });
|
|
141
141
|
|
|
142
|
-
const tags = store.listTags();
|
|
142
|
+
const tags = await store.listTags();
|
|
143
143
|
const daily = tags.find((t) => t.name === "daily");
|
|
144
144
|
expect(daily!.count).toBe(2);
|
|
145
145
|
});
|
|
146
146
|
|
|
147
|
-
it("tagging is idempotent", () => {
|
|
148
|
-
const note = store.createNote("Test", { tags: ["daily"] });
|
|
149
|
-
store.tagNote(note.id, ["daily"]); // duplicate
|
|
150
|
-
const found = store.getNote(note.id);
|
|
147
|
+
it("tagging is idempotent", async () => {
|
|
148
|
+
const note = await store.createNote("Test", { tags: ["daily"] });
|
|
149
|
+
await store.tagNote(note.id, ["daily"]); // duplicate
|
|
150
|
+
const found = await store.getNote(note.id);
|
|
151
151
|
expect(found!.tags!.filter((t) => t === "daily")).toHaveLength(1);
|
|
152
152
|
});
|
|
153
153
|
});
|
|
154
154
|
|
|
155
155
|
// ---- Vault Stats ----
|
|
156
156
|
|
|
157
|
-
describe("vault stats", () => {
|
|
158
|
-
it("handles empty vault gracefully", () => {
|
|
159
|
-
const stats = store.getVaultStats();
|
|
157
|
+
describe("vault stats", async () => {
|
|
158
|
+
it("handles empty vault gracefully", async () => {
|
|
159
|
+
const stats = await store.getVaultStats();
|
|
160
160
|
expect(stats.totalNotes).toBe(0);
|
|
161
161
|
expect(stats.earliestNote).toBeNull();
|
|
162
162
|
expect(stats.latestNote).toBeNull();
|
|
@@ -165,34 +165,34 @@ describe("vault stats", () => {
|
|
|
165
165
|
expect(stats.tagCount).toBe(0);
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
-
it("counts total notes and tagCount", () => {
|
|
169
|
-
store.createNote("A", { tags: ["daily", "voice"] });
|
|
170
|
-
store.createNote("B", { tags: ["daily"] });
|
|
171
|
-
store.createNote("C");
|
|
168
|
+
it("counts total notes and tagCount", async () => {
|
|
169
|
+
await store.createNote("A", { tags: ["daily", "voice"] });
|
|
170
|
+
await store.createNote("B", { tags: ["daily"] });
|
|
171
|
+
await store.createNote("C");
|
|
172
172
|
|
|
173
|
-
const stats = store.getVaultStats();
|
|
173
|
+
const stats = await store.getVaultStats();
|
|
174
174
|
expect(stats.totalNotes).toBe(3);
|
|
175
175
|
expect(stats.tagCount).toBe(2); // "daily" and "voice"
|
|
176
176
|
});
|
|
177
177
|
|
|
178
|
-
it("reports earliest and latest notes correctly", () => {
|
|
179
|
-
store.createNote("oldest", { id: "n1", created_at: "2025-01-15T10:00:00.000Z" });
|
|
180
|
-
store.createNote("middle", { id: "n2", created_at: "2025-06-20T10:00:00.000Z" });
|
|
181
|
-
store.createNote("newest", { id: "n3", created_at: "2026-03-01T10:00:00.000Z" });
|
|
178
|
+
it("reports earliest and latest notes correctly", async () => {
|
|
179
|
+
await store.createNote("oldest", { id: "n1", created_at: "2025-01-15T10:00:00.000Z" });
|
|
180
|
+
await store.createNote("middle", { id: "n2", created_at: "2025-06-20T10:00:00.000Z" });
|
|
181
|
+
await store.createNote("newest", { id: "n3", created_at: "2026-03-01T10:00:00.000Z" });
|
|
182
182
|
|
|
183
|
-
const stats = store.getVaultStats();
|
|
183
|
+
const stats = await store.getVaultStats();
|
|
184
184
|
expect(stats.earliestNote).toEqual({ id: "n1", createdAt: "2025-01-15T10:00:00.000Z" });
|
|
185
185
|
expect(stats.latestNote).toEqual({ id: "n3", createdAt: "2026-03-01T10:00:00.000Z" });
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
-
it("groups notes by month across all present months", () => {
|
|
189
|
-
store.createNote("a", { created_at: "2025-02-28T12:00:00.000Z" });
|
|
190
|
-
store.createNote("b", { created_at: "2025-03-01T08:00:00.000Z" });
|
|
191
|
-
store.createNote("c", { created_at: "2025-03-15T09:00:00.000Z" });
|
|
192
|
-
store.createNote("d", { created_at: "2025-03-20T11:00:00.000Z" });
|
|
193
|
-
store.createNote("e", { created_at: "2026-01-10T10:00:00.000Z" });
|
|
188
|
+
it("groups notes by month across all present months", async () => {
|
|
189
|
+
await store.createNote("a", { created_at: "2025-02-28T12:00:00.000Z" });
|
|
190
|
+
await store.createNote("b", { created_at: "2025-03-01T08:00:00.000Z" });
|
|
191
|
+
await store.createNote("c", { created_at: "2025-03-15T09:00:00.000Z" });
|
|
192
|
+
await store.createNote("d", { created_at: "2025-03-20T11:00:00.000Z" });
|
|
193
|
+
await store.createNote("e", { created_at: "2026-01-10T10:00:00.000Z" });
|
|
194
194
|
|
|
195
|
-
const stats = store.getVaultStats();
|
|
195
|
+
const stats = await store.getVaultStats();
|
|
196
196
|
expect(stats.notesByMonth).toEqual([
|
|
197
197
|
{ month: "2025-02", count: 1 },
|
|
198
198
|
{ month: "2025-03", count: 3 },
|
|
@@ -200,31 +200,31 @@ describe("vault stats", () => {
|
|
|
200
200
|
]);
|
|
201
201
|
});
|
|
202
202
|
|
|
203
|
-
it("returns topTags ordered by count desc, capped", () => {
|
|
203
|
+
it("returns topTags ordered by count desc, capped", async () => {
|
|
204
204
|
// Create notes with varying tag frequencies
|
|
205
|
-
for (let i = 0; i < 5; i++) store.createNote(`captured-${i}`, { tags: ["captured"] });
|
|
206
|
-
for (let i = 0; i < 3; i++) store.createNote(`reader-${i}`, { tags: ["reader"] });
|
|
207
|
-
store.createNote("one", { tags: ["rare"] });
|
|
205
|
+
for (let i = 0; i < 5; i++) await store.createNote(`captured-${i}`, { tags: ["captured"] });
|
|
206
|
+
for (let i = 0; i < 3; i++) await store.createNote(`reader-${i}`, { tags: ["reader"] });
|
|
207
|
+
await store.createNote("one", { tags: ["rare"] });
|
|
208
208
|
|
|
209
|
-
const stats = store.getVaultStats();
|
|
209
|
+
const stats = await store.getVaultStats();
|
|
210
210
|
expect(stats.topTags[0]).toEqual({ tag: "captured", count: 5 });
|
|
211
211
|
expect(stats.topTags[1]).toEqual({ tag: "reader", count: 3 });
|
|
212
212
|
expect(stats.topTags[2]).toEqual({ tag: "rare", count: 1 });
|
|
213
213
|
});
|
|
214
214
|
|
|
215
|
-
it("caps topTags at the requested limit", () => {
|
|
215
|
+
it("caps topTags at the requested limit", async () => {
|
|
216
216
|
// 25 distinct tags, one per note
|
|
217
217
|
for (let i = 0; i < 25; i++) {
|
|
218
|
-
store.createNote(`n-${i}`, { tags: [`tag-${String(i).padStart(2, "0")}`] });
|
|
218
|
+
await store.createNote(`n-${i}`, { tags: [`tag-${String(i).padStart(2, "0")}`] });
|
|
219
219
|
}
|
|
220
|
-
const stats = store.getVaultStats({ topTagsLimit: 20 });
|
|
220
|
+
const stats = await store.getVaultStats({ topTagsLimit: 20 });
|
|
221
221
|
expect(stats.topTags).toHaveLength(20);
|
|
222
222
|
expect(stats.tagCount).toBe(25);
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
-
it("response shape is complete", () => {
|
|
226
|
-
store.createNote("hello", { tags: ["a"] });
|
|
227
|
-
const stats = store.getVaultStats();
|
|
225
|
+
it("response shape is complete", async () => {
|
|
226
|
+
await store.createNote("hello", { tags: ["a"] });
|
|
227
|
+
const stats = await store.getVaultStats();
|
|
228
228
|
expect(stats).toHaveProperty("totalNotes");
|
|
229
229
|
expect(stats).toHaveProperty("earliestNote");
|
|
230
230
|
expect(stats).toHaveProperty("latestNote");
|
|
@@ -233,11 +233,11 @@ describe("vault stats", () => {
|
|
|
233
233
|
expect(stats).toHaveProperty("tagCount");
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
-
it("getVaultStats returns correct stats", () => {
|
|
237
|
-
store.createNote("one", { tags: ["x"], created_at: "2025-05-01T00:00:00.000Z" });
|
|
238
|
-
store.createNote("two", { tags: ["x", "y"], created_at: "2025-06-01T00:00:00.000Z" });
|
|
236
|
+
it("getVaultStats returns correct stats", async () => {
|
|
237
|
+
await store.createNote("one", { tags: ["x"], created_at: "2025-05-01T00:00:00.000Z" });
|
|
238
|
+
await store.createNote("two", { tags: ["x", "y"], created_at: "2025-06-01T00:00:00.000Z" });
|
|
239
239
|
|
|
240
|
-
const result = store.getVaultStats();
|
|
240
|
+
const result = await store.getVaultStats();
|
|
241
241
|
expect(result.totalNotes).toBe(2);
|
|
242
242
|
expect(result.tagCount).toBe(2);
|
|
243
243
|
expect(result.topTags[0].tag).toBe("x");
|
|
@@ -250,194 +250,194 @@ describe("vault stats", () => {
|
|
|
250
250
|
|
|
251
251
|
// ---- Query ----
|
|
252
252
|
|
|
253
|
-
describe("queryNotes", () => {
|
|
254
|
-
it("queries by tag", () => {
|
|
255
|
-
store.createNote("Daily 1", { tags: ["daily"] });
|
|
256
|
-
store.createNote("Doc 1", { tags: ["doc"] });
|
|
253
|
+
describe("queryNotes", async () => {
|
|
254
|
+
it("queries by tag", async () => {
|
|
255
|
+
await store.createNote("Daily 1", { tags: ["daily"] });
|
|
256
|
+
await store.createNote("Doc 1", { tags: ["doc"] });
|
|
257
257
|
|
|
258
|
-
const results = store.queryNotes({ tags: ["daily"] });
|
|
258
|
+
const results = await store.queryNotes({ tags: ["daily"] });
|
|
259
259
|
expect(results).toHaveLength(1);
|
|
260
260
|
expect(results[0].content).toBe("Daily 1");
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
-
it("queries by multiple tags (AND)", () => {
|
|
264
|
-
store.createNote("Voice daily", { tags: ["daily", "voice"] });
|
|
265
|
-
store.createNote("Text daily", { tags: ["daily"] });
|
|
263
|
+
it("queries by multiple tags (AND)", async () => {
|
|
264
|
+
await store.createNote("Voice daily", { tags: ["daily", "voice"] });
|
|
265
|
+
await store.createNote("Text daily", { tags: ["daily"] });
|
|
266
266
|
|
|
267
|
-
const results = store.queryNotes({ tags: ["daily", "voice"] });
|
|
267
|
+
const results = await store.queryNotes({ tags: ["daily", "voice"] });
|
|
268
268
|
expect(results).toHaveLength(1);
|
|
269
269
|
expect(results[0].content).toBe("Voice daily");
|
|
270
270
|
});
|
|
271
271
|
|
|
272
|
-
it("queries by multiple tags (OR)", () => {
|
|
273
|
-
store.createNote("Voice daily", { tags: ["daily", "voice"] });
|
|
274
|
-
store.createNote("Text daily", { tags: ["daily"] });
|
|
275
|
-
store.createNote("A doc", { tags: ["doc"] });
|
|
272
|
+
it("queries by multiple tags (OR)", async () => {
|
|
273
|
+
await store.createNote("Voice daily", { tags: ["daily", "voice"] });
|
|
274
|
+
await store.createNote("Text daily", { tags: ["daily"] });
|
|
275
|
+
await store.createNote("A doc", { tags: ["doc"] });
|
|
276
276
|
|
|
277
|
-
const results = store.queryNotes({ tags: ["voice", "doc"], tagMatch: "any" });
|
|
277
|
+
const results = await store.queryNotes({ tags: ["voice", "doc"], tagMatch: "any" });
|
|
278
278
|
expect(results).toHaveLength(2);
|
|
279
279
|
const contents = results.map((n) => n.content).sort();
|
|
280
280
|
expect(contents).toEqual(["A doc", "Voice daily"]);
|
|
281
281
|
});
|
|
282
282
|
|
|
283
|
-
it("excludes tags", () => {
|
|
284
|
-
store.createNote("Active", { tags: ["digest"] });
|
|
285
|
-
store.createNote("Archived", { tags: ["digest", "archived"] });
|
|
283
|
+
it("excludes tags", async () => {
|
|
284
|
+
await store.createNote("Active", { tags: ["digest"] });
|
|
285
|
+
await store.createNote("Archived", { tags: ["digest", "archived"] });
|
|
286
286
|
|
|
287
|
-
const results = store.queryNotes({ tags: ["digest"], excludeTags: ["archived"] });
|
|
287
|
+
const results = await store.queryNotes({ tags: ["digest"], excludeTags: ["archived"] });
|
|
288
288
|
expect(results).toHaveLength(1);
|
|
289
289
|
expect(results[0].content).toBe("Active");
|
|
290
290
|
});
|
|
291
291
|
|
|
292
|
-
it("filters by date range", () => {
|
|
293
|
-
store.createNote("Test");
|
|
294
|
-
const results = store.queryNotes({
|
|
292
|
+
it("filters by date range", async () => {
|
|
293
|
+
await store.createNote("Test");
|
|
294
|
+
const results = await store.queryNotes({
|
|
295
295
|
dateFrom: new Date(Date.now() - 60000).toISOString(),
|
|
296
296
|
dateTo: new Date(Date.now() + 60000).toISOString(),
|
|
297
297
|
});
|
|
298
298
|
expect(results.length).toBeGreaterThan(0);
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
-
it("sorts ascending and descending", () => {
|
|
302
|
-
store.createNote("First", { id: "first" });
|
|
303
|
-
store.createNote("Second", { id: "second" });
|
|
301
|
+
it("sorts ascending and descending", async () => {
|
|
302
|
+
await store.createNote("First", { id: "first" });
|
|
303
|
+
await store.createNote("Second", { id: "second" });
|
|
304
304
|
|
|
305
|
-
const asc = store.queryNotes({ sort: "asc" });
|
|
305
|
+
const asc = await store.queryNotes({ sort: "asc" });
|
|
306
306
|
expect(asc[0].content).toBe("First");
|
|
307
307
|
|
|
308
|
-
const desc = store.queryNotes({ sort: "desc" });
|
|
308
|
+
const desc = await store.queryNotes({ sort: "desc" });
|
|
309
309
|
expect(desc[0].content).toBe("Second");
|
|
310
310
|
});
|
|
311
311
|
|
|
312
|
-
it("limits results", () => {
|
|
313
|
-
for (let i = 0; i < 5; i++) store.createNote(`Note ${i}`);
|
|
314
|
-
const results = store.queryNotes({ limit: 3 });
|
|
312
|
+
it("limits results", async () => {
|
|
313
|
+
for (let i = 0; i < 5; i++) await store.createNote(`Note ${i}`);
|
|
314
|
+
const results = await store.queryNotes({ limit: 3 });
|
|
315
315
|
expect(results).toHaveLength(3);
|
|
316
316
|
});
|
|
317
317
|
});
|
|
318
318
|
|
|
319
319
|
// ---- Search ----
|
|
320
320
|
|
|
321
|
-
describe("searchNotes", () => {
|
|
322
|
-
it("finds notes by content", () => {
|
|
323
|
-
store.createNote("Walked up Flagstaff trail");
|
|
324
|
-
store.createNote("Meeting about Horizon");
|
|
321
|
+
describe("searchNotes", async () => {
|
|
322
|
+
it("finds notes by content", async () => {
|
|
323
|
+
await store.createNote("Walked up Flagstaff trail");
|
|
324
|
+
await store.createNote("Meeting about Horizon");
|
|
325
325
|
|
|
326
|
-
const results = store.searchNotes("Flagstaff");
|
|
326
|
+
const results = await store.searchNotes("Flagstaff");
|
|
327
327
|
expect(results).toHaveLength(1);
|
|
328
328
|
expect(results[0].content).toContain("Flagstaff");
|
|
329
329
|
});
|
|
330
330
|
|
|
331
|
-
it("filters search by tag", () => {
|
|
332
|
-
store.createNote("Daily Flagstaff", { tags: ["daily"] });
|
|
333
|
-
store.createNote("Doc Flagstaff", { tags: ["doc"] });
|
|
331
|
+
it("filters search by tag", async () => {
|
|
332
|
+
await store.createNote("Daily Flagstaff", { tags: ["daily"] });
|
|
333
|
+
await store.createNote("Doc Flagstaff", { tags: ["doc"] });
|
|
334
334
|
|
|
335
|
-
const results = store.searchNotes("Flagstaff", { tags: ["daily"] });
|
|
335
|
+
const results = await store.searchNotes("Flagstaff", { tags: ["daily"] });
|
|
336
336
|
expect(results).toHaveLength(1);
|
|
337
337
|
expect(results[0].tags).toContain("daily");
|
|
338
338
|
});
|
|
339
339
|
|
|
340
|
-
it("returns empty for no match", () => {
|
|
341
|
-
store.createNote("Hello world");
|
|
342
|
-
const results = store.searchNotes("nonexistent");
|
|
340
|
+
it("returns empty for no match", async () => {
|
|
341
|
+
await store.createNote("Hello world");
|
|
342
|
+
const results = await store.searchNotes("nonexistent");
|
|
343
343
|
expect(results).toHaveLength(0);
|
|
344
344
|
});
|
|
345
345
|
});
|
|
346
346
|
|
|
347
347
|
// ---- Links ----
|
|
348
348
|
|
|
349
|
-
describe("links", () => {
|
|
350
|
-
it("creates a link", () => {
|
|
351
|
-
store.createNote("A", { id: "a" });
|
|
352
|
-
store.createNote("B", { id: "b" });
|
|
349
|
+
describe("links", async () => {
|
|
350
|
+
it("creates a link", async () => {
|
|
351
|
+
await store.createNote("A", { id: "a" });
|
|
352
|
+
await store.createNote("B", { id: "b" });
|
|
353
353
|
|
|
354
|
-
const link = store.createLink("a", "b", "mentions");
|
|
354
|
+
const link = await store.createLink("a", "b", "mentions");
|
|
355
355
|
expect(link.sourceId).toBe("a");
|
|
356
356
|
expect(link.targetId).toBe("b");
|
|
357
357
|
expect(link.relationship).toBe("mentions");
|
|
358
358
|
});
|
|
359
359
|
|
|
360
|
-
it("deletes a link", () => {
|
|
361
|
-
store.createNote("A", { id: "a" });
|
|
362
|
-
store.createNote("B", { id: "b" });
|
|
363
|
-
store.createLink("a", "b", "mentions");
|
|
364
|
-
store.deleteLink("a", "b", "mentions");
|
|
360
|
+
it("deletes a link", async () => {
|
|
361
|
+
await store.createNote("A", { id: "a" });
|
|
362
|
+
await store.createNote("B", { id: "b" });
|
|
363
|
+
await store.createLink("a", "b", "mentions");
|
|
364
|
+
await store.deleteLink("a", "b", "mentions");
|
|
365
365
|
|
|
366
|
-
const links = store.getLinks("a");
|
|
366
|
+
const links = await store.getLinks("a");
|
|
367
367
|
expect(links).toHaveLength(0);
|
|
368
368
|
});
|
|
369
369
|
|
|
370
|
-
it("gets outbound links", () => {
|
|
371
|
-
store.createNote("A", { id: "a" });
|
|
372
|
-
store.createNote("B", { id: "b" });
|
|
373
|
-
store.createNote("C", { id: "c" });
|
|
374
|
-
store.createLink("a", "b", "mentions");
|
|
375
|
-
store.createLink("c", "a", "quotes");
|
|
370
|
+
it("gets outbound links", async () => {
|
|
371
|
+
await store.createNote("A", { id: "a" });
|
|
372
|
+
await store.createNote("B", { id: "b" });
|
|
373
|
+
await store.createNote("C", { id: "c" });
|
|
374
|
+
await store.createLink("a", "b", "mentions");
|
|
375
|
+
await store.createLink("c", "a", "quotes");
|
|
376
376
|
|
|
377
|
-
const outbound = store.getLinks("a", { direction: "outbound" });
|
|
377
|
+
const outbound = await store.getLinks("a", { direction: "outbound" });
|
|
378
378
|
expect(outbound).toHaveLength(1);
|
|
379
379
|
expect(outbound[0].targetId).toBe("b");
|
|
380
380
|
});
|
|
381
381
|
|
|
382
|
-
it("gets inbound links", () => {
|
|
383
|
-
store.createNote("A", { id: "a" });
|
|
384
|
-
store.createNote("B", { id: "b" });
|
|
385
|
-
store.createLink("a", "b", "mentions");
|
|
382
|
+
it("gets inbound links", async () => {
|
|
383
|
+
await store.createNote("A", { id: "a" });
|
|
384
|
+
await store.createNote("B", { id: "b" });
|
|
385
|
+
await store.createLink("a", "b", "mentions");
|
|
386
386
|
|
|
387
|
-
const inbound = store.getLinks("b", { direction: "inbound" });
|
|
387
|
+
const inbound = await store.getLinks("b", { direction: "inbound" });
|
|
388
388
|
expect(inbound).toHaveLength(1);
|
|
389
389
|
expect(inbound[0].sourceId).toBe("a");
|
|
390
390
|
});
|
|
391
391
|
|
|
392
|
-
it("gets all links (both directions)", () => {
|
|
393
|
-
store.createNote("A", { id: "a" });
|
|
394
|
-
store.createNote("B", { id: "b" });
|
|
395
|
-
store.createNote("C", { id: "c" });
|
|
396
|
-
store.createLink("a", "b", "mentions");
|
|
397
|
-
store.createLink("c", "a", "quotes");
|
|
392
|
+
it("gets all links (both directions)", async () => {
|
|
393
|
+
await store.createNote("A", { id: "a" });
|
|
394
|
+
await store.createNote("B", { id: "b" });
|
|
395
|
+
await store.createNote("C", { id: "c" });
|
|
396
|
+
await store.createLink("a", "b", "mentions");
|
|
397
|
+
await store.createLink("c", "a", "quotes");
|
|
398
398
|
|
|
399
|
-
const all = store.getLinks("a", { direction: "both" });
|
|
399
|
+
const all = await store.getLinks("a", { direction: "both" });
|
|
400
400
|
expect(all).toHaveLength(2);
|
|
401
401
|
});
|
|
402
402
|
|
|
403
|
-
it("link creation is idempotent", () => {
|
|
404
|
-
store.createNote("A", { id: "a" });
|
|
405
|
-
store.createNote("B", { id: "b" });
|
|
406
|
-
store.createLink("a", "b", "mentions");
|
|
407
|
-
store.createLink("a", "b", "mentions"); // duplicate
|
|
408
|
-
const links = store.getLinks("a");
|
|
403
|
+
it("link creation is idempotent", async () => {
|
|
404
|
+
await store.createNote("A", { id: "a" });
|
|
405
|
+
await store.createNote("B", { id: "b" });
|
|
406
|
+
await store.createLink("a", "b", "mentions");
|
|
407
|
+
await store.createLink("a", "b", "mentions"); // duplicate
|
|
408
|
+
const links = await store.getLinks("a");
|
|
409
409
|
expect(links.filter((l) => l.relationship === "mentions")).toHaveLength(1);
|
|
410
410
|
});
|
|
411
411
|
});
|
|
412
412
|
|
|
413
413
|
// ---- Attachments ----
|
|
414
414
|
|
|
415
|
-
describe("attachments", () => {
|
|
416
|
-
it("adds and retrieves attachments", () => {
|
|
417
|
-
const note = store.createNote("Voice memo", { tags: ["daily", "voice"] });
|
|
418
|
-
const attachment = store.addAttachment(note.id, "2026-03-31/audio.wav", "audio/wav");
|
|
415
|
+
describe("attachments", async () => {
|
|
416
|
+
it("adds and retrieves attachments", async () => {
|
|
417
|
+
const note = await store.createNote("Voice memo", { tags: ["daily", "voice"] });
|
|
418
|
+
const attachment = await store.addAttachment(note.id, "2026-03-31/audio.wav", "audio/wav");
|
|
419
419
|
|
|
420
420
|
expect(attachment.noteId).toBe(note.id);
|
|
421
421
|
expect(attachment.mimeType).toBe("audio/wav");
|
|
422
422
|
|
|
423
|
-
const attachments = store.getAttachments(note.id);
|
|
423
|
+
const attachments = await store.getAttachments(note.id);
|
|
424
424
|
expect(attachments).toHaveLength(1);
|
|
425
425
|
expect(attachments[0].path).toBe("2026-03-31/audio.wav");
|
|
426
426
|
});
|
|
427
427
|
|
|
428
|
-
it("cascade deletes attachments with note", () => {
|
|
429
|
-
const note = store.createNote("Test");
|
|
430
|
-
store.addAttachment(note.id, "file.png", "image/png");
|
|
431
|
-
store.deleteNote(note.id);
|
|
428
|
+
it("cascade deletes attachments with note", async () => {
|
|
429
|
+
const note = await store.createNote("Test");
|
|
430
|
+
await store.addAttachment(note.id, "file.png", "image/png");
|
|
431
|
+
await store.deleteNote(note.id);
|
|
432
432
|
|
|
433
|
-
const attachments = store.getAttachments(note.id);
|
|
433
|
+
const attachments = await store.getAttachments(note.id);
|
|
434
434
|
expect(attachments).toHaveLength(0);
|
|
435
435
|
});
|
|
436
436
|
});
|
|
437
437
|
|
|
438
438
|
// ---- MCP Tools ----
|
|
439
439
|
|
|
440
|
-
describe("MCP tools", () => {
|
|
440
|
+
describe("MCP tools", async () => {
|
|
441
441
|
it("generates all 9 consolidated tools", () => {
|
|
442
442
|
const tools = generateMcpTools(store);
|
|
443
443
|
const names = tools.map((t) => t.name);
|
|
@@ -454,18 +454,18 @@ describe("MCP tools", () => {
|
|
|
454
454
|
expect(tools).toHaveLength(9);
|
|
455
455
|
});
|
|
456
456
|
|
|
457
|
-
it("create-note tool works", () => {
|
|
457
|
+
it("create-note tool works", async () => {
|
|
458
458
|
const tools = generateMcpTools(store);
|
|
459
459
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
460
|
-
const result = createNote.execute({ content: "Hello", tags: ["daily"] }) as any;
|
|
460
|
+
const result = await createNote.execute({ content: "Hello", tags: ["daily"] }) as any;
|
|
461
461
|
expect(result.content).toBe("Hello");
|
|
462
462
|
expect(result.tags).toContain("daily");
|
|
463
463
|
});
|
|
464
464
|
|
|
465
|
-
it("create-note batch mode works", () => {
|
|
465
|
+
it("create-note batch mode works", async () => {
|
|
466
466
|
const tools = generateMcpTools(store);
|
|
467
467
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
468
|
-
const result = createNote.execute({
|
|
468
|
+
const result = await createNote.execute({
|
|
469
469
|
notes: [
|
|
470
470
|
{ content: "A", tags: ["daily"] },
|
|
471
471
|
{ content: "B", tags: ["doc"] },
|
|
@@ -476,87 +476,87 @@ describe("MCP tools", () => {
|
|
|
476
476
|
expect(result[1].tags).toContain("doc");
|
|
477
477
|
});
|
|
478
478
|
|
|
479
|
-
it("create-note with links resolves targets by path", () => {
|
|
479
|
+
it("create-note with links resolves targets by path", async () => {
|
|
480
480
|
const tools = generateMcpTools(store);
|
|
481
481
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
482
|
-
store.createNote("Target", { path: "People/Alice" });
|
|
483
|
-
const result = createNote.execute({
|
|
482
|
+
await store.createNote("Target", { path: "People/Alice" });
|
|
483
|
+
const result = await createNote.execute({
|
|
484
484
|
content: "Links to Alice",
|
|
485
485
|
links: [{ target: "People/Alice", relationship: "mentions" }],
|
|
486
486
|
}) as any;
|
|
487
|
-
const links = store.getLinks(result.id, { direction: "outbound" });
|
|
487
|
+
const links = await store.getLinks(result.id, { direction: "outbound" });
|
|
488
488
|
expect(links.some((l) => l.relationship === "mentions")).toBe(true);
|
|
489
489
|
});
|
|
490
490
|
|
|
491
|
-
it("update-note tool updates created_at", () => {
|
|
492
|
-
const note = store.createNote("Test");
|
|
491
|
+
it("update-note tool updates created_at", async () => {
|
|
492
|
+
const note = await store.createNote("Test");
|
|
493
493
|
const tools = generateMcpTools(store);
|
|
494
494
|
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
495
495
|
const newDate = "2025-03-01T00:00:00.000Z";
|
|
496
|
-
const result = updateNote.execute({ id: note.id, created_at: newDate }) as any;
|
|
496
|
+
const result = await updateNote.execute({ id: note.id, created_at: newDate }) as any;
|
|
497
497
|
expect(result.createdAt).toBe(newDate);
|
|
498
498
|
expect(result.content).toBe("Test");
|
|
499
499
|
});
|
|
500
500
|
|
|
501
|
-
it("update-note tool merges metadata", () => {
|
|
502
|
-
const note = store.createNote("Test", { metadata: { existing: "value" } });
|
|
501
|
+
it("update-note tool merges metadata", async () => {
|
|
502
|
+
const note = await store.createNote("Test", { metadata: { existing: "value" } });
|
|
503
503
|
const tools = generateMcpTools(store);
|
|
504
504
|
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
505
|
-
const result = updateNote.execute({ id: note.id, metadata: { importance: "high" } }) as any;
|
|
505
|
+
const result = await updateNote.execute({ id: note.id, metadata: { importance: "high" } }) as any;
|
|
506
506
|
expect(result.metadata).toEqual({ existing: "value", importance: "high" });
|
|
507
507
|
});
|
|
508
508
|
|
|
509
|
-
it("update-note tags add/remove works", () => {
|
|
510
|
-
const note = store.createNote("Test");
|
|
509
|
+
it("update-note tags add/remove works", async () => {
|
|
510
|
+
const note = await store.createNote("Test");
|
|
511
511
|
const tools = generateMcpTools(store);
|
|
512
512
|
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
513
513
|
|
|
514
514
|
// Add tags
|
|
515
|
-
updateNote.execute({ id: note.id, tags: { add: ["pinned", "daily"] } });
|
|
516
|
-
expect(store.getNote(note.id)!.tags).toContain("pinned");
|
|
517
|
-
expect(store.getNote(note.id)!.tags).toContain("daily");
|
|
515
|
+
await updateNote.execute({ id: note.id, tags: { add: ["pinned", "daily"] } });
|
|
516
|
+
expect((await store.getNote(note.id))!.tags).toContain("pinned");
|
|
517
|
+
expect((await store.getNote(note.id))!.tags).toContain("daily");
|
|
518
518
|
|
|
519
519
|
// Remove tags
|
|
520
|
-
updateNote.execute({ id: note.id, tags: { remove: ["pinned"] } });
|
|
521
|
-
expect(store.getNote(note.id)!.tags).not.toContain("pinned");
|
|
522
|
-
expect(store.getNote(note.id)!.tags).toContain("daily");
|
|
520
|
+
await updateNote.execute({ id: note.id, tags: { remove: ["pinned"] } });
|
|
521
|
+
expect((await store.getNote(note.id))!.tags).not.toContain("pinned");
|
|
522
|
+
expect((await store.getNote(note.id))!.tags).toContain("daily");
|
|
523
523
|
});
|
|
524
524
|
|
|
525
|
-
it("update-note links add/remove works", () => {
|
|
526
|
-
store.createNote("A", { id: "a" });
|
|
527
|
-
store.createNote("B", { id: "b" });
|
|
525
|
+
it("update-note links add/remove works", async () => {
|
|
526
|
+
await store.createNote("A", { id: "a" });
|
|
527
|
+
await store.createNote("B", { id: "b" });
|
|
528
528
|
const tools = generateMcpTools(store);
|
|
529
529
|
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
530
530
|
|
|
531
531
|
// Add link
|
|
532
|
-
updateNote.execute({ id: "a", links: { add: [{ target: "b", relationship: "mentions" }] } });
|
|
533
|
-
expect(store.getLinks("a", { direction: "outbound" })).toHaveLength(1);
|
|
532
|
+
await updateNote.execute({ id: "a", links: { add: [{ target: "b", relationship: "mentions" }] } });
|
|
533
|
+
expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(1);
|
|
534
534
|
|
|
535
535
|
// Remove link
|
|
536
|
-
updateNote.execute({ id: "a", links: { remove: [{ target: "b", relationship: "mentions" }] } });
|
|
537
|
-
expect(store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
|
|
536
|
+
await updateNote.execute({ id: "a", links: { remove: [{ target: "b", relationship: "mentions" }] } });
|
|
537
|
+
expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
|
|
538
538
|
});
|
|
539
539
|
|
|
540
|
-
it("update-note removes wikilink brackets when removing wikilink-type link", () => {
|
|
541
|
-
store.createNote("Target", { id: "target", path: "People/Alice" });
|
|
542
|
-
const source = store.createNote("See [[People/Alice]] for details", { id: "source" });
|
|
543
|
-
store.createLink("source", "target", "wikilink");
|
|
540
|
+
it("update-note removes wikilink brackets when removing wikilink-type link", async () => {
|
|
541
|
+
await store.createNote("Target", { id: "target", path: "People/Alice" });
|
|
542
|
+
const source = await store.createNote("See [[People/Alice]] for details", { id: "source" });
|
|
543
|
+
await store.createLink("source", "target", "wikilink");
|
|
544
544
|
|
|
545
545
|
const tools = generateMcpTools(store);
|
|
546
546
|
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
547
|
-
const result = updateNote.execute({
|
|
547
|
+
const result = await updateNote.execute({
|
|
548
548
|
id: "source",
|
|
549
549
|
links: { remove: [{ target: "target", relationship: "wikilink" }] },
|
|
550
550
|
}) as any;
|
|
551
551
|
expect(result.content).toBe("See People/Alice for details");
|
|
552
552
|
});
|
|
553
553
|
|
|
554
|
-
it("update-note batch mode works", () => {
|
|
555
|
-
const a = store.createNote("A", { id: "a" });
|
|
556
|
-
const b = store.createNote("B", { id: "b" });
|
|
554
|
+
it("update-note batch mode works", async () => {
|
|
555
|
+
const a = await store.createNote("A", { id: "a" });
|
|
556
|
+
const b = await store.createNote("B", { id: "b" });
|
|
557
557
|
const tools = generateMcpTools(store);
|
|
558
558
|
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
559
|
-
const result = updateNote.execute({
|
|
559
|
+
const result = await updateNote.execute({
|
|
560
560
|
notes: [
|
|
561
561
|
{ id: "a", content: "A updated" },
|
|
562
562
|
{ id: "b", tags: { add: ["pinned"] } },
|
|
@@ -564,48 +564,215 @@ describe("MCP tools", () => {
|
|
|
564
564
|
}) as any[];
|
|
565
565
|
expect(result).toHaveLength(2);
|
|
566
566
|
expect(result[0].content).toBe("A updated");
|
|
567
|
-
expect(store.getNote("b")!.tags).toContain("pinned");
|
|
567
|
+
expect((await store.getNote("b"))!.tags).toContain("pinned");
|
|
568
568
|
});
|
|
569
569
|
|
|
570
|
-
it("update-note resolves note by path", () => {
|
|
571
|
-
store.createNote("Test", { path: "Projects/README" });
|
|
570
|
+
it("update-note resolves note by path", async () => {
|
|
571
|
+
await store.createNote("Test", { path: "Projects/README" });
|
|
572
572
|
const tools = generateMcpTools(store);
|
|
573
573
|
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
574
|
-
const result = updateNote.execute({ id: "Projects/README", content: "Updated" }) as any;
|
|
574
|
+
const result = await updateNote.execute({ id: "Projects/README", content: "Updated" }) as any;
|
|
575
575
|
expect(result.content).toBe("Updated");
|
|
576
576
|
});
|
|
577
577
|
|
|
578
|
-
it("
|
|
579
|
-
const note = store.createNote("
|
|
578
|
+
it("update-note accepts if_updated_at when it matches current updated_at", async () => {
|
|
579
|
+
const note = await store.createNote("First");
|
|
580
|
+
const tools = generateMcpTools(store);
|
|
581
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
582
|
+
|
|
583
|
+
const first = await updateNote.execute({ id: note.id, content: "Second" }) as any;
|
|
584
|
+
expect(first.content).toBe("Second");
|
|
585
|
+
expect(first.updatedAt).toBeTruthy();
|
|
586
|
+
|
|
587
|
+
const second = await updateNote.execute({
|
|
588
|
+
id: note.id,
|
|
589
|
+
content: "Third",
|
|
590
|
+
if_updated_at: first.updatedAt,
|
|
591
|
+
}) as any;
|
|
592
|
+
expect(second.content).toBe("Third");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
it("update-note rejects if_updated_at mismatch with conflict error", async () => {
|
|
596
|
+
const note = await store.createNote("First");
|
|
597
|
+
const tools = generateMcpTools(store);
|
|
598
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
599
|
+
|
|
600
|
+
const after = await updateNote.execute({ id: note.id, content: "Second" }) as any;
|
|
601
|
+
|
|
602
|
+
// Simulate a stale client that has the pre-update timestamp (or something else).
|
|
603
|
+
const staleTimestamp = "2020-01-01T00:00:00.000Z";
|
|
604
|
+
expect(staleTimestamp).not.toBe(after.updatedAt);
|
|
605
|
+
|
|
606
|
+
let err: any;
|
|
607
|
+
try {
|
|
608
|
+
await updateNote.execute({
|
|
609
|
+
id: note.id,
|
|
610
|
+
content: "Third",
|
|
611
|
+
if_updated_at: staleTimestamp,
|
|
612
|
+
});
|
|
613
|
+
} catch (e) {
|
|
614
|
+
err = e;
|
|
615
|
+
}
|
|
616
|
+
expect(err).toBeTruthy();
|
|
617
|
+
expect(err.code).toBe("CONFLICT");
|
|
618
|
+
expect(err.note_id).toBe(note.id);
|
|
619
|
+
expect(err.current_updated_at).toBe(after.updatedAt);
|
|
620
|
+
expect(err.expected_updated_at).toBe(staleTimestamp);
|
|
621
|
+
|
|
622
|
+
// Note unchanged
|
|
623
|
+
expect((await store.getNote(note.id))!.content).toBe("Second");
|
|
624
|
+
});
|
|
625
|
+
|
|
626
|
+
it("update-note if_updated_at conflicts for a never-updated note when caller expects a value", async () => {
|
|
627
|
+
const note = await store.createNote("First");
|
|
628
|
+
expect(note.updatedAt).toBeUndefined();
|
|
629
|
+
const tools = generateMcpTools(store);
|
|
630
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
631
|
+
|
|
632
|
+
let err: any;
|
|
633
|
+
try {
|
|
634
|
+
await updateNote.execute({
|
|
635
|
+
id: note.id,
|
|
636
|
+
content: "Second",
|
|
637
|
+
if_updated_at: "2020-01-01T00:00:00.000Z",
|
|
638
|
+
});
|
|
639
|
+
} catch (e) {
|
|
640
|
+
err = e;
|
|
641
|
+
}
|
|
642
|
+
expect(err).toBeTruthy();
|
|
643
|
+
expect(err.code).toBe("CONFLICT");
|
|
644
|
+
expect(err.current_updated_at).toBeNull();
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
it("update-note batch aborts on first conflict without touching subsequent items", async () => {
|
|
648
|
+
await store.createNote("A", { id: "a" });
|
|
649
|
+
await store.createNote("B", { id: "b" });
|
|
650
|
+
const tools = generateMcpTools(store);
|
|
651
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
652
|
+
|
|
653
|
+
// Bump a's updated_at so any stale if_updated_at conflicts.
|
|
654
|
+
const bumped = await updateNote.execute({ id: "a", content: "A bumped" }) as any;
|
|
655
|
+
expect(bumped.updatedAt).toBeTruthy();
|
|
656
|
+
|
|
657
|
+
let err: any;
|
|
658
|
+
try {
|
|
659
|
+
await updateNote.execute({
|
|
660
|
+
notes: [
|
|
661
|
+
{ id: "a", content: "A new", if_updated_at: "2020-01-01T00:00:00.000Z" },
|
|
662
|
+
{ id: "b", content: "B new" },
|
|
663
|
+
],
|
|
664
|
+
});
|
|
665
|
+
} catch (e) {
|
|
666
|
+
err = e;
|
|
667
|
+
}
|
|
668
|
+
expect(err?.code).toBe("CONFLICT");
|
|
669
|
+
|
|
670
|
+
// a was not modified by this call; b was not touched.
|
|
671
|
+
expect((await store.getNote("a"))!.content).toBe("A bumped");
|
|
672
|
+
expect((await store.getNote("b"))!.content).toBe("B");
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
it("update-note is atomic under concurrent if_updated_at — exactly one winner", async () => {
|
|
676
|
+
// Fires two updates with the same if_updated_at via `Promise.allSettled`.
|
|
677
|
+
// bun:sqlite is synchronous, so these interleave at JS microtask
|
|
678
|
+
// boundaries rather than in true parallel — but that's the production
|
|
679
|
+
// concurrency model (one node, event-loop scheduling). The guarantee
|
|
680
|
+
// comes from the atomic conditional UPDATE in notes.ts: exactly one of
|
|
681
|
+
// the two statements can match `AND updated_at IS ?`. Without that
|
|
682
|
+
// atomicity both would commit and silently destroy one write — the
|
|
683
|
+
// scenario if_updated_at exists to prevent.
|
|
684
|
+
const note = await store.createNote("seed");
|
|
685
|
+
const tools = generateMcpTools(store);
|
|
686
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
687
|
+
|
|
688
|
+
// Establish a known updated_at the two callers both read.
|
|
689
|
+
const seed = await updateNote.execute({ id: note.id, content: "seed-v1" }) as any;
|
|
690
|
+
expect(seed.updatedAt).toBeTruthy();
|
|
691
|
+
|
|
692
|
+
const results = await Promise.allSettled([
|
|
693
|
+
updateNote.execute({ id: note.id, content: "racer-A", if_updated_at: seed.updatedAt }),
|
|
694
|
+
updateNote.execute({ id: note.id, content: "racer-B", if_updated_at: seed.updatedAt }),
|
|
695
|
+
]);
|
|
696
|
+
|
|
697
|
+
const fulfilled = results.filter((r) => r.status === "fulfilled");
|
|
698
|
+
const rejected = results.filter((r) => r.status === "rejected");
|
|
699
|
+
expect(fulfilled).toHaveLength(1);
|
|
700
|
+
expect(rejected).toHaveLength(1);
|
|
701
|
+
const err = (rejected[0] as PromiseRejectedResult).reason as any;
|
|
702
|
+
expect(err?.code).toBe("CONFLICT");
|
|
703
|
+
|
|
704
|
+
// The winner's content is what ended up persisted.
|
|
705
|
+
const winner = (fulfilled[0] as PromiseFulfilledResult<any>).value;
|
|
706
|
+
const persisted = await store.getNote(note.id);
|
|
707
|
+
expect(persisted!.content).toBe(winner.content);
|
|
708
|
+
expect(["racer-A", "racer-B"]).toContain(persisted!.content);
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
it("update-note with links.remove rolls back link deletion when if_updated_at conflicts", async () => {
|
|
712
|
+
await store.createNote("Target", { id: "target", path: "People/Alice" });
|
|
713
|
+
const source = await store.createNote("See [[People/Alice]] for details", {
|
|
714
|
+
id: "source",
|
|
715
|
+
});
|
|
716
|
+
await store.createLink("source", "target", "wikilink");
|
|
717
|
+
|
|
718
|
+
const tools = generateMcpTools(store);
|
|
719
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
720
|
+
|
|
721
|
+
// Bump so a stale if_updated_at conflicts; and capture state after bump.
|
|
722
|
+
await updateNote.execute({ id: "source", content: "See [[People/Alice]] for details" });
|
|
723
|
+
const preConflictLinks = await store.getLinks("source", { direction: "outbound" });
|
|
724
|
+
expect(preConflictLinks).toHaveLength(1);
|
|
725
|
+
|
|
726
|
+
let err: any;
|
|
727
|
+
try {
|
|
728
|
+
await updateNote.execute({
|
|
729
|
+
id: "source",
|
|
730
|
+
links: { remove: [{ target: "target", relationship: "wikilink" }] },
|
|
731
|
+
if_updated_at: "2020-01-01T00:00:00.000Z",
|
|
732
|
+
});
|
|
733
|
+
} catch (e) {
|
|
734
|
+
err = e;
|
|
735
|
+
}
|
|
736
|
+
expect(err?.code).toBe("CONFLICT");
|
|
737
|
+
|
|
738
|
+
// The link must still exist — if it had been removed before the
|
|
739
|
+
// conflict check, this would be 0.
|
|
740
|
+
const postConflictLinks = await store.getLinks("source", { direction: "outbound" });
|
|
741
|
+
expect(postConflictLinks).toHaveLength(1);
|
|
742
|
+
expect((await store.getNote("source"))!.content).toBe("See [[People/Alice]] for details");
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("query-notes single note by id", async () => {
|
|
746
|
+
const note = await store.createNote("Hello", { path: "test/note" });
|
|
580
747
|
const tools = generateMcpTools(store);
|
|
581
748
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
582
|
-
const result = query.execute({ id: note.id }) as any;
|
|
749
|
+
const result = await query.execute({ id: note.id }) as any;
|
|
583
750
|
expect(result.content).toBe("Hello");
|
|
584
751
|
expect(result.path).toBe("test/note");
|
|
585
752
|
});
|
|
586
753
|
|
|
587
|
-
it("query-notes single note by path", () => {
|
|
588
|
-
store.createNote("By Path", { path: "Projects/README" });
|
|
754
|
+
it("query-notes single note by path", async () => {
|
|
755
|
+
await store.createNote("By Path", { path: "Projects/README" });
|
|
589
756
|
const tools = generateMcpTools(store);
|
|
590
757
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
591
|
-
const result = query.execute({ id: "Projects/README" }) as any;
|
|
758
|
+
const result = await query.execute({ id: "Projects/README" }) as any;
|
|
592
759
|
expect(result.content).toBe("By Path");
|
|
593
760
|
});
|
|
594
761
|
|
|
595
|
-
it("query-notes by tag", () => {
|
|
596
|
-
store.createNote("Test", { tags: ["daily"] });
|
|
762
|
+
it("query-notes by tag", async () => {
|
|
763
|
+
await store.createNote("Test", { tags: ["daily"] });
|
|
597
764
|
const tools = generateMcpTools(store);
|
|
598
765
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
599
|
-
const result = query.execute({ tag: ["daily"] }) as any[];
|
|
766
|
+
const result = await query.execute({ tag: ["daily"] }) as any[];
|
|
600
767
|
expect(result).toHaveLength(1);
|
|
601
768
|
});
|
|
602
769
|
|
|
603
|
-
it("query-notes list defaults to no content (index mode)", () => {
|
|
770
|
+
it("query-notes list defaults to no content (index mode)", async () => {
|
|
604
771
|
const content = "This is the note body.";
|
|
605
|
-
store.createNote(content, { tags: ["daily"], path: "Notes/test", metadata: { status: "draft" } });
|
|
772
|
+
await store.createNote(content, { tags: ["daily"], path: "Notes/test", metadata: { status: "draft" } });
|
|
606
773
|
const tools = generateMcpTools(store);
|
|
607
774
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
608
|
-
const result = query.execute({ tag: ["daily"] }) as any[];
|
|
775
|
+
const result = await query.execute({ tag: ["daily"] }) as any[];
|
|
609
776
|
expect(result).toHaveLength(1);
|
|
610
777
|
const entry = result[0];
|
|
611
778
|
expect(entry.content).toBeUndefined();
|
|
@@ -614,21 +781,21 @@ describe("MCP tools", () => {
|
|
|
614
781
|
expect(entry.byteSize).toBe(Buffer.byteLength(content, "utf8"));
|
|
615
782
|
});
|
|
616
783
|
|
|
617
|
-
it("query-notes list with include_content: true returns full content", () => {
|
|
618
|
-
store.createNote("Full body", { tags: ["daily"] });
|
|
784
|
+
it("query-notes list with include_content: true returns full content", async () => {
|
|
785
|
+
await store.createNote("Full body", { tags: ["daily"] });
|
|
619
786
|
const tools = generateMcpTools(store);
|
|
620
787
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
621
|
-
const result = query.execute({ tag: ["daily"], include_content: true }) as any[];
|
|
788
|
+
const result = await query.execute({ tag: ["daily"], include_content: true }) as any[];
|
|
622
789
|
expect(result).toHaveLength(1);
|
|
623
790
|
expect(result[0].content).toBe("Full body");
|
|
624
791
|
});
|
|
625
792
|
|
|
626
|
-
it("query-notes index mode truncates preview and counts utf-8 bytes", () => {
|
|
793
|
+
it("query-notes index mode truncates preview and counts utf-8 bytes", async () => {
|
|
627
794
|
const longContent = "line one\nline two has\tlots of whitespace\n" + "x".repeat(300) + " ✨✨✨";
|
|
628
|
-
store.createNote(longContent, { tags: ["long"] });
|
|
795
|
+
await store.createNote(longContent, { tags: ["long"] });
|
|
629
796
|
const tools = generateMcpTools(store);
|
|
630
797
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
631
|
-
const result = query.execute({ tag: ["long"] }) as any[];
|
|
798
|
+
const result = await query.execute({ tag: ["long"] }) as any[];
|
|
632
799
|
expect(result).toHaveLength(1);
|
|
633
800
|
const entry = result[0];
|
|
634
801
|
expect(entry.byteSize).toBe(Buffer.byteLength(longContent, "utf8"));
|
|
@@ -637,13 +804,13 @@ describe("MCP tools", () => {
|
|
|
637
804
|
expect(entry.preview.includes("\n")).toBe(false);
|
|
638
805
|
});
|
|
639
806
|
|
|
640
|
-
it("query-notes index mode does not split astral-plane surrogate pairs", () => {
|
|
807
|
+
it("query-notes index mode does not split astral-plane surrogate pairs", async () => {
|
|
641
808
|
const emoji = "😀";
|
|
642
809
|
const longContent = emoji.repeat(130);
|
|
643
|
-
store.createNote(longContent, { tags: ["astral"] });
|
|
810
|
+
await store.createNote(longContent, { tags: ["astral"] });
|
|
644
811
|
const tools = generateMcpTools(store);
|
|
645
812
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
646
|
-
const result = query.execute({ tag: ["astral"] }) as any[];
|
|
813
|
+
const result = await query.execute({ tag: ["astral"] }) as any[];
|
|
647
814
|
expect(result).toHaveLength(1);
|
|
648
815
|
const preview = result[0].preview as string;
|
|
649
816
|
const codePoints = Array.from(preview);
|
|
@@ -653,17 +820,17 @@ describe("MCP tools", () => {
|
|
|
653
820
|
}
|
|
654
821
|
});
|
|
655
822
|
|
|
656
|
-
it("query-notes honors filters (date range, path_prefix, limit, offset)", () => {
|
|
657
|
-
store.createNote("A", { tags: ["keep"], path: "Projects/a", created_at: "2025-03-05T00:00:00.000Z" });
|
|
658
|
-
store.createNote("B", { tags: ["keep"], path: "Projects/b", created_at: "2025-03-10T00:00:00.000Z" });
|
|
659
|
-
store.createNote("C", { tags: ["keep"], path: "Other/c", created_at: "2025-03-15T00:00:00.000Z" });
|
|
660
|
-
store.createNote("D", { tags: ["keep"], path: "Projects/d", created_at: "2025-04-02T00:00:00.000Z" });
|
|
823
|
+
it("query-notes honors filters (date range, path_prefix, limit, offset)", async () => {
|
|
824
|
+
await store.createNote("A", { tags: ["keep"], path: "Projects/a", created_at: "2025-03-05T00:00:00.000Z" });
|
|
825
|
+
await store.createNote("B", { tags: ["keep"], path: "Projects/b", created_at: "2025-03-10T00:00:00.000Z" });
|
|
826
|
+
await store.createNote("C", { tags: ["keep"], path: "Other/c", created_at: "2025-03-15T00:00:00.000Z" });
|
|
827
|
+
await store.createNote("D", { tags: ["keep"], path: "Projects/d", created_at: "2025-04-02T00:00:00.000Z" });
|
|
661
828
|
|
|
662
829
|
const tools = generateMcpTools(store);
|
|
663
830
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
664
831
|
|
|
665
832
|
// date range filter
|
|
666
|
-
const inMarch = query.execute({
|
|
833
|
+
const inMarch = await query.execute({
|
|
667
834
|
date_from: "2025-03-01",
|
|
668
835
|
date_to: "2025-04-01",
|
|
669
836
|
sort: "asc",
|
|
@@ -672,12 +839,12 @@ describe("MCP tools", () => {
|
|
|
672
839
|
expect(inMarch.every((n) => n.content === undefined)).toBe(true);
|
|
673
840
|
|
|
674
841
|
// path_prefix filter
|
|
675
|
-
const projects = query.execute({ path_prefix: "Projects" }) as any[];
|
|
842
|
+
const projects = await query.execute({ path_prefix: "Projects" }) as any[];
|
|
676
843
|
expect(projects).toHaveLength(3);
|
|
677
844
|
expect(projects.every((n) => n.path!.startsWith("Projects"))).toBe(true);
|
|
678
845
|
|
|
679
846
|
// limit + offset
|
|
680
|
-
const page = query.execute({
|
|
847
|
+
const page = await query.execute({
|
|
681
848
|
path_prefix: "Projects",
|
|
682
849
|
sort: "asc",
|
|
683
850
|
limit: 2,
|
|
@@ -686,68 +853,68 @@ describe("MCP tools", () => {
|
|
|
686
853
|
expect(page).toHaveLength(2);
|
|
687
854
|
});
|
|
688
855
|
|
|
689
|
-
it("query-notes full-text search works", () => {
|
|
690
|
-
store.createNote("Flagstaff trail");
|
|
856
|
+
it("query-notes full-text search works", async () => {
|
|
857
|
+
await store.createNote("Flagstaff trail");
|
|
691
858
|
const tools = generateMcpTools(store);
|
|
692
859
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
693
|
-
const result = query.execute({ search: "Flagstaff" }) as any[];
|
|
860
|
+
const result = await query.execute({ search: "Flagstaff" }) as any[];
|
|
694
861
|
expect(result).toHaveLength(1);
|
|
695
862
|
});
|
|
696
863
|
|
|
697
|
-
it("query-notes with include_links enriches results", () => {
|
|
698
|
-
store.createNote("A", { id: "a", path: "alpha" });
|
|
699
|
-
store.createNote("B", { id: "b", path: "beta" });
|
|
700
|
-
store.createLink("a", "b", "mentions");
|
|
864
|
+
it("query-notes with include_links enriches results", async () => {
|
|
865
|
+
await store.createNote("A", { id: "a", path: "alpha" });
|
|
866
|
+
await store.createNote("B", { id: "b", path: "beta" });
|
|
867
|
+
await store.createLink("a", "b", "mentions");
|
|
701
868
|
const tools = generateMcpTools(store);
|
|
702
869
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
703
|
-
const result = query.execute({ id: "a", include_links: true }) as any;
|
|
870
|
+
const result = await query.execute({ id: "a", include_links: true }) as any;
|
|
704
871
|
expect(result.links).toBeDefined();
|
|
705
872
|
expect(result.links).toHaveLength(1);
|
|
706
873
|
});
|
|
707
874
|
|
|
708
|
-
it("query-notes include_metadata: true returns all metadata (single)", () => {
|
|
709
|
-
store.createNote("Body", { metadata: { summary: "short", status: "draft", priority: 1 } });
|
|
875
|
+
it("query-notes include_metadata: true returns all metadata (single)", async () => {
|
|
876
|
+
await store.createNote("Body", { metadata: { summary: "short", status: "draft", priority: 1 } });
|
|
710
877
|
const tools = generateMcpTools(store);
|
|
711
878
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
712
|
-
const result = query.execute({ id: store.queryNotes({})[0]
|
|
879
|
+
const result = await query.execute({ id: (await store.queryNotes({}))[0]!.id, include_metadata: true }) as any;
|
|
713
880
|
expect(result.metadata).toEqual({ summary: "short", status: "draft", priority: 1 });
|
|
714
881
|
});
|
|
715
882
|
|
|
716
|
-
it("query-notes include_metadata: false strips metadata (single)", () => {
|
|
717
|
-
store.createNote("Body", { metadata: { summary: "short", status: "draft" } });
|
|
883
|
+
it("query-notes include_metadata: false strips metadata (single)", async () => {
|
|
884
|
+
await store.createNote("Body", { metadata: { summary: "short", status: "draft" } });
|
|
718
885
|
const tools = generateMcpTools(store);
|
|
719
886
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
720
|
-
const result = query.execute({ id: store.queryNotes({})[0]
|
|
887
|
+
const result = await query.execute({ id: (await store.queryNotes({}))[0]!.id, include_metadata: false }) as any;
|
|
721
888
|
expect(result.metadata).toBeUndefined();
|
|
722
889
|
expect(result.content).toBe("Body"); // other fields unaffected
|
|
723
890
|
});
|
|
724
891
|
|
|
725
|
-
it("query-notes include_metadata: string[] returns only specified fields (single)", () => {
|
|
726
|
-
store.createNote("Body", { metadata: { summary: "short", status: "draft", priority: 1 } });
|
|
892
|
+
it("query-notes include_metadata: string[] returns only specified fields (single)", async () => {
|
|
893
|
+
await store.createNote("Body", { metadata: { summary: "short", status: "draft", priority: 1 } });
|
|
727
894
|
const tools = generateMcpTools(store);
|
|
728
895
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
729
|
-
const result = query.execute({ id: store.queryNotes({})[0]
|
|
896
|
+
const result = await query.execute({ id: (await store.queryNotes({}))[0]!.id, include_metadata: ["summary"] }) as any;
|
|
730
897
|
expect(result.metadata).toEqual({ summary: "short" });
|
|
731
898
|
});
|
|
732
899
|
|
|
733
|
-
it("query-notes include_metadata: false strips metadata (list)", () => {
|
|
734
|
-
store.createNote("A", { tags: ["meta-test"], metadata: { summary: "a" } });
|
|
735
|
-
store.createNote("B", { tags: ["meta-test"], metadata: { summary: "b" } });
|
|
900
|
+
it("query-notes include_metadata: false strips metadata (list)", async () => {
|
|
901
|
+
await store.createNote("A", { tags: ["meta-test"], metadata: { summary: "a" } });
|
|
902
|
+
await store.createNote("B", { tags: ["meta-test"], metadata: { summary: "b" } });
|
|
736
903
|
const tools = generateMcpTools(store);
|
|
737
904
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
738
|
-
const result = query.execute({ tag: "meta-test", include_metadata: false }) as any[];
|
|
905
|
+
const result = await query.execute({ tag: "meta-test", include_metadata: false }) as any[];
|
|
739
906
|
expect(result).toHaveLength(2);
|
|
740
907
|
for (const n of result) {
|
|
741
908
|
expect(n.metadata).toBeUndefined();
|
|
742
909
|
}
|
|
743
910
|
});
|
|
744
911
|
|
|
745
|
-
it("query-notes include_metadata: string[] filters fields (list)", () => {
|
|
746
|
-
store.createNote("A", { tags: ["meta-filter"], metadata: { summary: "a", status: "ok", extra: true } });
|
|
747
|
-
store.createNote("B", { tags: ["meta-filter"], metadata: { summary: "b", extra: false } });
|
|
912
|
+
it("query-notes include_metadata: string[] filters fields (list)", async () => {
|
|
913
|
+
await store.createNote("A", { tags: ["meta-filter"], metadata: { summary: "a", status: "ok", extra: true } });
|
|
914
|
+
await store.createNote("B", { tags: ["meta-filter"], metadata: { summary: "b", extra: false } });
|
|
748
915
|
const tools = generateMcpTools(store);
|
|
749
916
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
750
|
-
const result = query.execute({ tag: "meta-filter", include_metadata: ["summary", "status"] }) as any[];
|
|
917
|
+
const result = await query.execute({ tag: "meta-filter", include_metadata: ["summary", "status"] }) as any[];
|
|
751
918
|
expect(result).toHaveLength(2);
|
|
752
919
|
const a = result.find((n: any) => n.metadata?.summary === "a");
|
|
753
920
|
const b = result.find((n: any) => n.metadata?.summary === "b");
|
|
@@ -755,118 +922,118 @@ describe("MCP tools", () => {
|
|
|
755
922
|
expect(b.metadata).toEqual({ summary: "b" }); // status absent → omitted
|
|
756
923
|
});
|
|
757
924
|
|
|
758
|
-
it("query-notes include_metadata: string[] with no matching fields returns undefined metadata", () => {
|
|
759
|
-
store.createNote("A", { tags: ["no-match-meta"], metadata: { summary: "a" } });
|
|
925
|
+
it("query-notes include_metadata: string[] with no matching fields returns undefined metadata", async () => {
|
|
926
|
+
await store.createNote("A", { tags: ["no-match-meta"], metadata: { summary: "a" } });
|
|
760
927
|
const tools = generateMcpTools(store);
|
|
761
928
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
762
|
-
const result = query.execute({ tag: "no-match-meta", include_metadata: ["nonexistent"] }) as any[];
|
|
929
|
+
const result = await query.execute({ tag: "no-match-meta", include_metadata: ["nonexistent"] }) as any[];
|
|
763
930
|
expect(result).toHaveLength(1);
|
|
764
931
|
expect(result[0].metadata).toBeUndefined();
|
|
765
932
|
});
|
|
766
933
|
|
|
767
|
-
it("query-notes near param scopes results to graph neighborhood", () => {
|
|
768
|
-
store.createNote("Center", { id: "center" });
|
|
769
|
-
store.createNote("Near", { id: "near", tags: ["t"] });
|
|
770
|
-
store.createNote("Far", { id: "far", tags: ["t"] });
|
|
771
|
-
store.createLink("center", "near", "mentions");
|
|
934
|
+
it("query-notes near param scopes results to graph neighborhood", async () => {
|
|
935
|
+
await store.createNote("Center", { id: "center" });
|
|
936
|
+
await store.createNote("Near", { id: "near", tags: ["t"] });
|
|
937
|
+
await store.createNote("Far", { id: "far", tags: ["t"] });
|
|
938
|
+
await store.createLink("center", "near", "mentions");
|
|
772
939
|
// "far" is not linked to "center"
|
|
773
940
|
|
|
774
941
|
const tools = generateMcpTools(store);
|
|
775
942
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
776
|
-
const result = query.execute({ tag: "t", near: { note_id: "center", depth: 1 } }) as any[];
|
|
943
|
+
const result = await query.execute({ tag: "t", near: { note_id: "center", depth: 1 } }) as any[];
|
|
777
944
|
expect(result).toHaveLength(1);
|
|
778
945
|
expect(result[0].id).toBe("near");
|
|
779
946
|
});
|
|
780
947
|
|
|
781
|
-
it("delete-note accepts path", () => {
|
|
782
|
-
store.createNote("To delete", { path: "Temp/note" });
|
|
948
|
+
it("delete-note accepts path", async () => {
|
|
949
|
+
await store.createNote("To delete", { path: "Temp/note" });
|
|
783
950
|
const tools = generateMcpTools(store);
|
|
784
951
|
const deleteTool = tools.find((t) => t.name === "delete-note")!;
|
|
785
|
-
const result = deleteTool.execute({ id: "Temp/note" }) as any;
|
|
952
|
+
const result = await deleteTool.execute({ id: "Temp/note" }) as any;
|
|
786
953
|
expect(result.deleted).toBe(true);
|
|
787
|
-
expect(store.getNoteByPath("Temp/note")).toBeNull();
|
|
954
|
+
expect(await store.getNoteByPath("Temp/note")).toBeNull();
|
|
788
955
|
});
|
|
789
956
|
|
|
790
|
-
it("delete-tag with zero notes removes tag from list", () => {
|
|
791
|
-
store.createNote("Test", { tags: ["ephemeral"] });
|
|
792
|
-
store.untagNote(store.queryNotes({}).find((n) => n.tags?.includes("ephemeral"))!.id, ["ephemeral"]);
|
|
793
|
-
const before = store.listTags();
|
|
957
|
+
it("delete-tag with zero notes removes tag from list", async () => {
|
|
958
|
+
await store.createNote("Test", { tags: ["ephemeral"] });
|
|
959
|
+
await store.untagNote((await store.queryNotes({})).find((n) => n.tags?.includes("ephemeral"))!.id, ["ephemeral"]);
|
|
960
|
+
const before = await store.listTags();
|
|
794
961
|
expect(before.some((t) => t.name === "ephemeral")).toBe(true);
|
|
795
962
|
|
|
796
|
-
const result = store.deleteTag("ephemeral");
|
|
963
|
+
const result = await store.deleteTag("ephemeral");
|
|
797
964
|
expect(result).toEqual({ deleted: true, notes_untagged: 0 });
|
|
798
965
|
|
|
799
|
-
const after = store.listTags();
|
|
966
|
+
const after = await store.listTags();
|
|
800
967
|
expect(after.some((t) => t.name === "ephemeral")).toBe(false);
|
|
801
968
|
});
|
|
802
969
|
|
|
803
|
-
it("delete-tag with N notes untags all but preserves notes", () => {
|
|
804
|
-
const n1 = store.createNote("A", { tags: ["doomed"] });
|
|
805
|
-
const n2 = store.createNote("B", { tags: ["doomed", "keeper"] });
|
|
970
|
+
it("delete-tag with N notes untags all but preserves notes", async () => {
|
|
971
|
+
const n1 = await store.createNote("A", { tags: ["doomed"] });
|
|
972
|
+
const n2 = await store.createNote("B", { tags: ["doomed", "keeper"] });
|
|
806
973
|
|
|
807
|
-
const result = store.deleteTag("doomed");
|
|
974
|
+
const result = await store.deleteTag("doomed");
|
|
808
975
|
expect(result).toEqual({ deleted: true, notes_untagged: 2 });
|
|
809
976
|
|
|
810
|
-
expect(store.getNote(n1.id)).not.toBeNull();
|
|
811
|
-
expect(store.getNote(n2.id)).not.toBeNull();
|
|
812
|
-
expect(store.getNote(n1.id)!.tags).not.toContain("doomed");
|
|
813
|
-
expect(store.getNote(n2.id)!.tags).not.toContain("doomed");
|
|
814
|
-
expect(store.getNote(n2.id)!.tags).toContain("keeper");
|
|
815
|
-
expect(store.listTags().some((t) => t.name === "doomed")).toBe(false);
|
|
977
|
+
expect(await store.getNote(n1.id)).not.toBeNull();
|
|
978
|
+
expect(await store.getNote(n2.id)).not.toBeNull();
|
|
979
|
+
expect((await store.getNote(n1.id))!.tags).not.toContain("doomed");
|
|
980
|
+
expect((await store.getNote(n2.id))!.tags).not.toContain("doomed");
|
|
981
|
+
expect((await store.getNote(n2.id))!.tags).toContain("keeper");
|
|
982
|
+
expect((await store.listTags()).some((t) => t.name === "doomed")).toBe(false);
|
|
816
983
|
});
|
|
817
984
|
|
|
818
|
-
it("delete-tag nonexistent returns deleted: false", () => {
|
|
819
|
-
const result = store.deleteTag("never-existed");
|
|
985
|
+
it("delete-tag nonexistent returns deleted: false", async () => {
|
|
986
|
+
const result = await store.deleteTag("never-existed");
|
|
820
987
|
expect(result).toEqual({ deleted: false, notes_untagged: 0 });
|
|
821
988
|
});
|
|
822
989
|
|
|
823
|
-
it("delete-tag MCP tool works", () => {
|
|
990
|
+
it("delete-tag MCP tool works", async () => {
|
|
824
991
|
const tools = generateMcpTools(store);
|
|
825
992
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
826
|
-
createNote.execute({ content: "Test", tags: ["mcp-tag"] });
|
|
993
|
+
await createNote.execute({ content: "Test", tags: ["mcp-tag"] });
|
|
827
994
|
|
|
828
995
|
const deleteTool = tools.find((t) => t.name === "delete-tag")!;
|
|
829
|
-
const result = deleteTool.execute({ tag: "mcp-tag" }) as any;
|
|
996
|
+
const result = await deleteTool.execute({ tag: "mcp-tag" }) as any;
|
|
830
997
|
expect(result.deleted).toBe(true);
|
|
831
998
|
expect(result.notes_untagged).toBe(1);
|
|
832
999
|
|
|
833
1000
|
const listTool = tools.find((t) => t.name === "list-tags")!;
|
|
834
|
-
const tags = listTool.execute({}) as any[];
|
|
1001
|
+
const tags = await listTool.execute({}) as any[];
|
|
835
1002
|
expect(tags.some((t: any) => t.name === "mcp-tag")).toBe(false);
|
|
836
1003
|
});
|
|
837
1004
|
|
|
838
|
-
it("list-tags single tag detail with schema", () => {
|
|
839
|
-
store.createNote("Test", { tags: ["person"] });
|
|
840
|
-
store.upsertTagSchema("person", {
|
|
1005
|
+
it("list-tags single tag detail with schema", async () => {
|
|
1006
|
+
await store.createNote("Test", { tags: ["person"] });
|
|
1007
|
+
await store.upsertTagSchema("person", {
|
|
841
1008
|
description: "A person",
|
|
842
1009
|
fields: { name: { type: "string" } },
|
|
843
1010
|
});
|
|
844
1011
|
const tools = generateMcpTools(store);
|
|
845
1012
|
const listTags = tools.find((t) => t.name === "list-tags")!;
|
|
846
|
-
const result = listTags.execute({ tag: "person" }) as any;
|
|
1013
|
+
const result = await listTags.execute({ tag: "person" }) as any;
|
|
847
1014
|
expect(result.name).toBe("person");
|
|
848
1015
|
expect(result.count).toBe(1);
|
|
849
1016
|
expect(result.description).toBe("A person");
|
|
850
1017
|
expect(result.fields.name.type).toBe("string");
|
|
851
1018
|
});
|
|
852
1019
|
|
|
853
|
-
it("list-tags include_schema returns schemas for all tags", () => {
|
|
854
|
-
store.createNote("A", { tags: ["person"] });
|
|
855
|
-
store.createNote("B", { tags: ["project"] });
|
|
856
|
-
store.upsertTagSchema("person", { description: "A person" });
|
|
1020
|
+
it("list-tags include_schema returns schemas for all tags", async () => {
|
|
1021
|
+
await store.createNote("A", { tags: ["person"] });
|
|
1022
|
+
await store.createNote("B", { tags: ["project"] });
|
|
1023
|
+
await store.upsertTagSchema("person", { description: "A person" });
|
|
857
1024
|
const tools = generateMcpTools(store);
|
|
858
1025
|
const listTags = tools.find((t) => t.name === "list-tags")!;
|
|
859
|
-
const result = listTags.execute({ include_schema: true }) as any[];
|
|
1026
|
+
const result = await listTags.execute({ include_schema: true }) as any[];
|
|
860
1027
|
const person = result.find((t: any) => t.name === "person");
|
|
861
1028
|
expect(person.description).toBe("A person");
|
|
862
1029
|
const project = result.find((t: any) => t.name === "project");
|
|
863
1030
|
expect(project.description).toBeNull();
|
|
864
1031
|
});
|
|
865
1032
|
|
|
866
|
-
it("update-tag creates schema if not exists", () => {
|
|
1033
|
+
it("update-tag creates schema if not exists", async () => {
|
|
867
1034
|
const tools = generateMcpTools(store);
|
|
868
1035
|
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
869
|
-
const result = updateTag.execute({
|
|
1036
|
+
const result = await updateTag.execute({
|
|
870
1037
|
tag: "person",
|
|
871
1038
|
description: "A person",
|
|
872
1039
|
fields: { name: { type: "string" } },
|
|
@@ -875,14 +1042,14 @@ describe("MCP tools", () => {
|
|
|
875
1042
|
expect(result.description).toBe("A person");
|
|
876
1043
|
});
|
|
877
1044
|
|
|
878
|
-
it("update-tag merges fields with existing", () => {
|
|
879
|
-
store.upsertTagSchema("person", {
|
|
1045
|
+
it("update-tag merges fields with existing", async () => {
|
|
1046
|
+
await store.upsertTagSchema("person", {
|
|
880
1047
|
description: "A person",
|
|
881
1048
|
fields: { name: { type: "string" } },
|
|
882
1049
|
});
|
|
883
1050
|
const tools = generateMcpTools(store);
|
|
884
1051
|
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
885
|
-
const result = updateTag.execute({
|
|
1052
|
+
const result = await updateTag.execute({
|
|
886
1053
|
tag: "person",
|
|
887
1054
|
fields: { age: { type: "integer" } },
|
|
888
1055
|
}) as any;
|
|
@@ -890,34 +1057,34 @@ describe("MCP tools", () => {
|
|
|
890
1057
|
expect(result.fields.age.type).toBe("integer");
|
|
891
1058
|
});
|
|
892
1059
|
|
|
893
|
-
it("find-path works with ID/path resolution", () => {
|
|
894
|
-
store.createNote("A", { id: "a", path: "People/Alice" });
|
|
895
|
-
store.createNote("B", { id: "b" });
|
|
896
|
-
store.createNote("C", { id: "c", path: "Projects/X" });
|
|
897
|
-
store.createLink("a", "b", "mentions");
|
|
898
|
-
store.createLink("b", "c", "related-to");
|
|
1060
|
+
it("find-path works with ID/path resolution", async () => {
|
|
1061
|
+
await store.createNote("A", { id: "a", path: "People/Alice" });
|
|
1062
|
+
await store.createNote("B", { id: "b" });
|
|
1063
|
+
await store.createNote("C", { id: "c", path: "Projects/X" });
|
|
1064
|
+
await store.createLink("a", "b", "mentions");
|
|
1065
|
+
await store.createLink("b", "c", "related-to");
|
|
899
1066
|
|
|
900
1067
|
const tools = generateMcpTools(store);
|
|
901
1068
|
const findPath = tools.find((t) => t.name === "find-path")!;
|
|
902
|
-
const result = findPath.execute({ source: "People/Alice", target: "Projects/X" }) as any;
|
|
1069
|
+
const result = await findPath.execute({ source: "People/Alice", target: "Projects/X" }) as any;
|
|
903
1070
|
expect(result).not.toBeNull();
|
|
904
1071
|
expect(result.path).toEqual(["a", "b", "c"]);
|
|
905
1072
|
expect(result.relationships).toEqual(["mentions", "related-to"]);
|
|
906
1073
|
});
|
|
907
1074
|
|
|
908
|
-
it("create-note via store triggers wikilink sync", () => {
|
|
1075
|
+
it("create-note via store triggers wikilink sync", async () => {
|
|
909
1076
|
const tools = generateMcpTools(store);
|
|
910
1077
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
911
1078
|
|
|
912
|
-
store.createNote("Target", { path: "Target Note" });
|
|
913
|
-
const source = createNote.execute({ content: "See [[Target Note]]" }) as any;
|
|
1079
|
+
await store.createNote("Target", { path: "Target Note" });
|
|
1080
|
+
const source = await createNote.execute({ content: "See [[Target Note]]" }) as any;
|
|
914
1081
|
|
|
915
|
-
const links = store.getLinks(source.id, { direction: "outbound" });
|
|
1082
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
916
1083
|
expect(links.some((l) => l.relationship === "wikilink")).toBe(true);
|
|
917
1084
|
});
|
|
918
1085
|
|
|
919
|
-
it("create-note with schema tag auto-populates defaults", () => {
|
|
920
|
-
store.upsertTagSchema("person", {
|
|
1086
|
+
it("create-note with schema tag auto-populates defaults", async () => {
|
|
1087
|
+
await store.upsertTagSchema("person", {
|
|
921
1088
|
description: "A person",
|
|
922
1089
|
fields: {
|
|
923
1090
|
first_appeared: { type: "string" },
|
|
@@ -930,11 +1097,300 @@ describe("MCP tools", () => {
|
|
|
930
1097
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
931
1098
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
932
1099
|
|
|
933
|
-
const result = createNote.execute({ content: "Alice", tags: ["person"] }) as any;
|
|
934
|
-
const fresh = query.execute({ id: result.id }) as any;
|
|
1100
|
+
const result = await createNote.execute({ content: "Alice", tags: ["person"] }) as any;
|
|
1101
|
+
const fresh = await query.execute({ id: result.id }) as any;
|
|
935
1102
|
expect(fresh.metadata.first_appeared).toBe("");
|
|
936
1103
|
expect(fresh.metadata.active).toBe(false);
|
|
937
1104
|
expect(fresh.metadata.priority).toBe(0);
|
|
938
1105
|
expect(fresh.metadata.status).toBe("active");
|
|
939
1106
|
});
|
|
940
1107
|
});
|
|
1108
|
+
|
|
1109
|
+
// ---- query-notes link expansion ----
|
|
1110
|
+
|
|
1111
|
+
describe("query-notes link expansion", async () => {
|
|
1112
|
+
it("expands a single [[wikilink]] inline in full mode by default", async () => {
|
|
1113
|
+
await store.createNote("# Who I Am\nI teach Taiji.", { path: "Statements/Who" });
|
|
1114
|
+
await store.createNote(
|
|
1115
|
+
"Canon:\nSee [[Statements/Who]] for identity.",
|
|
1116
|
+
{ path: "Canon" },
|
|
1117
|
+
);
|
|
1118
|
+
const tools = generateMcpTools(store);
|
|
1119
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1120
|
+
|
|
1121
|
+
const result = await query.execute({
|
|
1122
|
+
id: "Canon",
|
|
1123
|
+
expand_links: true,
|
|
1124
|
+
}) as any;
|
|
1125
|
+
|
|
1126
|
+
expect(result.content).toContain('<expanded path="Statements/Who" mode="full">');
|
|
1127
|
+
expect(result.content).toContain("I teach Taiji.");
|
|
1128
|
+
expect(result.content).toContain("</expanded>");
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it("summary mode inlines only metadata.summary, not full content", async () => {
|
|
1132
|
+
await store.createNote(
|
|
1133
|
+
"# Long canonical statement\n\n(Many paragraphs of detail follow...)",
|
|
1134
|
+
{ path: "Statements/Philosophy", metadata: { summary: "Unforced / wu wei." } },
|
|
1135
|
+
);
|
|
1136
|
+
await store.createNote("Overview: [[Statements/Philosophy]]", { path: "Index" });
|
|
1137
|
+
const tools = generateMcpTools(store);
|
|
1138
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1139
|
+
|
|
1140
|
+
const result = await query.execute({
|
|
1141
|
+
id: "Index",
|
|
1142
|
+
expand_links: true,
|
|
1143
|
+
expand_mode: "summary",
|
|
1144
|
+
}) as any;
|
|
1145
|
+
|
|
1146
|
+
expect(result.content).toContain('mode="summary"');
|
|
1147
|
+
expect(result.content).toContain("Unforced / wu wei.");
|
|
1148
|
+
expect(result.content).not.toContain("Many paragraphs of detail");
|
|
1149
|
+
});
|
|
1150
|
+
|
|
1151
|
+
it("deduplicates: a linked note expanded once, subsequent references marked", async () => {
|
|
1152
|
+
await store.createNote("target body", { path: "Target" });
|
|
1153
|
+
await store.createNote(
|
|
1154
|
+
"First [[Target]], then [[Target]] again.",
|
|
1155
|
+
{ path: "Source" },
|
|
1156
|
+
);
|
|
1157
|
+
const tools = generateMcpTools(store);
|
|
1158
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1159
|
+
|
|
1160
|
+
const result = await query.execute({
|
|
1161
|
+
id: "Source",
|
|
1162
|
+
expand_links: true,
|
|
1163
|
+
}) as any;
|
|
1164
|
+
|
|
1165
|
+
// Exactly one <expanded> block.
|
|
1166
|
+
const openCount = (result.content.match(/<expanded /g) ?? []).length;
|
|
1167
|
+
expect(openCount).toBe(1);
|
|
1168
|
+
expect(result.content).toContain("(expanded above)");
|
|
1169
|
+
});
|
|
1170
|
+
|
|
1171
|
+
it("cycle guard: A→B→A does not expand A inside B", async () => {
|
|
1172
|
+
await store.createNote("A body with [[B]] reference.", { path: "A" });
|
|
1173
|
+
await store.createNote("B body with [[A]] reference.", { path: "B" });
|
|
1174
|
+
const tools = generateMcpTools(store);
|
|
1175
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1176
|
+
|
|
1177
|
+
const result = await query.execute({
|
|
1178
|
+
id: "A",
|
|
1179
|
+
expand_links: true,
|
|
1180
|
+
expand_depth: 3,
|
|
1181
|
+
}) as any;
|
|
1182
|
+
|
|
1183
|
+
// A appears as the container but should only be expanded once (in the top-level note).
|
|
1184
|
+
// B is expanded inside A; inside B, the [[A]] reference should NOT re-expand A.
|
|
1185
|
+
const expandedOpens = (result.content.match(/<expanded path="(A|B)" mode="full">/g) ?? []).length;
|
|
1186
|
+
expect(expandedOpens).toBe(1); // only B is expanded; A is the top note, never re-expanded
|
|
1187
|
+
expect(result.content).toContain("(expanded above)"); // B's reference to A becomes the marker
|
|
1188
|
+
});
|
|
1189
|
+
|
|
1190
|
+
it("expand_depth=1 (default) expands top-level wikilinks but not nested ones", async () => {
|
|
1191
|
+
await store.createNote("leaf content", { path: "Leaf" });
|
|
1192
|
+
await store.createNote("middle body with [[Leaf]] inside", { path: "Middle" });
|
|
1193
|
+
await store.createNote("root references [[Middle]]", { path: "Root" });
|
|
1194
|
+
const tools = generateMcpTools(store);
|
|
1195
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1196
|
+
|
|
1197
|
+
const result = await query.execute({ id: "Root", expand_links: true }) as any;
|
|
1198
|
+
|
|
1199
|
+
expect(result.content).toContain('<expanded path="Middle"');
|
|
1200
|
+
// Middle's content is inlined, including its raw [[Leaf]] reference — but Leaf is NOT expanded.
|
|
1201
|
+
expect(result.content).toContain("[[Leaf]]");
|
|
1202
|
+
expect(result.content).not.toContain('<expanded path="Leaf"');
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
it("expand_depth=2 recurses one additional level", async () => {
|
|
1206
|
+
await store.createNote("leaf content", { path: "Leaf" });
|
|
1207
|
+
await store.createNote("middle [[Leaf]] inside", { path: "Middle" });
|
|
1208
|
+
await store.createNote("root references [[Middle]]", { path: "Root" });
|
|
1209
|
+
const tools = generateMcpTools(store);
|
|
1210
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1211
|
+
|
|
1212
|
+
const result = await query.execute({
|
|
1213
|
+
id: "Root",
|
|
1214
|
+
expand_links: true,
|
|
1215
|
+
expand_depth: 2,
|
|
1216
|
+
}) as any;
|
|
1217
|
+
|
|
1218
|
+
expect(result.content).toContain('<expanded path="Middle"');
|
|
1219
|
+
expect(result.content).toContain('<expanded path="Leaf"');
|
|
1220
|
+
expect(result.content).toContain("leaf content");
|
|
1221
|
+
});
|
|
1222
|
+
|
|
1223
|
+
it("expand_depth is clamped to MAX_EXPAND_DEPTH (3)", async () => {
|
|
1224
|
+
await store.createNote("level-4", { path: "L4" });
|
|
1225
|
+
await store.createNote("level-3 [[L4]]", { path: "L3" });
|
|
1226
|
+
await store.createNote("level-2 [[L3]]", { path: "L2" });
|
|
1227
|
+
await store.createNote("level-1 [[L2]]", { path: "L1" });
|
|
1228
|
+
await store.createNote("root [[L1]]", { path: "Root" });
|
|
1229
|
+
const tools = generateMcpTools(store);
|
|
1230
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1231
|
+
|
|
1232
|
+
// Request depth=99 — should clamp to 3, so L4 is NOT expanded.
|
|
1233
|
+
const result = await query.execute({
|
|
1234
|
+
id: "Root",
|
|
1235
|
+
expand_links: true,
|
|
1236
|
+
expand_depth: 99,
|
|
1237
|
+
}) as any;
|
|
1238
|
+
|
|
1239
|
+
expect(result.content).toContain('<expanded path="L1"');
|
|
1240
|
+
expect(result.content).toContain('<expanded path="L2"');
|
|
1241
|
+
expect(result.content).toContain('<expanded path="L3"');
|
|
1242
|
+
expect(result.content).not.toContain('<expanded path="L4"');
|
|
1243
|
+
expect(result.content).toContain("[[L4]]"); // raw, beyond clamp
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
it("leaves unresolved [[wikilinks]] unchanged", async () => {
|
|
1247
|
+
await store.createNote("root mentions [[DoesNotExist]]", { path: "Root" });
|
|
1248
|
+
const tools = generateMcpTools(store);
|
|
1249
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1250
|
+
|
|
1251
|
+
const result = await query.execute({ id: "Root", expand_links: true }) as any;
|
|
1252
|
+
expect(result.content).toBe("root mentions [[DoesNotExist]]");
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
it("expand_links: false (default) leaves content untouched", async () => {
|
|
1256
|
+
await store.createNote("target body", { path: "Target" });
|
|
1257
|
+
await store.createNote("before [[Target]] after", { path: "Source" });
|
|
1258
|
+
const tools = generateMcpTools(store);
|
|
1259
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1260
|
+
|
|
1261
|
+
const result = await query.execute({ id: "Source" }) as any;
|
|
1262
|
+
expect(result.content).toBe("before [[Target]] after");
|
|
1263
|
+
expect(result.content).not.toContain("<expanded");
|
|
1264
|
+
});
|
|
1265
|
+
|
|
1266
|
+
it("list queries expand per-note and dedup across the result", async () => {
|
|
1267
|
+
await store.createNote("shared body", { path: "Shared" });
|
|
1268
|
+
await store.createNote(
|
|
1269
|
+
"first note references [[Shared]]",
|
|
1270
|
+
{ path: "A", tags: ["list-test"] },
|
|
1271
|
+
);
|
|
1272
|
+
await store.createNote(
|
|
1273
|
+
"second note also references [[Shared]]",
|
|
1274
|
+
{ path: "B", tags: ["list-test"] },
|
|
1275
|
+
);
|
|
1276
|
+
const tools = generateMcpTools(store);
|
|
1277
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1278
|
+
|
|
1279
|
+
const result = await query.execute({
|
|
1280
|
+
tag: ["list-test"],
|
|
1281
|
+
include_content: true,
|
|
1282
|
+
expand_links: true,
|
|
1283
|
+
sort: "asc",
|
|
1284
|
+
}) as any[];
|
|
1285
|
+
|
|
1286
|
+
expect(result).toHaveLength(2);
|
|
1287
|
+
const expandedBlocks = result
|
|
1288
|
+
.map((n) => (n.content.match(/<expanded /g) ?? []).length)
|
|
1289
|
+
.reduce((a, b) => a + b, 0);
|
|
1290
|
+
expect(expandedBlocks).toBe(1); // shared note expanded exactly once total
|
|
1291
|
+
const withMarker = result.find((n) => n.content.includes("(expanded above)"));
|
|
1292
|
+
expect(withMarker).toBeTruthy();
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
it("self-reference does not expand (note can't inline itself)", async () => {
|
|
1296
|
+
await store.createNote("I reference [[Self]] in my own body.", { path: "Self" });
|
|
1297
|
+
const tools = generateMcpTools(store);
|
|
1298
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1299
|
+
|
|
1300
|
+
const result = await query.execute({ id: "Self", expand_links: true }) as any;
|
|
1301
|
+
expect(result.content).not.toContain("<expanded");
|
|
1302
|
+
expect(result.content).toContain("(expanded above)");
|
|
1303
|
+
});
|
|
1304
|
+
|
|
1305
|
+
it("handles [[Target|alias]] and [[Target#anchor]] wikilink forms", async () => {
|
|
1306
|
+
await store.createNote("target body", { path: "Target" });
|
|
1307
|
+
await store.createNote(
|
|
1308
|
+
"See [[Target|the target]] or [[Target#section]].",
|
|
1309
|
+
{ path: "Source" },
|
|
1310
|
+
);
|
|
1311
|
+
const tools = generateMcpTools(store);
|
|
1312
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1313
|
+
|
|
1314
|
+
const result = await query.execute({ id: "Source", expand_links: true }) as any;
|
|
1315
|
+
// Both references resolve to same target — first expands, second marked.
|
|
1316
|
+
const openCount = (result.content.match(/<expanded /g) ?? []).length;
|
|
1317
|
+
expect(openCount).toBe(1);
|
|
1318
|
+
expect(result.content).toContain("(expanded above)");
|
|
1319
|
+
});
|
|
1320
|
+
|
|
1321
|
+
it("does not expand wikilinks inside fenced code blocks", async () => {
|
|
1322
|
+
await store.createNote("target body", { path: "Target" });
|
|
1323
|
+
await store.createNote(
|
|
1324
|
+
"Example code:\n```\n[[Target]]\n```\nAnd a real link: [[Target]].",
|
|
1325
|
+
{ path: "Src" },
|
|
1326
|
+
);
|
|
1327
|
+
const tools = generateMcpTools(store);
|
|
1328
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1329
|
+
|
|
1330
|
+
const result = await query.execute({ id: "Src", expand_links: true }) as any;
|
|
1331
|
+
|
|
1332
|
+
// The fenced [[Target]] stays verbatim; the real one gets expanded exactly once.
|
|
1333
|
+
const expandedOpens = (result.content.match(/<expanded /g) ?? []).length;
|
|
1334
|
+
expect(expandedOpens).toBe(1);
|
|
1335
|
+
expect(result.content).toContain("```\n[[Target]]\n```");
|
|
1336
|
+
});
|
|
1337
|
+
|
|
1338
|
+
it("does not expand wikilinks inside inline code", async () => {
|
|
1339
|
+
await store.createNote("target body", { path: "Target" });
|
|
1340
|
+
await store.createNote(
|
|
1341
|
+
"Pass `[[Target]]` to render a link. A real one: [[Target]].",
|
|
1342
|
+
{ path: "Src" },
|
|
1343
|
+
);
|
|
1344
|
+
const tools = generateMcpTools(store);
|
|
1345
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1346
|
+
|
|
1347
|
+
const result = await query.execute({ id: "Src", expand_links: true }) as any;
|
|
1348
|
+
const expandedOpens = (result.content.match(/<expanded /g) ?? []).length;
|
|
1349
|
+
expect(expandedOpens).toBe(1);
|
|
1350
|
+
expect(result.content).toContain("`[[Target]]`");
|
|
1351
|
+
});
|
|
1352
|
+
|
|
1353
|
+
it("expand_depth=0 is a no-op (no expansion performed)", async () => {
|
|
1354
|
+
await store.createNote("target body", { path: "Target" });
|
|
1355
|
+
await store.createNote("see [[Target]]", { path: "Src" });
|
|
1356
|
+
const tools = generateMcpTools(store);
|
|
1357
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1358
|
+
|
|
1359
|
+
const result = await query.execute({
|
|
1360
|
+
id: "Src",
|
|
1361
|
+
expand_links: true,
|
|
1362
|
+
expand_depth: 0,
|
|
1363
|
+
}) as any;
|
|
1364
|
+
expect(result.content).toBe("see [[Target]]");
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
it("expand_links=true is a silent no-op when include_content=false", async () => {
|
|
1368
|
+
await store.createNote("target body", { path: "Target" });
|
|
1369
|
+
await store.createNote("see [[Target]]", { path: "Src", tags: ["silent"] });
|
|
1370
|
+
const tools = generateMcpTools(store);
|
|
1371
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1372
|
+
|
|
1373
|
+
// List mode defaults to include_content=false; expansion has nothing to
|
|
1374
|
+
// operate on, so the result is the standard lean/index shape.
|
|
1375
|
+
const result = await query.execute({ tag: ["silent"], expand_links: true }) as any[];
|
|
1376
|
+
expect(result).toHaveLength(1);
|
|
1377
|
+
expect(result[0].content).toBeUndefined();
|
|
1378
|
+
expect(result[0].preview).toBeTruthy();
|
|
1379
|
+
});
|
|
1380
|
+
|
|
1381
|
+
it("expand_mode=summary with no metadata.summary renders empty body inline", async () => {
|
|
1382
|
+
await store.createNote("unsummarized body", { path: "Plain" });
|
|
1383
|
+
await store.createNote("see [[Plain]]", { path: "Src" });
|
|
1384
|
+
const tools = generateMcpTools(store);
|
|
1385
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1386
|
+
|
|
1387
|
+
const result = await query.execute({
|
|
1388
|
+
id: "Src",
|
|
1389
|
+
expand_links: true,
|
|
1390
|
+
expand_mode: "summary",
|
|
1391
|
+
}) as any;
|
|
1392
|
+
expect(result.content).toContain('mode="summary"');
|
|
1393
|
+
// Summary is empty — we still get the block but with nothing between delimiters.
|
|
1394
|
+
expect(result.content).not.toContain("unsummarized body");
|
|
1395
|
+
});
|
|
1396
|
+
});
|