@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/CLAUDE.md +2 -2
  3. package/README.md +289 -44
  4. package/core/src/core.test.ts +802 -346
  5. package/core/src/expand.ts +140 -0
  6. package/core/src/hooks.test.ts +27 -27
  7. package/core/src/hooks.ts +1 -1
  8. package/core/src/mcp.ts +102 -39
  9. package/core/src/notes.ts +82 -4
  10. package/core/src/obsidian.test.ts +11 -11
  11. package/core/src/paths.test.ts +46 -46
  12. package/core/src/schema.ts +18 -2
  13. package/core/src/store.ts +51 -51
  14. package/core/src/types.ts +29 -29
  15. package/core/src/wikilinks.test.ts +61 -61
  16. package/docs/HTTP_API.md +4 -2
  17. package/package.json +1 -1
  18. package/src/auth.test.ts +319 -0
  19. package/src/backup-launchd.test.ts +90 -0
  20. package/src/backup-launchd.ts +169 -0
  21. package/src/backup.test.ts +715 -0
  22. package/src/backup.ts +699 -0
  23. package/src/cli.ts +923 -31
  24. package/src/config.test.ts +173 -0
  25. package/src/config.ts +345 -15
  26. package/src/daemon.ts +136 -0
  27. package/src/doctor.test.ts +356 -0
  28. package/src/health.test.ts +201 -0
  29. package/src/health.ts +115 -0
  30. package/src/launchd.test.ts +91 -0
  31. package/src/launchd.ts +37 -40
  32. package/src/mcp-http.ts +1 -1
  33. package/src/mcp-tools.ts +7 -9
  34. package/src/oauth.test.ts +289 -8
  35. package/src/oauth.ts +66 -13
  36. package/src/published.test.ts +21 -21
  37. package/src/routes.ts +152 -70
  38. package/src/routing.test.ts +478 -0
  39. package/src/routing.ts +413 -0
  40. package/src/server.ts +7 -278
  41. package/src/systemd.test.ts +15 -0
  42. package/src/systemd.ts +18 -11
  43. package/src/triggers.test.ts +7 -7
  44. package/src/triggers.ts +6 -6
  45. package/src/vault-store.ts +20 -3
  46. package/src/vault.test.ts +356 -262
  47. package/.claude/settings.local.json +0 -31
  48. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  49. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  50. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  51. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  52. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  53. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  54. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  55. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  56. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  57. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  58. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  59. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  60. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  61. package/religions-abrahamic-filter.png +0 -0
  62. package/religions-buddhism-v2.png +0 -0
  63. package/religions-buddhism.png +0 -0
  64. package/religions-final.png +0 -0
  65. package/religions-v1.png +0 -0
  66. package/religions-v2.png +0 -0
  67. package/religions-zen.png +0 -0
  68. package/web/README.md +0 -73
  69. package/web/bun.lock +0 -827
  70. package/web/eslint.config.js +0 -23
  71. package/web/index.html +0 -15
  72. package/web/package.json +0 -36
  73. package/web/public/favicon.svg +0 -1
  74. package/web/public/icons.svg +0 -24
  75. package/web/src/App.tsx +0 -149
  76. package/web/src/Graph.tsx +0 -200
  77. package/web/src/NoteView.tsx +0 -155
  78. package/web/src/Sidebar.tsx +0 -186
  79. package/web/src/api.ts +0 -21
  80. package/web/src/index.css +0 -50
  81. package/web/src/main.tsx +0 -10
  82. package/web/src/types.ts +0 -37
  83. package/web/src/utils.ts +0 -107
  84. package/web/tsconfig.app.json +0 -25
  85. package/web/tsconfig.json +0 -7
  86. package/web/tsconfig.node.json +0 -24
  87. package/web/vite.config.ts +0 -15
@@ -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("query-notes single note by id", () => {
579
- const note = store.createNote("Hello", { path: "test/note" });
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].id, include_metadata: true }) as any;
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].id, include_metadata: false }) as any;
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].id, include_metadata: ["summary"] }) as any;
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
+ });