@openparachute/vault 0.1.0

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 (103) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +9 -0
  4. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
  5. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
  6. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
  7. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
  8. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
  10. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
  11. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
  12. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
  13. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
  14. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
  15. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
  16. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
  17. package/CLAUDE.md +115 -0
  18. package/Caddyfile +3 -0
  19. package/Dockerfile +22 -0
  20. package/LICENSE +661 -0
  21. package/README.md +356 -0
  22. package/bun.lock +219 -0
  23. package/bunfig.toml +2 -0
  24. package/core/package.json +7 -0
  25. package/core/src/core.test.ts +940 -0
  26. package/core/src/hooks.test.ts +361 -0
  27. package/core/src/hooks.ts +234 -0
  28. package/core/src/links.ts +352 -0
  29. package/core/src/mcp.ts +672 -0
  30. package/core/src/notes.ts +520 -0
  31. package/core/src/obsidian.test.ts +380 -0
  32. package/core/src/obsidian.ts +322 -0
  33. package/core/src/paths.test.ts +197 -0
  34. package/core/src/paths.ts +53 -0
  35. package/core/src/schema.ts +331 -0
  36. package/core/src/store.ts +303 -0
  37. package/core/src/tag-schemas.ts +104 -0
  38. package/core/src/test-preload.ts +8 -0
  39. package/core/src/types.ts +140 -0
  40. package/core/src/wikilinks.test.ts +277 -0
  41. package/core/src/wikilinks.ts +402 -0
  42. package/deploy/parachute-vault.service +20 -0
  43. package/docker-compose.yml +50 -0
  44. package/docs/HTTP_API.md +328 -0
  45. package/fly.toml +24 -0
  46. package/package.json +32 -0
  47. package/railway.json +14 -0
  48. package/religions-abrahamic-filter.png +0 -0
  49. package/religions-buddhism-v2.png +0 -0
  50. package/religions-buddhism.png +0 -0
  51. package/religions-final.png +0 -0
  52. package/religions-v1.png +0 -0
  53. package/religions-v2.png +0 -0
  54. package/religions-zen.png +0 -0
  55. package/scripts/migrate-audio-to-opus.test.ts +237 -0
  56. package/scripts/migrate-audio-to-opus.ts +499 -0
  57. package/src/auth.ts +170 -0
  58. package/src/cli.ts +1131 -0
  59. package/src/config-triggers.test.ts +83 -0
  60. package/src/config.test.ts +125 -0
  61. package/src/config.ts +716 -0
  62. package/src/db.ts +14 -0
  63. package/src/launchd.ts +109 -0
  64. package/src/mcp-http.ts +113 -0
  65. package/src/mcp-tools.ts +155 -0
  66. package/src/oauth.test.ts +1242 -0
  67. package/src/oauth.ts +729 -0
  68. package/src/owner-auth.ts +159 -0
  69. package/src/prompt.ts +141 -0
  70. package/src/published.test.ts +214 -0
  71. package/src/qrcode-terminal.d.ts +9 -0
  72. package/src/routes.ts +822 -0
  73. package/src/server.ts +450 -0
  74. package/src/systemd.ts +84 -0
  75. package/src/token-store.test.ts +174 -0
  76. package/src/token-store.ts +241 -0
  77. package/src/triggers.test.ts +397 -0
  78. package/src/triggers.ts +412 -0
  79. package/src/two-factor.test.ts +246 -0
  80. package/src/two-factor.ts +222 -0
  81. package/src/vault-store.ts +47 -0
  82. package/src/vault.test.ts +1309 -0
  83. package/tsconfig.json +29 -0
  84. package/web/README.md +73 -0
  85. package/web/bun.lock +827 -0
  86. package/web/eslint.config.js +23 -0
  87. package/web/index.html +15 -0
  88. package/web/package.json +36 -0
  89. package/web/public/favicon.svg +1 -0
  90. package/web/public/icons.svg +24 -0
  91. package/web/src/App.tsx +149 -0
  92. package/web/src/Graph.tsx +200 -0
  93. package/web/src/NoteView.tsx +155 -0
  94. package/web/src/Sidebar.tsx +186 -0
  95. package/web/src/api.ts +21 -0
  96. package/web/src/index.css +50 -0
  97. package/web/src/main.tsx +10 -0
  98. package/web/src/types.ts +37 -0
  99. package/web/src/utils.ts +107 -0
  100. package/web/tsconfig.app.json +25 -0
  101. package/web/tsconfig.json +7 -0
  102. package/web/tsconfig.node.json +24 -0
  103. package/web/vite.config.ts +15 -0
