@openparachute/vault 0.1.0 → 0.2.1
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/CHANGELOG.md +87 -0
- package/CLAUDE.md +2 -2
- package/README.md +289 -44
- package/core/src/core.test.ts +802 -346
- package/core/src/expand.ts +140 -0
- package/core/src/hooks.test.ts +27 -27
- package/core/src/hooks.ts +1 -1
- package/core/src/mcp.ts +102 -39
- package/core/src/notes.ts +82 -4
- package/core/src/obsidian.test.ts +11 -11
- package/core/src/paths.test.ts +46 -46
- package/core/src/schema.ts +18 -2
- package/core/src/store.ts +51 -51
- package/core/src/types.ts +29 -29
- package/core/src/wikilinks.test.ts +61 -61
- package/docs/HTTP_API.md +4 -2
- package/package.json +1 -1
- package/src/auth.test.ts +319 -0
- package/src/backup-launchd.test.ts +90 -0
- package/src/backup-launchd.ts +169 -0
- package/src/backup.test.ts +715 -0
- package/src/backup.ts +699 -0
- package/src/cli.ts +923 -31
- package/src/config.test.ts +173 -0
- package/src/config.ts +345 -15
- package/src/daemon.ts +136 -0
- package/src/doctor.test.ts +356 -0
- package/src/health.test.ts +201 -0
- package/src/health.ts +115 -0
- package/src/launchd.test.ts +91 -0
- package/src/launchd.ts +37 -40
- package/src/mcp-http.ts +1 -1
- package/src/mcp-tools.ts +7 -9
- package/src/oauth.test.ts +289 -8
- package/src/oauth.ts +66 -13
- package/src/published.test.ts +21 -21
- package/src/routes.ts +152 -70
- package/src/routing.test.ts +478 -0
- package/src/routing.ts +413 -0
- package/src/server.ts +7 -278
- package/src/systemd.test.ts +15 -0
- package/src/systemd.ts +18 -11
- package/src/triggers.test.ts +7 -7
- package/src/triggers.ts +6 -6
- package/src/vault-store.ts +20 -3
- package/src/vault.test.ts +356 -262
- package/.claude/settings.local.json +0 -31
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- 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/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- 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: {
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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]
|
|
342
|
-
path: notes[0]
|
|
343
|
-
tags: notes[0]
|
|
344
|
-
metadata: notes[0]
|
|
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
|
});
|
package/core/src/paths.test.ts
CHANGED
|
@@ -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
|
});
|
package/core/src/schema.ts
CHANGED
|
@@ -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 =
|
|
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;
|