@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
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Inline expansion of [[wikilinks]] in note content.
3
+ *
4
+ * Used by `query-notes` when `expand_links=true`. Replaces wikilink matches
5
+ * with delimited blocks containing the linked note's content (full mode) or
6
+ * metadata summary (summary mode). Deduplicates across the query and guards
7
+ * against cycles via a shared `expanded` set.
8
+ */
9
+
10
+ import { Database } from "bun:sqlite";
11
+ import type { Note } from "./types.js";
12
+ import * as noteOps from "./notes.js";
13
+ import { resolveWikilink } from "./wikilinks.js";
14
+
15
+ export type ExpandMode = "full" | "summary";
16
+
17
+ export const DEFAULT_EXPAND_DEPTH = 1;
18
+ export const MAX_EXPAND_DEPTH = 3;
19
+
20
+ export interface ExpandContext {
21
+ db: Database;
22
+ mode: ExpandMode;
23
+ /** Note IDs already expanded in this query. Shared across all expansions. */
24
+ expanded: Set<string>;
25
+ }
26
+
27
+ /**
28
+ * Matches wikilinks the same way the parser does — but retains positions.
29
+ * Embeds (`![[...]]`) are treated as regular links here; the `!` is discarded
30
+ * in the expansion output. If embed-specific rendering is needed later,
31
+ * inspect the first capture group.
32
+ */
33
+ const WIKILINK_RE = /(!?)\[\[([^\[\]\n]+?)\]\]/g;
34
+
35
+ /**
36
+ * Expand wikilinks in `content` up to `remainingDepth` levels deep. Returns
37
+ * content with `<expanded>` blocks replacing each wikilink occurrence.
38
+ *
39
+ * `remainingDepth` counts down: when it reaches 0, no further expansion
40
+ * happens. A call with remainingDepth=1 expands top-level wikilinks only;
41
+ * wikilinks inside those expansions are left as-is.
42
+ *
43
+ * Wikilinks inside fenced or inline code blocks are left untouched — mirrors
44
+ * the behavior of `parseWikilinks` in `wikilinks.ts` so the link graph and
45
+ * the expansion view stay consistent.
46
+ */
47
+ export function expandContent(
48
+ content: string,
49
+ ctx: ExpandContext,
50
+ remainingDepth: number,
51
+ ): string {
52
+ if (remainingDepth <= 0) return content;
53
+
54
+ const codeSkip = codeRanges(content);
55
+
56
+ return content.replace(WIKILINK_RE, (match, _bang: string, inner: string, offset: number) => {
57
+ if (inCodeRange(offset, codeSkip)) return match;
58
+
59
+ const target = parseTarget(inner);
60
+ if (!target) return match;
61
+
62
+ const noteId = resolveWikilink(ctx.db, target);
63
+ if (!noteId) return match; // unresolved or ambiguous — leave as-is
64
+
65
+ if (ctx.expanded.has(noteId)) {
66
+ return `${match} (expanded above)`;
67
+ }
68
+ ctx.expanded.add(noteId);
69
+
70
+ const note = noteOps.getNote(ctx.db, noteId);
71
+ if (!note) return match; // shouldn't happen, but be safe
72
+
73
+ if (ctx.mode === "summary") {
74
+ // Summary mode doesn't recurse: depth > 1 has no additional effect.
75
+ return renderSummary(note);
76
+ }
77
+
78
+ // Full mode — expand nested wikilinks one less level deep.
79
+ const nested = expandContent(note.content, ctx, remainingDepth - 1);
80
+ return renderFull(note, nested);
81
+ });
82
+ }
83
+
84
+ function codeRanges(content: string): [number, number][] {
85
+ const ranges: [number, number][] = [];
86
+ const fenced = /```[\s\S]*?```/g;
87
+ let m: RegExpExecArray | null;
88
+ while ((m = fenced.exec(content)) !== null) {
89
+ ranges.push([m.index, m.index + m[0].length]);
90
+ }
91
+ const inline = /`[^`\n]+`/g;
92
+ while ((m = inline.exec(content)) !== null) {
93
+ ranges.push([m.index, m.index + m[0].length]);
94
+ }
95
+ return ranges;
96
+ }
97
+
98
+ function inCodeRange(pos: number, ranges: [number, number][]): boolean {
99
+ for (const [start, end] of ranges) {
100
+ if (pos >= start && pos < end) return true;
101
+ }
102
+ return false;
103
+ }
104
+
105
+ function parseTarget(inner: string): string | null {
106
+ // Strip display alias: "target|display" → "target"
107
+ const pipeIdx = inner.indexOf("|");
108
+ let targetPart = pipeIdx === -1 ? inner : inner.slice(0, pipeIdx);
109
+ // Strip anchor/block-ref: "target#heading" → "target"
110
+ const hashIdx = targetPart.indexOf("#");
111
+ if (hashIdx !== -1) targetPart = targetPart.slice(0, hashIdx);
112
+ const target = targetPart.trim();
113
+ return target || null;
114
+ }
115
+
116
+ function renderFull(note: Note, content: string): string {
117
+ const pathAttr = escapeAttr(note.path ?? note.id);
118
+ return `<expanded path="${pathAttr}" mode="full">\n${content}\n</expanded>`;
119
+ }
120
+
121
+ function renderSummary(note: Note): string {
122
+ const pathAttr = escapeAttr(note.path ?? note.id);
123
+ const summary = summaryText(note);
124
+ return `<expanded path="${pathAttr}" mode="summary">\n${summary}\n</expanded>`;
125
+ }
126
+
127
+ function summaryText(note: Note): string {
128
+ const meta = note.metadata as Record<string, unknown> | undefined;
129
+ const s = meta?.summary;
130
+ if (typeof s === "string" && s.trim()) return s.trim();
131
+ return "";
132
+ }
133
+
134
+ function escapeAttr(s: string): string {
135
+ return s
136
+ .replace(/&/g, "&amp;")
137
+ .replace(/"/g, "&quot;")
138
+ .replace(/</g, "&lt;")
139
+ .replace(/>/g, "&gt;");
140
+ }
@@ -26,7 +26,7 @@ async function settle(): Promise<void> {
26
26
  await hooks.drain();
27
27
  }
28
28
 
29
- describe("HookRegistry", () => {
29
+ describe("HookRegistry", async () => {
30
30
  it("fires registered hook on createNote", async () => {
31
31
  const fired: string[] = [];
32
32
  hooks.onNote({
@@ -36,7 +36,7 @@ describe("HookRegistry", () => {
36
36
  },
37
37
  });
38
38
 
39
- const note = store.createNote("hello");
39
+ const note = await store.createNote("hello");
40
40
  expect(fired).toEqual([]); // async — not yet
41
41
  await settle();
42
42
  expect(fired).toEqual([note.id]);
@@ -51,11 +51,11 @@ describe("HookRegistry", () => {
51
51
  },
52
52
  });
53
53
 
54
- const note = store.createNote("hello");
54
+ const note = await store.createNote("hello");
55
55
  await settle();
56
56
  expect(fired).toEqual([]); // we only subscribed to updated
57
57
 
58
- store.updateNote(note.id, { content: "world" });
58
+ await store.updateNote(note.id, { content: "world" });
59
59
  await settle();
60
60
  expect(fired).toEqual([{ event: "updated", id: note.id }]);
61
61
  });
@@ -68,7 +68,7 @@ describe("HookRegistry", () => {
68
68
  },
69
69
  });
70
70
 
71
- const notes = store.createNotes([
71
+ const notes = await store.createNotes([
72
72
  { content: "a", id: "a1" },
73
73
  { content: "b", id: "b1" },
74
74
  { content: "c", id: "c1" },
@@ -87,8 +87,8 @@ describe("HookRegistry", () => {
87
87
  },
88
88
  });
89
89
 
90
- const skipped = store.createNote("plain", { tags: ["journal"] });
91
- const matched = store.createNote("reader-note", { tags: ["reader"] });
90
+ const skipped = await store.createNote("plain", { tags: ["journal"] });
91
+ const matched = await store.createNote("reader-note", { tags: ["reader"] });
92
92
  await settle();
93
93
 
94
94
  expect(fired).toEqual([matched.id]);
@@ -103,14 +103,14 @@ describe("HookRegistry", () => {
103
103
  },
104
104
  });
105
105
 
106
- const note = store.createNote("one");
106
+ const note = await store.createNote("one");
107
107
  await settle();
108
108
  expect(fired).toEqual([note.id]);
109
109
 
110
110
  fired.length = 0;
111
- store.getNote(note.id);
112
- store.getNotes([note.id]);
113
- store.queryNotes({});
111
+ await store.getNote(note.id);
112
+ await store.getNotes([note.id]);
113
+ await store.queryNotes({});
114
114
  await settle();
115
115
  expect(fired).toEqual([]);
116
116
  });
@@ -128,14 +128,14 @@ describe("HookRegistry", () => {
128
128
  },
129
129
  });
130
130
 
131
- const note = store.createNote("work me");
131
+ const note = await store.createNote("work me");
132
132
  await settle();
133
133
  // The handler ran once for "created"; its updateNote triggered an
134
134
  // "updated" dispatch, but the predicate excluded it because the
135
135
  // marker is now set. So exactly one call.
136
136
  expect(handlerCalls).toBe(1);
137
137
 
138
- const refreshed = store.getNote(note.id)!;
138
+ const refreshed = (await store.getNote(note.id))!;
139
139
  expect(refreshed.metadata?.processed_at).toBeTruthy();
140
140
  });
141
141
 
@@ -155,10 +155,10 @@ describe("HookRegistry", () => {
155
155
  },
156
156
  });
157
157
 
158
- const note = localStore.createNote("survive");
158
+ const note = await localStore.createNote("survive");
159
159
  expect(note.id).toBeTruthy();
160
160
  // Original mutation still persisted
161
- expect(localStore.getNote(note.id)?.content).toBe("survive");
161
+ expect((await localStore.getNote(note.id))?.content).toBe("survive");
162
162
 
163
163
  await Promise.resolve();
164
164
  await Promise.resolve();
@@ -184,9 +184,9 @@ describe("HookRegistry", () => {
184
184
  },
185
185
  });
186
186
 
187
- localStore.createNote("a");
188
- localStore.createNote("b");
189
- localStore.createNote("c");
187
+ await localStore.createNote("a");
188
+ await localStore.createNote("b");
189
+ await localStore.createNote("c");
190
190
 
191
191
  // Let dispatch microtasks enqueue tasks and the semaphore start one.
192
192
  await Promise.resolve();
@@ -216,12 +216,12 @@ describe("HookRegistry", () => {
216
216
  },
217
217
  });
218
218
 
219
- store.createNote("first");
219
+ await store.createNote("first");
220
220
  await settle();
221
221
  expect(fired.length).toBe(1);
222
222
 
223
223
  off();
224
- store.createNote("second");
224
+ await store.createNote("second");
225
225
  await settle();
226
226
  expect(fired.length).toBe(1);
227
227
  });
@@ -231,7 +231,7 @@ describe("HookRegistry", () => {
231
231
  hooks.onNote({ name: "one", handler: () => void order.push("one") });
232
232
  hooks.onNote({ name: "two", handler: () => void order.push("two") });
233
233
 
234
- store.createNote("both");
234
+ await store.createNote("both");
235
235
  await settle();
236
236
  expect(order.sort()).toEqual(["one", "two"]);
237
237
  });
@@ -244,7 +244,7 @@ describe("HookRegistry", () => {
244
244
  done = true;
245
245
  },
246
246
  });
247
- store.createNote("slow");
247
+ await store.createNote("slow");
248
248
  // Let dispatch schedule
249
249
  await Promise.resolve();
250
250
  await Promise.resolve();
@@ -278,7 +278,7 @@ describe("HookRegistry", () => {
278
278
  },
279
279
  });
280
280
 
281
- loggingStore.createNote("hi");
281
+ await loggingStore.createNote("hi");
282
282
  await Promise.resolve();
283
283
  await Promise.resolve();
284
284
  await loggingHooks.drain();
@@ -292,7 +292,7 @@ describe("HookRegistry", () => {
292
292
  });
293
293
  });
294
294
 
295
- describe("HookRegistry — HOOK_CONCURRENCY env var parsing", () => {
295
+ describe("HookRegistry — HOOK_CONCURRENCY env var parsing", async () => {
296
296
  const original = process.env.HOOK_CONCURRENCY;
297
297
  const restore = () => {
298
298
  if (original === undefined) delete process.env.HOOK_CONCURRENCY;
@@ -344,9 +344,9 @@ describe("HookRegistry — HOOK_CONCURRENCY env var parsing", () => {
344
344
  },
345
345
  });
346
346
 
347
- s.createNote("a");
348
- s.createNote("b");
349
- s.createNote("c");
347
+ await s.createNote("a");
348
+ await s.createNote("b");
349
+ await s.createNote("c");
350
350
  await Promise.resolve();
351
351
  await Promise.resolve();
352
352
  // Release them one at a time and let each drain through the semaphore.
package/core/src/hooks.ts CHANGED
@@ -203,7 +203,7 @@ export class HookRegistry {
203
203
  // Re-read the note so the handler sees the latest state (another
204
204
  // handler may have written back in between). If the note was
205
205
  // deleted, silently drop.
206
- const fresh = store.getNote(note.id) ?? note;
206
+ const fresh = (await store.getNote(note.id)) ?? note;
207
207
  await hook.handler(fresh, store, event);
208
208
  } catch (err) {
209
209
  this.logger.error(
package/core/src/mcp.ts CHANGED
@@ -4,12 +4,19 @@ import * as noteOps from "./notes.js";
4
4
  import { filterMetadata } from "./notes.js";
5
5
  import * as linkOps from "./links.js";
6
6
  import * as tagSchemaOps from "./tag-schemas.js";
7
+ import {
8
+ expandContent,
9
+ DEFAULT_EXPAND_DEPTH,
10
+ MAX_EXPAND_DEPTH,
11
+ type ExpandContext,
12
+ type ExpandMode,
13
+ } from "./expand.js";
7
14
 
8
15
  export interface McpToolDef {
9
16
  name: string;
10
17
  description: string;
11
18
  inputSchema: Record<string, unknown>;
12
- execute: (params: Record<string, unknown>) => unknown;
19
+ execute: (params: Record<string, unknown>) => unknown | Promise<unknown>;
13
20
  }
14
21
 
15
22
  // ---------------------------------------------------------------------------
@@ -78,7 +85,9 @@ export function generateMcpTools(store: Store): McpToolDef[] {
78
85
  - **Graph neighborhood**: pass \`near\` to scope results to notes within N hops of an anchor note
79
86
  - **No filters**: returns all notes (paginated)
80
87
 
81
- Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".`,
88
+ Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".
89
+
90
+ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.`,
82
91
  inputSchema: {
83
92
  type: "object",
84
93
  properties: {
@@ -121,21 +130,43 @@ Defaults: include_content=true for single note, false for lists. include_links=f
121
130
  },
122
131
  include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
123
132
  include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
133
+ expand_links: { type: "boolean", description: "Inline [[wikilinks]] in returned content (default: false). Has no effect if content is not included (e.g., default list mode with include_content=false); wikilinks inside fenced or inline code are not expanded." },
134
+ expand_depth: { type: "number", description: "Recursion depth for link expansion (default 1, max 3). Only meaningful in 'full' mode — 'summary' mode does not recurse." },
135
+ expand_mode: { type: "string", enum: ["full", "summary"], description: "Expansion rendering: 'full' inlines the linked note's content, 'summary' inlines only metadata.summary. Default: 'full'." },
124
136
  },
125
137
  },
126
- execute: (params) => {
138
+ execute: async (params) => {
139
+ // --- Link expansion config (shared across single + list paths) ---
140
+ const expandLinks = params.expand_links === true;
141
+ const expandMode = (params.expand_mode as ExpandMode) ?? "full";
142
+ const expandDepth = Math.max(
143
+ 0,
144
+ Math.min(
145
+ (params.expand_depth as number | undefined) ?? DEFAULT_EXPAND_DEPTH,
146
+ MAX_EXPAND_DEPTH,
147
+ ),
148
+ );
149
+ const expandCtx: ExpandContext | null = expandLinks
150
+ ? { db, mode: expandMode, expanded: new Set() }
151
+ : null;
152
+
127
153
  // --- Single note by ID/path ---
128
154
  if (params.id) {
129
155
  const note = resolveNote(db, params.id as string);
130
156
  if (!note) return { error: "Note not found", id: params.id };
131
157
  const includeContent = params.include_content !== false; // default true for single
132
158
  let result: any = includeContent ? { ...note } : noteOps.toNoteIndex(note);
159
+ if (expandCtx && includeContent && typeof result.content === "string") {
160
+ // Mark the top-level note as already expanded so it can't recursively inline itself.
161
+ expandCtx.expanded.add(note.id);
162
+ result.content = expandContent(result.content, expandCtx, expandDepth);
163
+ }
133
164
  result = filterMetadata(result, params.include_metadata as boolean | string[] | undefined);
134
165
  if (params.include_links) {
135
166
  result.links = linkOps.getLinksHydrated(db, note.id);
136
167
  }
137
168
  if (params.include_attachments) {
138
- result.attachments = store.getAttachments(note.id);
169
+ result.attachments = await store.getAttachments(note.id);
139
170
  }
140
171
  return result;
141
172
  }
@@ -189,7 +220,18 @@ Defaults: include_content=true for single note, false for lists. include_links=f
189
220
  // --- Format output ---
190
221
  const includeContent = params.include_content === true; // default false for list
191
222
  const includeMetadata = params.include_metadata as boolean | string[] | undefined;
192
- let output = includeContent ? results : results.map(noteOps.toNoteIndex);
223
+ let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(noteOps.toNoteIndex);
224
+
225
+ // --- Expand wikilinks inline (only meaningful when content is present) ---
226
+ if (expandCtx && includeContent) {
227
+ // Mark all top-level notes as already expanded so they can't inline each other.
228
+ for (const n of output) expandCtx.expanded.add(n.id);
229
+ for (const n of output) {
230
+ if (typeof n.content === "string") {
231
+ n.content = expandContent(n.content, expandCtx, expandDepth);
232
+ }
233
+ }
234
+ }
193
235
 
194
236
  // --- Apply metadata filtering ---
195
237
  if (includeMetadata !== undefined && includeMetadata !== true) {
@@ -198,12 +240,14 @@ Defaults: include_content=true for single note, false for lists. include_links=f
198
240
 
199
241
  // --- Hydrate links/attachments per note if requested ---
200
242
  if (params.include_links || params.include_attachments) {
201
- return output.map((n: any) => {
202
- const enriched = { ...n };
243
+ const enrichedOut: any[] = [];
244
+ for (const n of output as any[]) {
245
+ const enriched: any = { ...n };
203
246
  if (params.include_links) enriched.links = linkOps.getLinksHydrated(db, n.id);
204
- if (params.include_attachments) enriched.attachments = store.getAttachments(n.id);
205
- return enriched;
206
- });
247
+ if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
248
+ enrichedOut.push(enriched);
249
+ }
250
+ return enrichedOut;
207
251
  }
208
252
 
209
253
  return output;
@@ -256,13 +300,13 @@ Defaults: include_content=true for single note, false for lists. include_links=f
256
300
  },
257
301
  },
258
302
  },
259
- execute: (params) => {
303
+ execute: async (params) => {
260
304
  const batch = params.notes as any[] | undefined;
261
305
  const items = batch ?? [params];
262
306
 
263
307
  const created: Note[] = [];
264
308
  for (const item of items) {
265
- const note = store.createNote(item.content as string ?? "", {
309
+ const note = await store.createNote(item.content as string ?? "", {
266
310
  path: item.path as string | undefined,
267
311
  tags: item.tags as string[] | undefined,
268
312
  metadata: item.metadata as Record<string, unknown> | undefined,
@@ -274,7 +318,7 @@ Defaults: include_content=true for single note, false for lists. include_links=f
274
318
  for (const link of item.links as { target: string; relationship: string }[]) {
275
319
  const target = resolveNote(db, link.target);
276
320
  if (target) {
277
- store.createLink(note.id, target.id, link.relationship);
321
+ await store.createLink(note.id, target.id, link.relationship);
278
322
  }
279
323
  }
280
324
  }
@@ -285,7 +329,7 @@ Defaults: include_content=true for single note, false for lists. include_links=f
285
329
  // Apply tag schema effects
286
330
  for (const note of created) {
287
331
  if (note.tags && note.tags.length > 0) {
288
- applySchemaDefaults(store, db, [note.id], note.tags);
332
+ await applySchemaDefaults(store, db, [note.id], note.tags);
289
333
  }
290
334
  }
291
335
 
@@ -303,7 +347,8 @@ Defaults: include_content=true for single note, false for lists. include_links=f
303
347
  - \`tags: { add: ["x"], remove: ["y"] }\` — add/remove tags
304
348
  - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
305
349
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
306
- - For batch: pass a \`notes\` array, each with an \`id\` field.`,
350
+ - For batch: pass a \`notes\` array, each with an \`id\` field.
351
+ - Optimistic concurrency: pass \`if_updated_at\` with the \`updated_at\` value you last read. The update is rejected with a conflict error if the note has changed since. Re-read the note, reconcile, and retry.`,
307
352
  inputSchema: {
308
353
  type: "object",
309
354
  properties: {
@@ -312,6 +357,7 @@ Defaults: include_content=true for single note, false for lists. include_links=f
312
357
  path: { type: "string", description: "New path" },
313
358
  metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
314
359
  created_at: { type: "string", description: "New created_at timestamp" },
360
+ if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since." },
315
361
  tags: {
316
362
  type: "object",
317
363
  properties: {
@@ -359,6 +405,7 @@ Defaults: include_content=true for single note, false for lists. include_links=f
359
405
  path: { type: "string" },
360
406
  metadata: { type: "object" },
361
407
  created_at: { type: "string" },
408
+ if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since." },
362
409
  tags: { type: "object" },
363
410
  links: { type: "object" },
364
411
  },
@@ -368,35 +415,37 @@ Defaults: include_content=true for single note, false for lists. include_links=f
368
415
  },
369
416
  },
370
417
  },