@@ -0,0 +1,361 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "./store.js";
4
+ import { HookRegistry } from "./hooks.js";
5
+ import type { Note } from "./types.js";
6
+
7
+ let db: Database;
8
+ let hooks: HookRegistry;
9
+ let store: SqliteStore;
10
+
11
+ /** Silent logger so expected-error tests don't spam output. */
12
+ const silentLogger = { error: () => {} };
13
+
14
+ beforeEach(() => {
15
+ db = new Database(":memory:");
16
+ hooks = new HookRegistry({ concurrency: 4, logger: silentLogger });
17
+ store = new SqliteStore(db, { hooks });
18
+ });
19
+
20
+ /** Wait for all hook dispatches queued on the microtask loop and any
21
+ * currently in-flight handlers to settle. */
22
+ async function settle(): Promise<void> {
23
+ // Let queueMicrotask-scheduled dispatches enqueue their tasks.
24
+ await Promise.resolve();
25
+ await Promise.resolve();
26
+ await hooks.drain();
27
+ }
28
+
29
+ describe("HookRegistry", () => {
30
+ it("fires registered hook on createNote", async () => {
31
+ const fired: string[] = [];
32
+ hooks.onNote({
33
+ event: "created",
34
+ handler: (note) => {
35
+ fired.push(note.id);
36
+ },
37
+ });
38
+
39
+ const note = store.createNote("hello");
40
+ expect(fired).toEqual([]); // async — not yet
41
+ await settle();
42
+ expect(fired).toEqual([note.id]);
43
+ });
44
+
45
+ it("fires registered hook on updateNote", async () => {
46
+ const fired: Array<{ event: string; id: string }> = [];
47
+ hooks.onNote({
48
+ event: "updated",
49
+ handler: (note) => {
50
+ fired.push({ event: "updated", id: note.id });
51
+ },
52
+ });
53
+
54
+ const note = store.createNote("hello");
55
+ await settle();
56
+ expect(fired).toEqual([]); // we only subscribed to updated
57
+
58
+ store.updateNote(note.id, { content: "world" });
59
+ await settle();
60
+ expect(fired).toEqual([{ event: "updated", id: note.id }]);
61
+ });
62
+
63
+ it("fires for bulk createNotes after transaction commits", async () => {
64
+ const fired: string[] = [];
65
+ hooks.onNote({
66
+ handler: (note) => {
67
+ fired.push(note.id);
68
+ },
69
+ });
70
+
71
+ const notes = store.createNotes([
72
+ { content: "a", id: "a1" },
73
+ { content: "b", id: "b1" },
74
+ { content: "c", id: "c1" },
75
+ ]);
76
+ await settle();
77
+ expect(fired.sort()).toEqual(["a1", "b1", "c1"]);
78
+ expect(notes.length).toBe(3);
79
+ });
80
+
81
+ it("respects predicate — does not fire for non-matching notes", async () => {
82
+ const fired: string[] = [];
83
+ hooks.onNote({
84
+ when: (note) => (note.tags ?? []).includes("reader"),
85
+ handler: (note) => {
86
+ fired.push(note.id);
87
+ },
88
+ });
89
+
90
+ const skipped = store.createNote("plain", { tags: ["journal"] });
91
+ const matched = store.createNote("reader-note", { tags: ["reader"] });
92
+ await settle();
93
+
94
+ expect(fired).toEqual([matched.id]);
95
+ expect(fired).not.toContain(skipped.id);
96
+ });
97
+
98
+ it("does not fire on read paths (getNote, getNotes, queryNotes)", async () => {
99
+ const fired: string[] = [];
100
+ hooks.onNote({
101
+ handler: (note) => {
102
+ fired.push(note.id);
103
+ },
104
+ });
105
+
106
+ const note = store.createNote("one");
107
+ await settle();
108
+ expect(fired).toEqual([note.id]);
109
+
110
+ fired.length = 0;
111
+ store.getNote(note.id);
112
+ store.getNotes([note.id]);
113
+ store.queryNotes({});
114
+ await settle();
115
+ expect(fired).toEqual([]);
116
+ });
117
+
118
+ it("idempotency: handler writing a marker does not re-fire itself", async () => {
119
+ let handlerCalls = 0;
120
+ hooks.onNote({
121
+ event: ["created", "updated"],
122
+ when: (note) => !note.metadata?.processed_at,
123
+ handler: async (note, s) => {
124
+ handlerCalls++;
125
+ s.updateNote(note.id, {
126
+ metadata: { ...(note.metadata ?? {}), processed_at: new Date().toISOString() },
127
+ });
128
+ },
129
+ });
130
+
131
+ const note = store.createNote("work me");
132
+ await settle();
133
+ // The handler ran once for "created"; its updateNote triggered an
134
+ // "updated" dispatch, but the predicate excluded it because the
135
+ // marker is now set. So exactly one call.
136
+ expect(handlerCalls).toBe(1);
137
+
138
+ const refreshed = store.getNote(note.id)!;
139
+ expect(refreshed.metadata?.processed_at).toBeTruthy();
140
+ });
141
+
142
+ it("handler failure is logged but does not crash or affect the mutation", async () => {
143
+ const errors: unknown[] = [];
144
+ const localHooks = new HookRegistry({
145
+ concurrency: 2,
146
+ logger: { error: (...args) => errors.push(args) },
147
+ });
148
+ const localDb = new Database(":memory:");
149
+ const localStore = new SqliteStore(localDb, { hooks: localHooks });
150
+
151
+ localHooks.onNote({
152
+ name: "boom",
153
+ handler: async () => {
154
+ throw new Error("kaboom");
155
+ },
156
+ });
157
+
158
+ const note = localStore.createNote("survive");
159
+ expect(note.id).toBeTruthy();
160
+ // Original mutation still persisted
161
+ expect(localStore.getNote(note.id)?.content).toBe("survive");
162
+
163
+ await Promise.resolve();
164
+ await Promise.resolve();
165
+ await localHooks.drain();
166
+ expect(errors.length).toBe(1);
167
+ });
168
+
169
+ it("concurrency cap: HOOK_CONCURRENCY=1 serializes handler execution", async () => {
170
+ const localHooks = new HookRegistry({ concurrency: 1, logger: silentLogger });
171
+ const localDb = new Database(":memory:");
172
+ const localStore = new SqliteStore(localDb, { hooks: localHooks });
173
+
174
+ let running = 0;
175
+ let maxConcurrent = 0;
176
+ const releasers: Array<() => void> = [];
177
+
178
+ localHooks.onNote({
179
+ handler: async () => {
180
+ running++;
181
+ if (running > maxConcurrent) maxConcurrent = running;
182
+ await new Promise<void>((resolve) => releasers.push(resolve));
183
+ running--;
184
+ },
185
+ });
186
+
187
+ localStore.createNote("a");
188
+ localStore.createNote("b");
189
+ localStore.createNote("c");
190
+
191
+ // Let dispatch microtasks enqueue tasks and the semaphore start one.
192
+ await Promise.resolve();
193
+ await Promise.resolve();
194
+ await new Promise((r) => setTimeout(r, 10));
195
+
196
+ expect(maxConcurrent).toBe(1);
197
+ expect(running).toBe(1);
198
+ expect(releasers.length).toBe(1);
199
+
200
+ // Release them one at a time and verify only one runs at once.
201
+ while (releasers.length > 0) {
202
+ const next = releasers.shift()!;
203
+ next();
204
+ await new Promise((r) => setTimeout(r, 10));
205
+ }
206
+
207
+ await localHooks.drain();
208
+ expect(maxConcurrent).toBe(1);
209
+ });
210
+
211
+ it("unregister stops hook from firing", async () => {
212
+ const fired: string[] = [];
213
+ const off = hooks.onNote({
214
+ handler: (note) => {
215
+ fired.push(note.id);
216
+ },
217
+ });
218
+
219
+ store.createNote("first");
220
+ await settle();
221
+ expect(fired.length).toBe(1);
222
+
223
+ off();
224
+ store.createNote("second");
225
+ await settle();
226
+ expect(fired.length).toBe(1);
227
+ });
228
+
229
+ it("multiple hooks all fire for a matching note", async () => {
230
+ const order: string[] = [];
231
+ hooks.onNote({ name: "one", handler: () => void order.push("one") });
232
+ hooks.onNote({ name: "two", handler: () => void order.push("two") });
233
+
234
+ store.createNote("both");
235
+ await settle();
236
+ expect(order.sort()).toEqual(["one", "two"]);
237
+ });
238
+
239
+ it("drain waits for in-flight handlers", async () => {
240
+ let done = false;
241
+ hooks.onNote({
242
+ handler: async () => {
243
+ await new Promise((r) => setTimeout(r, 20));
244
+ done = true;
245
+ },
246
+ });
247
+ store.createNote("slow");
248
+ // Let dispatch schedule
249
+ await Promise.resolve();
250
+ await Promise.resolve();
251
+ expect(done).toBe(false);
252
+ await hooks.drain();
253
+ expect(done).toBe(true);
254
+ });
255
+
256
+ it("logs and skips a hook whose predicate throws; other hooks still run", async () => {
257
+ const errors: unknown[] = [];
258
+ const loggingHooks = new HookRegistry({
259
+ concurrency: 4,
260
+ logger: { error: (...args) => errors.push(args) },
261
+ });
262
+ const loggingStore = new SqliteStore(new Database(":memory:"), { hooks: loggingHooks });
263
+ let goodFired = 0;
264
+
265
+ loggingHooks.onNote({
266
+ name: "throwing-predicate",
267
+ when: () => {
268
+ throw new Error("predicate boom");
269
+ },
270
+ handler: () => {
271
+ throw new Error("should not reach here");
272
+ },
273
+ });
274
+ loggingHooks.onNote({
275
+ name: "good",
276
+ handler: () => {
277
+ goodFired++;
278
+ },
279
+ });
280
+
281
+ loggingStore.createNote("hi");
282
+ await Promise.resolve();
283
+ await Promise.resolve();
284
+ await loggingHooks.drain();
285
+
286
+ // The good hook ran.
287
+ expect(goodFired).toBe(1);
288
+ // The throwing predicate was logged.
289
+ expect(errors.length).toBeGreaterThanOrEqual(1);
290
+ const joined = errors.map((a) => JSON.stringify(a)).join(" ");
291
+ expect(joined).toContain("predicate");
292
+ });
293
+ });
294
+
295
+ describe("HookRegistry — HOOK_CONCURRENCY env var parsing", () => {
296
+ const original = process.env.HOOK_CONCURRENCY;
297
+ const restore = () => {
298
+ if (original === undefined) delete process.env.HOOK_CONCURRENCY;
299
+ else process.env.HOOK_CONCURRENCY = original;
300
+ };
301
+
302
+ it("defaults to 2 when HOOK_CONCURRENCY is unset", () => {
303
+ delete process.env.HOOK_CONCURRENCY;
304
+ const r = new HookRegistry();
305
+ // Acquire 3 in sequence — first 2 should resolve immediately, third should wait.
306
+ let resolvedCount = 0;
307
+ const pending: Array<Promise<() => void>> = [];
308
+ for (let i = 0; i < 3; i++) {
309
+ const p = (r as unknown as { semaphore: { acquire: () => Promise<() => void> } }).semaphore.acquire();
310
+ p.then(() => resolvedCount++);
311
+ pending.push(p);
312
+ }
313
+ return Promise.resolve().then(() => {
314
+ expect(resolvedCount).toBe(2);
315
+ restore();
316
+ });
317
+ });
318
+
319
+ it("falls back to default when HOOK_CONCURRENCY is NaN / empty / negative", () => {
320
+ for (const bad of ["", "abc", "0", "-5", "NaN"]) {
321
+ process.env.HOOK_CONCURRENCY = bad;
322
+ const r = new HookRegistry();
323
+ // Should not throw; registry is usable.
324
+ r.onNote({ handler: () => {} });
325
+ expect(r.size).toBe(1);
326
+ }
327
+ restore();
328
+ });
329
+
330
+ it("honors HOOK_CONCURRENCY=1 from env", async () => {
331
+ process.env.HOOK_CONCURRENCY = "1";
332
+ const r = new HookRegistry({ logger: silentLogger });
333
+ const s = new SqliteStore(new Database(":memory:"), { hooks: r });
334
+
335
+ let concurrent = 0;
336
+ let maxConcurrent = 0;
337
+ const releasers: Array<() => void> = [];
338
+ r.onNote({
339
+ handler: async () => {
340
+ concurrent++;
341
+ maxConcurrent = Math.max(maxConcurrent, concurrent);
342
+ await new Promise<void>((resolve) => releasers.push(resolve));
343
+ concurrent--;
344
+ },
345
+ });
346
+
347
+ s.createNote("a");
348
+ s.createNote("b");
349
+ s.createNote("c");
350
+ await Promise.resolve();
351
+ await Promise.resolve();
352
+ // Release them one at a time and let each drain through the semaphore.
353
+ while (releasers.length > 0) {
354
+ releasers.shift()!();
355
+ await new Promise((r) => setTimeout(r, 1));
356
+ }
357
+ await r.drain();
358
+ expect(maxConcurrent).toBe(1);
359
+ restore();
360
+ });
361
+ });
@@ -0,0 +1,234 @@
1
+ /**
2
+ * Async note-mutation hook infrastructure (#37).
3
+ *
4
+ * Lightweight in-process pub/sub over note mutations. Features register
5
+ * handlers via `HookRegistry.onNote` at startup; the store fires them
6
+ * after each mutation commits, out-of-band, capped by HOOK_CONCURRENCY.
7
+ *
8
+ * Design notes:
9
+ * - In-process only. No external queue, no DB table. If the process
10
+ * crashes mid-handler, the work is dropped — reconciliation is the
11
+ * responsibility of the predicate (idempotency markers in metadata).
12
+ * - Dispatch is strictly post-commit. The store calls `dispatch()` only
13
+ * after the SQLite write has returned, so handlers can safely read
14
+ * and update the same note without deadlocking the transaction.
15
+ * - Concurrency cap is global across all hooks (a bulk import should
16
+ * not be able to spawn 1000 parallel TTS jobs). Configured via
17
+ * HOOK_CONCURRENCY env var (default 2).
18
+ * - Failures are logged, not retried. Retries happen naturally on the
19
+ * next mutation when the predicate still matches (i.e. the marker
20
+ * wasn't written because the handler failed).
21
+ * - The API matches the proposal in the issue. Predicates are sync
22
+ * functions on the note; handlers are async and receive the note
23
+ * plus a `Store`-like interface so they can read/update/attach.
24
+ *
25
+ * ## Sharp edges for handler authors
26
+ *
27
+ * **1. Write your idempotency marker BEFORE any slow async work.**
28
+ * The predicate is re-evaluated on every dispatch. If your handler
29
+ * awaits a 30-second TTS call before writing the marker, a concurrent
30
+ * update to the same note during those 30 seconds will re-match the
31
+ * predicate and start a second handler run. The semaphore is global,
32
+ * not per-note, so it won't save you. Write the marker synchronously
33
+ * at the top of the handler, or — if the marker has to wait until
34
+ * the work actually succeeds — accept that duplicate runs are possible
35
+ * and make the handler idempotent in some other way.
36
+ *
37
+ * **2. No per-note serialization.** Two different hooks whose
38
+ * predicates match the same note run concurrently (up to the global
39
+ * cap). Last write wins if both touch the same fields.
40
+ *
41
+ * **3. Shutdown drain is a hard cut.** `drain()` on SIGINT/SIGTERM
42
+ * waits for in-flight handlers, but the server wraps it in a 5-second
43
+ * Promise.race. Long-running handlers (webhook triggers) may
44
+ * get killed mid-run. Handlers must not write the marker before the
45
+ * work is durably committed, so restart reconciliation works.
46
+ */
47
+
48
+ import type { Note, Store } from "./types.js";
49
+
50
+ export type HookEvent = "created" | "updated";
51
+
52
+ export interface NoteHook {
53
+ /** Events this hook listens for. Defaults to ["created", "updated"]. */
54
+ event?: HookEvent | HookEvent[];
55
+ /**
56
+ * Predicate — return true to run the handler for this note.
57
+ * Should be cheap and synchronous. Idempotency lives here: check
58
+ * whether a marker (e.g. `metadata.audio_rendered_at`) is already set
59
+ * and return false if so.
60
+ */
61
+ when?: (note: Note) => boolean;
62
+ /** Handler — runs async, off the request path. Third arg is the event type. */
63
+ handler: (note: Note, store: Store, event?: HookEvent) => Promise<void> | void;
64
+ /** Optional label for logs. */
65
+ name?: string;
66
+ }
67
+
68
+ interface RegisteredHook extends NoteHook {
69
+ events: Set<HookEvent>;
70
+ }
71
+
72
+ /**
73
+ * Tiny async semaphore — FIFO waiters, no dependencies.
74
+ * Used to cap concurrent handler execution across all hooks.
75
+ */
76
+ class Semaphore {
77
+ private available: number;
78
+ private waiters: Array<() => void> = [];
79
+
80
+ constructor(capacity: number) {
81
+ this.available = Math.max(1, capacity);
82
+ }
83
+
84
+ async acquire(): Promise<() => void> {
85
+ if (this.available > 0) {
86
+ this.available--;
87
+ return () => this.release();
88
+ }
89
+ return new Promise<() => void>((resolve) => {
90
+ this.waiters.push(() => {
91
+ this.available--;
92
+ resolve(() => this.release());
93
+ });
94
+ });
95
+ }
96
+
97
+ private release(): void {
98
+ this.available++;
99
+ const next = this.waiters.shift();
100
+ if (next) next();
101
+ }
102
+ }
103
+
104
+ export interface HookRegistryOptions {
105
+ /** Concurrency cap for handler execution. Defaults to HOOK_CONCURRENCY env var, then 2. */
106
+ concurrency?: number;
107
+ /** Logger. Defaults to console. */
108
+ logger?: { error: (...args: unknown[]) => void };
109
+ }
110
+
111
+ export class HookRegistry {
112
+ private hooks: RegisteredHook[] = [];
113
+ private semaphore: Semaphore;
114
+ private inFlight = new Set<Promise<void>>();
115
+ private logger: { error: (...args: unknown[]) => void };
116
+
117
+ constructor(opts: HookRegistryOptions = {}) {
118
+ const envCap = Number.parseInt(process.env.HOOK_CONCURRENCY ?? "", 10);
119
+ const capacity = opts.concurrency ?? (Number.isFinite(envCap) && envCap > 0 ? envCap : 2);
120
+ this.semaphore = new Semaphore(capacity);
121
+ this.logger = opts.logger ?? console;
122
+ }
123
+
124
+ /** Register a hook. Returns an unregister function. */
125
+ onNote(hook: NoteHook): () => void {
126
+ const events = new Set<HookEvent>(
127
+ Array.isArray(hook.event)
128
+ ? hook.event
129
+ : hook.event
130
+ ? [hook.event]
131
+ : (["created", "updated"] as HookEvent[]),
132
+ );
133
+ const entry: RegisteredHook = { ...hook, events };
134
+ this.hooks.push(entry);
135
+ return () => {
136
+ const idx = this.hooks.indexOf(entry);
137
+ if (idx >= 0) this.hooks.splice(idx, 1);
138
+ };
139
+ }
140
+
141
+ /** Remove all registered hooks. Mostly for tests. */
142
+ clear(): void {
143
+ this.hooks = [];
144
+ }
145
+
146
+ /** Count of currently registered hooks. */
147
+ get size(): number {
148
+ return this.hooks.length;
149
+ }
150
+
151
+ /** Count of currently in-flight handler executions. */
152
+ get inFlightCount(): number {
153
+ return this.inFlight.size;
154
+ }
155
+
156
+ /**
157
+ * Dispatch a mutation event. Matches hooks, schedules their handlers
158
+ * onto a microtask, and returns immediately. The caller is never
159
+ * blocked on handler execution.
160
+ *
161
+ * Must only be called after the triggering SQLite write has committed.
162
+ */
163
+ dispatch(event: HookEvent, note: Note, store: Store): void {
164
+ if (this.hooks.length === 0) return;
165
+
166
+ // Snapshot matches synchronously so subsequent hook registration
167
+ // changes don't affect this dispatch.
168
+ const matches: RegisteredHook[] = [];
169
+ for (const hook of this.hooks) {
170
+ if (!hook.events.has(event)) continue;
171
+ try {
172
+ if (hook.when && !hook.when(note)) continue;
173
+ } catch (err) {
174
+ this.logger.error(
175
+ `[hooks] predicate threw for ${hook.name ?? "anonymous"} on note ${note.id}:`,
176
+ err,
177
+ );
178
+ continue;
179
+ }
180
+ matches.push(hook);
181
+ }
182
+ if (matches.length === 0) return;
183
+
184
+ // Defer to a microtask so we unwind the caller's stack (and its
185
+ // SQLite transaction, if any) before handlers run.
186
+ queueMicrotask(() => {
187
+ for (const hook of matches) {
188
+ const task = this.runHandler(hook, event, note, store);
189
+ this.inFlight.add(task);
190
+ task.finally(() => this.inFlight.delete(task));
191
+ }
192
+ });
193
+ }
194
+
195
+ private async runHandler(
196
+ hook: RegisteredHook,
197
+ event: HookEvent,
198
+ note: Note,
199
+ store: Store,
200
+ ): Promise<void> {
201
+ const release = await this.semaphore.acquire();
202
+ try {
203
+ // Re-read the note so the handler sees the latest state (another
204
+ // handler may have written back in between). If the note was
205
+ // deleted, silently drop.
206
+ const fresh = store.getNote(note.id) ?? note;
207
+ await hook.handler(fresh, store, event);
208
+ } catch (err) {
209
+ this.logger.error(
210
+ `[hooks] handler ${hook.name ?? "anonymous"} threw on ${event} ${note.id}:`,
211
+ err,
212
+ );
213
+ } finally {
214
+ release();
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Wait for all currently in-flight handlers to settle. Best-effort
220
+ * drain for graceful shutdown. New hooks dispatched during the drain
221
+ * are also awaited.
222
+ */
223
+ async drain(): Promise<void> {
224
+ while (this.inFlight.size > 0) {
225
+ await Promise.allSettled(Array.from(this.inFlight));
226
+ }
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Module-level default registry. Most consumers (server, CLI) share
232
+ * this one instance; tests can construct their own for isolation.
233
+ */
234
+ export const defaultHookRegistry = new HookRegistry();