@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/src/vault.test.ts
CHANGED
|
@@ -29,39 +29,39 @@ afterEach(() => {
|
|
|
29
29
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
describe("BunStore", () => {
|
|
33
|
-
test("creates and retrieves a note", () => {
|
|
34
|
-
const note = store.createNote("Hello world");
|
|
32
|
+
describe("BunStore", async () => {
|
|
33
|
+
test("creates and retrieves a note", async () => {
|
|
34
|
+
const note = await store.createNote("Hello world");
|
|
35
35
|
expect(note.id).toBeTruthy();
|
|
36
36
|
expect(note.content).toBe("Hello world");
|
|
37
37
|
|
|
38
|
-
const fetched = store.getNote(note.id);
|
|
38
|
+
const fetched = await store.getNote(note.id);
|
|
39
39
|
expect(fetched).not.toBeNull();
|
|
40
40
|
expect(fetched!.content).toBe("Hello world");
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
test("creates note with tags", () => {
|
|
44
|
-
const note = store.createNote("Tagged note", { tags: ["daily", "pinned"] });
|
|
43
|
+
test("creates note with tags", async () => {
|
|
44
|
+
const note = await store.createNote("Tagged note", { tags: ["daily", "pinned"] });
|
|
45
45
|
expect(note.tags).toContain("daily");
|
|
46
46
|
expect(note.tags).toContain("pinned");
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
test("creates note with path", () => {
|
|
50
|
-
const note = store.createNote("Doc note", { path: "blog/first-post" });
|
|
49
|
+
test("creates note with path", async () => {
|
|
50
|
+
const note = await store.createNote("Doc note", { path: "blog/first-post" });
|
|
51
51
|
expect(note.path).toBe("blog/first-post");
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
-
test("updates a note", () => {
|
|
55
|
-
const note = store.createNote("Original");
|
|
56
|
-
const updated = store.updateNote(note.id, { content: "Updated" });
|
|
54
|
+
test("updates a note", async () => {
|
|
55
|
+
const note = await store.createNote("Original");
|
|
56
|
+
const updated = await store.updateNote(note.id, { content: "Updated" });
|
|
57
57
|
expect(updated.content).toBe("Updated");
|
|
58
58
|
expect(updated.updatedAt).toBeTruthy();
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
-
test("user updates bump updatedAt", () => {
|
|
62
|
-
const note = store.createNote("Original");
|
|
61
|
+
test("user updates bump updatedAt", async () => {
|
|
62
|
+
const note = await store.createNote("Original");
|
|
63
63
|
expect(note.updatedAt).toBeUndefined();
|
|
64
|
-
const updated = store.updateNote(note.id, { content: "Edited by user" });
|
|
64
|
+
const updated = await store.updateNote(note.id, { content: "Edited by user" });
|
|
65
65
|
expect(updated.updatedAt).toBeTruthy();
|
|
66
66
|
});
|
|
67
67
|
|
|
@@ -69,15 +69,15 @@ describe("BunStore", () => {
|
|
|
69
69
|
// Hook writes (e.g., the reader-audio hook's metadata markers) must not
|
|
70
70
|
// count as user activity. See issue #44 — hook writes were bumping
|
|
71
71
|
// updatedAt and wrecking Daily's reader sort.
|
|
72
|
-
const note = store.createNote("Content");
|
|
72
|
+
const note = await store.createNote("Content");
|
|
73
73
|
expect(note.updatedAt).toBeUndefined();
|
|
74
74
|
|
|
75
75
|
// Fresh note: a machine write must not set updatedAt.
|
|
76
|
-
store.updateNote(note.id, {
|
|
76
|
+
await store.updateNote(note.id, {
|
|
77
77
|
metadata: { audio_pending_at: "2026-04-09T10:00:00.000Z" },
|
|
78
78
|
skipUpdatedAt: true,
|
|
79
79
|
});
|
|
80
|
-
let fetched = store.getNote(note.id)!;
|
|
80
|
+
let fetched = (await store.getNote(note.id))!;
|
|
81
81
|
expect(fetched.updatedAt).toBeUndefined();
|
|
82
82
|
expect((fetched.metadata as { audio_pending_at?: string } | undefined)?.audio_pending_at).toBe(
|
|
83
83
|
"2026-04-09T10:00:00.000Z",
|
|
@@ -85,152 +85,152 @@ describe("BunStore", () => {
|
|
|
85
85
|
|
|
86
86
|
// Now a real user edit bumps it.
|
|
87
87
|
await new Promise((r) => setTimeout(r, 5));
|
|
88
|
-
store.updateNote(note.id, { content: "User edit" });
|
|
89
|
-
fetched = store.getNote(note.id)!;
|
|
88
|
+
await store.updateNote(note.id, { content: "User edit" });
|
|
89
|
+
fetched = (await store.getNote(note.id))!;
|
|
90
90
|
const userTs = fetched.updatedAt;
|
|
91
91
|
expect(userTs).toBeTruthy();
|
|
92
92
|
|
|
93
93
|
// A subsequent machine write must not overwrite the user's timestamp.
|
|
94
94
|
await new Promise((r) => setTimeout(r, 5));
|
|
95
|
-
store.updateNote(note.id, {
|
|
95
|
+
await store.updateNote(note.id, {
|
|
96
96
|
metadata: {
|
|
97
97
|
...(fetched.metadata as Record<string, unknown>),
|
|
98
98
|
audio_rendered_at: "2026-04-09T10:05:00.000Z",
|
|
99
99
|
},
|
|
100
100
|
skipUpdatedAt: true,
|
|
101
101
|
});
|
|
102
|
-
fetched = store.getNote(note.id)!;
|
|
102
|
+
fetched = (await store.getNote(note.id))!;
|
|
103
103
|
expect(fetched.updatedAt).toBe(userTs!);
|
|
104
104
|
expect((fetched.metadata as { audio_rendered_at?: string } | undefined)?.audio_rendered_at).toBe(
|
|
105
105
|
"2026-04-09T10:05:00.000Z",
|
|
106
106
|
);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
-
test("deletes a note", () => {
|
|
110
|
-
const note = store.createNote("To delete");
|
|
111
|
-
store.deleteNote(note.id);
|
|
112
|
-
expect(store.getNote(note.id)).toBeNull();
|
|
109
|
+
test("deletes a note", async () => {
|
|
110
|
+
const note = await store.createNote("To delete");
|
|
111
|
+
await store.deleteNote(note.id);
|
|
112
|
+
expect(await store.getNote(note.id)).toBeNull();
|
|
113
113
|
});
|
|
114
114
|
|
|
115
|
-
test("queries notes by tag", () => {
|
|
116
|
-
store.createNote("A", { tags: ["daily"] });
|
|
117
|
-
store.createNote("B", { tags: ["doc"] });
|
|
118
|
-
store.createNote("C", { tags: ["daily", "pinned"] });
|
|
115
|
+
test("queries notes by tag", async () => {
|
|
116
|
+
await store.createNote("A", { tags: ["daily"] });
|
|
117
|
+
await store.createNote("B", { tags: ["doc"] });
|
|
118
|
+
await store.createNote("C", { tags: ["daily", "pinned"] });
|
|
119
119
|
|
|
120
|
-
const daily = store.queryNotes({ tags: ["daily"] });
|
|
120
|
+
const daily = await store.queryNotes({ tags: ["daily"] });
|
|
121
121
|
expect(daily.length).toBe(2);
|
|
122
122
|
});
|
|
123
123
|
|
|
124
|
-
test("queries with exclude tags", () => {
|
|
125
|
-
store.createNote("A", { tags: ["daily"] });
|
|
126
|
-
store.createNote("B", { tags: ["daily", "archived"] });
|
|
124
|
+
test("queries with exclude tags", async () => {
|
|
125
|
+
await store.createNote("A", { tags: ["daily"] });
|
|
126
|
+
await store.createNote("B", { tags: ["daily", "archived"] });
|
|
127
127
|
|
|
128
|
-
const active = store.queryNotes({ tags: ["daily"], excludeTags: ["archived"] });
|
|
128
|
+
const active = await store.queryNotes({ tags: ["daily"], excludeTags: ["archived"] });
|
|
129
129
|
expect(active.length).toBe(1);
|
|
130
130
|
expect(active[0].content).toBe("A");
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
-
test("full-text search", () => {
|
|
134
|
-
store.createNote("The quick brown fox");
|
|
135
|
-
store.createNote("A lazy dog");
|
|
133
|
+
test("full-text search", async () => {
|
|
134
|
+
await store.createNote("The quick brown fox");
|
|
135
|
+
await store.createNote("A lazy dog");
|
|
136
136
|
|
|
137
|
-
const results = store.searchNotes("fox");
|
|
137
|
+
const results = await store.searchNotes("fox");
|
|
138
138
|
expect(results.length).toBe(1);
|
|
139
139
|
expect(results[0].content).toContain("fox");
|
|
140
140
|
});
|
|
141
141
|
|
|
142
|
-
test("tags and untags notes", () => {
|
|
143
|
-
const note = store.createNote("Taggable");
|
|
144
|
-
store.tagNote(note.id, ["important"]);
|
|
145
|
-
let fetched = store.getNote(note.id)!;
|
|
142
|
+
test("tags and untags notes", async () => {
|
|
143
|
+
const note = await store.createNote("Taggable");
|
|
144
|
+
await store.tagNote(note.id, ["important"]);
|
|
145
|
+
let fetched = (await store.getNote(note.id))!;
|
|
146
146
|
expect(fetched.tags).toContain("important");
|
|
147
147
|
|
|
148
|
-
store.untagNote(note.id, ["important"]);
|
|
149
|
-
fetched = store.getNote(note.id)!;
|
|
148
|
+
await store.untagNote(note.id, ["important"]);
|
|
149
|
+
fetched = (await store.getNote(note.id))!;
|
|
150
150
|
expect(fetched.tags).not.toContain("important");
|
|
151
151
|
});
|
|
152
152
|
|
|
153
|
-
test("lists tags with counts", () => {
|
|
154
|
-
store.createNote("A", { tags: ["daily"] });
|
|
155
|
-
store.createNote("B", { tags: ["daily"] });
|
|
156
|
-
store.createNote("C", { tags: ["doc"] });
|
|
153
|
+
test("lists tags with counts", async () => {
|
|
154
|
+
await store.createNote("A", { tags: ["daily"] });
|
|
155
|
+
await store.createNote("B", { tags: ["daily"] });
|
|
156
|
+
await store.createNote("C", { tags: ["doc"] });
|
|
157
157
|
|
|
158
|
-
const tags = store.listTags();
|
|
158
|
+
const tags = await store.listTags();
|
|
159
159
|
const daily = tags.find((t) => t.name === "daily");
|
|
160
160
|
expect(daily?.count).toBe(2);
|
|
161
161
|
const doc = tags.find((t) => t.name === "doc");
|
|
162
162
|
expect(doc?.count).toBe(1);
|
|
163
163
|
});
|
|
164
164
|
|
|
165
|
-
test("creates and queries links", () => {
|
|
166
|
-
const a = store.createNote("Note A");
|
|
167
|
-
const b = store.createNote("Note B");
|
|
165
|
+
test("creates and queries links", async () => {
|
|
166
|
+
const a = await store.createNote("Note A");
|
|
167
|
+
const b = await store.createNote("Note B");
|
|
168
168
|
|
|
169
|
-
const link = store.createLink(a.id, b.id, "related-to");
|
|
169
|
+
const link = await store.createLink(a.id, b.id, "related-to");
|
|
170
170
|
expect(link.sourceId).toBe(a.id);
|
|
171
171
|
expect(link.targetId).toBe(b.id);
|
|
172
172
|
expect(link.relationship).toBe("related-to");
|
|
173
173
|
|
|
174
|
-
const outbound = store.getLinks(a.id, { direction: "outbound" });
|
|
174
|
+
const outbound = await store.getLinks(a.id, { direction: "outbound" });
|
|
175
175
|
expect(outbound.length).toBe(1);
|
|
176
176
|
|
|
177
|
-
const inbound = store.getLinks(b.id, { direction: "inbound" });
|
|
177
|
+
const inbound = await store.getLinks(b.id, { direction: "inbound" });
|
|
178
178
|
expect(inbound.length).toBe(1);
|
|
179
179
|
|
|
180
|
-
store.deleteLink(a.id, b.id, "related-to");
|
|
181
|
-
expect(store.getLinks(a.id).length).toBe(0);
|
|
180
|
+
await store.deleteLink(a.id, b.id, "related-to");
|
|
181
|
+
expect((await store.getLinks(a.id)).length).toBe(0);
|
|
182
182
|
});
|
|
183
183
|
|
|
184
|
-
test("attachments", () => {
|
|
185
|
-
const note = store.createNote("With attachment");
|
|
186
|
-
const att = store.addAttachment(note.id, "/path/to/file.png", "image/png");
|
|
184
|
+
test("attachments", async () => {
|
|
185
|
+
const note = await store.createNote("With attachment");
|
|
186
|
+
const att = await store.addAttachment(note.id, "/path/to/file.png", "image/png");
|
|
187
187
|
expect(att.noteId).toBe(note.id);
|
|
188
188
|
|
|
189
|
-
const atts = store.getAttachments(note.id);
|
|
189
|
+
const atts = await store.getAttachments(note.id);
|
|
190
190
|
expect(atts.length).toBe(1);
|
|
191
191
|
expect(atts[0].mimeType).toBe("image/png");
|
|
192
192
|
});
|
|
193
193
|
|
|
194
|
-
test("starts with no tags", () => {
|
|
195
|
-
const tags = store.listTags();
|
|
194
|
+
test("starts with no tags", async () => {
|
|
195
|
+
const tags = await store.listTags();
|
|
196
196
|
expect(tags.length).toBe(0);
|
|
197
197
|
});
|
|
198
198
|
|
|
199
|
-
test("gets note by path", () => {
|
|
200
|
-
store.createNote("README content", { path: "Projects/Parachute/README" });
|
|
201
|
-
const note = store.getNoteByPath("Projects/Parachute/README");
|
|
199
|
+
test("gets note by path", async () => {
|
|
200
|
+
await store.createNote("README content", { path: "Projects/Parachute/README" });
|
|
201
|
+
const note = await store.getNoteByPath("Projects/Parachute/README");
|
|
202
202
|
expect(note).not.toBeNull();
|
|
203
203
|
expect(note!.content).toBe("README content");
|
|
204
204
|
expect(note!.path).toBe("Projects/Parachute/README");
|
|
205
205
|
});
|
|
206
206
|
|
|
207
|
-
test("gets multiple notes by IDs", () => {
|
|
208
|
-
const a = store.createNote("A");
|
|
209
|
-
const b = store.createNote("B");
|
|
210
|
-
const c = store.createNote("C");
|
|
207
|
+
test("gets multiple notes by IDs", async () => {
|
|
208
|
+
const a = await store.createNote("A");
|
|
209
|
+
const b = await store.createNote("B");
|
|
210
|
+
const c = await store.createNote("C");
|
|
211
211
|
|
|
212
|
-
const fetched = store.getNotes([a.id, c.id]);
|
|
212
|
+
const fetched = await store.getNotes([a.id, c.id]);
|
|
213
213
|
expect(fetched.length).toBe(2);
|
|
214
214
|
expect(fetched.map((n) => n.content)).toContain("A");
|
|
215
215
|
expect(fetched.map((n) => n.content)).toContain("C");
|
|
216
216
|
});
|
|
217
217
|
|
|
218
|
-
test("queries by path prefix", () => {
|
|
219
|
-
store.createNote("Root README", { path: "README" });
|
|
220
|
-
store.createNote("Project README", { path: "Projects/Parachute/README" });
|
|
221
|
-
store.createNote("Project Notes", { path: "Projects/Parachute/Notes" });
|
|
222
|
-
store.createNote("Other", { path: "Other/Stuff" });
|
|
218
|
+
test("queries by path prefix", async () => {
|
|
219
|
+
await store.createNote("Root README", { path: "README" });
|
|
220
|
+
await store.createNote("Project README", { path: "Projects/Parachute/README" });
|
|
221
|
+
await store.createNote("Project Notes", { path: "Projects/Parachute/Notes" });
|
|
222
|
+
await store.createNote("Other", { path: "Other/Stuff" });
|
|
223
223
|
|
|
224
|
-
const results = store.queryNotes({ pathPrefix: "Projects/Parachute" });
|
|
224
|
+
const results = await store.queryNotes({ pathPrefix: "Projects/Parachute" });
|
|
225
225
|
expect(results.length).toBe(2);
|
|
226
226
|
expect(results.map((n) => n.path)).toContain("Projects/Parachute/README");
|
|
227
227
|
expect(results.map((n) => n.path)).toContain("Projects/Parachute/Notes");
|
|
228
228
|
});
|
|
229
229
|
});
|
|
230
230
|
|
|
231
|
-
describe("metadata", () => {
|
|
232
|
-
test("creates note with metadata", () => {
|
|
233
|
-
const note = store.createNote("Meeting notes", {
|
|
231
|
+
describe("metadata", async () => {
|
|
232
|
+
test("creates note with metadata", async () => {
|
|
233
|
+
const note = await store.createNote("Meeting notes", {
|
|
234
234
|
path: "Meetings/standup",
|
|
235
235
|
metadata: { status: "draft", priority: "high", attendees: ["alice", "bob"] },
|
|
236
236
|
});
|
|
@@ -240,35 +240,35 @@ describe("metadata", () => {
|
|
|
240
240
|
expect(note.metadata!.attendees).toEqual(["alice", "bob"]);
|
|
241
241
|
});
|
|
242
242
|
|
|
243
|
-
test("updates note metadata", () => {
|
|
244
|
-
const note = store.createNote("Doc", { metadata: { status: "draft" } });
|
|
245
|
-
const updated = store.updateNote(note.id, { metadata: { status: "published", version: 2 } });
|
|
243
|
+
test("updates note metadata", async () => {
|
|
244
|
+
const note = await store.createNote("Doc", { metadata: { status: "draft" } });
|
|
245
|
+
const updated = await store.updateNote(note.id, { metadata: { status: "published", version: 2 } });
|
|
246
246
|
expect(updated.metadata!.status).toBe("published");
|
|
247
247
|
expect(updated.metadata!.version).toBe(2);
|
|
248
248
|
});
|
|
249
249
|
|
|
250
|
-
test("queries notes by metadata", () => {
|
|
251
|
-
store.createNote("Draft 1", { metadata: { status: "draft" } });
|
|
252
|
-
store.createNote("Draft 2", { metadata: { status: "draft" } });
|
|
253
|
-
store.createNote("Published", { metadata: { status: "published" } });
|
|
250
|
+
test("queries notes by metadata", async () => {
|
|
251
|
+
await store.createNote("Draft 1", { metadata: { status: "draft" } });
|
|
252
|
+
await store.createNote("Draft 2", { metadata: { status: "draft" } });
|
|
253
|
+
await store.createNote("Published", { metadata: { status: "published" } });
|
|
254
254
|
|
|
255
|
-
const drafts = store.queryNotes({ metadata: { status: "draft" } });
|
|
255
|
+
const drafts = await store.queryNotes({ metadata: { status: "draft" } });
|
|
256
256
|
expect(drafts.length).toBe(2);
|
|
257
257
|
|
|
258
|
-
const published = store.queryNotes({ metadata: { status: "published" } });
|
|
258
|
+
const published = await store.queryNotes({ metadata: { status: "published" } });
|
|
259
259
|
expect(published.length).toBe(1);
|
|
260
260
|
expect(published[0].content).toBe("Published");
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
-
test("notes without metadata return undefined metadata", () => {
|
|
264
|
-
const note = store.createNote("Plain note");
|
|
263
|
+
test("notes without metadata return undefined metadata", async () => {
|
|
264
|
+
const note = await store.createNote("Plain note");
|
|
265
265
|
expect(note.metadata).toBeUndefined();
|
|
266
266
|
});
|
|
267
267
|
|
|
268
|
-
test("creates link with metadata", () => {
|
|
269
|
-
const a = store.createNote("A");
|
|
270
|
-
const b = store.createNote("B");
|
|
271
|
-
const link = store.createLink(a.id, b.id, "related-to", {
|
|
268
|
+
test("creates link with metadata", async () => {
|
|
269
|
+
const a = await store.createNote("A");
|
|
270
|
+
const b = await store.createNote("B");
|
|
271
|
+
const link = await store.createLink(a.id, b.id, "related-to", {
|
|
272
272
|
confidence: 0.9,
|
|
273
273
|
context: "mentioned in meeting",
|
|
274
274
|
});
|
|
@@ -277,10 +277,10 @@ describe("metadata", () => {
|
|
|
277
277
|
expect(link.metadata!.context).toBe("mentioned in meeting");
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
-
test("hydrated links include note metadata", () => {
|
|
281
|
-
const a = store.createNote("A", { metadata: { type: "project" } });
|
|
282
|
-
const b = store.createNote("B", { metadata: { type: "task" } });
|
|
283
|
-
store.createLink(a.id, b.id, "contains");
|
|
280
|
+
test("hydrated links include note metadata", async () => {
|
|
281
|
+
const a = await store.createNote("A", { metadata: { type: "project" } });
|
|
282
|
+
const b = await store.createNote("B", { metadata: { type: "task" } });
|
|
283
|
+
await store.createLink(a.id, b.id, "contains");
|
|
284
284
|
|
|
285
285
|
const links = getLinksHydrated(db, a.id);
|
|
286
286
|
expect(links[0].sourceNote?.metadata?.type).toBe("project");
|
|
@@ -288,9 +288,9 @@ describe("metadata", () => {
|
|
|
288
288
|
});
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
-
describe("bulk operations", () => {
|
|
292
|
-
test("creates multiple notes at once", () => {
|
|
293
|
-
const notes = store.createNotes([
|
|
291
|
+
describe("bulk operations", async () => {
|
|
292
|
+
test("creates multiple notes at once", async () => {
|
|
293
|
+
const notes = await store.createNotes([
|
|
294
294
|
{ content: "Note 1", tags: ["daily"] },
|
|
295
295
|
{ content: "Note 2", tags: ["doc"] },
|
|
296
296
|
{ content: "Note 3" },
|
|
@@ -300,8 +300,8 @@ describe("bulk operations", () => {
|
|
|
300
300
|
expect(notes[1].tags).toContain("doc");
|
|
301
301
|
});
|
|
302
302
|
|
|
303
|
-
test("createNotes accepts per-note metadata and created_at (mixed batch)", () => {
|
|
304
|
-
const notes = store.createNotes([
|
|
303
|
+
test("createNotes accepts per-note metadata and created_at (mixed batch)", async () => {
|
|
304
|
+
const notes = await store.createNotes([
|
|
305
305
|
{ content: "Plain", tags: ["daily"] },
|
|
306
306
|
{
|
|
307
307
|
content: "With metadata",
|
|
@@ -331,7 +331,7 @@ describe("bulk operations", () => {
|
|
|
331
331
|
expect(notes[2].metadata?.source).toBe("tana-import");
|
|
332
332
|
});
|
|
333
333
|
|
|
334
|
-
test("createNotes preserves per-note metadata isolation across many notes", () => {
|
|
334
|
+
test("createNotes preserves per-note metadata isolation across many notes", async () => {
|
|
335
335
|
const inputs = Array.from({ length: 5 }, (_, i) => ({
|
|
336
336
|
content: `Day ${i}`,
|
|
337
337
|
path: `Journal/2024-06-${String(i + 1).padStart(2, "0")}`,
|
|
@@ -343,7 +343,7 @@ describe("bulk operations", () => {
|
|
|
343
343
|
},
|
|
344
344
|
created_at: `2024-06-${String(i + 1).padStart(2, "0")}T12:00:00.000Z`,
|
|
345
345
|
}));
|
|
346
|
-
const notes = store.createNotes(inputs);
|
|
346
|
+
const notes = await store.createNotes(inputs);
|
|
347
347
|
expect(notes.length).toBe(5);
|
|
348
348
|
for (let i = 0; i < 5; i++) {
|
|
349
349
|
expect(notes[i].path).toBe(`Journal/2024-06-${String(i + 1).padStart(2, "0")}`);
|
|
@@ -354,9 +354,9 @@ describe("bulk operations", () => {
|
|
|
354
354
|
}
|
|
355
355
|
});
|
|
356
356
|
|
|
357
|
-
test("createNotes is backwards compatible — omitted metadata/created_at use defaults", () => {
|
|
357
|
+
test("createNotes is backwards compatible — omitted metadata/created_at use defaults", async () => {
|
|
358
358
|
const before = new Date().toISOString();
|
|
359
|
-
const notes = store.createNotes([
|
|
359
|
+
const notes = await store.createNotes([
|
|
360
360
|
{ content: "Just content" },
|
|
361
361
|
{ content: "Content + tags", tags: ["x"] },
|
|
362
362
|
]);
|
|
@@ -368,89 +368,89 @@ describe("bulk operations", () => {
|
|
|
368
368
|
expect(notes[0].createdAt <= after).toBe(true);
|
|
369
369
|
});
|
|
370
370
|
|
|
371
|
-
test("batch tags multiple notes", () => {
|
|
372
|
-
const a = store.createNote("A");
|
|
373
|
-
const b = store.createNote("B");
|
|
374
|
-
const c = store.createNote("C");
|
|
371
|
+
test("batch tags multiple notes", async () => {
|
|
372
|
+
const a = await store.createNote("A");
|
|
373
|
+
const b = await store.createNote("B");
|
|
374
|
+
const c = await store.createNote("C");
|
|
375
375
|
|
|
376
|
-
store.batchTag([a.id, b.id, c.id], ["important", "review"]);
|
|
376
|
+
await store.batchTag([a.id, b.id, c.id], ["important", "review"]);
|
|
377
377
|
|
|
378
|
-
expect(store.getNote(a.id)!.tags).toContain("important");
|
|
379
|
-
expect(store.getNote(b.id)!.tags).toContain("review");
|
|
380
|
-
expect(store.getNote(c.id)!.tags).toContain("important");
|
|
378
|
+
expect((await store.getNote(a.id))!.tags).toContain("important");
|
|
379
|
+
expect((await store.getNote(b.id))!.tags).toContain("review");
|
|
380
|
+
expect((await store.getNote(c.id))!.tags).toContain("important");
|
|
381
381
|
});
|
|
382
382
|
|
|
383
|
-
test("batch untags multiple notes", () => {
|
|
384
|
-
const a = store.createNote("A", { tags: ["daily", "pinned"] });
|
|
385
|
-
const b = store.createNote("B", { tags: ["daily", "pinned"] });
|
|
383
|
+
test("batch untags multiple notes", async () => {
|
|
384
|
+
const a = await store.createNote("A", { tags: ["daily", "pinned"] });
|
|
385
|
+
const b = await store.createNote("B", { tags: ["daily", "pinned"] });
|
|
386
386
|
|
|
387
|
-
store.batchUntag([a.id, b.id], ["pinned"]);
|
|
387
|
+
await store.batchUntag([a.id, b.id], ["pinned"]);
|
|
388
388
|
|
|
389
|
-
expect(store.getNote(a.id)!.tags).toContain("daily");
|
|
390
|
-
expect(store.getNote(a.id)!.tags).not.toContain("pinned");
|
|
391
|
-
expect(store.getNote(b.id)!.tags).not.toContain("pinned");
|
|
389
|
+
expect((await store.getNote(a.id))!.tags).toContain("daily");
|
|
390
|
+
expect((await store.getNote(a.id))!.tags).not.toContain("pinned");
|
|
391
|
+
expect((await store.getNote(b.id))!.tags).not.toContain("pinned");
|
|
392
392
|
});
|
|
393
393
|
});
|
|
394
394
|
|
|
395
|
-
describe("deeper link queries", () => {
|
|
396
|
-
test("traverses links multi-hop", () => {
|
|
397
|
-
const a = store.createNote("A");
|
|
398
|
-
const b = store.createNote("B");
|
|
399
|
-
const c = store.createNote("C");
|
|
400
|
-
const d = store.createNote("D");
|
|
395
|
+
describe("deeper link queries", async () => {
|
|
396
|
+
test("traverses links multi-hop", async () => {
|
|
397
|
+
const a = await store.createNote("A");
|
|
398
|
+
const b = await store.createNote("B");
|
|
399
|
+
const c = await store.createNote("C");
|
|
400
|
+
const d = await store.createNote("D");
|
|
401
401
|
|
|
402
|
-
store.createLink(a.id, b.id, "related-to");
|
|
403
|
-
store.createLink(b.id, c.id, "related-to");
|
|
404
|
-
store.createLink(c.id, d.id, "related-to");
|
|
402
|
+
await store.createLink(a.id, b.id, "related-to");
|
|
403
|
+
await store.createLink(b.id, c.id, "related-to");
|
|
404
|
+
await store.createLink(c.id, d.id, "related-to");
|
|
405
405
|
|
|
406
406
|
// 1 hop from A: should find B
|
|
407
|
-
const hop1 = store.traverseLinks(a.id, { max_depth: 1 });
|
|
407
|
+
const hop1 = await store.traverseLinks(a.id, { max_depth: 1 });
|
|
408
408
|
expect(hop1.length).toBe(1);
|
|
409
409
|
expect(hop1[0].noteId).toBe(b.id);
|
|
410
410
|
|
|
411
411
|
// 2 hops from A: should find B and C
|
|
412
|
-
const hop2 = store.traverseLinks(a.id, { max_depth: 2 });
|
|
412
|
+
const hop2 = await store.traverseLinks(a.id, { max_depth: 2 });
|
|
413
413
|
expect(hop2.length).toBe(2);
|
|
414
414
|
const ids2 = hop2.map((n) => n.noteId);
|
|
415
415
|
expect(ids2).toContain(b.id);
|
|
416
416
|
expect(ids2).toContain(c.id);
|
|
417
417
|
|
|
418
418
|
// 3 hops from A: should find B, C, and D
|
|
419
|
-
const hop3 = store.traverseLinks(a.id, { max_depth: 3 });
|
|
419
|
+
const hop3 = await store.traverseLinks(a.id, { max_depth: 3 });
|
|
420
420
|
expect(hop3.length).toBe(3);
|
|
421
421
|
});
|
|
422
422
|
|
|
423
|
-
test("traverses with relationship filter", () => {
|
|
424
|
-
const a = store.createNote("A");
|
|
425
|
-
const b = store.createNote("B");
|
|
426
|
-
const c = store.createNote("C");
|
|
423
|
+
test("traverses with relationship filter", async () => {
|
|
424
|
+
const a = await store.createNote("A");
|
|
425
|
+
const b = await store.createNote("B");
|
|
426
|
+
const c = await store.createNote("C");
|
|
427
427
|
|
|
428
|
-
store.createLink(a.id, b.id, "mentions");
|
|
429
|
-
store.createLink(a.id, c.id, "related-to");
|
|
428
|
+
await store.createLink(a.id, b.id, "mentions");
|
|
429
|
+
await store.createLink(a.id, c.id, "related-to");
|
|
430
430
|
|
|
431
|
-
const mentions = store.traverseLinks(a.id, { max_depth: 1, relationship: "mentions" });
|
|
431
|
+
const mentions = await store.traverseLinks(a.id, { max_depth: 1, relationship: "mentions" });
|
|
432
432
|
expect(mentions.length).toBe(1);
|
|
433
433
|
expect(mentions[0].noteId).toBe(b.id);
|
|
434
434
|
});
|
|
435
435
|
|
|
436
|
-
test("finds path between notes", () => {
|
|
437
|
-
const a = store.createNote("A");
|
|
438
|
-
const b = store.createNote("B");
|
|
439
|
-
const c = store.createNote("C");
|
|
436
|
+
test("finds path between notes", async () => {
|
|
437
|
+
const a = await store.createNote("A");
|
|
438
|
+
const b = await store.createNote("B");
|
|
439
|
+
const c = await store.createNote("C");
|
|
440
440
|
|
|
441
|
-
store.createLink(a.id, b.id, "related-to");
|
|
442
|
-
store.createLink(b.id, c.id, "mentions");
|
|
441
|
+
await store.createLink(a.id, b.id, "related-to");
|
|
442
|
+
await store.createLink(b.id, c.id, "mentions");
|
|
443
443
|
|
|
444
|
-
const result = store.findPath(a.id, c.id);
|
|
444
|
+
const result = await store.findPath(a.id, c.id);
|
|
445
445
|
expect(result).not.toBeNull();
|
|
446
446
|
expect(result!.path).toEqual([a.id, b.id, c.id]);
|
|
447
447
|
expect(result!.relationships).toEqual(["related-to", "mentions"]);
|
|
448
448
|
});
|
|
449
449
|
|
|
450
|
-
test("get-links returns hydrated note summaries", () => {
|
|
451
|
-
const a = store.createNote("Note A", { path: "a", tags: ["important"] });
|
|
452
|
-
const b = store.createNote("Note B", { path: "b" });
|
|
453
|
-
store.createLink(a.id, b.id, "related-to");
|
|
450
|
+
test("get-links returns hydrated note summaries", async () => {
|
|
451
|
+
const a = await store.createNote("Note A", { path: "a", tags: ["important"] });
|
|
452
|
+
const b = await store.createNote("Note B", { path: "b" });
|
|
453
|
+
await store.createLink(a.id, b.id, "related-to");
|
|
454
454
|
|
|
455
455
|
const result = getLinksHydrated(db, a.id);
|
|
456
456
|
expect(result.length).toBe(1);
|
|
@@ -459,17 +459,17 @@ describe("deeper link queries", () => {
|
|
|
459
459
|
expect(result[0].sourceNote?.tags).toContain("important");
|
|
460
460
|
});
|
|
461
461
|
|
|
462
|
-
test("returns null when no path exists", () => {
|
|
463
|
-
const a = store.createNote("A");
|
|
464
|
-
const b = store.createNote("B");
|
|
462
|
+
test("returns null when no path exists", async () => {
|
|
463
|
+
const a = await store.createNote("A");
|
|
464
|
+
const b = await store.createNote("B");
|
|
465
465
|
// No link between them
|
|
466
466
|
|
|
467
|
-
const result = store.findPath(a.id, b.id);
|
|
467
|
+
const result = await store.findPath(a.id, b.id);
|
|
468
468
|
expect(result).toBeNull();
|
|
469
469
|
});
|
|
470
470
|
});
|
|
471
471
|
|
|
472
|
-
describe("MCP tools", () => {
|
|
472
|
+
describe("MCP tools", async () => {
|
|
473
473
|
test("generates all 9 core tools", () => {
|
|
474
474
|
const tools = generateMcpTools(store);
|
|
475
475
|
expect(tools.length).toBe(9);
|
|
@@ -486,29 +486,29 @@ describe("MCP tools", () => {
|
|
|
486
486
|
expect(names).toContain("vault-info");
|
|
487
487
|
});
|
|
488
488
|
|
|
489
|
-
test("query-notes by id works", () => {
|
|
489
|
+
test("query-notes by id works", async () => {
|
|
490
490
|
const tools = generateMcpTools(store);
|
|
491
|
-
const note = store.createNote("By ID", { path: "test/note" });
|
|
491
|
+
const note = await store.createNote("By ID", { path: "test/note" });
|
|
492
492
|
|
|
493
493
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
494
|
-
const result = query.execute({ id: note.id }) as any;
|
|
494
|
+
const result = await query.execute({ id: note.id }) as any;
|
|
495
495
|
expect(result.content).toBe("By ID");
|
|
496
496
|
expect(result.path).toBe("test/note");
|
|
497
497
|
});
|
|
498
498
|
|
|
499
|
-
test("query-notes by path works", () => {
|
|
499
|
+
test("query-notes by path works", async () => {
|
|
500
500
|
const tools = generateMcpTools(store);
|
|
501
|
-
store.createNote("By Path", { path: "Projects/README" });
|
|
501
|
+
await store.createNote("By Path", { path: "Projects/README" });
|
|
502
502
|
|
|
503
503
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
504
|
-
const result = query.execute({ id: "Projects/README" }) as any;
|
|
504
|
+
const result = await query.execute({ id: "Projects/README" }) as any;
|
|
505
505
|
expect(result.content).toBe("By Path");
|
|
506
506
|
});
|
|
507
507
|
|
|
508
|
-
test("create-note tool works via execute", () => {
|
|
508
|
+
test("create-note tool works via execute", async () => {
|
|
509
509
|
const tools = generateMcpTools(store);
|
|
510
510
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
511
|
-
const result = createNote.execute({ content: "MCP note", tags: ["daily"] }) as any;
|
|
511
|
+
const result = await createNote.execute({ content: "MCP note", tags: ["daily"] }) as any;
|
|
512
512
|
expect(result.content).toBe("MCP note");
|
|
513
513
|
expect(result.tags).toContain("daily");
|
|
514
514
|
});
|
|
@@ -522,7 +522,7 @@ describe("MCP tools", () => {
|
|
|
522
522
|
});
|
|
523
523
|
});
|
|
524
524
|
|
|
525
|
-
describe("unified MCP wrapper", () => {
|
|
525
|
+
describe("unified MCP wrapper", async () => {
|
|
526
526
|
test("vault-info routes through vault param", async () => {
|
|
527
527
|
const { generateUnifiedMcpTools } = await import("./mcp-tools.ts");
|
|
528
528
|
const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
|
|
@@ -538,14 +538,14 @@ describe("unified MCP wrapper", () => {
|
|
|
538
538
|
writeGlobalConfig({ port: 1940, default_vault: vaultName });
|
|
539
539
|
|
|
540
540
|
const vaultStore = getVaultStore(vaultName);
|
|
541
|
-
vaultStore.createNote("alpha", { tags: ["x", "y"] });
|
|
542
|
-
vaultStore.createNote("beta", { tags: ["x"] });
|
|
541
|
+
await vaultStore.createNote("alpha", { tags: ["x", "y"] });
|
|
542
|
+
await vaultStore.createNote("beta", { tags: ["x"] });
|
|
543
543
|
|
|
544
544
|
const tools = generateUnifiedMcpTools();
|
|
545
545
|
const vaultInfo = tools.find((t) => t.name === "vault-info");
|
|
546
546
|
expect(vaultInfo).toBeTruthy();
|
|
547
547
|
|
|
548
|
-
const result = vaultInfo!.execute({ vault: vaultName, include_stats: true }) as any;
|
|
548
|
+
const result = await vaultInfo!.execute({ vault: vaultName, include_stats: true }) as any;
|
|
549
549
|
expect(result.name).toBe(vaultName);
|
|
550
550
|
expect(result.description).toBe("Test vault");
|
|
551
551
|
expect(result.stats.totalNotes).toBe(2);
|
|
@@ -568,8 +568,8 @@ describe("unified MCP wrapper", () => {
|
|
|
568
568
|
writeGlobalConfig({ port: 1940, default_vault: vaultName });
|
|
569
569
|
|
|
570
570
|
const vaultStore = getVaultStore(vaultName);
|
|
571
|
-
vaultStore.createNote("A", { tags: ["person"] });
|
|
572
|
-
vaultStore.upsertTagSchema("person", {
|
|
571
|
+
await vaultStore.createNote("A", { tags: ["person"] });
|
|
572
|
+
await vaultStore.upsertTagSchema("person", {
|
|
573
573
|
description: "A person",
|
|
574
574
|
fields: { name: { type: "string", description: "Full name" } },
|
|
575
575
|
});
|
|
@@ -578,7 +578,7 @@ describe("unified MCP wrapper", () => {
|
|
|
578
578
|
|
|
579
579
|
// list-tags with tag param for single tag detail
|
|
580
580
|
const listTags = tools.find((t) => t.name === "list-tags")!;
|
|
581
|
-
const detail = listTags.execute({ vault: vaultName, tag: "person" }) as any;
|
|
581
|
+
const detail = await listTags.execute({ vault: vaultName, tag: "person" }) as any;
|
|
582
582
|
expect(detail.name).toBe("person");
|
|
583
583
|
expect(detail.count).toBe(1);
|
|
584
584
|
expect(detail.description).toBe("A person");
|
|
@@ -601,7 +601,7 @@ describe("unified MCP wrapper", () => {
|
|
|
601
601
|
writeGlobalConfig({ port: 1940, default_vault: vaultName });
|
|
602
602
|
|
|
603
603
|
const vaultStore = getVaultStore(vaultName);
|
|
604
|
-
vaultStore.upsertTagSchema("person", {
|
|
604
|
+
await vaultStore.upsertTagSchema("person", {
|
|
605
605
|
description: "A person",
|
|
606
606
|
fields: {
|
|
607
607
|
first_appeared: { type: "string", description: "When" },
|
|
@@ -614,7 +614,7 @@ describe("unified MCP wrapper", () => {
|
|
|
614
614
|
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
615
615
|
|
|
616
616
|
// Create a note tagged person with no metadata — defaults auto-populated
|
|
617
|
-
const result = createNote.execute({
|
|
617
|
+
const result = await createNote.execute({
|
|
618
618
|
vault: vaultName,
|
|
619
619
|
content: "Alice",
|
|
620
620
|
tags: ["person"],
|
|
@@ -622,18 +622,18 @@ describe("unified MCP wrapper", () => {
|
|
|
622
622
|
expect(result.content).toBe("Alice");
|
|
623
623
|
|
|
624
624
|
// Verify defaults were written
|
|
625
|
-
const fresh = queryNotes.execute({ vault: vaultName, id: result.id }) as any;
|
|
625
|
+
const fresh = await queryNotes.execute({ vault: vaultName, id: result.id }) as any;
|
|
626
626
|
expect(fresh.metadata.first_appeared).toBe("");
|
|
627
627
|
expect(fresh.metadata.relationship).toBe("");
|
|
628
628
|
|
|
629
629
|
// Create with explicit metadata — preserved
|
|
630
|
-
const result2 = createNote.execute({
|
|
630
|
+
const result2 = await createNote.execute({
|
|
631
631
|
vault: vaultName,
|
|
632
632
|
content: "Bob",
|
|
633
633
|
tags: ["person"],
|
|
634
634
|
metadata: { first_appeared: "2024-01", relationship: "friend" },
|
|
635
635
|
}) as any;
|
|
636
|
-
const fresh2 = queryNotes.execute({ vault: vaultName, id: result2.id }) as any;
|
|
636
|
+
const fresh2 = await queryNotes.execute({ vault: vaultName, id: result2.id }) as any;
|
|
637
637
|
expect(fresh2.metadata.first_appeared).toBe("2024-01");
|
|
638
638
|
expect(fresh2.metadata.relationship).toBe("friend");
|
|
639
639
|
|
|
@@ -654,14 +654,14 @@ describe("unified MCP wrapper", () => {
|
|
|
654
654
|
writeGlobalConfig({ port: 1940, default_vault: vaultName });
|
|
655
655
|
|
|
656
656
|
const vaultStore = getVaultStore(vaultName);
|
|
657
|
-
vaultStore.upsertTagSchema("person", {
|
|
657
|
+
await vaultStore.upsertTagSchema("person", {
|
|
658
658
|
description: "A person",
|
|
659
659
|
fields: {
|
|
660
660
|
first_appeared: { type: "string", description: "When" },
|
|
661
661
|
relationship: { type: "string", description: "How" },
|
|
662
662
|
},
|
|
663
663
|
});
|
|
664
|
-
vaultStore.upsertTagSchema("project", {
|
|
664
|
+
await vaultStore.upsertTagSchema("project", {
|
|
665
665
|
description: "A project",
|
|
666
666
|
fields: {
|
|
667
667
|
status: { type: "string", enum: ["active", "completed", "abandoned"], description: "Status" },
|
|
@@ -675,35 +675,35 @@ describe("unified MCP wrapper", () => {
|
|
|
675
675
|
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
676
676
|
|
|
677
677
|
// Create a note, then add #person tag via update-note
|
|
678
|
-
const note = createNote.execute({ vault: vaultName, content: "Alice" }) as any;
|
|
679
|
-
updateNote.execute({ vault: vaultName, id: note.id, tags: { add: ["person"] } });
|
|
680
|
-
const after = queryNotes.execute({ vault: vaultName, id: note.id }) as any;
|
|
678
|
+
const note = await createNote.execute({ vault: vaultName, content: "Alice" }) as any;
|
|
679
|
+
await updateNote.execute({ vault: vaultName, id: note.id, tags: { add: ["person"] } });
|
|
680
|
+
const after = await queryNotes.execute({ vault: vaultName, id: note.id }) as any;
|
|
681
681
|
expect(after.metadata.first_appeared).toBe("");
|
|
682
682
|
expect(after.metadata.relationship).toBe("");
|
|
683
683
|
|
|
684
684
|
// Tag note that already has partial metadata — only missing fields populated
|
|
685
|
-
const note2 = createNote.execute({
|
|
685
|
+
const note2 = await createNote.execute({
|
|
686
686
|
vault: vaultName,
|
|
687
687
|
content: "Bob",
|
|
688
688
|
metadata: { first_appeared: "2023-11" },
|
|
689
689
|
}) as any;
|
|
690
|
-
updateNote.execute({ vault: vaultName, id: note2.id, tags: { add: ["person"] } });
|
|
691
|
-
const after2 = queryNotes.execute({ vault: vaultName, id: note2.id }) as any;
|
|
690
|
+
await updateNote.execute({ vault: vaultName, id: note2.id, tags: { add: ["person"] } });
|
|
691
|
+
const after2 = await queryNotes.execute({ vault: vaultName, id: note2.id }) as any;
|
|
692
692
|
expect(after2.metadata.first_appeared).toBe("2023-11"); // preserved
|
|
693
693
|
expect(after2.metadata.relationship).toBe(""); // added
|
|
694
694
|
|
|
695
695
|
// Tag with #project — enum defaults to first value, boolean to false, integer to 0
|
|
696
|
-
const note4 = createNote.execute({ vault: vaultName, content: "My Project" }) as any;
|
|
697
|
-
updateNote.execute({ vault: vaultName, id: note4.id, tags: { add: ["project"] } });
|
|
698
|
-
const after4 = queryNotes.execute({ vault: vaultName, id: note4.id }) as any;
|
|
696
|
+
const note4 = await createNote.execute({ vault: vaultName, content: "My Project" }) as any;
|
|
697
|
+
await updateNote.execute({ vault: vaultName, id: note4.id, tags: { add: ["project"] } });
|
|
698
|
+
const after4 = await queryNotes.execute({ vault: vaultName, id: note4.id }) as any;
|
|
699
699
|
expect(after4.metadata.status).toBe("active");
|
|
700
700
|
expect(after4.metadata.active).toBe(false);
|
|
701
701
|
expect(after4.metadata.priority).toBe(0);
|
|
702
702
|
|
|
703
703
|
// Multiple schema tags at once — all defaults merged
|
|
704
|
-
const note5 = createNote.execute({ vault: vaultName, content: "Multi" }) as any;
|
|
705
|
-
updateNote.execute({ vault: vaultName, id: note5.id, tags: { add: ["person", "project"] } });
|
|
706
|
-
const after5 = queryNotes.execute({ vault: vaultName, id: note5.id }) as any;
|
|
704
|
+
const note5 = await createNote.execute({ vault: vaultName, content: "Multi" }) as any;
|
|
705
|
+
await updateNote.execute({ vault: vaultName, id: note5.id, tags: { add: ["person", "project"] } });
|
|
706
|
+
const after5 = await queryNotes.execute({ vault: vaultName, id: note5.id }) as any;
|
|
707
707
|
expect(after5.metadata.first_appeared).toBe("");
|
|
708
708
|
expect(after5.metadata.relationship).toBe("");
|
|
709
709
|
expect(after5.metadata.status).toBe("active");
|
|
@@ -726,7 +726,7 @@ describe("unified MCP wrapper", () => {
|
|
|
726
726
|
writeGlobalConfig({ port: 1940, default_vault: vaultName });
|
|
727
727
|
|
|
728
728
|
const vaultStore = getVaultStore(vaultName);
|
|
729
|
-
vaultStore.upsertTagSchema("person", {
|
|
729
|
+
await vaultStore.upsertTagSchema("person", {
|
|
730
730
|
description: "A person",
|
|
731
731
|
fields: { name: { type: "string" } },
|
|
732
732
|
});
|
|
@@ -736,10 +736,10 @@ describe("unified MCP wrapper", () => {
|
|
|
736
736
|
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
737
737
|
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
738
738
|
|
|
739
|
-
const note = createNote.execute({ vault: vaultName, content: "Test" }) as any;
|
|
739
|
+
const note = await createNote.execute({ vault: vaultName, content: "Test" }) as any;
|
|
740
740
|
const originalUpdatedAt = note.updatedAt;
|
|
741
|
-
updateNote.execute({ vault: vaultName, id: note.id, tags: { add: ["person"] } });
|
|
742
|
-
const after = queryNotes.execute({ vault: vaultName, id: note.id }) as any;
|
|
741
|
+
await updateNote.execute({ vault: vaultName, id: note.id, tags: { add: ["person"] } });
|
|
742
|
+
const after = await queryNotes.execute({ vault: vaultName, id: note.id }) as any;
|
|
743
743
|
expect(after.updatedAt).toBe(originalUpdatedAt);
|
|
744
744
|
expect(after.metadata.name).toBe("");
|
|
745
745
|
|
|
@@ -807,10 +807,10 @@ function mkReq(method: string, path: string, body?: unknown): Request {
|
|
|
807
807
|
return new Request(`${BASE}${path}`, init);
|
|
808
808
|
}
|
|
809
809
|
|
|
810
|
-
describe("HTTP /notes", () => {
|
|
810
|
+
describe("HTTP /notes", async () => {
|
|
811
811
|
test("GET /notes defaults to lean index (no content field)", async () => {
|
|
812
|
-
store.createNote("one content", { path: "a", tags: ["t"] });
|
|
813
|
-
store.createNote("two content", { path: "b", tags: ["t"] });
|
|
812
|
+
await store.createNote("one content", { path: "a", tags: ["t"] });
|
|
813
|
+
await store.createNote("two content", { path: "b", tags: ["t"] });
|
|
814
814
|
const res = await handleNotes(mkReq("GET", "/notes"), store, "");
|
|
815
815
|
const body = await res.json() as any[];
|
|
816
816
|
expect(body).toHaveLength(2);
|
|
@@ -820,22 +820,22 @@ describe("HTTP /notes", () => {
|
|
|
820
820
|
});
|
|
821
821
|
|
|
822
822
|
test("GET /notes?include_content=true returns full notes", async () => {
|
|
823
|
-
store.createNote("full body", { path: "a" });
|
|
823
|
+
await store.createNote("full body", { path: "a" });
|
|
824
824
|
const res = await handleNotes(mkReq("GET", "/notes?include_content=true"), store, "");
|
|
825
825
|
const body = await res.json() as any[];
|
|
826
826
|
expect(body[0].content).toBe("full body");
|
|
827
827
|
});
|
|
828
828
|
|
|
829
829
|
test("GET /notes?search=fox full-text search", async () => {
|
|
830
|
-
store.createNote("The quick brown fox");
|
|
831
|
-
store.createNote("A lazy dog");
|
|
830
|
+
await store.createNote("The quick brown fox");
|
|
831
|
+
await store.createNote("A lazy dog");
|
|
832
832
|
const res = await handleNotes(mkReq("GET", "/notes?search=fox"), store, "");
|
|
833
833
|
const body = await res.json() as any[];
|
|
834
834
|
expect(body).toHaveLength(1);
|
|
835
835
|
});
|
|
836
836
|
|
|
837
837
|
test("GET /notes?search=fox&include_metadata=false strips metadata from search results", async () => {
|
|
838
|
-
store.createNote("The quick brown fox", { metadata: { summary: "animal" } });
|
|
838
|
+
await store.createNote("The quick brown fox", { metadata: { summary: "animal" } });
|
|
839
839
|
const res = await handleNotes(mkReq("GET", "/notes?search=fox&include_metadata=false"), store, "");
|
|
840
840
|
const body = await res.json() as any[];
|
|
841
841
|
expect(body).toHaveLength(1);
|
|
@@ -843,14 +843,14 @@ describe("HTTP /notes", () => {
|
|
|
843
843
|
});
|
|
844
844
|
|
|
845
845
|
test("GET /notes/:id defaults to full content", async () => {
|
|
846
|
-
const n = store.createNote("hello", { id: "x" });
|
|
846
|
+
const n = await store.createNote("hello", { id: "x" });
|
|
847
847
|
const res = await handleNotes(mkReq("GET", "/notes/x"), store, "/x");
|
|
848
848
|
const body = await res.json() as any;
|
|
849
849
|
expect(body.content).toBe("hello");
|
|
850
850
|
});
|
|
851
851
|
|
|
852
852
|
test("GET /notes/:id?include_content=false returns lean shape", async () => {
|
|
853
|
-
store.createNote("hello", { id: "x" });
|
|
853
|
+
await store.createNote("hello", { id: "x" });
|
|
854
854
|
const res = await handleNotes(mkReq("GET", "/notes/x?include_content=false"), store, "/x");
|
|
855
855
|
const body = await res.json() as any;
|
|
856
856
|
expect(body).not.toHaveProperty("content");
|
|
@@ -859,8 +859,8 @@ describe("HTTP /notes", () => {
|
|
|
859
859
|
});
|
|
860
860
|
|
|
861
861
|
test("GET /notes?include_metadata=false strips metadata from list", async () => {
|
|
862
|
-
store.createNote("a", { tags: ["m"], metadata: { summary: "hello", status: "ok" } });
|
|
863
|
-
store.createNote("b", { tags: ["m"], metadata: { summary: "world" } });
|
|
862
|
+
await store.createNote("a", { tags: ["m"], metadata: { summary: "hello", status: "ok" } });
|
|
863
|
+
await store.createNote("b", { tags: ["m"], metadata: { summary: "world" } });
|
|
864
864
|
const res = await handleNotes(mkReq("GET", "/notes?tag=m&include_metadata=false"), store, "");
|
|
865
865
|
const body = await res.json() as any[];
|
|
866
866
|
expect(body).toHaveLength(2);
|
|
@@ -870,7 +870,7 @@ describe("HTTP /notes", () => {
|
|
|
870
870
|
});
|
|
871
871
|
|
|
872
872
|
test("GET /notes?include_metadata=summary,status returns only those fields", async () => {
|
|
873
|
-
store.createNote("a", { tags: ["mf"], metadata: { summary: "hello", status: "ok", extra: true } });
|
|
873
|
+
await store.createNote("a", { tags: ["mf"], metadata: { summary: "hello", status: "ok", extra: true } });
|
|
874
874
|
const res = await handleNotes(mkReq("GET", "/notes?tag=mf&include_metadata=summary,status"), store, "");
|
|
875
875
|
const body = await res.json() as any[];
|
|
876
876
|
expect(body).toHaveLength(1);
|
|
@@ -878,7 +878,7 @@ describe("HTTP /notes", () => {
|
|
|
878
878
|
});
|
|
879
879
|
|
|
880
880
|
test("GET /notes/:id?include_metadata=false strips metadata from single note", async () => {
|
|
881
|
-
store.createNote("hello", { id: "xm", metadata: { summary: "s" } });
|
|
881
|
+
await store.createNote("hello", { id: "xm", metadata: { summary: "s" } });
|
|
882
882
|
const res = await handleNotes(mkReq("GET", "/notes/xm?include_metadata=false"), store, "/xm");
|
|
883
883
|
const body = await res.json() as any;
|
|
884
884
|
expect(body.metadata).toBeUndefined();
|
|
@@ -886,12 +886,61 @@ describe("HTTP /notes", () => {
|
|
|
886
886
|
});
|
|
887
887
|
|
|
888
888
|
test("GET /notes/:id?include_metadata=summary returns only specified fields", async () => {
|
|
889
|
-
store.createNote("hello", { id: "xm2", metadata: { summary: "s", status: "draft" } });
|
|
889
|
+
await store.createNote("hello", { id: "xm2", metadata: { summary: "s", status: "draft" } });
|
|
890
890
|
const res = await handleNotes(mkReq("GET", "/notes/xm2?include_metadata=summary"), store, "/xm2");
|
|
891
891
|
const body = await res.json() as any;
|
|
892
892
|
expect(body.metadata).toEqual({ summary: "s" });
|
|
893
893
|
});
|
|
894
894
|
|
|
895
|
+
test("GET /notes/:id?expand_links=true inlines wikilink content", async () => {
|
|
896
|
+
await store.createNote("target body", { path: "Target" });
|
|
897
|
+
await store.createNote("see [[Target]]", { id: "src", path: "Src" });
|
|
898
|
+
const res = await handleNotes(
|
|
899
|
+
mkReq("GET", "/notes/src?expand_links=true"),
|
|
900
|
+
store,
|
|
901
|
+
"/src",
|
|
902
|
+
);
|
|
903
|
+
const body = await res.json() as any;
|
|
904
|
+
expect(body.content).toContain('<expanded path="Target" mode="full">');
|
|
905
|
+
expect(body.content).toContain("target body");
|
|
906
|
+
});
|
|
907
|
+
|
|
908
|
+
test("GET /notes/:id?expand_links=true&expand_mode=summary inlines metadata.summary only", async () => {
|
|
909
|
+
await store.createNote("long body", {
|
|
910
|
+
path: "T",
|
|
911
|
+
metadata: { summary: "short" },
|
|
912
|
+
});
|
|
913
|
+
await store.createNote("see [[T]]", { id: "sx", path: "Sx" });
|
|
914
|
+
const res = await handleNotes(
|
|
915
|
+
mkReq("GET", "/notes/sx?expand_links=true&expand_mode=summary"),
|
|
916
|
+
store,
|
|
917
|
+
"/sx",
|
|
918
|
+
);
|
|
919
|
+
const body = await res.json() as any;
|
|
920
|
+
expect(body.content).toContain('mode="summary"');
|
|
921
|
+
expect(body.content).toContain("short");
|
|
922
|
+
expect(body.content).not.toContain("long body");
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
test("GET /notes?tag=&include_content=true&expand_links=true expands per-note with cross-note dedup", async () => {
|
|
926
|
+
await store.createNote("shared body", { path: "Shared" });
|
|
927
|
+
await store.createNote("first [[Shared]]", { path: "A", tags: ["el"] });
|
|
928
|
+
await store.createNote("second [[Shared]]", { path: "B", tags: ["el"] });
|
|
929
|
+
const res = await handleNotes(
|
|
930
|
+
mkReq("GET", "/notes?tag=el&include_content=true&expand_links=true&sort=asc"),
|
|
931
|
+
store,
|
|
932
|
+
"",
|
|
933
|
+
);
|
|
934
|
+
const body = await res.json() as any[];
|
|
935
|
+
expect(body).toHaveLength(2);
|
|
936
|
+
const expandedBlocks = body
|
|
937
|
+
.map((n: any) => (n.content.match(/<expanded /g) ?? []).length)
|
|
938
|
+
.reduce((a: number, b: number) => a + b, 0);
|
|
939
|
+
expect(expandedBlocks).toBe(1);
|
|
940
|
+
const withMarker = body.find((n: any) => n.content.includes("(expanded above)"));
|
|
941
|
+
expect(withMarker).toBeTruthy();
|
|
942
|
+
});
|
|
943
|
+
|
|
895
944
|
test("POST /notes accepts createdAt (camelCase) in body", async () => {
|
|
896
945
|
const res = await handleNotes(
|
|
897
946
|
mkReq("POST", "/notes", { content: "hi", createdAt: "2025-01-01T00:00:00.000Z" }),
|
|
@@ -903,7 +952,7 @@ describe("HTTP /notes", () => {
|
|
|
903
952
|
});
|
|
904
953
|
|
|
905
954
|
test("POST /notes/:id/attachments accepts mimeType (camelCase) in body", async () => {
|
|
906
|
-
const n = store.createNote("x", { id: "x" });
|
|
955
|
+
const n = await store.createNote("x", { id: "x" });
|
|
907
956
|
const res = await handleNotes(
|
|
908
957
|
mkReq("POST", "/notes/x/attachments", { path: "files/a.png", mimeType: "image/png" }),
|
|
909
958
|
store,
|
|
@@ -915,13 +964,13 @@ describe("HTTP /notes", () => {
|
|
|
915
964
|
});
|
|
916
965
|
});
|
|
917
966
|
|
|
918
|
-
describe("HTTP GET /notes?format=graph", () => {
|
|
967
|
+
describe("HTTP GET /notes?format=graph", async () => {
|
|
919
968
|
test("returns nodes and edges for linked notes", async () => {
|
|
920
|
-
const a = store.createNote("A", { id: "a", path: "People/Alice", tags: ["person"] });
|
|
921
|
-
const b = store.createNote("B", { id: "b", path: "People/Bob", tags: ["person"] });
|
|
922
|
-
const c = store.createNote("C", { id: "c", path: "Projects/X" });
|
|
923
|
-
store.createLink("a", "b", "knows");
|
|
924
|
-
store.createLink("a", "c", "works-on");
|
|
969
|
+
const a = await store.createNote("A", { id: "a", path: "People/Alice", tags: ["person"] });
|
|
970
|
+
const b = await store.createNote("B", { id: "b", path: "People/Bob", tags: ["person"] });
|
|
971
|
+
const c = await store.createNote("C", { id: "c", path: "Projects/X" });
|
|
972
|
+
await store.createLink("a", "b", "knows");
|
|
973
|
+
await store.createLink("a", "c", "works-on");
|
|
925
974
|
|
|
926
975
|
const res = await handleNotes(
|
|
927
976
|
mkReq("GET", "/notes?format=graph&include_links=true"),
|
|
@@ -941,9 +990,9 @@ describe("HTTP GET /notes?format=graph", () => {
|
|
|
941
990
|
});
|
|
942
991
|
|
|
943
992
|
test("returns empty edges when include_links is not set", async () => {
|
|
944
|
-
store.createNote("A", { id: "a" });
|
|
945
|
-
store.createNote("B", { id: "b" });
|
|
946
|
-
store.createLink("a", "b", "ref");
|
|
993
|
+
await store.createNote("A", { id: "a" });
|
|
994
|
+
await store.createNote("B", { id: "b" });
|
|
995
|
+
await store.createLink("a", "b", "ref");
|
|
947
996
|
|
|
948
997
|
const res = await handleNotes(
|
|
949
998
|
mkReq("GET", "/notes?format=graph"),
|
|
@@ -956,12 +1005,12 @@ describe("HTTP GET /notes?format=graph", () => {
|
|
|
956
1005
|
});
|
|
957
1006
|
|
|
958
1007
|
test("composes with near param for subgraph", async () => {
|
|
959
|
-
const a = store.createNote("A", { id: "a", path: "People/Mickey" });
|
|
960
|
-
const b = store.createNote("B", { id: "b" });
|
|
961
|
-
const c = store.createNote("C", { id: "c" });
|
|
962
|
-
const d = store.createNote("D", { id: "d" }); // not connected
|
|
963
|
-
store.createLink("a", "b", "knows");
|
|
964
|
-
store.createLink("b", "c", "knows");
|
|
1008
|
+
const a = await store.createNote("A", { id: "a", path: "People/Mickey" });
|
|
1009
|
+
const b = await store.createNote("B", { id: "b" });
|
|
1010
|
+
const c = await store.createNote("C", { id: "c" });
|
|
1011
|
+
const d = await store.createNote("D", { id: "d" }); // not connected
|
|
1012
|
+
await store.createLink("a", "b", "knows");
|
|
1013
|
+
await store.createLink("b", "c", "knows");
|
|
965
1014
|
|
|
966
1015
|
const res = await handleNotes(
|
|
967
1016
|
mkReq("GET", "/notes?format=graph&include_links=true&near[note_id]=People/Mickey&near[depth]=2"),
|
|
@@ -976,11 +1025,11 @@ describe("HTTP GET /notes?format=graph", () => {
|
|
|
976
1025
|
});
|
|
977
1026
|
|
|
978
1027
|
test("near with depth=1 limits subgraph", async () => {
|
|
979
|
-
const a = store.createNote("A", { id: "a" });
|
|
980
|
-
const b = store.createNote("B", { id: "b" });
|
|
981
|
-
const c = store.createNote("C", { id: "c" });
|
|
982
|
-
store.createLink("a", "b", "ref");
|
|
983
|
-
store.createLink("b", "c", "ref");
|
|
1028
|
+
const a = await store.createNote("A", { id: "a" });
|
|
1029
|
+
const b = await store.createNote("B", { id: "b" });
|
|
1030
|
+
const c = await store.createNote("C", { id: "c" });
|
|
1031
|
+
await store.createLink("a", "b", "ref");
|
|
1032
|
+
await store.createLink("b", "c", "ref");
|
|
984
1033
|
|
|
985
1034
|
const res = await handleNotes(
|
|
986
1035
|
mkReq("GET", "/notes?format=graph&include_links=true&near[note_id]=a&near[depth]=1"),
|
|
@@ -995,9 +1044,9 @@ describe("HTTP GET /notes?format=graph", () => {
|
|
|
995
1044
|
});
|
|
996
1045
|
});
|
|
997
1046
|
|
|
998
|
-
describe("HTTP PATCH /notes/:idOrPath (update)", () => {
|
|
1047
|
+
describe("HTTP PATCH /notes/:idOrPath (update)", async () => {
|
|
999
1048
|
test("PATCH updates content and merges metadata", async () => {
|
|
1000
|
-
const note = store.createNote("original", { id: "x", metadata: { a: 1 } });
|
|
1049
|
+
const note = await store.createNote("original", { id: "x", metadata: { a: 1 } });
|
|
1001
1050
|
const res = await handleNotes(
|
|
1002
1051
|
mkReq("PATCH", "/notes/x", { content: "updated", metadata: { b: 2 } }),
|
|
1003
1052
|
store,
|
|
@@ -1009,7 +1058,7 @@ describe("HTTP PATCH /notes/:idOrPath (update)", () => {
|
|
|
1009
1058
|
});
|
|
1010
1059
|
|
|
1011
1060
|
test("PATCH adds/removes tags", async () => {
|
|
1012
|
-
store.createNote("x", { id: "x", tags: ["old"] });
|
|
1061
|
+
await store.createNote("x", { id: "x", tags: ["old"] });
|
|
1013
1062
|
const res = await handleNotes(
|
|
1014
1063
|
mkReq("PATCH", "/notes/x", { tags: { add: ["new"], remove: ["old"] } }),
|
|
1015
1064
|
store,
|
|
@@ -1021,15 +1070,15 @@ describe("HTTP PATCH /notes/:idOrPath (update)", () => {
|
|
|
1021
1070
|
});
|
|
1022
1071
|
|
|
1023
1072
|
test("PATCH adds/removes links", async () => {
|
|
1024
|
-
store.createNote("a", { id: "a" });
|
|
1025
|
-
store.createNote("b", { id: "b" });
|
|
1073
|
+
await store.createNote("a", { id: "a" });
|
|
1074
|
+
await store.createNote("b", { id: "b" });
|
|
1026
1075
|
const res = await handleNotes(
|
|
1027
1076
|
mkReq("PATCH", "/notes/a", { links: { add: [{ target: "b", relationship: "mentions" }] } }),
|
|
1028
1077
|
store,
|
|
1029
1078
|
"/a",
|
|
1030
1079
|
);
|
|
1031
1080
|
expect(res.status).toBe(200);
|
|
1032
|
-
const links = store.getLinks("a", { direction: "outbound" });
|
|
1081
|
+
const links = await store.getLinks("a", { direction: "outbound" });
|
|
1033
1082
|
expect(links).toHaveLength(1);
|
|
1034
1083
|
|
|
1035
1084
|
// Remove
|
|
@@ -1038,11 +1087,11 @@ describe("HTTP PATCH /notes/:idOrPath (update)", () => {
|
|
|
1038
1087
|
store,
|
|
1039
1088
|
"/a",
|
|
1040
1089
|
);
|
|
1041
|
-
expect(store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
|
|
1090
|
+
expect(await store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
|
|
1042
1091
|
});
|
|
1043
1092
|
|
|
1044
1093
|
test("PATCH resolves note by path", async () => {
|
|
1045
|
-
store.createNote("x", { path: "Projects/README" });
|
|
1094
|
+
await store.createNote("x", { path: "Projects/README" });
|
|
1046
1095
|
const res = await handleNotes(
|
|
1047
1096
|
mkReq("PATCH", `/notes/${encodeURIComponent("Projects/README")}`, { content: "updated" }),
|
|
1048
1097
|
store,
|
|
@@ -1052,8 +1101,53 @@ describe("HTTP PATCH /notes/:idOrPath (update)", () => {
|
|
|
1052
1101
|
expect(body.content).toBe("updated");
|
|
1053
1102
|
});
|
|
1054
1103
|
|
|
1104
|
+
test("PATCH with matching if_updated_at succeeds", async () => {
|
|
1105
|
+
const note = await store.createNote("first", { id: "x" });
|
|
1106
|
+
// First bump — sets updated_at
|
|
1107
|
+
const first = await handleNotes(
|
|
1108
|
+
mkReq("PATCH", "/notes/x", { content: "second" }),
|
|
1109
|
+
store,
|
|
1110
|
+
"/x",
|
|
1111
|
+
);
|
|
1112
|
+
const firstBody = await first.json() as any;
|
|
1113
|
+
expect(firstBody.updatedAt).toBeTruthy();
|
|
1114
|
+
|
|
1115
|
+
const res = await handleNotes(
|
|
1116
|
+
mkReq("PATCH", "/notes/x", { content: "third", if_updated_at: firstBody.updatedAt }),
|
|
1117
|
+
store,
|
|
1118
|
+
"/x",
|
|
1119
|
+
);
|
|
1120
|
+
expect(res.status).toBe(200);
|
|
1121
|
+
const body = await res.json() as any;
|
|
1122
|
+
expect(body.content).toBe("third");
|
|
1123
|
+
});
|
|
1124
|
+
|
|
1125
|
+
test("PATCH with stale if_updated_at returns 409 and does not modify note", async () => {
|
|
1126
|
+
await store.createNote("first", { id: "x" });
|
|
1127
|
+
await handleNotes(mkReq("PATCH", "/notes/x", { content: "second" }), store, "/x");
|
|
1128
|
+
const current = await store.getNote("x");
|
|
1129
|
+
|
|
1130
|
+
const res = await handleNotes(
|
|
1131
|
+
mkReq("PATCH", "/notes/x", {
|
|
1132
|
+
content: "third",
|
|
1133
|
+
if_updated_at: "2020-01-01T00:00:00.000Z",
|
|
1134
|
+
}),
|
|
1135
|
+
store,
|
|
1136
|
+
"/x",
|
|
1137
|
+
);
|
|
1138
|
+
expect(res.status).toBe(409);
|
|
1139
|
+
const body = await res.json() as any;
|
|
1140
|
+
expect(body.error).toBe("conflict");
|
|
1141
|
+
expect(body.note_id).toBe("x");
|
|
1142
|
+
expect(body.current_updated_at).toBe(current!.updatedAt);
|
|
1143
|
+
expect(body.expected_updated_at).toBe("2020-01-01T00:00:00.000Z");
|
|
1144
|
+
|
|
1145
|
+
// Unchanged
|
|
1146
|
+
expect((await store.getNote("x"))!.content).toBe("second");
|
|
1147
|
+
});
|
|
1148
|
+
|
|
1055
1149
|
test("DELETE resolves note by path", async () => {
|
|
1056
|
-
store.createNote("x", { path: "Temp/note" });
|
|
1150
|
+
await store.createNote("x", { path: "Temp/note" });
|
|
1057
1151
|
const res = await handleNotes(
|
|
1058
1152
|
mkReq("DELETE", `/notes/${encodeURIComponent("Temp/note")}`),
|
|
1059
1153
|
store,
|
|
@@ -1061,14 +1155,14 @@ describe("HTTP PATCH /notes/:idOrPath (update)", () => {
|
|
|
1061
1155
|
);
|
|
1062
1156
|
const body = await res.json() as any;
|
|
1063
1157
|
expect(body.deleted).toBe(true);
|
|
1064
|
-
expect(store.getNoteByPath("Temp/note")).toBeNull();
|
|
1158
|
+
expect(await store.getNoteByPath("Temp/note")).toBeNull();
|
|
1065
1159
|
});
|
|
1066
1160
|
});
|
|
1067
1161
|
|
|
1068
|
-
describe("HTTP /tags", () => {
|
|
1162
|
+
describe("HTTP /tags", async () => {
|
|
1069
1163
|
test("GET /tags lists all tags", async () => {
|
|
1070
|
-
store.createNote("A", { tags: ["daily"] });
|
|
1071
|
-
store.createNote("B", { tags: ["daily", "pinned"] });
|
|
1164
|
+
await store.createNote("A", { tags: ["daily"] });
|
|
1165
|
+
await store.createNote("B", { tags: ["daily", "pinned"] });
|
|
1072
1166
|
const res = await handleTags(mkReq("GET", "/tags"), store);
|
|
1073
1167
|
const body = await res.json() as any[];
|
|
1074
1168
|
const daily = body.find((t: any) => t.name === "daily");
|
|
@@ -1076,8 +1170,8 @@ describe("HTTP /tags", () => {
|
|
|
1076
1170
|
});
|
|
1077
1171
|
|
|
1078
1172
|
test("GET /tags?tag=name returns single tag detail with schema", async () => {
|
|
1079
|
-
store.createNote("A", { tags: ["person"] });
|
|
1080
|
-
store.upsertTagSchema("person", { description: "A person", fields: { name: { type: "string" } } });
|
|
1173
|
+
await store.createNote("A", { tags: ["person"] });
|
|
1174
|
+
await store.upsertTagSchema("person", { description: "A person", fields: { name: { type: "string" } } });
|
|
1081
1175
|
const res = await handleTags(mkReq("GET", "/tags?tag=person"), store);
|
|
1082
1176
|
const body = await res.json() as any;
|
|
1083
1177
|
expect(body.name).toBe("person");
|
|
@@ -1098,43 +1192,43 @@ describe("HTTP /tags", () => {
|
|
|
1098
1192
|
});
|
|
1099
1193
|
|
|
1100
1194
|
test("DELETE /tags/:name removes tag and schema", async () => {
|
|
1101
|
-
store.createNote("A", { tags: ["doomed"] });
|
|
1102
|
-
store.upsertTagSchema("doomed", { description: "will be deleted" });
|
|
1195
|
+
await store.createNote("A", { tags: ["doomed"] });
|
|
1196
|
+
await store.upsertTagSchema("doomed", { description: "will be deleted" });
|
|
1103
1197
|
const res = await handleTags(mkReq("DELETE", "/tags/doomed"), store, "/doomed");
|
|
1104
1198
|
const body = await res.json() as any;
|
|
1105
1199
|
expect(body.deleted).toBe(true);
|
|
1106
|
-
expect(store.listTags().some((t) => t.name === "doomed")).toBe(false);
|
|
1200
|
+
expect((await store.listTags()).some((t) => t.name === "doomed")).toBe(false);
|
|
1107
1201
|
});
|
|
1108
1202
|
});
|
|
1109
1203
|
|
|
1110
|
-
describe("HTTP /find-path", () => {
|
|
1204
|
+
describe("HTTP /find-path", async () => {
|
|
1111
1205
|
test("finds path between two notes", async () => {
|
|
1112
|
-
store.createNote("a", { id: "a" });
|
|
1113
|
-
store.createNote("b", { id: "b" });
|
|
1114
|
-
store.createNote("c", { id: "c" });
|
|
1115
|
-
store.createLink("a", "b", "mentions");
|
|
1116
|
-
store.createLink("b", "c", "related-to");
|
|
1117
|
-
const res = handleFindPath(mkReq("GET", "/find-path?source=a&target=c"), store);
|
|
1206
|
+
await store.createNote("a", { id: "a" });
|
|
1207
|
+
await store.createNote("b", { id: "b" });
|
|
1208
|
+
await store.createNote("c", { id: "c" });
|
|
1209
|
+
await store.createLink("a", "b", "mentions");
|
|
1210
|
+
await store.createLink("b", "c", "related-to");
|
|
1211
|
+
const res = await handleFindPath(mkReq("GET", "/find-path?source=a&target=c"), store);
|
|
1118
1212
|
const body = await res.json() as any;
|
|
1119
1213
|
expect(body.path).toEqual(["a", "b", "c"]);
|
|
1120
1214
|
expect(body.relationships).toEqual(["mentions", "related-to"]);
|
|
1121
1215
|
});
|
|
1122
1216
|
|
|
1123
1217
|
test("returns null when no path exists", async () => {
|
|
1124
|
-
store.createNote("a", { id: "a" });
|
|
1125
|
-
store.createNote("b", { id: "b" });
|
|
1126
|
-
const res = handleFindPath(mkReq("GET", "/find-path?source=a&target=b"), store);
|
|
1218
|
+
await store.createNote("a", { id: "a" });
|
|
1219
|
+
await store.createNote("b", { id: "b" });
|
|
1220
|
+
const res = await handleFindPath(mkReq("GET", "/find-path?source=a&target=b"), store);
|
|
1127
1221
|
const body = await res.json() as any;
|
|
1128
1222
|
expect(body).toBeNull();
|
|
1129
1223
|
});
|
|
1130
1224
|
|
|
1131
1225
|
test("requires source and target params", async () => {
|
|
1132
|
-
const res = handleFindPath(mkReq("GET", "/find-path?source=a"), store);
|
|
1226
|
+
const res = await handleFindPath(mkReq("GET", "/find-path?source=a"), store);
|
|
1133
1227
|
expect(res.status).toBe(400);
|
|
1134
1228
|
});
|
|
1135
1229
|
});
|
|
1136
1230
|
|
|
1137
|
-
describe("stateless MCP transport", () => {
|
|
1231
|
+
describe("stateless MCP transport", async () => {
|
|
1138
1232
|
test("tools/call works without prior initialize handshake", async () => {
|
|
1139
1233
|
const { handleUnifiedMcp } = await import("./mcp-http.ts");
|
|
1140
1234
|
const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
|
|
@@ -1149,7 +1243,7 @@ describe("stateless MCP transport", () => {
|
|
|
1149
1243
|
writeGlobalConfig({ port: 1940, default_vault: vaultName });
|
|
1150
1244
|
|
|
1151
1245
|
const vaultStore = getVaultStore(vaultName);
|
|
1152
|
-
vaultStore.createNote("test note", { tags: ["daily"] });
|
|
1246
|
+
await vaultStore.createNote("test note", { tags: ["daily"] });
|
|
1153
1247
|
|
|
1154
1248
|
// Direct tools/call — no initialize, no session header
|
|
1155
1249
|
const req = new Request("http://localhost:1940/mcp", {
|