@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,197 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "./store.js";
4
+ import { normalizePath, pathTitle, hasInvalidChars } from "./paths.js";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Path normalization
8
+ // ---------------------------------------------------------------------------
9
+
10
+ describe("normalizePath", () => {
11
+ it("passes through simple paths", () => {
12
+ expect(normalizePath("Projects/README")).toBe("Projects/README");
13
+ });
14
+
15
+ it("strips .md extension", () => {
16
+ expect(normalizePath("Note.md")).toBe("Note");
17
+ expect(normalizePath("Projects/README.md")).toBe("Projects/README");
18
+ expect(normalizePath("Note.MD")).toBe("Note");
19
+ });
20
+
21
+ it("strips leading/trailing slashes", () => {
22
+ expect(normalizePath("/Projects/README")).toBe("Projects/README");
23
+ expect(normalizePath("Projects/README/")).toBe("Projects/README");
24
+ });
25
+
26
+ it("collapses multiple slashes", () => {
27
+ expect(normalizePath("Projects//Parachute///README")).toBe("Projects/Parachute/README");
28
+ });
29
+
30
+ it("converts backslashes to forward slashes", () => {
31
+ expect(normalizePath("Projects\\Parachute\\README")).toBe("Projects/Parachute/README");
32
+ });
33
+
34
+ it("trims whitespace", () => {
35
+ expect(normalizePath(" My Note ")).toBe("My Note");
36
+ });
37
+
38
+ it("returns null for empty/whitespace", () => {
39
+ expect(normalizePath("")).toBeNull();
40
+ expect(normalizePath(" ")).toBeNull();
41
+ expect(normalizePath(null)).toBeNull();
42
+ expect(normalizePath(undefined)).toBeNull();
43
+ });
44
+
45
+ it("returns null for just .md", () => {
46
+ expect(normalizePath(".md")).toBeNull();
47
+ });
48
+ });
49
+
50
+ describe("pathTitle", () => {
51
+ it("returns last segment", () => {
52
+ expect(pathTitle("Projects/Parachute/README")).toBe("README");
53
+ });
54
+
55
+ it("returns the path itself when no slashes", () => {
56
+ expect(pathTitle("Grocery List")).toBe("Grocery List");
57
+ });
58
+ });
59
+
60
+ describe("hasInvalidChars", () => {
61
+ it("detects forbidden characters", () => {
62
+ expect(hasInvalidChars("Note*")).toBe(true);
63
+ expect(hasInvalidChars("Note<1>")).toBe(true);
64
+ expect(hasInvalidChars('Note"quoted"')).toBe(true);
65
+ });
66
+
67
+ it("allows valid characters", () => {
68
+ expect(hasInvalidChars("My Note")).toBe(false);
69
+ expect(hasInvalidChars("Projects/Parachute/README")).toBe(false);
70
+ expect(hasInvalidChars("2026-04-06")).toBe(false);
71
+ expect(hasInvalidChars("note_with-dashes")).toBe(false);
72
+ });
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Path uniqueness
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe("path uniqueness", () => {
80
+ let store: SqliteStore;
81
+
82
+ beforeEach(() => {
83
+ store = new SqliteStore(new Database(":memory:"));
84
+ });
85
+
86
+ it("allows multiple notes without paths", () => {
87
+ store.createNote("A");
88
+ store.createNote("B");
89
+ // Both should exist
90
+ const notes = store.queryNotes({ limit: 10 });
91
+ expect(notes).toHaveLength(2);
92
+ });
93
+
94
+ it("rejects duplicate paths", () => {
95
+ store.createNote("A", { path: "My Note" });
96
+ expect(() => store.createNote("B", { path: "My Note" })).toThrow();
97
+ });
98
+
99
+ it("normalizes before checking uniqueness", () => {
100
+ store.createNote("A", { path: "My Note.md" });
101
+ // "My Note.md" normalizes to "My Note" — should conflict
102
+ expect(() => store.createNote("B", { path: "My Note" })).toThrow();
103
+ });
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();
110
+ });
111
+ });
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Path normalization in store operations
115
+ // ---------------------------------------------------------------------------
116
+
117
+ describe("path normalization in store", () => {
118
+ let store: SqliteStore;
119
+
120
+ beforeEach(() => {
121
+ store = new SqliteStore(new Database(":memory:"));
122
+ });
123
+
124
+ it("normalizes path on create", () => {
125
+ const note = store.createNote("Test", { path: " Projects//README.md " });
126
+ expect(note.path).toBe("Projects/README");
127
+ });
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" });
132
+ expect(updated.path).toBe("New Path");
133
+ });
134
+ });
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Rename cascading
138
+ // ---------------------------------------------------------------------------
139
+
140
+ describe("rename cascading", () => {
141
+ let store: SqliteStore;
142
+
143
+ beforeEach(() => {
144
+ store = new SqliteStore(new Database(":memory:"));
145
+ });
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.");
150
+
151
+ // Verify link exists
152
+ expect(store.getLinks(source.id, { direction: "outbound" })).toHaveLength(1);
153
+
154
+ // Rename the target
155
+ store.updateNote(target.id, { path: "New Name" });
156
+
157
+ // Source content should be updated
158
+ const updatedSource = store.getNote(source.id)!;
159
+ expect(updatedSource.content).toBe("See [[New Name]] for details.");
160
+
161
+ // Link should still work
162
+ const links = store.getLinks(source.id, { direction: "outbound" });
163
+ expect(links).toHaveLength(1);
164
+ expect(links[0].targetId).toBe(target.id);
165
+ });
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.");
170
+
171
+ store.updateNote(target.id, { path: "New" });
172
+
173
+ const updated = store.getNote(source.id)!;
174
+ expect(updated.content).toBe("See [[New|click here]] for info.");
175
+ });
176
+
177
+ it("updates wikilinks with anchors", () => {
178
+ const target = store.createNote("Target", { path: "Old" });
179
+ const source = store.createNote("See [[Old#Section]].");
180
+
181
+ store.updateNote(target.id, { path: "New" });
182
+
183
+ const updated = store.getNote(source.id)!;
184
+ expect(updated.content).toBe("See [[New#Section]].");
185
+ });
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]].");
191
+
192
+ store.updateNote(store.getNoteByPath("Old")!.id, { path: "New" });
193
+
194
+ const updated = store.getNote(source.id)!;
195
+ expect(updated.content).toBe("See [[Other]] and [[New]].");
196
+ });
197
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Path normalization and validation for Obsidian interop.
3
+ *
4
+ * Conventions:
5
+ * - No .md extension (stored without, added on export)
6
+ * - No leading/trailing slashes
7
+ * - Forward slashes only (no backslash)
8
+ * - Collapse multiple slashes
9
+ * - Trim whitespace
10
+ * - Paths are nullable (not all notes need them)
11
+ * - Paths are unique when set (enforced at DB level)
12
+ */
13
+
14
+ /**
15
+ * Normalize a note path for storage.
16
+ * Returns null if the path is empty after normalization.
17
+ */
18
+ export function normalizePath(path: string | null | undefined): string | null {
19
+ if (path === null || path === undefined) return null;
20
+
21
+ let p = path
22
+ .trim()
23
+ .replace(/\\/g, "/") // backslash → forward slash
24
+ .replace(/\.md$/i, "") // strip .md extension
25
+ .replace(/\/+/g, "/") // collapse multiple slashes
26
+ .replace(/^\//, "") // no leading slash
27
+ .replace(/\/$/, ""); // no trailing slash
28
+
29
+ if (p === "") return null;
30
+ return p;
31
+ }
32
+
33
+ /**
34
+ * Extract the display title from a path.
35
+ * Returns the last segment (filename without folders).
36
+ *
37
+ * "Projects/Parachute/README" → "README"
38
+ * "Grocery List" → "Grocery List"
39
+ */
40
+ export function pathTitle(path: string): string {
41
+ const segments = path.split("/");
42
+ return segments[segments.length - 1];
43
+ }
44
+
45
+ /**
46
+ * Characters forbidden in Obsidian filenames.
47
+ * We don't enforce this strictly — just provide a check for import/export.
48
+ */
49
+ const FORBIDDEN_CHARS = /[*"<>:|?]/;
50
+
51
+ export function hasInvalidChars(path: string): boolean {
52
+ return FORBIDDEN_CHARS.test(path);
53
+ }
@@ -0,0 +1,331 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { normalizePath } from "./paths.js";
3
+
4
+ export const SCHEMA_VERSION = 8;
5
+
6
+ export const SCHEMA_SQL = `
7
+ -- Notes: the universal record
8
+ CREATE TABLE IF NOT EXISTS notes (
9
+ id TEXT PRIMARY KEY,
10
+ content TEXT DEFAULT '',
11
+ path TEXT,
12
+ metadata TEXT DEFAULT '{}',
13
+ created_at TEXT NOT NULL,
14
+ updated_at TEXT
15
+ );
16
+
17
+ -- Tags: flat labels
18
+ CREATE TABLE IF NOT EXISTS tags (
19
+ name TEXT PRIMARY KEY
20
+ );
21
+
22
+ -- Note-Tag join
23
+ CREATE TABLE IF NOT EXISTS note_tags (
24
+ note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
25
+ tag_name TEXT NOT NULL REFERENCES tags(name),
26
+ PRIMARY KEY (note_id, tag_name)
27
+ );
28
+
29
+ -- Attachments: files associated with notes
30
+ CREATE TABLE IF NOT EXISTS attachments (
31
+ id TEXT PRIMARY KEY,
32
+ note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
33
+ path TEXT NOT NULL,
34
+ mime_type TEXT NOT NULL,
35
+ metadata TEXT DEFAULT '{}',
36
+ created_at TEXT NOT NULL
37
+ );
38
+
39
+ -- Links: directed relationships between notes
40
+ CREATE TABLE IF NOT EXISTS links (
41
+ source_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
42
+ target_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
43
+ relationship TEXT NOT NULL,
44
+ metadata TEXT DEFAULT '{}',
45
+ created_at TEXT NOT NULL,
46
+ UNIQUE(source_id, target_id, relationship)
47
+ );
48
+
49
+ -- Tag schemas: optional metadata schema per tag
50
+ CREATE TABLE IF NOT EXISTS tag_schemas (
51
+ tag_name TEXT PRIMARY KEY REFERENCES tags(name) ON DELETE CASCADE,
52
+ description TEXT,
53
+ fields TEXT -- JSON: { "field_name": { "type": "string", "description": "..." }, ... }
54
+ );
55
+
56
+ -- Tokens: API authentication with scoped permissions
57
+ CREATE TABLE IF NOT EXISTS tokens (
58
+ token_hash TEXT PRIMARY KEY,
59
+ label TEXT NOT NULL,
60
+ permission TEXT NOT NULL DEFAULT 'admin',
61
+ scope_tag TEXT,
62
+ scope_path_prefix TEXT,
63
+ expires_at TEXT,
64
+ created_at TEXT NOT NULL,
65
+ last_used_at TEXT
66
+ );
67
+
68
+ -- OAuth: registered clients (Dynamic Client Registration)
69
+ CREATE TABLE IF NOT EXISTS oauth_clients (
70
+ client_id TEXT PRIMARY KEY,
71
+ client_name TEXT,
72
+ redirect_uris TEXT,
73
+ created_at TEXT NOT NULL
74
+ );
75
+
76
+ -- OAuth: authorization codes (single-use, short-lived)
77
+ CREATE TABLE IF NOT EXISTS oauth_codes (
78
+ code TEXT PRIMARY KEY,
79
+ client_id TEXT NOT NULL,
80
+ code_challenge TEXT NOT NULL,
81
+ code_challenge_method TEXT NOT NULL DEFAULT 'S256',
82
+ scope TEXT NOT NULL DEFAULT 'full',
83
+ redirect_uri TEXT NOT NULL,
84
+ expires_at TEXT NOT NULL,
85
+ used INTEGER NOT NULL DEFAULT 0,
86
+ created_at TEXT NOT NULL
87
+ );
88
+
89
+ -- Schema version tracking
90
+ CREATE TABLE IF NOT EXISTS schema_version (
91
+ version INTEGER PRIMARY KEY,
92
+ applied_at TEXT NOT NULL
93
+ );
94
+
95
+ -- Full-text search on note content
96
+ CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
97
+ content,
98
+ content='notes',
99
+ content_rowid='rowid'
100
+ );
101
+
102
+ -- FTS triggers
103
+ CREATE TRIGGER IF NOT EXISTS notes_fts_insert AFTER INSERT ON notes BEGIN
104
+ INSERT INTO notes_fts(rowid, content) VALUES (new.rowid, new.content);
105
+ END;
106
+
107
+ CREATE TRIGGER IF NOT EXISTS notes_fts_delete AFTER DELETE ON notes BEGIN
108
+ INSERT INTO notes_fts(notes_fts, rowid, content) VALUES('delete', old.rowid, old.content);
109
+ END;
110
+
111
+ CREATE TRIGGER IF NOT EXISTS notes_fts_update AFTER UPDATE OF content ON notes BEGIN
112
+ INSERT INTO notes_fts(notes_fts, rowid, content) VALUES('delete', old.rowid, old.content);
113
+ INSERT INTO notes_fts(rowid, content) VALUES (new.rowid, new.content);
114
+ END;
115
+
116
+ -- Indexes
117
+ CREATE INDEX IF NOT EXISTS idx_notes_created ON notes(created_at);
118
+ CREATE INDEX IF NOT EXISTS idx_notes_path ON notes(path) WHERE path IS NOT NULL;
119
+ CREATE INDEX IF NOT EXISTS idx_note_tags_note ON note_tags(note_id, tag_name);
120
+ CREATE INDEX IF NOT EXISTS idx_note_tags_tag ON note_tags(tag_name, note_id);
121
+ CREATE INDEX IF NOT EXISTS idx_attachments_note ON attachments(note_id);
122
+ CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_id);
123
+ CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_id);
124
+ `;
125
+
126
+ /**
127
+ * Initialize database schema. Idempotent — safe to call on every startup.
128
+ */
129
+ export function initSchema(db: Database): void {
130
+ db.exec("PRAGMA journal_mode = WAL");
131
+ db.exec("PRAGMA foreign_keys = ON");
132
+
133
+ // Check if we need to migrate from v2
134
+ const hasOldTables = hasTable(db, "things");
135
+ if (hasOldTables) {
136
+ migrateFromV2(db);
137
+ }
138
+
139
+ db.exec(SCHEMA_SQL);
140
+
141
+ // Migrate v3 → v4: add metadata columns
142
+ migrateToV4(db);
143
+
144
+ // Migrate v4 → v5: unique path constraint
145
+ migrateToV5(db);
146
+
147
+ // Migrate v5 → v6: tag_schemas table (created by SCHEMA_SQL above,
148
+ // this just ensures the table exists for databases created before v6)
149
+ migrateToV6(db);
150
+
151
+ // Migrate v6 → v7: tokens table (created by SCHEMA_SQL above,
152
+ // this just ensures the table exists for databases created before v7)
153
+ migrateToV7(db);
154
+
155
+ // Migrate v7 → v8: OAuth tables (created by SCHEMA_SQL above,
156
+ // this just ensures the tables exist for databases created before v8)
157
+ migrateToV8(db);
158
+
159
+ // Record schema version
160
+ db.prepare("INSERT OR REPLACE INTO schema_version (version, applied_at) VALUES (?, ?)").run(
161
+ SCHEMA_VERSION,
162
+ new Date().toISOString(),
163
+ );
164
+ }
165
+
166
+ function hasColumn(db: Database, table: string, column: string): boolean {
167
+ const rows = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];
168
+ return rows.some((r) => r.name === column);
169
+ }
170
+
171
+ /**
172
+ * Migrate v3 → v4: add metadata JSON columns to notes and links.
173
+ */
174
+ function migrateToV4(db: Database): void {
175
+ if (hasTable(db, "notes") && !hasColumn(db, "notes", "metadata")) {
176
+ db.exec("ALTER TABLE notes ADD COLUMN metadata TEXT DEFAULT '{}'");
177
+ }
178
+ if (hasTable(db, "links") && !hasColumn(db, "links", "metadata")) {
179
+ db.exec("ALTER TABLE links ADD COLUMN metadata TEXT DEFAULT '{}'");
180
+ }
181
+ if (hasTable(db, "attachments") && !hasColumn(db, "attachments", "metadata")) {
182
+ db.exec("ALTER TABLE attachments ADD COLUMN metadata TEXT DEFAULT '{}'");
183
+ }
184
+ }
185
+
186
+ /**
187
+ * Migrate v4 → v5: add UNIQUE constraint on path, normalize existing paths.
188
+ */
189
+ function migrateToV5(db: Database): void {
190
+ if (!hasTable(db, "notes")) return;
191
+
192
+ // Check if the unique index already exists
193
+ const indexes = db.prepare(
194
+ "SELECT name FROM sqlite_master WHERE type='index' AND name='idx_notes_path_unique'",
195
+ ).all();
196
+ if (indexes.length > 0) return;
197
+
198
+ // Normalize existing paths
199
+ const rows = db.prepare("SELECT id, path FROM notes WHERE path IS NOT NULL").all() as { id: string; path: string }[];
200
+ for (const row of rows) {
201
+ const normalized = normalizePath(row.path);
202
+ if (normalized !== row.path) {
203
+ db.prepare("UPDATE notes SET path = ? WHERE id = ?").run(normalized, row.id);
204
+ }
205
+ }
206
+
207
+ // Handle duplicate paths (can happen after normalization) — append note ID suffix
208
+ const dupes = db.prepare(`
209
+ SELECT path, GROUP_CONCAT(id) as ids FROM notes
210
+ WHERE path IS NOT NULL
211
+ GROUP BY path COLLATE NOCASE
212
+ HAVING COUNT(*) > 1
213
+ `).all() as { path: string; ids: string }[];
214
+ for (const dupe of dupes) {
215
+ const ids = dupe.ids.split(",");
216
+ // Keep first, rename the rest
217
+ for (let i = 1; i < ids.length; i++) {
218
+ const newPath = `${dupe.path}-${i}`;
219
+ db.prepare("UPDATE notes SET path = ? WHERE id = ?").run(newPath, ids[i]);
220
+ }
221
+ }
222
+
223
+ // Drop the old non-unique partial index and create a unique one
224
+ db.exec("DROP INDEX IF EXISTS idx_notes_path");
225
+ db.exec("CREATE UNIQUE INDEX idx_notes_path_unique ON notes(path) WHERE path IS NOT NULL");
226
+ }
227
+
228
+ /**
229
+ * Migrate v5 → v6: create tag_schemas table.
230
+ * The table is already in SCHEMA_SQL so it's created for new vaults.
231
+ * This migration handles existing vaults that were created before v6.
232
+ */
233
+ function migrateToV6(db: Database): void {
234
+ // SCHEMA_SQL already creates the table via CREATE TABLE IF NOT EXISTS,
235
+ // so this is a no-op for new vaults. For existing vaults where SCHEMA_SQL
236
+ // ran above, the table now exists. Nothing extra needed here — the
237
+ // vault.yaml → DB migration happens at the server level (see server.ts),
238
+ // not at the core schema level, because core doesn't know about config files.
239
+ }
240
+
241
+ /**
242
+ * Migrate v6 → v7: create tokens table.
243
+ * The table is already in SCHEMA_SQL so it's created for new vaults.
244
+ * This migration handles existing vaults that were created before v7.
245
+ */
246
+ function migrateToV7(db: Database): void {
247
+ // SCHEMA_SQL already creates the table via CREATE TABLE IF NOT EXISTS,
248
+ // so this is a no-op for new vaults. For existing vaults where SCHEMA_SQL
249
+ // ran above, the table now exists. Nothing extra needed here.
250
+ }
251
+
252
+ function migrateToV8(db: Database): void {
253
+ // SCHEMA_SQL already creates oauth_clients and oauth_codes via
254
+ // CREATE TABLE IF NOT EXISTS. Nothing extra needed here.
255
+ }
256
+
257
+ function hasTable(db: Database, name: string): boolean {
258
+ const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
259
+ return !!row;
260
+ }
261
+
262
+ /**
263
+ * Migrate from v2 (things/thing_tags/edges/tools) to v3 (notes/note_tags/links).
264
+ */
265
+ function migrateFromV2(db: Database): void {
266
+ const alreadyMigrated = hasTable(db, "notes");
267
+ if (alreadyMigrated) return;
268
+
269
+ // Disable FK checks during migration to allow dropping tables freely
270
+ db.exec("PRAGMA foreign_keys = OFF");
271
+
272
+ // Drop old FTS, triggers, and tables that will be recreated with new schema
273
+ db.exec("DROP TRIGGER IF EXISTS things_fts_insert");
274
+ db.exec("DROP TRIGGER IF EXISTS things_fts_delete");
275
+ db.exec("DROP TRIGGER IF EXISTS things_fts_update");
276
+ db.exec("DROP TABLE IF EXISTS things_fts");
277
+
278
+ // Rename old tags table so we can create the new simplified one
279
+ // (old tags has display_name, schema_json, etc. — new one is just name)
280
+ db.exec("ALTER TABLE tags RENAME TO _old_tags");
281
+
282
+ // Create new tables
283
+ db.exec(SCHEMA_SQL);
284
+
285
+ // Migrate things → notes
286
+ db.exec(`
287
+ INSERT INTO notes (id, content, created_at, updated_at)
288
+ SELECT id, content, created_at, updated_at FROM things WHERE status = 'active'
289
+ `);
290
+
291
+ // Collect tag names from thing_tags, renaming known ones
292
+ // We insert into the new tags table (which only has a 'name' column)
293
+ db.exec(`
294
+ INSERT OR IGNORE INTO tags (name)
295
+ SELECT DISTINCT CASE
296
+ WHEN tag_name = 'note' THEN 'daily'
297
+ WHEN tag_name = 'daily-note' THEN 'daily'
298
+ ELSE tag_name
299
+ END
300
+ FROM thing_tags
301
+ `);
302
+
303
+ // Migrate thing_tags → note_tags
304
+ db.exec(`
305
+ INSERT OR IGNORE INTO note_tags (note_id, tag_name)
306
+ SELECT tt.thing_id, CASE
307
+ WHEN tt.tag_name = 'note' THEN 'daily'
308
+ WHEN tt.tag_name = 'daily-note' THEN 'daily'
309
+ ELSE tt.tag_name
310
+ END
311
+ FROM thing_tags tt
312
+ WHERE tt.thing_id IN (SELECT id FROM notes)
313
+ `);
314
+
315
+ // Migrate edges → links
316
+ db.exec(`
317
+ INSERT OR IGNORE INTO links (source_id, target_id, relationship, created_at)
318
+ SELECT source_id, target_id, relationship, created_at FROM edges
319
+ WHERE source_id IN (SELECT id FROM notes) AND target_id IN (SELECT id FROM notes)
320
+ `);
321
+
322
+ // Drop old tables
323
+ db.exec("DROP TABLE IF EXISTS thing_tags");
324
+ db.exec("DROP TABLE IF EXISTS edges");
325
+ db.exec("DROP TABLE IF EXISTS tools");
326
+ db.exec("DROP TABLE IF EXISTS things");
327
+ db.exec("DROP TABLE IF EXISTS _old_tags");
328
+
329
+ // Re-enable FK checks
330
+ db.exec("PRAGMA foreign_keys = ON");
331
+ }