@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
@@ -57,18 +57,18 @@ function makeStore(notes: Record<string, { content: string; tags?: string[]; met
57
57
  } as any;
58
58
  }
59
59
 
60
- describe("handleViewNote", () => {
61
- it("returns 404 for non-existent note", () => {
60
+ describe("handleViewNote", async () => {
61
+ it("returns 404 for non-existent note", async () => {
62
62
  const store = makeStore({});
63
- const resp = handleViewNote(store, "missing");
63
+ const resp = await handleViewNote(store, "missing");
64
64
  expect(resp.status).toBe(404);
65
65
  });
66
66
 
67
- it("returns 404 for note without publish tag (unauthenticated)", () => {
67
+ it("returns 404 for note without publish tag (unauthenticated)", async () => {
68
68
  const store = makeStore({
69
69
  "n1": { content: "hello", tags: ["other"] },
70
70
  });
71
- const resp = handleViewNote(store, "n1");
71
+ const resp = await handleViewNote(store, "n1");
72
72
  expect(resp.status).toBe(404);
73
73
  });
74
74
 
@@ -76,7 +76,7 @@ describe("handleViewNote", () => {
76
76
  const store = makeStore({
77
77
  "n1": { content: "# Hello\n\nWorld", tags: ["publish"], path: "Blog/My Post.md" },
78
78
  });
79
- const resp = handleViewNote(store, "n1");
79
+ const resp = await handleViewNote(store, "n1");
80
80
  expect(resp.status).toBe(200);
81
81
  expect(resp.headers.get("Content-Type")).toBe("text/html; charset=utf-8");
82
82
 
@@ -90,7 +90,7 @@ describe("handleViewNote", () => {
90
90
  const store = makeStore({
91
91
  "n2": { content: "Content here", metadata: { published: true } },
92
92
  });
93
- const resp = handleViewNote(store, "n2");
93
+ const resp = await handleViewNote(store, "n2");
94
94
  expect(resp.status).toBe(200);
95
95
  const html = await resp.text();
96
96
  expect(html).toContain("<p>Content here</p>");
@@ -103,7 +103,7 @@ describe("handleViewNote", () => {
103
103
  tags: ["publish"],
104
104
  },
105
105
  });
106
- const resp = handleViewNote(store, "n3");
106
+ const resp = await handleViewNote(store, "n3");
107
107
  const html = await resp.text();
108
108
  expect(html).toContain("<strong>bold</strong>");
109
109
  expect(html).toContain("<em>italic</em>");
@@ -117,7 +117,7 @@ describe("handleViewNote", () => {
117
117
  const store = makeStore({
118
118
  "n4": { content: "<script>alert('xss')</script>", tags: ["publish"] },
119
119
  });
120
- const resp = handleViewNote(store, "n4");
120
+ const resp = await handleViewNote(store, "n4");
121
121
  const html = await resp.text();
122
122
  expect(html).not.toContain("<script>");
123
123
  expect(html).toContain("&lt;script&gt;");
@@ -127,7 +127,7 @@ describe("handleViewNote", () => {
127
127
  const store = makeStore({
128
128
  "n6": { content: "[click me](javascript:alert(1))", tags: ["publish"] },
129
129
  });
130
- const resp = handleViewNote(store, "n6");
130
+ const resp = await handleViewNote(store, "n6");
131
131
  const html = await resp.text();
132
132
  expect(html).not.toContain("javascript:");
133
133
  expect(html).toContain("click me");
@@ -138,7 +138,7 @@ describe("handleViewNote", () => {
138
138
  const store = makeStore({
139
139
  "n7": { content: "[click](data:text/html;base64,PHNjcmlwdD4=)", tags: ["publish"] },
140
140
  });
141
- const resp = handleViewNote(store, "n7");
141
+ const resp = await handleViewNote(store, "n7");
142
142
  const html = await resp.text();
143
143
  expect(html).not.toContain("data:");
144
144
  expect(html).toContain("click");
@@ -148,7 +148,7 @@ describe("handleViewNote", () => {
148
148
  const store = makeStore({
149
149
  "n8": { content: "[site](https://example.com) and [mail](mailto:a@b.com)", tags: ["publish"] },
150
150
  });
151
- const resp = handleViewNote(store, "n8");
151
+ const resp = await handleViewNote(store, "n8");
152
152
  const html = await resp.text();
153
153
  expect(html).toContain('href="https://example.com"');
154
154
  expect(html).toContain('href="mailto:a@b.com"');
@@ -158,17 +158,17 @@ describe("handleViewNote", () => {
158
158
  const store = makeStore({
159
159
  "n5": { content: "test", tags: ["publish"] },
160
160
  });
161
- const resp = handleViewNote(store, "n5");
161
+ const resp = await handleViewNote(store, "n5");
162
162
  const html = await resp.text();
163
163
  expect(html).toContain("prefers-color-scheme: dark");
164
164
  });
165
165
 
166
166
  // CSP header
167
- it("includes Content-Security-Policy header", () => {
167
+ it("includes Content-Security-Policy header", async () => {
168
168
  const store = makeStore({
169
169
  "n1": { content: "test", tags: ["publish"] },
170
170
  });
171
- const resp = handleViewNote(store, "n1");
171
+ const resp = await handleViewNote(store, "n1");
172
172
  const csp = resp.headers.get("Content-Security-Policy");
173
173
  expect(csp).toContain("script-src 'none'");
174
174
  expect(csp).toContain("default-src 'self'");
@@ -179,17 +179,17 @@ describe("handleViewNote", () => {
179
179
  const store = makeStore({
180
180
  "private": { content: "secret stuff", tags: ["internal"] },
181
181
  });
182
- const resp = handleViewNote(store, "private", { authenticated: true });
182
+ const resp = await handleViewNote(store, "private", { authenticated: true });
183
183
  expect(resp.status).toBe(200);
184
184
  const html = await resp.text();
185
185
  expect(html).toContain("<p>secret stuff</p>");
186
186
  });
187
187
 
188
- it("returns 404 for unpublished note when not authenticated", () => {
188
+ it("returns 404 for unpublished note when not authenticated", async () => {
189
189
  const store = makeStore({
190
190
  "private": { content: "secret stuff", tags: ["internal"] },
191
191
  });
192
- const resp = handleViewNote(store, "private");
192
+ const resp = await handleViewNote(store, "private");
193
193
  expect(resp.status).toBe(404);
194
194
  });
195
195
 
@@ -198,17 +198,17 @@ describe("handleViewNote", () => {
198
198
  const store = makeStore({
199
199
  "n1": { content: "public content", tags: ["public"] },
200
200
  });
201
- const resp = handleViewNote(store, "n1", { publishedTag: "public" });
201
+ const resp = await handleViewNote(store, "n1", { publishedTag: "public" });
202
202
  expect(resp.status).toBe(200);
203
203
  const html = await resp.text();
204
204
  expect(html).toContain("<p>public content</p>");
205
205
  });
206
206
 
207
- it("rejects note with default tag when custom tag is configured", () => {
207
+ it("rejects note with default tag when custom tag is configured", async () => {
208
208
  const store = makeStore({
209
209
  "n1": { content: "content", tags: ["publish"] },
210
210
  });
211
- const resp = handleViewNote(store, "n1", { publishedTag: "public" });
211
+ const resp = await handleViewNote(store, "n1", { publishedTag: "public" });
212
212
  expect(resp.status).toBe(404);
213
213
  });
214
214
  });
package/src/routes.ts CHANGED
@@ -16,6 +16,13 @@ import { listUnresolvedWikilinks } from "../core/src/wikilinks.ts";
16
16
  import { toNoteIndex, filterMetadata } from "../core/src/notes.ts";
17
17
  import * as linkOps from "../core/src/links.ts";
18
18
  import * as tagSchemaOps from "../core/src/tag-schemas.ts";
19
+ import {
20
+ expandContent,
21
+ DEFAULT_EXPAND_DEPTH,
22
+ MAX_EXPAND_DEPTH,
23
+ type ExpandContext,
24
+ type ExpandMode,
25
+ } from "../core/src/expand.ts";
19
26
  import { join, extname, normalize } from "path";
20
27
  import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs";
21
28
  import { vaultDir } from "./config.ts";
@@ -65,18 +72,39 @@ function parseIncludeMetadata(url: URL): boolean | string[] | undefined {
65
72
  return fields;
66
73
  }
67
74
 
75
+ /**
76
+ * Parse expand_links/expand_depth/expand_mode from query params, returning
77
+ * an (ExpandContext, depth) pair if expansion is requested, else null.
78
+ */
79
+ function parseExpandParams(
80
+ url: URL,
81
+ db: any,
82
+ ): { ctx: ExpandContext; depth: number } | null {
83
+ if (!parseBool(parseQuery(url, "expand_links"), false)) return null;
84
+ const modeRaw = parseQuery(url, "expand_mode");
85
+ const mode: ExpandMode = modeRaw === "summary" ? "summary" : "full";
86
+ const depth = Math.max(
87
+ 0,
88
+ Math.min(
89
+ parseInt10(parseQuery(url, "expand_depth")) ?? DEFAULT_EXPAND_DEPTH,
90
+ MAX_EXPAND_DEPTH,
91
+ ),
92
+ );
93
+ return { ctx: { db, mode, expanded: new Set() }, depth };
94
+ }
95
+
68
96
 
69
97
  /**
70
98
  * Resolve a note by ID or path. Tries ID first, then case-insensitive path.
71
99
  */
72
- function resolveNote(store: Store, idOrPath: string): Note | null {
73
- const byId = store.getNote(idOrPath);
100
+ async function resolveNote(store: Store, idOrPath: string): Promise<Note | null> {
101
+ const byId = await store.getNote(idOrPath);
74
102
  if (byId) return byId;
75
- return store.getNoteByPath(idOrPath);
103
+ return await store.getNoteByPath(idOrPath);
76
104
  }
77
105
 
78
- function requireNote(store: Store, idOrPath: string): Note {
79
- const note = resolveNote(store, idOrPath);
106
+ async function requireNote(store: Store, idOrPath: string): Promise<Note> {
107
+ const note = await resolveNote(store, idOrPath);
80
108
  if (!note) throw new NotFoundError(`Note not found: "${idOrPath}"`);
81
109
  return note;
82
110
  }
@@ -110,16 +138,21 @@ export async function handleNotes(
110
138
 
111
139
  // Single note by id/path
112
140
  if (id) {
113
- const note = resolveNote(store, id);
141
+ const note = await resolveNote(store, id);
114
142
  if (!note) return json({ error: "Note not found", id }, 404);
115
143
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
116
144
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
145
+ const expand = parseExpandParams(url, db);
146
+ if (expand && includeContent && typeof result.content === "string") {
147
+ expand.ctx.expanded.add(note.id);
148
+ result.content = expandContent(result.content, expand.ctx, expand.depth);
149
+ }
117
150
  result = filterMetadata(result, parseIncludeMetadata(url));
118
151
  if (parseBool(parseQuery(url, "include_links"), false)) {
119
152
  result.links = linkOps.getLinksHydrated(db, note.id);
120
153
  }
121
154
  if (parseBool(parseQuery(url, "include_attachments"), false)) {
122
- result.attachments = store.getAttachments(note.id);
155
+ result.attachments = await store.getAttachments(note.id);
123
156
  }
124
157
  return json(result);
125
158
  }
@@ -128,10 +161,19 @@ export async function handleNotes(
128
161
  if (search) {
129
162
  const searchTags = parseQueryList(url, "tag");
130
163
  const limit = parseInt10(parseQuery(url, "limit")) ?? 50;
131
- const results = store.searchNotes(search, { tags: searchTags, limit });
164
+ const results = await store.searchNotes(search, { tags: searchTags, limit });
132
165
  const includeContent = parseBool(parseQuery(url, "include_content"), false);
133
166
  const inclMeta = parseIncludeMetadata(url);
134
- let output = includeContent ? results : results.map(toNoteIndex);
167
+ let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
168
+ const expand = parseExpandParams(url, db);
169
+ if (expand && includeContent) {
170
+ for (const n of output) expand.ctx.expanded.add(n.id);
171
+ for (const n of output) {
172
+ if (typeof n.content === "string") {
173
+ n.content = expandContent(n.content, expand.ctx, expand.depth);
174
+ }
175
+ }
176
+ }
135
177
  if (inclMeta !== undefined && inclMeta !== true) {
136
178
  output = output.map((n: any) => filterMetadata(n, inclMeta));
137
179
  }
@@ -140,7 +182,7 @@ export async function handleNotes(
140
182
 
141
183
  // Structured query
142
184
  const tags = parseQueryList(url, "tag");
143
- let results: Note[] = store.queryNotes({
185
+ let results: Note[] = await store.queryNotes({
144
186
  tags,
145
187
  tagMatch: (parseQuery(url, "tag_match") as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
146
188
  excludeTags: parseQueryList(url, "exclude_tag"),
@@ -157,7 +199,7 @@ export async function handleNotes(
157
199
  // Near-scope filter (graph neighborhood)
158
200
  const nearNoteId = parseQuery(url, "near[note_id]");
159
201
  if (nearNoteId) {
160
- const anchor = resolveNote(store, nearNoteId);
202
+ const anchor = await resolveNote(store, nearNoteId);
161
203
  if (!anchor) return json({ error: "Anchor note not found", note_id: nearNoteId }, 404);
162
204
  const depth = Math.min(parseInt10(parseQuery(url, "near[depth]")) ?? 2, 5);
163
205
  const relationship = parseQuery(url, "near[relationship]") ?? undefined;
@@ -170,7 +212,16 @@ export async function handleNotes(
170
212
  const includeLinks = parseBool(parseQuery(url, "include_links"), false);
171
213
  const includeAttachments = parseBool(parseQuery(url, "include_attachments"), false);
172
214
  const inclMeta = parseIncludeMetadata(url);
173
- let output: any[] = includeContent ? results : results.map(toNoteIndex);
215
+ let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(toNoteIndex);
216
+ const expand = parseExpandParams(url, db);
217
+ if (expand && includeContent) {
218
+ for (const n of output) expand.ctx.expanded.add(n.id);
219
+ for (const n of output) {
220
+ if (typeof n.content === "string") {
221
+ n.content = expandContent(n.content, expand.ctx, expand.depth);
222
+ }
223
+ }
224
+ }
174
225
  if (inclMeta !== undefined && inclMeta !== true) {
175
226
  output = output.map((n: any) => filterMetadata(n, inclMeta));
176
227
  }
@@ -194,12 +245,14 @@ export async function handleNotes(
194
245
  }
195
246
 
196
247
  if (includeLinks || includeAttachments) {
197
- return json(output.map((n: any) => {
198
- const enriched = { ...n };
248
+ const enrichedOut: any[] = [];
249
+ for (const n of output) {
250
+ const enriched: any = { ...n };
199
251
  if (includeLinks) enriched.links = linkOps.getLinksHydrated(db, n.id);
200
- if (includeAttachments) enriched.attachments = store.getAttachments(n.id);
201
- return enriched;
202
- }));
252
+ if (includeAttachments) enriched.attachments = await store.getAttachments(n.id);
253
+ enrichedOut.push(enriched);
254
+ }
255
+ return json(enrichedOut);
203
256
  }
204
257
 
205
258
  return json(output);
@@ -212,7 +265,7 @@ export async function handleNotes(
212
265
 
213
266
  const created: Note[] = [];
214
267
  for (const item of items) {
215
- const note = store.createNote(item.content ?? "", {
268
+ const note = await store.createNote(item.content ?? "", {
216
269
  id: item.id,
217
270
  path: item.path,
218
271
  tags: item.tags,
@@ -223,18 +276,18 @@ export async function handleNotes(
223
276
  // Create explicit links
224
277
  if (item.links) {
225
278
  for (const link of item.links as { target: string; relationship: string }[]) {
226
- const target = resolveNote(store, link.target);
227
- if (target) store.createLink(note.id, target.id, link.relationship);
279
+ const target = await resolveNote(store, link.target);
280
+ if (target) await store.createLink(note.id, target.id, link.relationship);
228
281
  }
229
282
  }
230
283
 
231
- created.push(store.getNote(note.id) ?? note);
284
+ created.push((await store.getNote(note.id)) ?? note);
232
285
  }
233
286
 
234
287
  // Apply tag schema defaults
235
288
  for (const note of created) {
236
289
  if (note.tags?.length) {
237
- applySchemaDefaults(store, db, [note.id], note.tags);
290
+ await applySchemaDefaults(store, db, [note.id], note.tags);
238
291
  }
239
292
  }
240
293
 
@@ -254,16 +307,16 @@ export async function handleNotes(
254
307
  // Attachments sub-routes (keep as-is — Daily needs them)
255
308
  if (sub === "/attachments") {
256
309
  if (method === "POST") {
257
- const note = resolveNote(store, idOrPath);
310
+ const note = await resolveNote(store, idOrPath);
258
311
  if (!note) return json({ error: "Not found" }, 404);
259
312
  const body = await req.json() as { path: string; mimeType: string };
260
313
  if (!body.path || !body.mimeType) return json({ error: "path and mimeType are required" }, 400);
261
- return json(store.addAttachment(note.id, body.path, body.mimeType), 201);
314
+ return json(await store.addAttachment(note.id, body.path, body.mimeType), 201);
262
315
  }
263
316
  if (method === "GET") {
264
- const note = resolveNote(store, idOrPath);
317
+ const note = await resolveNote(store, idOrPath);
265
318
  if (!note) return json({ error: "Not found" }, 404);
266
- return json(store.getAttachments(note.id));
319
+ return json(await store.getAttachments(note.id));
267
320
  }
268
321
  return json({ error: "Method not allowed" }, 405);
269
322
  }
@@ -272,16 +325,21 @@ export async function handleNotes(
272
325
 
273
326
  // GET /notes/:idOrPath — single note
274
327
  if (method === "GET") {
275
- const note = resolveNote(store, idOrPath);
328
+ const note = await resolveNote(store, idOrPath);
276
329
  if (!note) return json({ error: "Not found" }, 404);
277
330
  const includeContent = parseBool(parseQuery(url, "include_content"), true);
278
331
  let result: any = includeContent ? { ...note } : toNoteIndex(note);
332
+ const expand = parseExpandParams(url, db);
333
+ if (expand && includeContent && typeof result.content === "string") {
334
+ expand.ctx.expanded.add(note.id);
335
+ result.content = expandContent(result.content, expand.ctx, expand.depth);
336
+ }
279
337
  result = filterMetadata(result, parseIncludeMetadata(url));
280
338
  if (parseBool(parseQuery(url, "include_links"), false)) {
281
339
  result.links = linkOps.getLinksHydrated(db, note.id);
282
340
  }
283
341
  if (parseBool(parseQuery(url, "include_attachments"), false)) {
284
- result.attachments = store.getAttachments(note.id);
342
+ result.attachments = await store.getAttachments(note.id);
285
343
  }
286
344
  return json(result);
287
345
  }
@@ -289,28 +347,30 @@ export async function handleNotes(
289
347
  // PATCH /notes/:idOrPath — update (content, path, metadata, tags, links)
290
348
  if (method === "PATCH") {
291
349
  try {
292
- const note = resolveNote(store, idOrPath);
350
+ const note = await resolveNote(store, idOrPath);
293
351
  if (!note) throw new NotFoundError(`Note not found: "${idOrPath}"`);
294
352
  const body = await req.json() as any;
295
353
 
296
- // Remove links first (before content update for bracket removal)
297
- const linksRemove = body.links?.remove as { target: string; relationship: string }[] | undefined;
354
+ // --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
355
+ // The actual link deletions happen only after the core UPDATE succeeds,
356
+ // so a conflict leaves the note untouched.
298
357
  let contentOverride = body.content as string | undefined;
358
+ const linksRemove = body.links?.remove as { target: string; relationship: string }[] | undefined;
359
+ const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
299
360
  if (linksRemove) {
300
361
  for (const link of linksRemove) {
301
- const target = resolveNote(store, link.target);
302
- if (target) {
303
- store.deleteLink(note.id, target.id, link.relationship);
304
- if (link.relationship === "wikilink" && target.path) {
305
- const current = contentOverride ?? note.content;
306
- const cleaned = removeWikilinkBrackets(current, target.path);
307
- if (cleaned !== current) contentOverride = cleaned;
308
- }
362
+ const target = await resolveNote(store, link.target);
363
+ if (!target) continue;
364
+ resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
365
+ if (link.relationship === "wikilink" && target.path) {
366
+ const current = contentOverride ?? note.content;
367
+ const cleaned = removeWikilinkBrackets(current, target.path);
368
+ if (cleaned !== current) contentOverride = cleaned;
309
369
  }
310
370
  }
311
371
  }
312
372
 
313
- // Core update
373
+ // --- Core update (runs the if_updated_at check atomically) ---
314
374
  const updates: any = {};
315
375
  if (contentOverride !== undefined) updates.content = contentOverride;
316
376
  if (body.path !== undefined) updates.path = body.path;
@@ -321,40 +381,63 @@ export async function handleNotes(
321
381
  if (body.created_at !== undefined || body.createdAt !== undefined) {
322
382
  updates.created_at = body.created_at ?? body.createdAt;
323
383
  }
384
+ if (body.if_updated_at !== undefined) {
385
+ updates.if_updated_at = body.if_updated_at;
386
+ }
324
387
 
325
388
  if (Object.keys(updates).length > 0) {
326
- store.updateNote(note.id, updates);
389
+ await store.updateNote(note.id, updates);
390
+ }
391
+
392
+ // --- Remove links (after core UPDATE; conflict would have thrown already) ---
393
+ for (const { targetId, relationship } of resolvedLinksToRemove) {
394
+ await store.deleteLink(note.id, targetId, relationship);
327
395
  }
328
396
 
329
397
  // Tags
330
398
  if (body.tags?.add?.length) {
331
- store.tagNote(note.id, body.tags.add);
332
- applySchemaDefaults(store, db, [note.id], body.tags.add);
399
+ await store.tagNote(note.id, body.tags.add);
400
+ await applySchemaDefaults(store, db, [note.id], body.tags.add);
333
401
  }
334
402
  if (body.tags?.remove?.length) {
335
- store.untagNote(note.id, body.tags.remove);
403
+ await store.untagNote(note.id, body.tags.remove);
336
404
  }
337
405
 
338
406
  // Add links
339
407
  if (body.links?.add) {
340
408
  for (const link of body.links.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[]) {
341
- const target = resolveNote(store, link.target);
342
- if (target) store.createLink(note.id, target.id, link.relationship, link.metadata);
409
+ const target = await resolveNote(store, link.target);
410
+ if (target) await store.createLink(note.id, target.id, link.relationship, link.metadata);
343
411
  }
344
412
  }
345
413
 
346
- return json(store.getNote(note.id));
414
+ return json(await store.getNote(note.id));
347
415
  } catch (e: any) {
348
416
  if (e instanceof NotFoundError) return json({ error: e.message }, 404);
417
+ // Duck-type on `code` rather than `instanceof ConflictError`: this
418
+ // error originates in the core package and survives any future
419
+ // bundling / module-boundary split more robustly than a prototype check.
420
+ if (e && e.code === "CONFLICT") {
421
+ return json(
422
+ {
423
+ error: "conflict",
424
+ message: e.message,
425
+ note_id: e.note_id,
426
+ current_updated_at: e.current_updated_at ?? null,
427
+ expected_updated_at: e.expected_updated_at,
428
+ },
429
+ 409,
430
+ );
431
+ }
349
432
  throw e;
350
433
  }
351
434
  }
352
435
 
353
436
  // DELETE /notes/:idOrPath — admin only (enforced at server level)
354
437
  if (method === "DELETE") {
355
- const note = resolveNote(store, idOrPath);
438
+ const note = await resolveNote(store, idOrPath);
356
439
  if (!note) return json({ error: "Not found" }, 404);
357
- store.deleteNote(note.id);
440
+ await store.deleteNote(note.id);
358
441
  return json({ deleted: true, id: note.id });
359
442
  }
360
443
 
@@ -367,16 +450,15 @@ export async function handleNotes(
367
450
 
368
451
  export async function handleTags(req: Request, store: Store, subpath = ""): Promise<Response> {
369
452
  const url = new URL(req.url);
370
- const db = (store as any).db;
371
453
 
372
454
  // GET /tags — list all, or get single tag detail
373
455
  if (req.method === "GET" && subpath === "") {
374
456
  const singleTag = parseQuery(url, "tag");
375
457
 
376
458
  if (singleTag) {
377
- const allTags = store.listTags();
459
+ const allTags = await store.listTags();
378
460
  const found = allTags.find((t) => t.name === singleTag);
379
- const schema = store.getTagSchema(singleTag);
461
+ const schema = await store.getTagSchema(singleTag);
380
462
  return json({
381
463
  name: singleTag,
382
464
  count: found?.count ?? 0,
@@ -385,9 +467,9 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
385
467
  });
386
468
  }
387
469
 
388
- const tags = store.listTags();
470
+ const tags = await store.listTags();
389
471
  if (parseBool(parseQuery(url, "include_schema"), false)) {
390
- const schemas = store.getTagSchemaMap();
472
+ const schemas = await store.getTagSchemaMap();
391
473
  return json(tags.map((t) => ({
392
474
  ...t,
393
475
  description: schemas[t.name]?.description ?? null,
@@ -404,9 +486,9 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
404
486
 
405
487
  // GET /tags/:name — single tag detail
406
488
  if (req.method === "GET") {
407
- const allTags = store.listTags();
489
+ const allTags = await store.listTags();
408
490
  const found = allTags.find((t) => t.name === tagName);
409
- const schema = store.getTagSchema(tagName);
491
+ const schema = await store.getTagSchema(tagName);
410
492
  return json({
411
493
  name: tagName,
412
494
  count: found?.count ?? 0,
@@ -418,9 +500,9 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
418
500
  // PUT /tags/:name — upsert tag schema (description + fields)
419
501
  if (req.method === "PUT") {
420
502
  const body = await req.json() as { description?: string; fields?: Record<string, unknown> };
421
- const existing = store.getTagSchema(tagName);
503
+ const existing = await store.getTagSchema(tagName);
422
504
  const mergedFields = { ...existing?.fields, ...(body.fields as any) };
423
- const schema = store.upsertTagSchema(tagName, {
505
+ const schema = await store.upsertTagSchema(tagName, {
424
506
  description: body.description ?? existing?.description,
425
507
  fields: Object.keys(mergedFields).length > 0 ? mergedFields : undefined,
426
508
  });
@@ -429,8 +511,8 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
429
511
 
430
512
  // DELETE /tags/:name — delete tag + schema from all notes
431
513
  if (req.method === "DELETE") {
432
- store.deleteTagSchema(tagName);
433
- return json(store.deleteTag(tagName));
514
+ await store.deleteTagSchema(tagName);
515
+ return json(await store.deleteTag(tagName));
434
516
  }
435
517
 
436
518
  return json({ error: "Method not allowed" }, 405);
@@ -440,7 +522,7 @@ export async function handleTags(req: Request, store: Store, subpath = ""): Prom
440
522
  // Find-path — GET /api/find-path?source=...&target=...
441
523
  // ---------------------------------------------------------------------------
442
524
 
443
- export function handleFindPath(req: Request, store: Store): Response {
525
+ export async function handleFindPath(req: Request, store: Store): Promise<Response> {
444
526
  if (req.method !== "GET") return json({ error: "Method not allowed" }, 405);
445
527
 
446
528
  const url = new URL(req.url);
@@ -450,9 +532,9 @@ export function handleFindPath(req: Request, store: Store): Response {
450
532
 
451
533
  const db = (store as any).db;
452
534
  try {
453
- const sourceNote = resolveNote(store, source);
535
+ const sourceNote = await resolveNote(store, source);
454
536
  if (!sourceNote) return json({ error: `Note not found: "${source}"` }, 404);
455
- const targetNote = resolveNote(store, target);
537
+ const targetNote = await resolveNote(store, target);
456
538
  if (!targetNote) return json({ error: `Note not found: "${target}"` }, 404);
457
539
  const maxDepth = Math.min(parseInt10(parseQuery(url, "max_depth")) ?? 5, 10);
458
540
 
@@ -482,7 +564,7 @@ export async function handleVault(
482
564
  description: vaultConfig.description ?? null,
483
565
  };
484
566
  if (parseBool(parseQuery(url, "include_stats"), false)) {
485
- result.stats = store.getVaultStats();
567
+ result.stats = await store.getVaultStats();
486
568
  }
487
569
  return json(result);
488
570
  }
@@ -611,13 +693,13 @@ function isNotePublished(note: { tags?: string[]; metadata?: unknown }, publishe
611
693
  * GET /view/:idOrPath — serve a note as clean HTML.
612
694
  * Supports ID or path resolution.
613
695
  */
614
- export function handleViewNote(
696
+ export async function handleViewNote(
615
697
  store: Store,
616
698
  idOrPath: string,
617
699
  options: { authenticated?: boolean; publishedTag?: string } = {},
618
- ): Response {
700
+ ): Promise<Response> {
619
701
  const { authenticated = false, publishedTag = "publish" } = options;
620
- const note = resolveNote(store, idOrPath);
702
+ const note = await resolveNote(store, idOrPath);
621
703
  if (!note) {
622
704
  return new Response("Not Found", { status: 404, headers: { "Content-Type": "text/plain" } });
623
705
  }
@@ -773,7 +855,7 @@ export async function handleStorage(req: Request, path: string, vault: string):
773
855
  // Tag schema defaults — same logic as core/src/mcp.ts applySchemaDefaults
774
856
  // ---------------------------------------------------------------------------
775
857
 
776
- function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): void {
858
+ async function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: string[]): Promise<void> {
777
859
  const schemas = tagSchemaOps.getTagSchemaMap(db);
778
860
  if (Object.keys(schemas).length === 0) return;
779
861
 
@@ -790,7 +872,7 @@ function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: str
790
872
  if (Object.keys(defaults).length === 0) return;
791
873
 
792
874
  for (const noteId of noteIds) {
793
- const note = store.getNote(noteId);
875
+ const note = await store.getNote(noteId);
794
876
  if (!note) continue;
795
877
  const existing = (note.metadata as Record<string, unknown>) ?? {};
796
878
  const missing: Record<string, unknown> = {};
@@ -798,7 +880,7 @@ function applySchemaDefaults(store: Store, db: any, noteIds: string[], tags: str
798
880
  if (!(field in existing)) missing[field] = value;
799
881
  }
800
882
  if (Object.keys(missing).length === 0) continue;
801
- store.updateNote(noteId, {
883
+ await store.updateNote(noteId, {
802
884
  metadata: { ...existing, ...missing },
803
885
  skipUpdatedAt: true,
804
886
  });