@openparachute/vault 0.1.0 → 0.2.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 (87) hide show
  1. package/CHANGELOG.md +80 -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 +57 -12
  36. package/src/published.test.ts +21 -21
  37. package/src/routes.ts +152 -70
  38. package/src/routing.test.ts +347 -0
  39. package/src/routing.ts +365 -0
  40. package/src/server.ts +7 -278
  41. package/src/systemd.test.ts +15 -0
  42. package/src/systemd.ts +18 -11
  43. package/src/triggers.test.ts +7 -7
  44. package/src/triggers.ts +6 -6
  45. package/src/vault-store.ts +20 -3
  46. package/src/vault.test.ts +356 -262
  47. package/.claude/settings.local.json +0 -31
  48. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  49. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  50. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  51. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  52. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  53. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  54. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  55. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  56. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  57. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  58. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  59. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  60. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  61. package/religions-abrahamic-filter.png +0 -0
  62. package/religions-buddhism-v2.png +0 -0
  63. package/religions-buddhism.png +0 -0
  64. package/religions-final.png +0 -0
  65. package/religions-v1.png +0 -0
  66. package/religions-v2.png +0 -0
  67. package/religions-zen.png +0 -0
  68. package/web/README.md +0 -73
  69. package/web/bun.lock +0 -827
  70. package/web/eslint.config.js +0 -23
  71. package/web/index.html +0 -15
  72. package/web/package.json +0 -36
  73. package/web/public/favicon.svg +0 -1
  74. package/web/public/icons.svg +0 -24
  75. package/web/src/App.tsx +0 -149
  76. package/web/src/Graph.tsx +0 -200
  77. package/web/src/NoteView.tsx +0 -155
  78. package/web/src/Sidebar.tsx +0 -186
  79. package/web/src/api.ts +0 -21
  80. package/web/src/index.css +0 -50
  81. package/web/src/main.tsx +0 -10
  82. package/web/src/types.ts +0 -37
  83. package/web/src/utils.ts +0 -107
  84. package/web/tsconfig.app.json +0 -25
  85. package/web/tsconfig.json +0 -7
  86. package/web/tsconfig.node.json +0 -24
  87. package/web/vite.config.ts +0 -15
package/core/src/notes.ts CHANGED
@@ -72,10 +72,45 @@ export function getNotes(db: Database, ids: string[]): Note[] {
72
72
  });
73
73
  }
74
74
 
