@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
package/src/vault.test.ts CHANGED
@@ -29,39 +29,39 @@ afterEach(() => {
29
29
  rmSync(tmpDir, { recursive: true, force: true });
30
30
  });
31
31
 
32
- describe("BunStore", () => {
33
- test("creates and retrieves a note", () => {
34
- const note = store.createNote("Hello world");
32
+ describe("BunStore", async () => {
33
+ test("creates and retrieves a note", async () => {
34
+ const note = await store.createNote("Hello world");
35
35
  expect(note.id).toBeTruthy();
36
36
  expect(note.content).toBe("Hello world");
37
37
 
38
- const fetched = store.getNote(note.id);
38
+ const fetched = await store.getNote(note.id);
39
39
  expect(fetched).not.toBeNull();
40
40
  expect(fetched!.content).toBe("Hello world");
41
41
  });
42
42
 
43
- test("creates note with tags", () => {
44
- const note = store.createNote("Tagged note", { tags: ["daily", "pinned"] });
43
+ test("creates note with tags", async () => {
44
+ const note = await store.createNote("Tagged note", { tags: ["daily", "pinned"] });
45
45
  expect(note.tags).toContain("daily");
46
46
  expect(note.tags).toContain("pinned");
47
47
  });
48
48
 
49
- test("creates note with path", () => {
50
- const note = store.createNote("Doc note", { path: "blog/first-post" });
49
+ test("creates note with path", async () => {
50
+ const note = await store.createNote("Doc note", { path: "blog/first-post" });
51
51
  expect(note.path).toBe("blog/first-post");
52
52
  });
53
53
 
54
- test("updates a note", () => {
55
- const note = store.createNote("Original");
56
- const updated = store.updateNote(note.id, { content: "Updated" });
54
+ test("updates a note", async () => {
55
+ const note = await store.createNote("Original");
56
+ const updated = await store.updateNote(note.id, { content: "Updated" });
57
57
  expect(updated.content).toBe("Updated");
58
58
  expect(updated.updatedAt).toBeTruthy();
59
59
  });
60
60
 
61
- test("user updates bump updatedAt", () => {
62
- const note = store.createNote("Original");
61
+ test("user updates bump updatedAt", async () => {
62
+ const note = await store.createNote("Original");
63
63
  expect(note.updatedAt).toBeUndefined();
64
- const updated = store.updateNote(note.id, { content: "Edited by user" });
64
+ const updated = await store.updateNote(note.id, { content: "Edited by user" });
65
65
  expect(updated.updatedAt).toBeTruthy();
66
66
  });
67
67
 
@@ -69,15 +69,15 @@ describe("BunStore", () => {
69
69
  // Hook writes (e.g., the reader-audio hook's metadata markers) must not
70
70
  // count as user activity. See issue #44 — hook writes were bumping
71
71
  // updatedAt and wrecking Daily's reader sort.
72
- const note = store.createNote("Content");
72
+ const note = await store.createNote("Content");
73
73
  expect(note.updatedAt).toBeUndefined();
74
74
 
75
75
  // Fresh note: a machine write must not set updatedAt.
76
- store.updateNote(note.id, {
76
+ await store.updateNote(note.id, {
77
77
  metadata: { audio_pending_at: "2026-04-09T10:00:00.000Z" },
78
78
  skipUpdatedAt: true,
79
79
  });
80
- let fetched = store.getNote(note.id)!;
80
+ let fetched = (await store.getNote(note.id))!;
81
81
  expect(fetched.updatedAt).toBeUndefined();
82
82
  expect((fetched.metadata as { audio_pending_at?: string } | undefined)?.audio_pending_at).toBe(
83
83
  "2026-04-09T10:00:00.000Z",
@@ -85,152 +85,152 @@ describe("BunStore", () => {
85
85
 
86
86
  // Now a real user edit bumps it.
87
87
  await new Promise((r) => setTimeout(r, 5));
88
- store.updateNote(note.id, { content: "User edit" });
89
- fetched = store.getNote(note.id)!;
88
+ await store.updateNote(note.id, { content: "User edit" });
89
+ fetched = (await store.getNote(note.id))!;
90
90
  const userTs = fetched.updatedAt;
91
91
  expect(userTs).toBeTruthy();
92
92
 
93
93
  // A subsequent machine write must not overwrite the user's timestamp.
94
94
  await new Promise((r) => setTimeout(r, 5));
95
- store.updateNote(note.id, {
95
+ await store.updateNote(note.id, {
96
96
  metadata: {
97
97
  ...(fetched.metadata as Record<string, unknown>),
98
98
  audio_rendered_at: "2026-04-09T10:05:00.000Z",
99
99
  },
100
100
  skipUpdatedAt: true,
101
101
  });
102
- fetched = store.getNote(note.id)!;
102
+ fetched = (await store.getNote(note.id))!;
103
103
  expect(fetched.updatedAt).toBe(userTs!);
104
104
  expect((fetched.metadata as { audio_rendered_at?: string } | undefined)?.audio_rendered_at).toBe(
105
105
  "2026-04-09T10:05:00.000Z",
106
106
  );
107
107
  });
108
108
 
109
- test("deletes a note", () => {
110
- const note = store.createNote("To delete");
111
- store.deleteNote(note.id);
112
- expect(store.getNote(note.id)).toBeNull();
109
+ test("deletes a note", async () => {
110
+ const note = await store.createNote("To delete");
111
+ await store.deleteNote(note.id);
112
+ expect(await store.getNote(note.id)).toBeNull();
113
113
  });
114
114
 
115
- test("queries notes by tag", () => {
116
- store.createNote("A", { tags: ["daily"] });
117
- store.createNote("B", { tags: ["doc"] });
118
- store.createNote("C", { tags: ["daily", "pinned"] });
115
+ test("queries notes by tag", async () => {
116
+ await store.createNote("A", { tags: ["daily"] });
117
+ await store.createNote("B", { tags: ["doc"] });
118
+ await store.createNote("C", { tags: ["daily", "pinned"] });
119
119
 
120
- const daily = store.queryNotes({ tags: ["daily"] });
120
+ const daily = await store.queryNotes({ tags: ["daily"] });
121
121
  expect(daily.length).toBe(2);
122
122
  });
123
123
 
124
- test("queries with exclude tags", () => {
125
- store.createNote("A", { tags: ["daily"] });
126
- store.createNote("B", { tags: ["daily", "archived"] });
124
+ test("queries with exclude tags", async () => {
125
+ await store.createNote("A", { tags: ["daily"] });
126
+ await store.createNote("B", { tags: ["daily", "archived"] });
127
127
 
128
- const active = store.queryNotes({ tags: ["daily"], excludeTags: ["archived"] });
128
+ const active = await store.queryNotes({ tags: ["daily"], excludeTags: ["archived"] });
129
129
  expect(active.length).toBe(1);
130
130
  expect(active[0].content).toBe("A");
131
131
  });
132
132
 
133
- test("full-text search", () => {
134
- store.createNote("The quick brown fox");
135
- store.createNote("A lazy dog");
133
+ test("full-text search", async () => {
134
+ await store.createNote("The quick brown fox");
135
+ await store.createNote("A lazy dog");
136
136
 
137
- const results = store.searchNotes("fox");
137
+ const results = await store.searchNotes("fox");
138
138
  expect(results.length).toBe(1);
139
139
  expect(results[0].content).toContain("fox");
140
140
  });
141
141
 
142
- test("tags and untags notes", () => {
143
- const note = store.createNote("Taggable");
144
- store.tagNote(note.id, ["important"]);
145
- let fetched = store.getNote(note.id)!;
142
+ test("tags and untags notes", async () => {
143
+ const note = await store.createNote("Taggable");
144
+ await store.tagNote(note.id, ["important"]);
145
+ let fetched = (await store.getNote(note.id))!;
146
146
  expect(fetched.tags).toContain("important");
147
147
 
148
- store.untagNote(note.id, ["important"]);
149
- fetched = store.getNote(note.id)!;
148
+ await store.untagNote(note.id, ["important"]);
149
+ fetched = (await store.getNote(note.id))!;
150
150
  expect(fetched.tags).not.toContain("important");
151
151
  });
152
152
 
153
- test("lists tags with counts", () => {
154
- store.createNote("A", { tags: ["daily"] });
155
- store.createNote("B", { tags: ["daily"] });
156
- store.createNote("C", { tags: ["doc"] });
153
+ test("lists tags with counts", async () => {
154
+ await store.createNote("A", { tags: ["daily"] });
155
+ await store.createNote("B", { tags: ["daily"] });
156
+ await store.createNote("C", { tags: ["doc"] });
157
157
 
158
- const tags = store.listTags();
158
+ const tags = await store.listTags();
159
159
  const daily = tags.find((t) => t.name === "daily");
160
160
  expect(daily?.count).toBe(2);
161
161
  const doc = tags.find((t) => t.name === "doc");
162
162
  expect(doc?.count).toBe(1);
163
163
  });
164
164
 
165
- test("creates and queries links", () => {
166
- const a = store.createNote("Note A");
167
- const b = store.createNote("Note B");
165
+ test("creates and queries links", async () => {
166
+ const a = await store.createNote("Note A");
167
+ const b = await store.createNote("Note B");
168
168
 
169
- const link = store.createLink(a.id, b.id, "related-to");
169
+ const link = await store.createLink(a.id, b.id, "related-to");
170
170
  expect(link.sourceId).toBe(a.id);
171
171
  expect(link.targetId).toBe(b.id);
172
172
  expect(link.relationship).toBe("related-to");
173
173
 
174
- const outbound = store.getLinks(a.id, { direction: "outbound" });
174
+ const outbound = await store.getLinks(a.id, { direction: "outbound" });
175
175
  expect(outbound.length).toBe(1);
176
176
 
177
- const inbound = store.getLinks(b.id, { direction: "inbound" });
177
+ const inbound = await store.getLinks(b.id, { direction: "inbound" });
178
178
  expect(inbound.length).toBe(1);
179
179
 
180
- store.deleteLink(a.id, b.id, "related-to");
181
- expect(store.getLinks(a.id).length).toBe(0);
180
+ await store.deleteLink(a.id, b.id, "related-to");
181
+ expect((await store.getLinks(a.id)).length).toBe(0);
182
182
  });
183
183
 
184
- test("attachments", () => {
185
- const note = store.createNote("With attachment");
186
- const att = store.addAttachment(note.id, "/path/to/file.png", "image/png");
184
+ test("attachments", async () => {
185
+ const note = await store.createNote("With attachment");
186
+ const att = await store.addAttachment(note.id, "/path/to/file.png", "image/png");
187
187
  expect(att.noteId).toBe(note.id);
188
188
 
189
- const atts = store.getAttachments(note.id);
189
+ const atts = await store.getAttachments(note.id);
190
190
  expect(atts.length).toBe(1);
191
191
  expect(atts[0].mimeType).toBe("image/png");
192
192
  });
193
193
 
194
- test("starts with no tags", () => {
195
- const tags = store.listTags();
194
+ test("starts with no tags", async () => {
195
+ const tags = await store.listTags();
196
196
  expect(tags.length).toBe(0);
197
197
  });
198
198
 
199
- test("gets note by path", () => {
200
- store.createNote("README content", { path: "Projects/Parachute/README" });
201
- const note = store.getNoteByPath("Projects/Parachute/README");
199
+ test("gets note by path", async () => {
200
+ await store.createNote("README content", { path: "Projects/Parachute/README" });
201
+ const note = await store.getNoteByPath("Projects/Parachute/README");
202
202
  expect(note).not.toBeNull();
203
203
  expect(note!.content).toBe("README content");
204
204
  expect(note!.path).toBe("Projects/Parachute/README");
205
205
  });
206
206
 
207
- test("gets multiple notes by IDs", () => {
208
- const a = store.createNote("A");
209
- const b = store.createNote("B");
210
- const c = store.createNote("C");
207
+ test("gets multiple notes by IDs", async () => {
208
+ const a = await store.createNote("A");
209
+ const b = await store.createNote("B");
210
+ const c = await store.createNote("C");
211
211
 
212
- const fetched = store.getNotes([a.id, c.id]);
212
+ const fetched = await store.getNotes([a.id, c.id]);
213
213
  expect(fetched.length).toBe(2);
214
214
  expect(fetched.map((n) => n.content)).toContain("A");
215
215
  expect(fetched.map((n) => n.content)).toContain("C");
216
216
  });
217
217
 
218
- test("queries by path prefix", () => {
219
- store.createNote("Root README", { path: "README" });
220
- store.createNote("Project README", { path: "Projects/Parachute/README" });
221
- store.createNote("Project Notes", { path: "Projects/Parachute/Notes" });
222
- store.createNote("Other", { path: "Other/Stuff" });
218
+ test("queries by path prefix", async () => {
219
+ await store.createNote("Root README", { path: "README" });
220
+ await store.createNote("Project README", { path: "Projects/Parachute/README" });
221
+ await store.createNote("Project Notes", { path: "Projects/Parachute/Notes" });
222
+ await store.createNote("Other", { path: "Other/Stuff" });
223
223
 
224
- const results = store.queryNotes({ pathPrefix: "Projects/Parachute" });
224
+ const results = await store.queryNotes({ pathPrefix: "Projects/Parachute" });
225
225
  expect(results.length).toBe(2);
226
226
  expect(results.map((n) => n.path)).toContain("Projects/Parachute/README");
227
227
  expect(results.map((n) => n.path)).toContain("Projects/Parachute/Notes");
228
228
  });
229
229
  });
230
230
 
231
- describe("metadata", () => {
232
- test("creates note with metadata", () => {
233
- const note = store.createNote("Meeting notes", {
231
+ describe("metadata", async () => {
232
+ test("creates note with metadata", async () => {
233
+ const note = await store.createNote("Meeting notes", {
234
234
  path: "Meetings/standup",
235
235
  metadata: { status: "draft", priority: "high", attendees: ["alice", "bob"] },
236
236
  });
@@ -240,35 +240,35 @@ describe("metadata", () => {
240
240
  expect(note.metadata!.attendees).toEqual(["alice", "bob"]);
241
241
  });
242
242
 
243
- test("updates note metadata", () => {
244
- const note = store.createNote("Doc", { metadata: { status: "draft" } });
245
- const updated = store.updateNote(note.id, { metadata: { status: "published", version: 2 } });
243
+ test("updates note metadata", async () => {
244
+ const note = await store.createNote("Doc", { metadata: { status: "draft" } });
245
+ const updated = await store.updateNote(note.id, { metadata: { status: "published", version: 2 } });
246
246
  expect(updated.metadata!.status).toBe("published");
247
247
  expect(updated.metadata!.version).toBe(2);
248
248
  });
249
249
 
250
- test("queries notes by metadata", () => {
251
- store.createNote("Draft 1", { metadata: { status: "draft" } });
252
- store.createNote("Draft 2", { metadata: { status: "draft" } });
253
- store.createNote("Published", { metadata: { status: "published" } });
250
+ test("queries notes by metadata", async () => {
251
+ await store.createNote("Draft 1", { metadata: { status: "draft" } });
252
+ await store.createNote("Draft 2", { metadata: { status: "draft" } });
253
+ await store.createNote("Published", { metadata: { status: "published" } });
254
254
 
255
- const drafts = store.queryNotes({ metadata: { status: "draft" } });
255
+ const drafts = await store.queryNotes({ metadata: { status: "draft" } });
256
256
  expect(drafts.length).toBe(2);
257
257
 
258
- const published = store.queryNotes({ metadata: { status: "published" } });
258
+ const published = await store.queryNotes({ metadata: { status: "published" } });
259
259
  expect(published.length).toBe(1);
260
260
  expect(published[0].content).toBe("Published");
261
261
  });
262
262
 
263
- test("notes without metadata return undefined metadata", () => {
264
- const note = store.createNote("Plain note");
263
+ test("notes without metadata return undefined metadata", async () => {
264
+ const note = await store.createNote("Plain note");
265
265
  expect(note.metadata).toBeUndefined();
266
266
  });
267
267
 
268
- test("creates link with metadata", () => {
269
- const a = store.createNote("A");
270
- const b = store.createNote("B");
271
- const link = store.createLink(a.id, b.id, "related-to", {
268
+ test("creates link with metadata", async () => {
269
+ const a = await store.createNote("A");
270
+ const b = await store.createNote("B");
271
+ const link = await store.createLink(a.id, b.id, "related-to", {
272
272
  confidence: 0.9,
273
273
  context: "mentioned in meeting",
274
274
  });
@@ -277,10 +277,10 @@ describe("metadata", () => {
277
277
  expect(link.metadata!.context).toBe("mentioned in meeting");
278
278
  });
279
279
 
280
- test("hydrated links include note metadata", () => {
281
- const a = store.createNote("A", { metadata: { type: "project" } });
282
- const b = store.createNote("B", { metadata: { type: "task" } });
283
- store.createLink(a.id, b.id, "contains");
280
+ test("hydrated links include note metadata", async () => {
281
+ const a = await store.createNote("A", { metadata: { type: "project" } });
282
+ const b = await store.createNote("B", { metadata: { type: "task" } });
283
+ await store.createLink(a.id, b.id, "contains");
284
284
 
285
285
  const links = getLinksHydrated(db, a.id);
286
286
  expect(links[0].sourceNote?.metadata?.type).toBe("project");
@@ -288,9 +288,9 @@ describe("metadata", () => {
288
288
  });
289
289
  });
290
290
 
291
- describe("bulk operations", () => {
292
- test("creates multiple notes at once", () => {
293
- const notes = store.createNotes([
291
+ describe("bulk operations", async () => {
292
+ test("creates multiple notes at once", async () => {
293
+ const notes = await store.createNotes([
294
294
  { content: "Note 1", tags: ["daily"] },
295
295
  { content: "Note 2", tags: ["doc"] },
296
296
  { content: "Note 3" },
@@ -300,8 +300,8 @@ describe("bulk operations", () => {
300
300
  expect(notes[1].tags).toContain("doc");
301
301
  });
302
302
 
303
- test("createNotes accepts per-note metadata and created_at (mixed batch)", () => {
304
- const notes = store.createNotes([
303
+ test("createNotes accepts per-note metadata and created_at (mixed batch)", async () => {
304
+ const notes = await store.createNotes([
305
305
  { content: "Plain", tags: ["daily"] },
306
306
  {
307
307
  content: "With metadata",
@@ -331,7 +331,7 @@ describe("bulk operations", () => {
331
331
  expect(notes[2].metadata?.source).toBe("tana-import");
332
332
  });
333
333
 
334
- test("createNotes preserves per-note metadata isolation across many notes", () => {
334
+ test("createNotes preserves per-note metadata isolation across many notes", async () => {
335
335
  const inputs = Array.from({ length: 5 }, (_, i) => ({
336
336
  content: `Day ${i}`,
337
337
  path: `Journal/2024-06-${String(i + 1).padStart(2, "0")}`,
@@ -343,7 +343,7 @@ describe("bulk operations", () => {
343
343
  },
344
344
  created_at: `2024-06-${String(i + 1).padStart(2, "0")}T12:00:00.000Z`,
345
345
  }));
346
- const notes = store.createNotes(inputs);
346
+ const notes = await store.createNotes(inputs);
347
347
  expect(notes.length).toBe(5);
348
348
  for (let i = 0; i < 5; i++) {
349
349
  expect(notes[i].path).toBe(`Journal/2024-06-${String(i + 1).padStart(2, "0")}`);
@@ -354,9 +354,9 @@ describe("bulk operations", () => {
354
354
  }
355
355
  });
356
356
 
357
- test("createNotes is backwards compatible — omitted metadata/created_at use defaults", () => {
357
+ test("createNotes is backwards compatible — omitted metadata/created_at use defaults", async () => {
358
358
  const before = new Date().toISOString();
359
- const notes = store.createNotes([
359
+ const notes = await store.createNotes([
360
360
  { content: "Just content" },
361
361
  { content: "Content + tags", tags: ["x"] },
362
362
  ]);
@@ -368,89 +368,89 @@ describe("bulk operations", () => {
368
368
  expect(notes[0].createdAt <= after).toBe(true);
369
369
  });
370
370
 
371
- test("batch tags multiple notes", () => {
372
- const a = store.createNote("A");
373
- const b = store.createNote("B");
374
- const c = store.createNote("C");
371
+ test("batch tags multiple notes", async () => {
372
+ const a = await store.createNote("A");
373
+ const b = await store.createNote("B");
374
+ const c = await store.createNote("C");
375
375
 
376
- store.batchTag([a.id, b.id, c.id], ["important", "review"]);
376
+ await store.batchTag([a.id, b.id, c.id], ["important", "review"]);
377
377
 
378
- expect(store.getNote(a.id)!.tags).toContain("important");
379
- expect(store.getNote(b.id)!.tags).toContain("review");
380
- expect(store.getNote(c.id)!.tags).toContain("important");
378
+ expect((await store.getNote(a.id))!.tags).toContain("important");
379
+ expect((await store.getNote(b.id))!.tags).toContain("review");
380
+ expect((await store.getNote(c.id))!.tags).toContain("important");
381
381
  });
382
382
 
383
- test("batch untags multiple notes", () => {
384
- const a = store.createNote("A", { tags: ["daily", "pinned"] });
385
- const b = store.createNote("B", { tags: ["daily", "pinned"] });
383
+ test("batch untags multiple notes", async () => {
384
+ const a = await store.createNote("A", { tags: ["daily", "pinned"] });
385
+ const b = await store.createNote("B", { tags: ["daily", "pinned"] });
386
386
 
387
- store.batchUntag([a.id, b.id], ["pinned"]);
387
+ await store.batchUntag([a.id, b.id], ["pinned"]);
388
388
 
389
- expect(store.getNote(a.id)!.tags).toContain("daily");
390
- expect(store.getNote(a.id)!.tags).not.toContain("pinned");
391
- expect(store.getNote(b.id)!.tags).not.toContain("pinned");
389
+ expect((await store.getNote(a.id))!.tags).toContain("daily");
390
+ expect((await store.getNote(a.id))!.tags).not.toContain("pinned");
391
+ expect((await store.getNote(b.id))!.tags).not.toContain("pinned");
392
392
  });
393
393
  });
394
394
 
395
- describe("deeper link queries", () => {
396
- test("traverses links multi-hop", () => {
397
- const a = store.createNote("A");
398
- const b = store.createNote("B");
399
- const c = store.createNote("C");
400
- const d = store.createNote("D");
395
+ describe("deeper link queries", async () => {
396
+ test("traverses links multi-hop", async () => {
397
+ const a = await store.createNote("A");
398
+ const b = await store.createNote("B");
399
+ const c = await store.createNote("C");
400
+ const d = await store.createNote("D");
401
401
 
402
- store.createLink(a.id, b.id, "related-to");
403
- store.createLink(b.id, c.id, "related-to");
404
- store.createLink(c.id, d.id, "related-to");
402
+ await store.createLink(a.id, b.id, "related-to");
403
+ await store.createLink(b.id, c.id, "related-to");
404
+ await store.createLink(c.id, d.id, "related-to");
405
405
 
406
406
  // 1 hop from A: should find B
407
- const hop1 = store.traverseLinks(a.id, { max_depth: 1 });
407
+ const hop1 = await store.traverseLinks(a.id, { max_depth: 1 });
408
408
  expect(hop1.length).toBe(1);
409
409
  expect(hop1[0].noteId).toBe(b.id);
410
410
 
411
411
  // 2 hops from A: should find B and C
412
- const hop2 = store.traverseLinks(a.id, { max_depth: 2 });
412
+ const hop2 = await store.traverseLinks(a.id, { max_depth: 2 });
413
413
  expect(hop2.length).toBe(2);
414
414
  const ids2 = hop2.map((n) => n.noteId);
415
415
  expect(ids2).toContain(b.id);
416
416
  expect(ids2).toContain(c.id);
417
417
 
418
418
  // 3 hops from A: should find B, C, and D
419
- const hop3 = store.traverseLinks(a.id, { max_depth: 3 });
419
+ const hop3 = await store.traverseLinks(a.id, { max_depth: 3 });
420
420
  expect(hop3.length).toBe(3);
421
421
  });
422
422
 
423
- test("traverses with relationship filter", () => {
424
- const a = store.createNote("A");
425
- const b = store.createNote("B");
426
- const c = store.createNote("C");
423
+ test("traverses with relationship filter", async () => {
424
+ const a = await store.createNote("A");
425
+ const b = await store.createNote("B");
426
+ const c = await store.createNote("C");
427
427
 
428
- store.createLink(a.id, b.id, "mentions");
429
- store.createLink(a.id, c.id, "related-to");
428
+ await store.createLink(a.id, b.id, "mentions");
429
+ await store.createLink(a.id, c.id, "related-to");
430
430
 
431
- const mentions = store.traverseLinks(a.id, { max_depth: 1, relationship: "mentions" });
431
+ const mentions = await store.traverseLinks(a.id, { max_depth: 1, relationship: "mentions" });
432
432
  expect(mentions.length).toBe(1);
433
433
  expect(mentions[0].noteId).toBe(b.id);
434
434
  });
435
435
 
436
- test("finds path between notes", () => {
437
- const a = store.createNote("A");
438
- const b = store.createNote("B");
439
- const c = store.createNote("C");
436
+ test("finds path between notes", async () => {
437
+ const a = await store.createNote("A");
438
+ const b = await store.createNote("B");
439
+ const c = await store.createNote("C");
440
440
 
441
- store.createLink(a.id, b.id, "related-to");
442
- store.createLink(b.id, c.id, "mentions");
441
+ await store.createLink(a.id, b.id, "related-to");
442
+ await store.createLink(b.id, c.id, "mentions");
443
443
 
444
- const result = store.findPath(a.id, c.id);
444
+ const result = await store.findPath(a.id, c.id);
445
445
  expect(result).not.toBeNull();
446
446
  expect(result!.path).toEqual([a.id, b.id, c.id]);
447
447
  expect(result!.relationships).toEqual(["related-to", "mentions"]);
448
448
  });
449
449
 
450
- test("get-links returns hydrated note summaries", () => {
451
- const a = store.createNote("Note A", { path: "a", tags: ["important"] });
452
- const b = store.createNote("Note B", { path: "b" });
453
- store.createLink(a.id, b.id, "related-to");
450
+ test("get-links returns hydrated note summaries", async () => {
451
+ const a = await store.createNote("Note A", { path: "a", tags: ["important"] });
452
+ const b = await store.createNote("Note B", { path: "b" });
453
+ await store.createLink(a.id, b.id, "related-to");
454
454
 
455
455
  const result = getLinksHydrated(db, a.id);
456
456
  expect(result.length).toBe(1);
@@ -459,17 +459,17 @@ describe("deeper link queries", () => {
459
459
  expect(result[0].sourceNote?.tags).toContain("important");
460
460
  });
461
461
 
462
- test("returns null when no path exists", () => {
463
- const a = store.createNote("A");
464
- const b = store.createNote("B");
462
+ test("returns null when no path exists", async () => {
463
+ const a = await store.createNote("A");
464
+ const b = await store.createNote("B");
465
465
  // No link between them
466
466
 
467
- const result = store.findPath(a.id, b.id);
467
+ const result = await store.findPath(a.id, b.id);
468
468
  expect(result).toBeNull();
469
469
  });
470
470
  });
471
471
 
472
- describe("MCP tools", () => {
472
+ describe("MCP tools", async () => {
473
473
  test("generates all 9 core tools", () => {
474
474
  const tools = generateMcpTools(store);
475
475
  expect(tools.length).toBe(9);
@@ -486,29 +486,29 @@ describe("MCP tools", () => {
486
486
  expect(names).toContain("vault-info");
487
487
  });
488
488
 
489
- test("query-notes by id works", () => {
489
+ test("query-notes by id works", async () => {
490
490
  const tools = generateMcpTools(store);
491
- const note = store.createNote("By ID", { path: "test/note" });
491
+ const note = await store.createNote("By ID", { path: "test/note" });
492
492
 
493
493
  const query = tools.find((t) => t.name === "query-notes")!;
494
- const result = query.execute({ id: note.id }) as any;
494
+ const result = await query.execute({ id: note.id }) as any;
495
495
  expect(result.content).toBe("By ID");
496
496
  expect(result.path).toBe("test/note");
497
497
  });
498
498
 
499
- test("query-notes by path works", () => {
499
+ test("query-notes by path works", async () => {
500
500
  const tools = generateMcpTools(store);
501
- store.createNote("By Path", { path: "Projects/README" });
501
+ await store.createNote("By Path", { path: "Projects/README" });
502
502
 
503
503
  const query = tools.find((t) => t.name === "query-notes")!;
504
- const result = query.execute({ id: "Projects/README" }) as any;
504
+ const result = await query.execute({ id: "Projects/README" }) as any;
505
505
  expect(result.content).toBe("By Path");
506
506
  });
507
507
 
508
- test("create-note tool works via execute", () => {
508
+ test("create-note tool works via execute", async () => {
509
509
  const tools = generateMcpTools(store);
510
510
  const createNote = tools.find((t) => t.name === "create-note")!;
511
- const result = createNote.execute({ content: "MCP note", tags: ["daily"] }) as any;
511
+ const result = await createNote.execute({ content: "MCP note", tags: ["daily"] }) as any;
512
512
  expect(result.content).toBe("MCP note");
513
513
  expect(result.tags).toContain("daily");
514
514
  });
@@ -522,7 +522,7 @@ describe("MCP tools", () => {
522
522
  });
523
523
  });
524
524
 
525
- describe("unified MCP wrapper", () => {
525
+ describe("unified MCP wrapper", async () => {
526
526
  test("vault-info routes through vault param", async () => {
527
527
  const { generateUnifiedMcpTools } = await import("./mcp-tools.ts");
528
528
  const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
@@ -538,14 +538,14 @@ describe("unified MCP wrapper", () => {
538
538
  writeGlobalConfig({ port: 1940, default_vault: vaultName });
539
539
 
540
540
  const vaultStore = getVaultStore(vaultName);
541
- vaultStore.createNote("alpha", { tags: ["x", "y"] });
542
- vaultStore.createNote("beta", { tags: ["x"] });
541
+ await vaultStore.createNote("alpha", { tags: ["x", "y"] });
542
+ await vaultStore.createNote("beta", { tags: ["x"] });
543
543
 
544
544
  const tools = generateUnifiedMcpTools();
545
545
  const vaultInfo = tools.find((t) => t.name === "vault-info");
546
546
  expect(vaultInfo).toBeTruthy();
547
547
 
548
- const result = vaultInfo!.execute({ vault: vaultName, include_stats: true }) as any;
548
+ const result = await vaultInfo!.execute({ vault: vaultName, include_stats: true }) as any;
549
549
  expect(result.name).toBe(vaultName);
550
550
  expect(result.description).toBe("Test vault");
551
551
  expect(result.stats.totalNotes).toBe(2);
@@ -568,8 +568,8 @@ describe("unified MCP wrapper", () => {
568
568
  writeGlobalConfig({ port: 1940, default_vault: vaultName });
569
569
 
570
570
  const vaultStore = getVaultStore(vaultName);
571
- vaultStore.createNote("A", { tags: ["person"] });
572
- vaultStore.upsertTagSchema("person", {
571
+ await vaultStore.createNote("A", { tags: ["person"] });
572
+ await vaultStore.upsertTagSchema("person", {
573
573
  description: "A person",
574
574
  fields: { name: { type: "string", description: "Full name" } },
575
575
  });
@@ -578,7 +578,7 @@ describe("unified MCP wrapper", () => {
578
578
 
579
579
  // list-tags with tag param for single tag detail
580
580
  const listTags = tools.find((t) => t.name === "list-tags")!;
581
- const detail = listTags.execute({ vault: vaultName, tag: "person" }) as any;
581
+ const detail = await listTags.execute({ vault: vaultName, tag: "person" }) as any;
582
582
  expect(detail.name).toBe("person");
583
583
  expect(detail.count).toBe(1);
584
584
  expect(detail.description).toBe("A person");
@@ -601,7 +601,7 @@ describe("unified MCP wrapper", () => {
601
601
  writeGlobalConfig({ port: 1940, default_vault: vaultName });
602
602
 
603
603
  const vaultStore = getVaultStore(vaultName);
604
- vaultStore.upsertTagSchema("person", {
604
+ await vaultStore.upsertTagSchema("person", {
605
605
  description: "A person",
606
606
  fields: {
607
607
  first_appeared: { type: "string", description: "When" },
@@ -614,7 +614,7 @@ describe("unified MCP wrapper", () => {
614
614
  const queryNotes = tools.find((t) => t.name === "query-notes")!;
615
615
 
616
616
  // Create a note tagged person with no metadata — defaults auto-populated
617
- const result = createNote.execute({
617
+ const result = await createNote.execute({
618
618
  vault: vaultName,
619
619
  content: "Alice",
620
620
  tags: ["person"],
@@ -622,18 +622,18 @@ describe("unified MCP wrapper", () => {
622
622
  expect(result.content).toBe("Alice");
623
623
 
624
624
  // Verify defaults were written
625
- const fresh = queryNotes.execute({ vault: vaultName, id: result.id }) as any;
625
+ const fresh = await queryNotes.execute({ vault: vaultName, id: result.id }) as any;
626
626
  expect(fresh.metadata.first_appeared).toBe("");
627
627
  expect(fresh.metadata.relationship).toBe("");
628
628
 
629
629
  // Create with explicit metadata — preserved
630
- const result2 = createNote.execute({
630
+ const result2 = await createNote.execute({
631
631
  vault: vaultName,
632
632
  content: "Bob",
633
633
  tags: ["person"],
634
634
  metadata: { first_appeared: "2024-01", relationship: "friend" },
635
635
  }) as any;
636
- const fresh2 = queryNotes.execute({ vault: vaultName, id: result2.id }) as any;
636
+ const fresh2 = await queryNotes.execute({ vault: vaultName, id: result2.id }) as any;
637
637
  expect(fresh2.metadata.first_appeared).toBe("2024-01");
638
638
  expect(fresh2.metadata.relationship).toBe("friend");
639
639
 
@@ -654,14 +654,14 @@ describe("unified MCP wrapper", () => {
654
654
  writeGlobalConfig({ port: 1940, default_vault: vaultName });
655
655
 
656
656
  const vaultStore = getVaultStore(vaultName);
657
- vaultStore.upsertTagSchema("person", {
657
+ await vaultStore.upsertTagSchema("person", {
658
658
  description: "A person",
659
659
  fields: {
660
660
  first_appeared: { type: "string", description: "When" },
661
661
  relationship: { type: "string", description: "How" },
662
662
  },
663
663
  });
664
- vaultStore.upsertTagSchema("project", {
664
+ await vaultStore.upsertTagSchema("project", {
665
665
  description: "A project",
666
666
  fields: {
667
667
  status: { type: "string", enum: ["active", "completed", "abandoned"], description: "Status" },
@@ -675,35 +675,35 @@ describe("unified MCP wrapper", () => {
675
675
  const queryNotes = tools.find((t) => t.name === "query-notes")!;
676
676
 
677
677
  // Create a note, then add #person tag via update-note
678
- const note = createNote.execute({ vault: vaultName, content: "Alice" }) as any;
679
- updateNote.execute({ vault: vaultName, id: note.id, tags: { add: ["person"] } });
680
- const after = queryNotes.execute({ vault: vaultName, id: note.id }) as any;
678
+ const note = await createNote.execute({ vault: vaultName, content: "Alice" }) as any;
679
+ await updateNote.execute({ vault: vaultName, id: note.id, tags: { add: ["person"] } });
680
+ const after = await queryNotes.execute({ vault: vaultName, id: note.id }) as any;
681
681
  expect(after.metadata.first_appeared).toBe("");
682
682
  expect(after.metadata.relationship).toBe("");
683
683
 
684
684
  // Tag note that already has partial metadata — only missing fields populated
685
- const note2 = createNote.execute({
685
+ const note2 = await createNote.execute({
686
686
  vault: vaultName,
687
687
  content: "Bob",
688
688
  metadata: { first_appeared: "2023-11" },
689
689
  }) as any;
690
- updateNote.execute({ vault: vaultName, id: note2.id, tags: { add: ["person"] } });
691
- const after2 = queryNotes.execute({ vault: vaultName, id: note2.id }) as any;
690
+ await updateNote.execute({ vault: vaultName, id: note2.id, tags: { add: ["person"] } });
691
+ const after2 = await queryNotes.execute({ vault: vaultName, id: note2.id }) as any;
692
692
  expect(after2.metadata.first_appeared).toBe("2023-11"); // preserved
693
693
  expect(after2.metadata.relationship).toBe(""); // added
694
694
 
695
695
  // Tag with #project — enum defaults to first value, boolean to false, integer to 0
696
- const note4 = createNote.execute({ vault: vaultName, content: "My Project" }) as any;
697
- updateNote.execute({ vault: vaultName, id: note4.id, tags: { add: ["project"] } });
698
- const after4 = queryNotes.execute({ vault: vaultName, id: note4.id }) as any;
696
+ const note4 = await createNote.execute({ vault: vaultName, content: "My Project" }) as any;
697
+ await updateNote.execute({ vault: vaultName, id: note4.id, tags: { add: ["project"] } });
698
+ const after4 = await queryNotes.execute({ vault: vaultName, id: note4.id }) as any;
699
699
  expect(after4.metadata.status).toBe("active");
700
700
  expect(after4.metadata.active).toBe(false);
701
701
  expect(after4.metadata.priority).toBe(0);
702
702
 
703
703
  // Multiple schema tags at once — all defaults merged
704
- const note5 = createNote.execute({ vault: vaultName, content: "Multi" }) as any;
705
- updateNote.execute({ vault: vaultName, id: note5.id, tags: { add: ["person", "project"] } });
706
- const after5 = queryNotes.execute({ vault: vaultName, id: note5.id }) as any;
704
+ const note5 = await createNote.execute({ vault: vaultName, content: "Multi" }) as any;
705
+ await updateNote.execute({ vault: vaultName, id: note5.id, tags: { add: ["person", "project"] } });
706
+ const after5 = await queryNotes.execute({ vault: vaultName, id: note5.id }) as any;
707
707
  expect(after5.metadata.first_appeared).toBe("");
708
708
  expect(after5.metadata.relationship).toBe("");
709
709
  expect(after5.metadata.status).toBe("active");
@@ -726,7 +726,7 @@ describe("unified MCP wrapper", () => {
726
726
  writeGlobalConfig({ port: 1940, default_vault: vaultName });
727
727
 
728
728
  const vaultStore = getVaultStore(vaultName);
729
- vaultStore.upsertTagSchema("person", {
729
+ await vaultStore.upsertTagSchema("person", {
730
730
  description: "A person",
731
731
  fields: { name: { type: "string" } },
732
732
  });
@@ -736,10 +736,10 @@ describe("unified MCP wrapper", () => {
736
736
  const updateNote = tools.find((t) => t.name === "update-note")!;
737
737
  const queryNotes = tools.find((t) => t.name === "query-notes")!;
738
738
 
739
- const note = createNote.execute({ vault: vaultName, content: "Test" }) as any;
739
+ const note = await createNote.execute({ vault: vaultName, content: "Test" }) as any;
740
740
  const originalUpdatedAt = note.updatedAt;
741
- updateNote.execute({ vault: vaultName, id: note.id, tags: { add: ["person"] } });
742
- const after = queryNotes.execute({ vault: vaultName, id: note.id }) as any;
741
+ await updateNote.execute({ vault: vaultName, id: note.id, tags: { add: ["person"] } });
742
+ const after = await queryNotes.execute({ vault: vaultName, id: note.id }) as any;
743
743
  expect(after.updatedAt).toBe(originalUpdatedAt);
744
744
  expect(after.metadata.name).toBe("");
745
745
 
@@ -807,10 +807,10 @@ function mkReq(method: string, path: string, body?: unknown): Request {
807
807
  return new Request(`${BASE}${path}`, init);
808
808
  }
809
809
 
810
- describe("HTTP /notes", () => {
810
+ describe("HTTP /notes", async () => {
811
811
  test("GET /notes defaults to lean index (no content field)", async () => {
812
- store.createNote("one content", { path: "a", tags: ["t"] });
813
- store.createNote("two content", { path: "b", tags: ["t"] });
812
+ await store.createNote("one content", { path: "a", tags: ["t"] });
813
+ await store.createNote("two content", { path: "b", tags: ["t"] });
814
814
  const res = await handleNotes(mkReq("GET", "/notes"), store, "");
815
815
  const body = await res.json() as any[];
816
816
  expect(body).toHaveLength(2);
@@ -820,22 +820,22 @@ describe("HTTP /notes", () => {
820
820
  });
821
821
 
822
822
  test("GET /notes?include_content=true returns full notes", async () => {
823
- store.createNote("full body", { path: "a" });
823
+ await store.createNote("full body", { path: "a" });
824
824
  const res = await handleNotes(mkReq("GET", "/notes?include_content=true"), store, "");
825
825
  const body = await res.json() as any[];
826
826
  expect(body[0].content).toBe("full body");
827
827
  });
828
828
 
829
829
  test("GET /notes?search=fox full-text search", async () => {
830
- store.createNote("The quick brown fox");
831
- store.createNote("A lazy dog");
830
+ await store.createNote("The quick brown fox");
831
+ await store.createNote("A lazy dog");
832
832
  const res = await handleNotes(mkReq("GET", "/notes?search=fox"), store, "");
833
833
  const body = await res.json() as any[];
834
834
  expect(body).toHaveLength(1);
835
835
  });
836
836
 
837
837
  test("GET /notes?search=fox&include_metadata=false strips metadata from search results", async () => {
838
- store.createNote("The quick brown fox", { metadata: { summary: "animal" } });
838
+ await store.createNote("The quick brown fox", { metadata: { summary: "animal" } });
839
839
  const res = await handleNotes(mkReq("GET", "/notes?search=fox&include_metadata=false"), store, "");
840
840
  const body = await res.json() as any[];
841
841
  expect(body).toHaveLength(1);
@@ -843,14 +843,14 @@ describe("HTTP /notes", () => {
843
843
  });
844
844
 
845
845
  test("GET /notes/:id defaults to full content", async () => {
846
- const n = store.createNote("hello", { id: "x" });
846
+ const n = await store.createNote("hello", { id: "x" });
847
847
  const res = await handleNotes(mkReq("GET", "/notes/x"), store, "/x");
848
848
  const body = await res.json() as any;
849
849
  expect(body.content).toBe("hello");
850
850
  });
851
851
 
852
852
  test("GET /notes/:id?include_content=false returns lean shape", async () => {
853
- store.createNote("hello", { id: "x" });
853
+ await store.createNote("hello", { id: "x" });
854
854
  const res = await handleNotes(mkReq("GET", "/notes/x?include_content=false"), store, "/x");
855
855
  const body = await res.json() as any;
856
856
  expect(body).not.toHaveProperty("content");
@@ -859,8 +859,8 @@ describe("HTTP /notes", () => {
859
859
  });
860
860
 
861
861
  test("GET /notes?include_metadata=false strips metadata from list", async () => {
862
- store.createNote("a", { tags: ["m"], metadata: { summary: "hello", status: "ok" } });
863
- store.createNote("b", { tags: ["m"], metadata: { summary: "world" } });
862
+ await store.createNote("a", { tags: ["m"], metadata: { summary: "hello", status: "ok" } });
863
+ await store.createNote("b", { tags: ["m"], metadata: { summary: "world" } });
864
864
  const res = await handleNotes(mkReq("GET", "/notes?tag=m&include_metadata=false"), store, "");
865
865
  const body = await res.json() as any[];
866
866
  expect(body).toHaveLength(2);
@@ -870,7 +870,7 @@ describe("HTTP /notes", () => {
870
870
  });
871
871
 
872
872
  test("GET /notes?include_metadata=summary,status returns only those fields", async () => {
873
- store.createNote("a", { tags: ["mf"], metadata: { summary: "hello", status: "ok", extra: true } });
873
+ await store.createNote("a", { tags: ["mf"], metadata: { summary: "hello", status: "ok", extra: true } });
874
874
  const res = await handleNotes(mkReq("GET", "/notes?tag=mf&include_metadata=summary,status"), store, "");
875
875
  const body = await res.json() as any[];
876
876
  expect(body).toHaveLength(1);
@@ -878,7 +878,7 @@ describe("HTTP /notes", () => {
878
878
  });
879
879
 
880
880
  test("GET /notes/:id?include_metadata=false strips metadata from single note", async () => {
881
- store.createNote("hello", { id: "xm", metadata: { summary: "s" } });
881
+ await store.createNote("hello", { id: "xm", metadata: { summary: "s" } });
882
882
  const res = await handleNotes(mkReq("GET", "/notes/xm?include_metadata=false"), store, "/xm");
883
883
  const body = await res.json() as any;
884
884
  expect(body.metadata).toBeUndefined();
@@ -886,12 +886,61 @@ describe("HTTP /notes", () => {
886
886
  });
887
887
 
888
888
  test("GET /notes/:id?include_metadata=summary returns only specified fields", async () => {
889
- store.createNote("hello", { id: "xm2", metadata: { summary: "s", status: "draft" } });
889
+ await store.createNote("hello", { id: "xm2", metadata: { summary: "s", status: "draft" } });
890
890
  const res = await handleNotes(mkReq("GET", "/notes/xm2?include_metadata=summary"), store, "/xm2");
891
891
  const body = await res.json() as any;
892
892
  expect(body.metadata).toEqual({ summary: "s" });
893
893
  });
894
894
 
895
+ test("GET /notes/:id?expand_links=true inlines wikilink content", async () => {
896
+ await store.createNote("target body", { path: "Target" });
897
+ await store.createNote("see [[Target]]", { id: "src", path: "Src" });
898
+ const res = await handleNotes(
899
+ mkReq("GET", "/notes/src?expand_links=true"),
900
+ store,
901
+ "/src",
902
+ );
903
+ const body = await res.json() as any;
904
+ expect(body.content).toContain('<expanded path="Target" mode="full">');
905
+ expect(body.content).toContain("target body");
906
+ });
907
+
908
+ test("GET /notes/:id?expand_links=true&expand_mode=summary inlines metadata.summary only", async () => {
909
+ await store.createNote("long body", {
910
+ path: "T",
911
+ metadata: { summary: "short" },
912
+ });
913
+ await store.createNote("see [[T]]", { id: "sx", path: "Sx" });
914
+ const res = await handleNotes(
915
+ mkReq("GET", "/notes/sx?expand_links=true&expand_mode=summary"),
916
+ store,
917
+ "/sx",
918
+ );
919
+ const body = await res.json() as any;
920
+ expect(body.content).toContain('mode="summary"');
921
+ expect(body.content).toContain("short");
922
+ expect(body.content).not.toContain("long body");
923
+ });
924
+
925
+ test("GET /notes?tag=&include_content=true&expand_links=true expands per-note with cross-note dedup", async () => {
926
+ await store.createNote("shared body", { path: "Shared" });
927
+ await store.createNote("first [[Shared]]", { path: "A", tags: ["el"] });
928
+ await store.createNote("second [[Shared]]", { path: "B", tags: ["el"] });
929
+ const res = await handleNotes(
930
+ mkReq("GET", "/notes?tag=el&include_content=true&expand_links=true&sort=asc"),
931
+ store,
932
+ "",
933
+ );
934
+ const body = await res.json() as any[];
935
+ expect(body).toHaveLength(2);
936
+ const expandedBlocks = body
937
+ .map((n: any) => (n.content.match(/<expanded /g) ?? []).length)
938
+ .reduce((a: number, b: number) => a + b, 0);
939
+ expect(expandedBlocks).toBe(1);
940
+ const withMarker = body.find((n: any) => n.content.includes("(expanded above)"));
941
+ expect(withMarker).toBeTruthy();
942
+ });
943
+
895
944
  test("POST /notes accepts createdAt (camelCase) in body", async () => {
896
945
  const res = await handleNotes(
897
946
  mkReq("POST", "/notes", { content: "hi", createdAt: "2025-01-01T00:00:00.000Z" }),
@@ -903,7 +952,7 @@ describe("HTTP /notes", () => {
903
952
  });
904
953
 
905
954
  test("POST /notes/:id/attachments accepts mimeType (camelCase) in body", async () => {
906
- const n = store.createNote("x", { id: "x" });
955
+ const n = await store.createNote("x", { id: "x" });
907
956
  const res = await handleNotes(
908
957
  mkReq("POST", "/notes/x/attachments", { path: "files/a.png", mimeType: "image/png" }),
909
958
  store,
@@ -915,13 +964,13 @@ describe("HTTP /notes", () => {
915
964
  });
916
965
  });
917
966
 
918
- describe("HTTP GET /notes?format=graph", () => {
967
+ describe("HTTP GET /notes?format=graph", async () => {
919
968
  test("returns nodes and edges for linked notes", async () => {
920
- const a = store.createNote("A", { id: "a", path: "People/Alice", tags: ["person"] });
921
- const b = store.createNote("B", { id: "b", path: "People/Bob", tags: ["person"] });
922
- const c = store.createNote("C", { id: "c", path: "Projects/X" });
923
- store.createLink("a", "b", "knows");
924
- store.createLink("a", "c", "works-on");
969
+ const a = await store.createNote("A", { id: "a", path: "People/Alice", tags: ["person"] });
970
+ const b = await store.createNote("B", { id: "b", path: "People/Bob", tags: ["person"] });
971
+ const c = await store.createNote("C", { id: "c", path: "Projects/X" });
972
+ await store.createLink("a", "b", "knows");
973
+ await store.createLink("a", "c", "works-on");
925
974
 
926
975
  const res = await handleNotes(
927
976
  mkReq("GET", "/notes?format=graph&include_links=true"),
@@ -941,9 +990,9 @@ describe("HTTP GET /notes?format=graph", () => {
941
990
  });
942
991
 
943
992
  test("returns empty edges when include_links is not set", async () => {
944
- store.createNote("A", { id: "a" });
945
- store.createNote("B", { id: "b" });
946
- store.createLink("a", "b", "ref");
993
+ await store.createNote("A", { id: "a" });
994
+ await store.createNote("B", { id: "b" });
995
+ await store.createLink("a", "b", "ref");
947
996
 
948
997
  const res = await handleNotes(
949
998
  mkReq("GET", "/notes?format=graph"),
@@ -956,12 +1005,12 @@ describe("HTTP GET /notes?format=graph", () => {
956
1005
  });
957
1006
 
958
1007
  test("composes with near param for subgraph", async () => {
959
- const a = store.createNote("A", { id: "a", path: "People/Mickey" });
960
- const b = store.createNote("B", { id: "b" });
961
- const c = store.createNote("C", { id: "c" });
962
- const d = store.createNote("D", { id: "d" }); // not connected
963
- store.createLink("a", "b", "knows");
964
- store.createLink("b", "c", "knows");
1008
+ const a = await store.createNote("A", { id: "a", path: "People/Mickey" });
1009
+ const b = await store.createNote("B", { id: "b" });
1010
+ const c = await store.createNote("C", { id: "c" });
1011
+ const d = await store.createNote("D", { id: "d" }); // not connected
1012
+ await store.createLink("a", "b", "knows");
1013
+ await store.createLink("b", "c", "knows");
965
1014
 
966
1015
  const res = await handleNotes(
967
1016
  mkReq("GET", "/notes?format=graph&include_links=true&near[note_id]=People/Mickey&near[depth]=2"),
@@ -976,11 +1025,11 @@ describe("HTTP GET /notes?format=graph", () => {
976
1025
  });
977
1026
 
978
1027
  test("near with depth=1 limits subgraph", async () => {
979
- const a = store.createNote("A", { id: "a" });
980
- const b = store.createNote("B", { id: "b" });
981
- const c = store.createNote("C", { id: "c" });
982
- store.createLink("a", "b", "ref");
983
- store.createLink("b", "c", "ref");
1028
+ const a = await store.createNote("A", { id: "a" });
1029
+ const b = await store.createNote("B", { id: "b" });
1030
+ const c = await store.createNote("C", { id: "c" });
1031
+ await store.createLink("a", "b", "ref");
1032
+ await store.createLink("b", "c", "ref");
984
1033
 
985
1034
  const res = await handleNotes(
986
1035
  mkReq("GET", "/notes?format=graph&include_links=true&near[note_id]=a&near[depth]=1"),
@@ -995,9 +1044,9 @@ describe("HTTP GET /notes?format=graph", () => {
995
1044
  });
996
1045
  });
997
1046
 
998
- describe("HTTP PATCH /notes/:idOrPath (update)", () => {
1047
+ describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
999
1048
  test("PATCH updates content and merges metadata", async () => {
1000
- const note = store.createNote("original", { id: "x", metadata: { a: 1 } });
1049
+ const note = await store.createNote("original", { id: "x", metadata: { a: 1 } });
1001
1050
  const res = await handleNotes(
1002
1051
  mkReq("PATCH", "/notes/x", { content: "updated", metadata: { b: 2 } }),
1003
1052
  store,
@@ -1009,7 +1058,7 @@ describe("HTTP PATCH /notes/:idOrPath (update)", () => {
1009
1058
  });
1010
1059
 
1011
1060
  test("PATCH adds/removes tags", async () => {
1012
- store.createNote("x", { id: "x", tags: ["old"] });
1061
+ await store.createNote("x", { id: "x", tags: ["old"] });
1013
1062
  const res = await handleNotes(
1014
1063
  mkReq("PATCH", "/notes/x", { tags: { add: ["new"], remove: ["old"] } }),
1015
1064
  store,
@@ -1021,15 +1070,15 @@ describe("HTTP PATCH /notes/:idOrPath (update)", () => {
1021
1070
  });
1022
1071
 
1023
1072
  test("PATCH adds/removes links", async () => {
1024
- store.createNote("a", { id: "a" });
1025
- store.createNote("b", { id: "b" });
1073
+ await store.createNote("a", { id: "a" });
1074
+ await store.createNote("b", { id: "b" });
1026
1075
  const res = await handleNotes(
1027
1076
  mkReq("PATCH", "/notes/a", { links: { add: [{ target: "b", relationship: "mentions" }] } }),
1028
1077
  store,
1029
1078
  "/a",
1030
1079
  );
1031
1080
  expect(res.status).toBe(200);
1032
- const links = store.getLinks("a", { direction: "outbound" });
1081
+ const links = await store.getLinks("a", { direction: "outbound" });
1033
1082
  expect(links).toHaveLength(1);
1034
1083
 
1035
1084
  // Remove
@@ -1038,11 +1087,11 @@ describe("HTTP PATCH /notes/:idOrPath (update)", () => {
1038
1087
  store,
1039
1088
  "/a",
1040
1089
  );
1041
- expect(store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
1090
+ expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
1042
1091
  });
1043
1092
 
1044
1093
  test("PATCH resolves note by path", async () => {
1045
- store.createNote("x", { path: "Projects/README" });
1094
+ await store.createNote("x", { path: "Projects/README" });
1046
1095
  const res = await handleNotes(
1047
1096
  mkReq("PATCH", `/notes/${encodeURIComponent("Projects/README")}`, { content: "updated" }),
1048
1097
  store,
@@ -1052,8 +1101,53 @@ describe("HTTP PATCH /notes/:idOrPath (update)", () => {
1052
1101
  expect(body.content).toBe("updated");
1053
1102
  });
1054
1103
 
1104
+ test("PATCH with matching if_updated_at succeeds", async () => {
1105
+ const note = await store.createNote("first", { id: "x" });
1106
+ // First bump — sets updated_at
1107
+ const first = await handleNotes(
1108
+ mkReq("PATCH", "/notes/x", { content: "second" }),
1109
+ store,
1110
+ "/x",
1111
+ );
1112
+ const firstBody = await first.json() as any;
1113
+ expect(firstBody.updatedAt).toBeTruthy();
1114
+
1115
+ const res = await handleNotes(
1116
+ mkReq("PATCH", "/notes/x", { content: "third", if_updated_at: firstBody.updatedAt }),
1117
+ store,
1118
+ "/x",
1119
+ );
1120
+ expect(res.status).toBe(200);
1121
+ const body = await res.json() as any;
1122
+ expect(body.content).toBe("third");
1123
+ });
1124
+
1125
+ test("PATCH with stale if_updated_at returns 409 and does not modify note", async () => {
1126
+ await store.createNote("first", { id: "x" });
1127
+ await handleNotes(mkReq("PATCH", "/notes/x", { content: "second" }), store, "/x");
1128
+ const current = await store.getNote("x");
1129
+
1130
+ const res = await handleNotes(
1131
+ mkReq("PATCH", "/notes/x", {
1132
+ content: "third",
1133
+ if_updated_at: "2020-01-01T00:00:00.000Z",
1134
+ }),
1135
+ store,
1136
+ "/x",
1137
+ );
1138
+ expect(res.status).toBe(409);
1139
+ const body = await res.json() as any;
1140
+ expect(body.error).toBe("conflict");
1141
+ expect(body.note_id).toBe("x");
1142
+ expect(body.current_updated_at).toBe(current!.updatedAt);
1143
+ expect(body.expected_updated_at).toBe("2020-01-01T00:00:00.000Z");
1144
+
1145
+ // Unchanged
1146
+ expect((await store.getNote("x"))!.content).toBe("second");
1147
+ });
1148
+
1055
1149
  test("DELETE resolves note by path", async () => {
1056
- store.createNote("x", { path: "Temp/note" });
1150
+ await store.createNote("x", { path: "Temp/note" });
1057
1151
  const res = await handleNotes(
1058
1152
  mkReq("DELETE", `/notes/${encodeURIComponent("Temp/note")}`),
1059
1153
  store,
@@ -1061,14 +1155,14 @@ describe("HTTP PATCH /notes/:idOrPath (update)", () => {
1061
1155
  );
1062
1156
  const body = await res.json() as any;
1063
1157
  expect(body.deleted).toBe(true);
1064
- expect(store.getNoteByPath("Temp/note")).toBeNull();
1158
+ expect(await store.getNoteByPath("Temp/note")).toBeNull();
1065
1159
  });
1066
1160
  });
1067
1161
 
1068
- describe("HTTP /tags", () => {
1162
+ describe("HTTP /tags", async () => {
1069
1163
  test("GET /tags lists all tags", async () => {
1070
- store.createNote("A", { tags: ["daily"] });
1071
- store.createNote("B", { tags: ["daily", "pinned"] });
1164
+ await store.createNote("A", { tags: ["daily"] });
1165
+ await store.createNote("B", { tags: ["daily", "pinned"] });
1072
1166
  const res = await handleTags(mkReq("GET", "/tags"), store);
1073
1167
  const body = await res.json() as any[];
1074
1168
  const daily = body.find((t: any) => t.name === "daily");
@@ -1076,8 +1170,8 @@ describe("HTTP /tags", () => {
1076
1170
  });
1077
1171
 
1078
1172
  test("GET /tags?tag=name returns single tag detail with schema", async () => {
1079
- store.createNote("A", { tags: ["person"] });
1080
- store.upsertTagSchema("person", { description: "A person", fields: { name: { type: "string" } } });
1173
+ await store.createNote("A", { tags: ["person"] });
1174
+ await store.upsertTagSchema("person", { description: "A person", fields: { name: { type: "string" } } });
1081
1175
  const res = await handleTags(mkReq("GET", "/tags?tag=person"), store);
1082
1176
  const body = await res.json() as any;
1083
1177
  expect(body.name).toBe("person");
@@ -1098,43 +1192,43 @@ describe("HTTP /tags", () => {
1098
1192
  });
1099
1193
 
1100
1194
  test("DELETE /tags/:name removes tag and schema", async () => {
1101
- store.createNote("A", { tags: ["doomed"] });
1102
- store.upsertTagSchema("doomed", { description: "will be deleted" });
1195
+ await store.createNote("A", { tags: ["doomed"] });
1196
+ await store.upsertTagSchema("doomed", { description: "will be deleted" });
1103
1197
  const res = await handleTags(mkReq("DELETE", "/tags/doomed"), store, "/doomed");
1104
1198
  const body = await res.json() as any;
1105
1199
  expect(body.deleted).toBe(true);
1106
- expect(store.listTags().some((t) => t.name === "doomed")).toBe(false);
1200
+ expect((await store.listTags()).some((t) => t.name === "doomed")).toBe(false);
1107
1201
  });
1108
1202
  });
1109
1203
 
1110
- describe("HTTP /find-path", () => {
1204
+ describe("HTTP /find-path", async () => {
1111
1205
  test("finds path between two notes", async () => {
1112
- store.createNote("a", { id: "a" });
1113
- store.createNote("b", { id: "b" });
1114
- store.createNote("c", { id: "c" });
1115
- store.createLink("a", "b", "mentions");
1116
- store.createLink("b", "c", "related-to");
1117
- const res = handleFindPath(mkReq("GET", "/find-path?source=a&target=c"), store);
1206
+ await store.createNote("a", { id: "a" });
1207
+ await store.createNote("b", { id: "b" });
1208
+ await store.createNote("c", { id: "c" });
1209
+ await store.createLink("a", "b", "mentions");
1210
+ await store.createLink("b", "c", "related-to");
1211
+ const res = await handleFindPath(mkReq("GET", "/find-path?source=a&target=c"), store);
1118
1212
  const body = await res.json() as any;
1119
1213
  expect(body.path).toEqual(["a", "b", "c"]);
1120
1214
  expect(body.relationships).toEqual(["mentions", "related-to"]);
1121
1215
  });
1122
1216
 
1123
1217
  test("returns null when no path exists", async () => {
1124
- store.createNote("a", { id: "a" });
1125
- store.createNote("b", { id: "b" });
1126
- const res = handleFindPath(mkReq("GET", "/find-path?source=a&target=b"), store);
1218
+ await store.createNote("a", { id: "a" });
1219
+ await store.createNote("b", { id: "b" });
1220
+ const res = await handleFindPath(mkReq("GET", "/find-path?source=a&target=b"), store);
1127
1221
  const body = await res.json() as any;
1128
1222
  expect(body).toBeNull();
1129
1223
  });
1130
1224
 
1131
1225
  test("requires source and target params", async () => {
1132
- const res = handleFindPath(mkReq("GET", "/find-path?source=a"), store);
1226
+ const res = await handleFindPath(mkReq("GET", "/find-path?source=a"), store);
1133
1227
  expect(res.status).toBe(400);
1134
1228
  });
1135
1229
  });
1136
1230
 
1137
- describe("stateless MCP transport", () => {
1231
+ describe("stateless MCP transport", async () => {
1138
1232
  test("tools/call works without prior initialize handshake", async () => {
1139
1233
  const { handleUnifiedMcp } = await import("./mcp-http.ts");
1140
1234
  const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
@@ -1149,7 +1243,7 @@ describe("stateless MCP transport", () => {
1149
1243
  writeGlobalConfig({ port: 1940, default_vault: vaultName });
1150
1244
 
1151
1245
  const vaultStore = getVaultStore(vaultName);
1152
- vaultStore.createNote("test note", { tags: ["daily"] });
1246
+ await vaultStore.createNote("test note", { tags: ["daily"] });
1153
1247
 
1154
1248
  // Direct tools/call — no initialize, no session header
1155
1249
  const req = new Request("http://localhost:1940/mcp", {