@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,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
|
+
}
|