371
- execute: (params) => {
418
+ execute: async (params) => {
372
419
  const batch = params.notes as any[] | undefined;
373
420
  const items = batch ?? [params];
374
421
 
375
422
  const updated: Note[] = [];
376
423
  for (const item of items) {
377
424
  const note = requireNote(db, item.id as string);
378
- let contentOverride = item.content as string | undefined;
379
425
 
380
- // --- Remove links (before content update, so bracket removal applies) ---
426
+ // --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
427
+ // We compute the cleaned content so we can do the core UPDATE first
428
+ // (with if_updated_at atomically) before any link deletions. If the
429
+ // UPDATE fails on a conflict, nothing has been mutated.
430
+ let contentOverride = item.content as string | undefined;
381
431
  const linksRemove = (item.links as any)?.remove as { target: string; relationship: string }[] | undefined;
432
+ const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
382
433
  if (linksRemove) {
383
434
  for (const link of linksRemove) {
384
435
  const target = resolveNote(db, link.target);
385
- if (target) {
386
- store.deleteLink(note.id, target.id, link.relationship);
387
- // Remove [[brackets]] from content if this was a wikilink
388
- if (link.relationship === "wikilink" && target.path) {
389
- const currentContent = contentOverride ?? note.content;
390
- const cleaned = removeWikilinkBrackets(currentContent, target.path);
391
- if (cleaned !== currentContent) {
392
- contentOverride = cleaned;
393
- }
436
+ if (!target) continue;
437
+ resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
438
+ if (link.relationship === "wikilink" && target.path) {
439
+ const currentContent = contentOverride ?? note.content;
440
+ const cleaned = removeWikilinkBrackets(currentContent, target.path);
441
+ if (cleaned !== currentContent) {
442
+ contentOverride = cleaned;
394
443
  }
395
444
  }
396
445
  }
397
446
  }
398
447
 
399
- // --- Core update (content, path, metadata, created_at) ---
448
+ // --- Core update (content, path, metadata, created_at + concurrency check) ---
400
449
  const updates: any = {};
401
450
  if (contentOverride !== undefined) updates.content = contentOverride;
402
451
  if (item.path !== undefined) updates.path = item.path;
@@ -406,22 +455,32 @@ Defaults: include_content=true for single note, false for lists. include_links=f
406
455
  updates.metadata = { ...existing, ...(item.metadata as Record<string, unknown>) };
407
456
  }
408
457
  if (item.created_at !== undefined) updates.created_at = item.created_at;
458
+ if (item.if_updated_at !== undefined) updates.if_updated_at = item.if_updated_at as string;
409
459
 
410
460
  let result: Note;
411
461
  if (Object.keys(updates).length > 0) {
412
- result = store.updateNote(note.id, updates);
462
+ // store.updateNote routes through noteOps.updateNote, which runs
463
+ // the UPDATE (with optional `AND updated_at IS ?`) atomically and
464
+ // throws ConflictError on mismatch. No mutations have happened
465
+ // yet, so a throw here leaves the note untouched.
466
+ result = await store.updateNote(note.id, updates);
413
467
  } else {
414
468
  result = note;
415
469
  }
416
470
 
471
+ // --- Remove links (after core UPDATE so a conflict leaves them intact) ---
472
+ for (const { targetId, relationship } of resolvedLinksToRemove) {
473
+ await store.deleteLink(note.id, targetId, relationship);
474
+ }
475
+
417
476
  // --- Tags ---
418
477
  const tagsOp = item.tags as { add?: string[]; remove?: string[] } | undefined;
419
478
  if (tagsOp?.add?.length) {
420
- store.tagNote(note.id, tagsOp.add);
421
- applySchemaDefaults(store, db, [note.id], tagsOp.add);
479
+ await store.tagNote(note.id, tagsOp.add);
480
+ await applySchemaDefaults(store, db, [note.id], tagsOp.add);
422
481
  }
423
482
  if (tagsOp?.remove?.length) {
424
- store.untagNote(note.id, tagsOp.remove);
483
+ await store.untagNote(note.id, tagsOp.remove);
425
484
  }
426
485
 
427
486
  // --- Add links ---
@@ -430,7 +489,7 @@ Defaults: include_content=true for single note, false for lists. include_links=f
430
489
  for (const link of linksAdd) {
431
490
  const target = resolveNote(db, link.target);
432
491
  if (target) {
433
- store.createLink(note.id, target.id, link.relationship, link.metadata);
492
+ await store.createLink(note.id, target.id, link.relationship, link.metadata);
434
493
  }
435
494
  }
436
495
  }
@@ -456,9 +515,9 @@ Defaults: include_content=true for single note, false for lists. include_links=f
456
515
  },
457
516
  required: ["id"],
458
517
  },
459
- execute: (params) => {
518
+ execute: async (params) => {
460
519
  const note = requireNote(db, params.id as string);
461
- store.deleteNote(note.id);
520
+ await store.deleteNote(note.id);
462
521
  return { deleted: true, id: note.id };
463
522
  },
464
523
  },
@@ -557,11 +616,11 @@ Defaults: include_content=true for single note, false for lists. include_links=f
557
616
  },