75
+ /**
76
+ * Thrown by `updateNote` when an `if_updated_at` precondition does not match
77
+ * the note's current `updated_at`. The SELECT+check+UPDATE happens as one
78
+ * atomic conditional UPDATE so two concurrent callers cannot both pass the
79
+ * check and both commit.
80
+ */
81
+ export class ConflictError extends Error {
82
+ code = "CONFLICT" as const;
83
+ note_id: string;
84
+ current_updated_at: string | null;
85
+ expected_updated_at: string;
86
+
87
+ constructor(noteId: string, current: string | null, expected: string) {
88
+ super(
89
+ `conflict: note "${noteId}" has been modified (current updated_at=${current ?? "null"}, expected=${expected})`,
90
+ );
91
+ this.name = "ConflictError";
92
+ this.note_id = noteId;
93
+ this.current_updated_at = current;
94
+ this.expected_updated_at = expected;
95
+ }
96
+ }
97
+
75
98
  export function updateNote(
76
99
  db: Database,
77
100
  id: string,
78
- updates: { content?: string; path?: string; metadata?: Record<string, unknown>; created_at?: string; skipUpdatedAt?: boolean },
101
+ updates: {
102
+ content?: string;
103
+ path?: string;
104
+ metadata?: Record<string, unknown>;
105
+ created_at?: string;
106
+ skipUpdatedAt?: boolean;
107
+ /**
108
+ * Optimistic concurrency token. When provided, the UPDATE runs with an
109
+ * additional `AND updated_at IS ?` clause; if no row is affected and the
110
+ * note still exists, a `ConflictError` is thrown.
111
+ */
112
+ if_updated_at?: string;
113
+ },
79
114
  ): Note {
80
115
  const sets: string[] = [];
81
116
  const values: unknown[] = [];
@@ -83,8 +118,19 @@ export function updateNote(
83
118
  // Hooks and other machine-level writers pass `skipUpdatedAt: true` so
84
119
  // their metadata markers don't look like user activity. See issue #44.
85
120
  if (!updates.skipUpdatedAt) {
121
+ let now = new Date().toISOString();
122
+ // OC contract: the new updated_at must be strictly greater than the
123
+ // caller's if_updated_at so a subsequent OC reader can distinguish
124
+ // pre- from post-update state. Without this, two writes landing in the
125
+ // same wall-clock millisecond would produce identical timestamps and
126
+ // let a second OC writer see the first writer's work as "unchanged."
127
+ // Comparison is lexicographic on ISO 8601 strings — valid because
128
+ // `.toISOString()` always emits fixed-width UTC (`...Z`).
129
+ if (updates.if_updated_at !== undefined && now <= updates.if_updated_at) {
130
+ now = new Date(new Date(updates.if_updated_at).getTime() + 1).toISOString();
131
+ }
86
132
  sets.push("updated_at = ?");
87
- values.push(new Date().toISOString());
133
+ values.push(now);
88
134
  }
89
135
 
90
136
  if (updates.content !== undefined) {
@@ -104,17 +150,49 @@ export function updateNote(
104
150
  values.push(updates.created_at);
105
151
  }
106
152
 
107
- // No-op: skipUpdatedAt with no other fields. Avoid generating invalid SQL.
153
+ // No-op: no SET fields. If a caller still passed `if_updated_at`, we
154
+ // need to validate the precondition; a conditional UPDATE that sets
155
+ // updated_at to itself does exactly that atomically — even a no-net-
156
+ // change UPDATE takes the write lock in WAL mode, so it still serializes
157
+ // with other writers and `.changes` reflects whether the WHERE matched.
108
158
  if (sets.length === 0) {
159
+ if (updates.if_updated_at !== undefined) {
160
+ const probe = db.prepare(
161
+ "UPDATE notes SET updated_at = updated_at WHERE id = ? AND updated_at IS ?",
162
+ ).run(id, updates.if_updated_at);
163
+ if (probe.changes === 0) {
164
+ throwConflictOrMissing(db, id, updates.if_updated_at);
165
+ }
166
+ }
109
167
  return getNote(db, id)!;
110
168
  }
111
169
 
112
170
  values.push(id);
113
- db.prepare(`UPDATE notes SET ${sets.join(", ")} WHERE id = ?`).run(...values);
171
+ let sql = `UPDATE notes SET ${sets.join(", ")} WHERE id = ?`;
172
+ if (updates.if_updated_at !== undefined) {
173
+ sql += " AND updated_at IS ?";
174
+ values.push(updates.if_updated_at);
175
+ }
176
+
177
+ const res = db.prepare(sql).run(...values);
178
+
179
+ if (updates.if_updated_at !== undefined && res.changes === 0) {
180
+ throwConflictOrMissing(db, id, updates.if_updated_at);
181
+ }
114
182
 
115
183
  return getNote(db, id)!;
116
184
  }
117
185
 
186
+ function throwConflictOrMissing(db: Database, id: string, expected: string): never {
187
+ const row = db.prepare("SELECT updated_at FROM notes WHERE id = ?").get(id) as
188
+ | { updated_at: string | null }
189
+ | undefined;
190
+ if (!row) {
191
+ throw new Error(`Note not found: "${id}"`);
192
+ }
193
+ throw new ConflictError(id, row.updated_at, expected);
194
+ }
195
+
118
196
  export function deleteNote(db: Database, id: string): void {
119
197
  db.prepare("DELETE FROM notes WHERE id = ?").run(id);
120
198
  }
@@ -315,7 +315,7 @@ describe("exportFilePath", () => {
315
315
  // Round-trip: import → export
316
316
  // ---------------------------------------------------------------------------
317
317
 
318
- describe("round-trip", () => {
318
+ describe("round-trip", async () => {
319
319
  const tmpBase = join(tmpdir(), "parachute-test-roundtrip");
320
320
  let store: SqliteStore;
321
321
 
@@ -325,7 +325,7 @@ describe("round-trip", () => {
325
325
  store = new SqliteStore(new Database(":memory:"));
326
326
  });
327
327
 
328
- it("preserves content through import → vault → export", () => {
328
+ it("preserves content through import → vault → export", async () => {
329
329
  // Create source files
330
330
  writeFileSync(join(tmpBase, "Note.md"), `---
331
331
  tags: [daily]
@@ -338,10 +338,10 @@ Hello world.`);
338
338
  expect(notes).toHaveLength(1);
339
339
 
340
340
  // Import into vault
341
- const note = store.createNote(notes[0].content, {
342
- path: notes[0].path,
343
- tags: notes[0].tags,
344
- metadata: notes[0].frontmatter,
341
+ const note = await store.createNote(notes[0]!.content, {
342
+ path: notes[0]!.path,
343
+ tags: notes[0]!.tags,
344
+ metadata: notes[0]!.frontmatter,
345
345
  });
346
346
 
347
347
  expect(note.content).toBe("Hello world.");
@@ -357,7 +357,7 @@ Hello world.`);
357
357
  expect(md).toContain("Hello world.");
358
358
  });
359
359
 
360
- it("resolves wikilinks during import", () => {
360
+ it("resolves wikilinks during import", async () => {
361
361
  writeFileSync(join(tmpBase, "A.md"), "See [[B]] for details.");
362
362
  writeFileSync(join(tmpBase, "B.md"), "I am note B.");
363
363
 
@@ -365,16 +365,16 @@ Hello world.`);
365
365
 
366
366
  // Import all notes
367
367
  for (const n of notes) {
368
- store.createNote(n.content, {
368
+ await store.createNote(n.content, {
369
369
  path: n.path,
370
370
  tags: n.tags.length > 0 ? n.tags : undefined,
371
371
  });
372
372
  }
373
373
 
374
374
  // Check that A links to B
375
- const noteA = store.getNoteByPath("A")!;
376
- const noteB = store.getNoteByPath("B")!;
377
- const links = store.getLinks(noteA.id, { direction: "outbound" });
375
+ const noteA = (await store.getNoteByPath("A"))!;
376
+ const noteB = (await store.getNoteByPath("B"))!;
377
+ const links = await store.getLinks(noteA.id, { direction: "outbound" });
378
378
  expect(links.some((l) => l.targetId === noteB.id && l.relationship === "wikilink")).toBe(true);
379
379
  });
380
380
  });
@@ -76,37 +76,37 @@ describe("hasInvalidChars", () => {
76
76
  // Path uniqueness
77
77
  // ---------------------------------------------------------------------------
78
78
 
79
- describe("path uniqueness", () => {
79
+ describe("path uniqueness", async () => {
80
80
  let store: SqliteStore;
81
81
 
82
82
  beforeEach(() => {
83
83
  store = new SqliteStore(new Database(":memory:"));
84
84
  });
85
85
 
86
- it("allows multiple notes without paths", () => {
87
- store.createNote("A");
88
- store.createNote("B");
86
+ it("allows multiple notes without paths", async () => {
87
+ await store.createNote("A");
88
+ await store.createNote("B");
89
89
  // Both should exist
90
- const notes = store.queryNotes({ limit: 10 });
90
+ const notes = await store.queryNotes({ limit: 10 });
91
91
  expect(notes).toHaveLength(2);
92
92
  });
93
93
 
94
- it("rejects duplicate paths", () => {
95
- store.createNote("A", { path: "My Note" });
96
- expect(() => store.createNote("B", { path: "My Note" })).toThrow();
94
+ it("rejects duplicate paths", async () => {
95
+ await store.createNote("A", { path: "My Note" });
96
+ expect(async () => await store.createNote("B", { path: "My Note" })).toThrow();
97
97
  });
98
98
 
99
- it("normalizes before checking uniqueness", () => {
100
- store.createNote("A", { path: "My Note.md" });
99
+ it("normalizes before checking uniqueness", async () => {
100
+ await store.createNote("A", { path: "My Note.md" });
101
101
  // "My Note.md" normalizes to "My Note" — should conflict
102
- expect(() => store.createNote("B", { path: "My Note" })).toThrow();
102
+ expect(async () => await store.createNote("B", { path: "My Note" })).toThrow();
103
103
  });
104
104
 
105
- it("allows different paths", () => {
106
- store.createNote("A", { path: "Note A" });
107
- store.createNote("B", { path: "Note B" });
108
- expect(store.getNoteByPath("Note A")).toBeTruthy();
109
- expect(store.getNoteByPath("Note B")).toBeTruthy();
105
+ it("allows different paths", async () => {
106
+ await store.createNote("A", { path: "Note A" });
107
+ await store.createNote("B", { path: "Note B" });
108
+ expect(await store.getNoteByPath("Note A")).toBeTruthy();
109
+ expect(await store.getNoteByPath("Note B")).toBeTruthy();
110
110
  });
111
111
  });
112
112
 
@@ -114,21 +114,21 @@ describe("path uniqueness", () => {
114
114
  // Path normalization in store operations
115
115
  // ---------------------------------------------------------------------------
116
116
 
117
- describe("path normalization in store", () => {
117
+ describe("path normalization in store", async () => {
118
118
  let store: SqliteStore;
119
119
 
120
120
  beforeEach(() => {
121
121
  store = new SqliteStore(new Database(":memory:"));
122
122
  });
123
123
 
124
- it("normalizes path on create", () => {
125
- const note = store.createNote("Test", { path: " Projects//README.md " });
124
+ it("normalizes path on create", async () => {
125
+ const note = await store.createNote("Test", { path: " Projects//README.md " });
126
126
  expect(note.path).toBe("Projects/README");
127
127
  });
128
128
 
129
- it("normalizes path on update", () => {
130
- const note = store.createNote("Test", { path: "Old Path" });
131
- const updated = store.updateNote(note.id, { path: "New Path.md" });
129
+ it("normalizes path on update", async () => {
130
+ const note = await store.createNote("Test", { path: "Old Path" });
131
+ const updated = await store.updateNote(note.id, { path: "New Path.md" });
132
132
  expect(updated.path).toBe("New Path");
133
133
  });
134
134
  });
@@ -137,61 +137,61 @@ describe("path normalization in store", () => {
137
137
  // Rename cascading
138
138
  // ---------------------------------------------------------------------------
139
139
 
140
- describe("rename cascading", () => {
140
+ describe("rename cascading", async () => {
141
141
  let store: SqliteStore;
142
142
 
143
143
  beforeEach(() => {
144
144
  store = new SqliteStore(new Database(":memory:"));
145
145
  });
146
146
 
147
- it("updates wikilinks in other notes when path changes", () => {
148
- const target = store.createNote("I am the target", { path: "Old Name" });
149
- const source = store.createNote("See [[Old Name]] for details.");
147
+ it("updates wikilinks in other notes when path changes", async () => {
148
+ const target = await store.createNote("I am the target", { path: "Old Name" });
149
+ const source = await store.createNote("See [[Old Name]] for details.");
150
150
 
151
151
  // Verify link exists
152
- expect(store.getLinks(source.id, { direction: "outbound" })).toHaveLength(1);
152
+ expect(await store.getLinks(source.id, { direction: "outbound" })).toHaveLength(1);
153
153
 
154
154
  // Rename the target
155
- store.updateNote(target.id, { path: "New Name" });
155
+ await store.updateNote(target.id, { path: "New Name" });
156
156
 
157
157
  // Source content should be updated
158
- const updatedSource = store.getNote(source.id)!;
158
+ const updatedSource = (await store.getNote(source.id))!;
159
159
  expect(updatedSource.content).toBe("See [[New Name]] for details.");
160
160
 
161
161
  // Link should still work
162
- const links = store.getLinks(source.id, { direction: "outbound" });
162
+ const links = await store.getLinks(source.id, { direction: "outbound" });
163
163
  expect(links).toHaveLength(1);
164
164
  expect(links[0].targetId).toBe(target.id);
165
165
  });
166
166
 
167
- it("updates aliased wikilinks", () => {
168
- const target = store.createNote("Target", { path: "Old" });
169
- const source = store.createNote("See [[Old|click here]] for info.");
167
+ it("updates aliased wikilinks", async () => {
168
+ const target = await store.createNote("Target", { path: "Old" });
169
+ const source = await store.createNote("See [[Old|click here]] for info.");
170
170
 
171
- store.updateNote(target.id, { path: "New" });
171
+ await store.updateNote(target.id, { path: "New" });
172
172
 
173
- const updated = store.getNote(source.id)!;
173
+ const updated = (await store.getNote(source.id))!;
174
174
  expect(updated.content).toBe("See [[New|click here]] for info.");
175
175
  });
176
176
 
177
- it("updates wikilinks with anchors", () => {
178
- const target = store.createNote("Target", { path: "Old" });
179
- const source = store.createNote("See [[Old#Section]].");
177
+ it("updates wikilinks with anchors", async () => {
178
+ const target = await store.createNote("Target", { path: "Old" });
179
+ const source = await store.createNote("See [[Old#Section]].");
180
180
 
181
- store.updateNote(target.id, { path: "New" });
181
+ await store.updateNote(target.id, { path: "New" });
182
182
 
183
- const updated = store.getNote(source.id)!;
183
+ const updated = (await store.getNote(source.id))!;
184
184
  expect(updated.content).toBe("See [[New#Section]].");
185
185
  });
186
186
 
187
- it("does not update unrelated wikilinks", () => {
188
- store.createNote("Target", { path: "Old" });
189
- const other = store.createNote("Other", { path: "Other" });
190
- const source = store.createNote("See [[Other]] and [[Old]].");
187
+ it("does not update unrelated wikilinks", async () => {
188
+ await store.createNote("Target", { path: "Old" });
189
+ const other = await store.createNote("Other", { path: "Other" });
190
+ const source = await store.createNote("See [[Other]] and [[Old]].");
191
191
 
192
- store.updateNote(store.getNoteByPath("Old")!.id, { path: "New" });
192
+ await store.updateNote((await store.getNoteByPath("Old"))!.id, { path: "New" });
193
193
 
194
- const updated = store.getNote(source.id)!;
194
+ const updated = (await store.getNote(source.id))!;
195
195
  expect(updated.content).toBe("See [[Other]] and [[New]].");
196
196
  });
197
197
  });
@@ -1,7 +1,7 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import { normalizePath } from "./paths.js";
3
3
 
4
- export const SCHEMA_VERSION = 8;
4
+ export const SCHEMA_VERSION = 9;
5
5
 
6
6
  export const SCHEMA_SQL = `
7
7
  -- Notes: the universal record
@@ -74,6 +74,9 @@ CREATE TABLE IF NOT EXISTS oauth_clients (
74
74
  );
75
75
 
76
76
  -- OAuth: authorization codes (single-use, short-lived)
77
+ -- vault_name pins the code to the vault it was issued for. handleToken
78
+ -- must verify it matches the requested vault — otherwise a code issued
79
+ -- under /vaults/A/oauth/authorize could be redeemed at /vaults/B/oauth/token.
77
80
  CREATE TABLE IF NOT EXISTS oauth_codes (
78
81
  code TEXT PRIMARY KEY,
79
82
  client_id TEXT NOT NULL,
@@ -83,7 +86,8 @@ CREATE TABLE IF NOT EXISTS oauth_codes (
83
86
  redirect_uri TEXT NOT NULL,
84
87
  expires_at TEXT NOT NULL,
85
88
  used INTEGER NOT NULL DEFAULT 0,
86
- created_at TEXT NOT NULL
89
+ created_at TEXT NOT NULL,
90
+ vault_name TEXT
87
91
  );
88
92
 
89
93
  -- Schema version tracking
@@ -156,6 +160,9 @@ export function initSchema(db: Database): void {
156
160
  // this just ensures the tables exist for databases created before v8)
157
161
  migrateToV8(db);
158
162
 
163
+ // Migrate v8 → v9: add vault_name column to oauth_codes
164
+ migrateToV9(db);
165
+
159
166
  // Record schema version
160
167
  db.prepare("INSERT OR REPLACE INTO schema_version (version, applied_at) VALUES (?, ?)").run(
161
168
  SCHEMA_VERSION,
@@ -254,6 +261,15 @@ function migrateToV8(db: Database): void {
254
261
  // CREATE TABLE IF NOT EXISTS. Nothing extra needed here.
255
262
  }
256
263
 
264
+ function migrateToV9(db: Database): void {
265
+ // Add vault_name column to existing oauth_codes tables. Codes predating
266
+ // this migration have NULL vault_name and will fail the token-exchange
267
+ // vault check — acceptable because codes expire in 10 minutes.
268
+ if (hasTable(db, "oauth_codes") && !hasColumn(db, "oauth_codes", "vault_name")) {
269
+ db.exec("ALTER TABLE oauth_codes ADD COLUMN vault_name TEXT");
270
+ }
271
+ }
272
+
257
273
  function hasTable(db: Database, name: string): boolean {
258
274
  const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
259
275
  return !!row;