@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.
- package/.claude/settings.local.json +31 -0
- package/.dockerignore +8 -0
- package/.env.example +9 -0
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
- package/CLAUDE.md +115 -0
- package/Caddyfile +3 -0
- package/Dockerfile +22 -0
- package/LICENSE +661 -0
- package/README.md +356 -0
- package/bun.lock +219 -0
- package/bunfig.toml +2 -0
- package/core/package.json +7 -0
- package/core/src/core.test.ts +940 -0
- package/core/src/hooks.test.ts +361 -0
- package/core/src/hooks.ts +234 -0
- package/core/src/links.ts +352 -0
- package/core/src/mcp.ts +672 -0
- package/core/src/notes.ts +520 -0
- package/core/src/obsidian.test.ts +380 -0
- package/core/src/obsidian.ts +322 -0
- package/core/src/paths.test.ts +197 -0
- package/core/src/paths.ts +53 -0
- package/core/src/schema.ts +331 -0
- package/core/src/store.ts +303 -0
- package/core/src/tag-schemas.ts +104 -0
- package/core/src/test-preload.ts +8 -0
- package/core/src/types.ts +140 -0
- package/core/src/wikilinks.test.ts +277 -0
- package/core/src/wikilinks.ts +402 -0
- package/deploy/parachute-vault.service +20 -0
- package/docker-compose.yml +50 -0
- package/docs/HTTP_API.md +328 -0
- package/fly.toml +24 -0
- package/package.json +32 -0
- package/railway.json +14 -0
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/scripts/migrate-audio-to-opus.test.ts +237 -0
- package/scripts/migrate-audio-to-opus.ts +499 -0
- package/src/auth.ts +170 -0
- package/src/cli.ts +1131 -0
- package/src/config-triggers.test.ts +83 -0
- package/src/config.test.ts +125 -0
- package/src/config.ts +716 -0
- package/src/db.ts +14 -0
- package/src/launchd.ts +109 -0
- package/src/mcp-http.ts +113 -0
- package/src/mcp-tools.ts +155 -0
- package/src/oauth.test.ts +1242 -0
- package/src/oauth.ts +729 -0
- package/src/owner-auth.ts +159 -0
- package/src/prompt.ts +141 -0
- package/src/published.test.ts +214 -0
- package/src/qrcode-terminal.d.ts +9 -0
- package/src/routes.ts +822 -0
- package/src/server.ts +450 -0
- package/src/systemd.ts +84 -0
- package/src/token-store.test.ts +174 -0
- package/src/token-store.ts +241 -0
- package/src/triggers.test.ts +397 -0
- package/src/triggers.ts +412 -0
- package/src/two-factor.test.ts +246 -0
- package/src/two-factor.ts +222 -0
- package/src/vault-store.ts +47 -0
- package/src/vault.test.ts +1309 -0
- package/tsconfig.json +29 -0
- package/web/README.md +73 -0
- package/web/bun.lock +827 -0
- package/web/eslint.config.js +23 -0
- package/web/index.html +15 -0
- package/web/package.json +36 -0
- package/web/public/favicon.svg +1 -0
- package/web/public/icons.svg +24 -0
- package/web/src/App.tsx +149 -0
- package/web/src/Graph.tsx +200 -0
- package/web/src/NoteView.tsx +155 -0
- package/web/src/Sidebar.tsx +186 -0
- package/web/src/api.ts +21 -0
- package/web/src/index.css +50 -0
- package/web/src/main.tsx +10 -0
- package/web/src/types.ts +37 -0
- package/web/src/utils.ts +107 -0
- package/web/tsconfig.app.json +25 -0
- package/web/tsconfig.json +7 -0
- package/web/tsconfig.node.json +24 -0
- 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();
|