558
617
  required: ["tag"],
559
618
  },
560
- execute: (params) => {
619
+ execute: async (params) => {
561
620
  const tag = params.tag as string;
562
621
  // Delete schema first (FK cascade would handle it, but be explicit)
563
622
  tagSchemaOps.deleteTagSchema(db, tag);
564
- return store.deleteTag(tag);
623
+ return await store.deleteTag(tag);
565
624
  },
566
625
  },
567
626
 
@@ -617,7 +676,7 @@ Defaults: include_content=true for single note, false for lists. include_links=f
617
676
  // Tag schema effects — auto-populate defaults when tags are applied
618
677
  // ---------------------------------------------------------------------------
619
678
 
620
- function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): void {
679
+ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<void> {
621
680
  const schemas = tagSchemaOps.getTagSchemaMap(db);
622
681
  if (Object.keys(schemas).length === 0) return;
623
682
 
@@ -644,7 +703,7 @@ function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags
644
703
  }
645
704
  }
646
705
  if (Object.keys(missing).length === 0) continue;
647
- store.updateNote(noteId, {
706
+ await store.updateNote(noteId, {
648
707
  metadata: { ...existing, ...missing },
649
708
  skipUpdatedAt: true,
650
709
  });
@@ -670,3 +729,7 @@ function normalizeTags(tag: unknown): string[] | undefined {
670
729
  return [tag as string];
671
730
  }
672
731
 
732
+ // Re-exported for backward compat; defined in notes.ts alongside the
733
+ // conditional-UPDATE implementation that raises it.
734
+ export { ConflictError } from "./notes.js";
735
+