@openparachute/vault 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.parachute/module.json +15 -0
- package/README.md +9 -5
- package/core/src/core.test.ts +2252 -7
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +801 -67
- package/core/src/note-schemas.ts +232 -0
- package/core/src/notes.ts +313 -35
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +287 -0
- package/core/src/schema.ts +393 -9
- package/core/src/store.ts +248 -6
- package/core/src/tag-hierarchy.ts +137 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +100 -6
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +231 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +144 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +384 -78
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +296 -0
- package/src/hub-jwt.ts +79 -0
- package/src/init-summary.test.ts +133 -0
- package/src/init-summary.ts +90 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +30 -28
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +294 -6
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +31 -14
- package/src/routes.ts +686 -58
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +108 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +720 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +868 -3
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- package/scripts/migrate-audio-to-opus.ts +0 -499
package/core/src/core.test.ts
CHANGED
|
@@ -136,6 +136,117 @@ describe("notes", async () => {
|
|
|
136
136
|
await store.deleteNote("a");
|
|
137
137
|
expect(await store.getLinks("b")).toHaveLength(0);
|
|
138
138
|
});
|
|
139
|
+
|
|
140
|
+
// ---- PathConflictError: typed 409 on duplicate path (#126) ----
|
|
141
|
+
|
|
142
|
+
it("createNote throws PathConflictError when path is taken (#126)", async () => {
|
|
143
|
+
await store.createNote("First", { path: "Inbox/note" });
|
|
144
|
+
let caught: any;
|
|
145
|
+
try {
|
|
146
|
+
await store.createNote("Second", { path: "Inbox/note" });
|
|
147
|
+
} catch (e) {
|
|
148
|
+
caught = e;
|
|
149
|
+
}
|
|
150
|
+
expect(caught).toBeTruthy();
|
|
151
|
+
expect(caught.code).toBe("PATH_CONFLICT");
|
|
152
|
+
expect(caught.path).toBe("Inbox/note");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("createNote on path collision does not insert the second note (#126)", async () => {
|
|
156
|
+
await store.createNote("First", { id: "a", path: "Inbox/note" });
|
|
157
|
+
try {
|
|
158
|
+
await store.createNote("Second", { id: "b", path: "Inbox/note" });
|
|
159
|
+
} catch {}
|
|
160
|
+
expect(await store.getNote("b")).toBeNull();
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("updateNote throws PathConflictError when renaming onto an existing path (#126)", async () => {
|
|
164
|
+
const a = await store.createNote("First", { path: "a" });
|
|
165
|
+
await store.createNote("Second", { path: "b" });
|
|
166
|
+
let caught: any;
|
|
167
|
+
try {
|
|
168
|
+
await store.updateNote(a.id, { path: "b", if_updated_at: a.createdAt });
|
|
169
|
+
} catch (e) {
|
|
170
|
+
caught = e;
|
|
171
|
+
}
|
|
172
|
+
expect(caught).toBeTruthy();
|
|
173
|
+
expect(caught.code).toBe("PATH_CONFLICT");
|
|
174
|
+
expect(caught.path).toBe("b");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("updateNote with no path collision still succeeds (#126 — no false positives)", async () => {
|
|
178
|
+
const a = await store.createNote("First", { path: "a" });
|
|
179
|
+
await store.createNote("Second", { path: "b" });
|
|
180
|
+
const updated = await store.updateNote(a.id, { path: "c", if_updated_at: a.createdAt });
|
|
181
|
+
expect(updated.path).toBe("c");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("updateNote with no path field is unaffected by the path-conflict guard (#126)", async () => {
|
|
185
|
+
const a = await store.createNote("First", { path: "a" });
|
|
186
|
+
const updated = await store.updateNote(a.id, { content: "edited", if_updated_at: a.createdAt });
|
|
187
|
+
expect(updated.content).toBe("edited");
|
|
188
|
+
expect(updated.path).toBe("a");
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// -------------------------------------------------------------------------
|
|
192
|
+
// Empty-note invariant at the Store boundary (#213)
|
|
193
|
+
// -------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
it("createNote rejects content+path both absent → EmptyNoteError", async () => {
|
|
196
|
+
await expect(store.createNote("")).rejects.toMatchObject({ code: "EMPTY_NOTE" });
|
|
197
|
+
await expect(store.createNote(" ")).rejects.toMatchObject({ code: "EMPTY_NOTE" });
|
|
198
|
+
await expect(store.createNote("", { metadata: { x: 1 } })).rejects.toMatchObject({
|
|
199
|
+
code: "EMPTY_NOTE",
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("createNote accepts content-only (un-pathed jot)", async () => {
|
|
204
|
+
const n = await store.createNote("just a jot");
|
|
205
|
+
expect(n.content).toBe("just a jot");
|
|
206
|
+
expect(n.path).toBeUndefined();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("createNote accepts path-only (wikilink placeholder / _schemas/* shape)", async () => {
|
|
210
|
+
const n = await store.createNote("", { path: "wiki/placeholder" });
|
|
211
|
+
expect(n.content).toBe("");
|
|
212
|
+
expect(n.path).toBe("wiki/placeholder");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("updateNote rejects clearing both content and path → EmptyNoteError", async () => {
|
|
216
|
+
const n = await store.createNote("body", { path: "p" });
|
|
217
|
+
await expect(
|
|
218
|
+
store.updateNote(n.id, { content: "", path: "", if_updated_at: n.createdAt }),
|
|
219
|
+
).rejects.toMatchObject({ code: "EMPTY_NOTE", note_id: n.id });
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("updateNote rejects clearing content when path is already null", async () => {
|
|
223
|
+
const n = await store.createNote("body");
|
|
224
|
+
await expect(
|
|
225
|
+
store.updateNote(n.id, { content: "", if_updated_at: n.createdAt }),
|
|
226
|
+
).rejects.toMatchObject({ code: "EMPTY_NOTE" });
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("updateNote allows clearing content when path is set (or being set)", async () => {
|
|
230
|
+
const n = await store.createNote("body", { path: "p" });
|
|
231
|
+
const updated = await store.updateNote(n.id, { content: "", if_updated_at: n.createdAt });
|
|
232
|
+
expect(updated.content).toBe("");
|
|
233
|
+
expect(updated.path).toBe("p");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("updateNote with metadata-only update against a (legacy) empty row passes", async () => {
|
|
237
|
+
// Tag/metadata-only updates don't touch content or path, so they don't
|
|
238
|
+
// trigger the new guard — important so any pre-existing empty rows
|
|
239
|
+
// (from before #213) can still be cleaned up via metadata operations.
|
|
240
|
+
const n = await store.createNote("seed", { path: "x" });
|
|
241
|
+
// Simulate a legacy row by directly clearing content via SQL (bypasses
|
|
242
|
+
// the guard); this mirrors what an old data row could look like.
|
|
243
|
+
db.prepare("UPDATE notes SET content = '', path = NULL WHERE id = ?").run(n.id);
|
|
244
|
+
const updated = await store.updateNote(n.id, {
|
|
245
|
+
metadata: { tag: "cleanup" },
|
|
246
|
+
if_updated_at: n.createdAt,
|
|
247
|
+
});
|
|
248
|
+
expect(updated.metadata).toMatchObject({ tag: "cleanup" });
|
|
249
|
+
});
|
|
139
250
|
});
|
|
140
251
|
|
|
141
252
|
// ---- Backfill migration: legacy rows with NULL updated_at ----
|
|
@@ -476,12 +587,24 @@ describe("vault stats", async () => {
|
|
|
476
587
|
const result = await store.getVaultStats();
|
|
477
588
|
expect(result.totalNotes).toBe(2);
|
|
478
589
|
expect(result.tagCount).toBe(2);
|
|
590
|
+
expect(result.attachmentCount).toBe(0);
|
|
479
591
|
expect(result.topTags[0].tag).toBe("x");
|
|
480
592
|
expect(result.topTags[0].count).toBe(2);
|
|
481
593
|
expect(result.notesByMonth).toHaveLength(2);
|
|
482
594
|
expect(result.earliestNote!.createdAt).toBe("2025-05-01T00:00:00.000Z");
|
|
483
595
|
expect(result.latestNote!.createdAt).toBe("2025-06-01T00:00:00.000Z");
|
|
484
596
|
});
|
|
597
|
+
|
|
598
|
+
it("getVaultStats counts attachments", async () => {
|
|
599
|
+
const n1 = await store.createNote("one");
|
|
600
|
+
const n2 = await store.createNote("two");
|
|
601
|
+
await store.addAttachment(n1.id, "/tmp/a1.mp3", "audio/mp3");
|
|
602
|
+
await store.addAttachment(n1.id, "/tmp/i1.png", "image/png");
|
|
603
|
+
await store.addAttachment(n2.id, "/tmp/a2.mp3", "audio/mp3");
|
|
604
|
+
|
|
605
|
+
const result = await store.getVaultStats();
|
|
606
|
+
expect(result.attachmentCount).toBe(3);
|
|
607
|
+
});
|
|
485
608
|
});
|
|
486
609
|
|
|
487
610
|
// ---- Query ----
|
|
@@ -534,6 +657,126 @@ describe("queryNotes", async () => {
|
|
|
534
657
|
expect(results.length).toBeGreaterThan(0);
|
|
535
658
|
});
|
|
536
659
|
|
|
660
|
+
// ---- Generalized date_filter (vault#215) ----
|
|
661
|
+
//
|
|
662
|
+
// The legacy `dateFrom` / `dateTo` always filter on `n.created_at` (vault
|
|
663
|
+
// ingestion time). The new `dateFilter: { field, from, to }` shape lets a
|
|
664
|
+
// caller filter on any *content* date — an email's received date, a
|
|
665
|
+
// meeting's scheduled date — by pointing `field` at an indexed metadata
|
|
666
|
+
// field. `field` defaults to `created_at`, in which case the SQL is
|
|
667
|
+
// identical to the legacy path.
|
|
668
|
+
describe("dateFilter (generalized)", () => {
|
|
669
|
+
async function declareEmailDate() {
|
|
670
|
+
const { declareField } = await import("./indexed-fields.js");
|
|
671
|
+
declareField(db, "email_date", "TEXT", "email");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
it("dateFilter with no field defaults to created_at (matches the legacy shorthand)", async () => {
|
|
675
|
+
await store.createNote("A", { created_at: "2026-01-15T00:00:00.000Z" });
|
|
676
|
+
await store.createNote("B", { created_at: "2026-02-15T00:00:00.000Z" });
|
|
677
|
+
await store.createNote("C", { created_at: "2026-03-15T00:00:00.000Z" });
|
|
678
|
+
|
|
679
|
+
const results = await store.queryNotes({
|
|
680
|
+
dateFilter: { from: "2026-02-01", to: "2026-03-01" },
|
|
681
|
+
});
|
|
682
|
+
expect(results.map((n) => n.content)).toEqual(["B"]);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("dateFilter on an indexed metadata field filters on content date, not ingestion date", async () => {
|
|
686
|
+
await declareEmailDate();
|
|
687
|
+
// Ingestion order doesn't match email_date order — that's the whole
|
|
688
|
+
// point: the bug was that `dateFrom` returned rows by ingestion time.
|
|
689
|
+
await store.createNote("recently-synced old email", {
|
|
690
|
+
metadata: { email_date: "2025-12-01T00:00:00.000Z" },
|
|
691
|
+
});
|
|
692
|
+
await store.createNote("recently-synced new email", {
|
|
693
|
+
metadata: { email_date: "2026-04-25T00:00:00.000Z" },
|
|
694
|
+
});
|
|
695
|
+
await store.createNote("recently-synced ancient", {
|
|
696
|
+
metadata: { email_date: "2024-08-15T00:00:00.000Z" },
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
const results = await store.queryNotes({
|
|
700
|
+
dateFilter: { field: "email_date", from: "2026-04-01", to: "2026-05-01" },
|
|
701
|
+
});
|
|
702
|
+
expect(results.map((n) => n.content)).toEqual(["recently-synced new email"]);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("dateFilter on a non-indexed field rejects with FIELD_NOT_INDEXED", async () => {
|
|
706
|
+
await store.createNote("X", { metadata: { meeting_date: "2026-04-25T00:00:00.000Z" } });
|
|
707
|
+
// Note: not declared via declareField, so the field has no generated
|
|
708
|
+
// column. The error mirrors the metadata-operator + order_by gate.
|
|
709
|
+
try {
|
|
710
|
+
await store.queryNotes({
|
|
711
|
+
dateFilter: { field: "meeting_date", from: "2026-04-01" },
|
|
712
|
+
});
|
|
713
|
+
throw new Error("expected QueryError");
|
|
714
|
+
} catch (err: any) {
|
|
715
|
+
expect(err.name).toBe("QueryError");
|
|
716
|
+
expect(err.code).toBe("FIELD_NOT_INDEXED");
|
|
717
|
+
expect(err.message).toContain("meeting_date");
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("dateFilter combined with top-level dateFrom rejects with INVALID_QUERY", async () => {
|
|
722
|
+
await declareEmailDate();
|
|
723
|
+
try {
|
|
724
|
+
await store.queryNotes({
|
|
725
|
+
dateFrom: "2026-01-01",
|
|
726
|
+
dateFilter: { field: "email_date", from: "2026-04-01" },
|
|
727
|
+
});
|
|
728
|
+
throw new Error("expected QueryError");
|
|
729
|
+
} catch (err: any) {
|
|
730
|
+
expect(err.name).toBe("QueryError");
|
|
731
|
+
expect(err.code).toBe("INVALID_QUERY");
|
|
732
|
+
expect(err.message).toMatch(/cannot combine/i);
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it("dateFilter with only `from` is open-ended on the upper bound", async () => {
|
|
737
|
+
await declareEmailDate();
|
|
738
|
+
await store.createNote("old", { metadata: { email_date: "2025-01-01T00:00:00.000Z" } });
|
|
739
|
+
await store.createNote("middle", { metadata: { email_date: "2026-04-15T00:00:00.000Z" } });
|
|
740
|
+
await store.createNote("new", { metadata: { email_date: "2026-05-01T00:00:00.000Z" } });
|
|
741
|
+
|
|
742
|
+
const results = await store.queryNotes({
|
|
743
|
+
dateFilter: { field: "email_date", from: "2026-04-01" },
|
|
744
|
+
});
|
|
745
|
+
expect(results.map((n) => n.content).sort()).toEqual(["middle", "new"]);
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
it("dateFilter with explicit field='created_at' routes to the legacy SQL path", async () => {
|
|
749
|
+
// The implicit-default case is covered above; this asserts the explicit
|
|
750
|
+
// form behaves identically — no indexed-field gate, same n.created_at SQL.
|
|
751
|
+
await store.createNote("A", { created_at: "2026-01-15T00:00:00.000Z" });
|
|
752
|
+
await store.createNote("B", { created_at: "2026-02-15T00:00:00.000Z" });
|
|
753
|
+
await store.createNote("C", { created_at: "2026-03-15T00:00:00.000Z" });
|
|
754
|
+
|
|
755
|
+
const results = await store.queryNotes({
|
|
756
|
+
dateFilter: { field: "created_at", from: "2026-02-01", to: "2026-03-01" },
|
|
757
|
+
});
|
|
758
|
+
expect(results.map((n) => n.content)).toEqual(["B"]);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("query-notes accepts date_filter on an indexed metadata field (vault#215)", async () => {
|
|
762
|
+
await declareEmailDate();
|
|
763
|
+
await store.createNote("old email", {
|
|
764
|
+
metadata: { email_date: "2025-12-01T00:00:00.000Z" },
|
|
765
|
+
});
|
|
766
|
+
await store.createNote("recent email", {
|
|
767
|
+
metadata: { email_date: "2026-04-25T00:00:00.000Z" },
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
const tools = generateMcpTools(store);
|
|
771
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
772
|
+
const results = await query.execute({
|
|
773
|
+
date_filter: { field: "email_date", from: "2026-04-01", to: "2026-05-01" },
|
|
774
|
+
include_content: true,
|
|
775
|
+
}) as any[];
|
|
776
|
+
expect(results.map((n) => n.content)).toEqual(["recent email"]);
|
|
777
|
+
});
|
|
778
|
+
});
|
|
779
|
+
|
|
537
780
|
it("sorts ascending and descending", async () => {
|
|
538
781
|
await store.createNote("First", { id: "first" });
|
|
539
782
|
await store.createNote("Second", { id: "second" });
|
|
@@ -907,7 +1150,7 @@ describe("attachments", async () => {
|
|
|
907
1150
|
// ---- MCP Tools ----
|
|
908
1151
|
|
|
909
1152
|
describe("MCP tools", async () => {
|
|
910
|
-
it("generates
|
|
1153
|
+
it("generates the consolidated tool set", () => {
|
|
911
1154
|
const tools = generateMcpTools(store);
|
|
912
1155
|
const names = tools.map((t) => t.name);
|
|
913
1156
|
|
|
@@ -918,9 +1161,16 @@ describe("MCP tools", async () => {
|
|
|
918
1161
|
expect(names).toContain("list-tags");
|
|
919
1162
|
expect(names).toContain("update-tag");
|
|
920
1163
|
expect(names).toContain("delete-tag");
|
|
1164
|
+
expect(names).toContain("list-note-schemas");
|
|
1165
|
+
expect(names).toContain("update-note-schema");
|
|
1166
|
+
expect(names).toContain("delete-note-schema");
|
|
1167
|
+
expect(names).toContain("list-schema-mappings");
|
|
1168
|
+
expect(names).toContain("set-schema-mapping");
|
|
1169
|
+
expect(names).toContain("delete-schema-mapping");
|
|
921
1170
|
expect(names).toContain("find-path");
|
|
1171
|
+
expect(names).toContain("synthesize-notes");
|
|
922
1172
|
expect(names).toContain("vault-info");
|
|
923
|
-
expect(tools).toHaveLength(
|
|
1173
|
+
expect(tools).toHaveLength(16);
|
|
924
1174
|
});
|
|
925
1175
|
|
|
926
1176
|
it("create-note tool works", async () => {
|
|
@@ -1267,6 +1517,276 @@ describe("MCP tools", async () => {
|
|
|
1267
1517
|
expect((await store.getNote("source"))!.content).toBe("See [[People/Alice]] for details");
|
|
1268
1518
|
});
|
|
1269
1519
|
|
|
1520
|
+
it("update-note append concatenates to end without precondition", async () => {
|
|
1521
|
+
const note = await store.createNote("first line\n", { id: "n1" });
|
|
1522
|
+
const tools = generateMcpTools(store);
|
|
1523
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1524
|
+
|
|
1525
|
+
// No if_updated_at and no force — append-only is precondition-exempt.
|
|
1526
|
+
const result = await updateNote.execute({ id: note.id, append: "second line\n" }) as any;
|
|
1527
|
+
expect(result.content).toBe("first line\nsecond line\n");
|
|
1528
|
+
|
|
1529
|
+
const persisted = await store.getNote(note.id);
|
|
1530
|
+
expect(persisted!.content).toBe("first line\nsecond line\n");
|
|
1531
|
+
});
|
|
1532
|
+
|
|
1533
|
+
it("update-note prepend concatenates to start without precondition", async () => {
|
|
1534
|
+
const note = await store.createNote("body", { id: "n1" });
|
|
1535
|
+
const tools = generateMcpTools(store);
|
|
1536
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1537
|
+
|
|
1538
|
+
const result = await updateNote.execute({ id: note.id, prepend: "header\n" }) as any;
|
|
1539
|
+
expect(result.content).toBe("header\nbody");
|
|
1540
|
+
});
|
|
1541
|
+
|
|
1542
|
+
it("update-note prepend on frontmatter-led content injects after closing --- (#203)", async () => {
|
|
1543
|
+
const original = "---\ntitle: Foo\ntags: [bar]\n---\nbody line 1\n";
|
|
1544
|
+
const note = await store.createNote(original, { id: "n1" });
|
|
1545
|
+
const tools = generateMcpTools(store);
|
|
1546
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1547
|
+
|
|
1548
|
+
const result = await updateNote.execute({ id: note.id, prepend: "preamble\n" }) as any;
|
|
1549
|
+
// Frontmatter still at byte 0 — parsers expecting `---\n` will find it.
|
|
1550
|
+
expect(result.content.startsWith("---\ntitle: Foo\ntags: [bar]\n---\n")).toBe(true);
|
|
1551
|
+
// Prepend lands immediately after the closing fence, before the body.
|
|
1552
|
+
expect(result.content).toBe(
|
|
1553
|
+
"---\ntitle: Foo\ntags: [bar]\n---\npreamble\nbody line 1\n",
|
|
1554
|
+
);
|
|
1555
|
+
});
|
|
1556
|
+
|
|
1557
|
+
it("update-note prepend on content lacking frontmatter injects at byte 0", async () => {
|
|
1558
|
+
const note = await store.createNote("# Heading\nbody\n", { id: "n1" });
|
|
1559
|
+
const tools = generateMcpTools(store);
|
|
1560
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1561
|
+
|
|
1562
|
+
const result = await updateNote.execute({ id: note.id, prepend: "preamble\n" }) as any;
|
|
1563
|
+
expect(result.content).toBe("preamble\n# Heading\nbody\n");
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
it("update-note append+prepend in one call lands both contributions", async () => {
|
|
1567
|
+
const note = await store.createNote("middle", { id: "n1" });
|
|
1568
|
+
const tools = generateMcpTools(store);
|
|
1569
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1570
|
+
|
|
1571
|
+
const result = await updateNote.execute({
|
|
1572
|
+
id: note.id,
|
|
1573
|
+
prepend: "[start] ",
|
|
1574
|
+
append: " [end]",
|
|
1575
|
+
}) as any;
|
|
1576
|
+
expect(result.content).toBe("[start] middle [end]");
|
|
1577
|
+
});
|
|
1578
|
+
|
|
1579
|
+
it("update-note rejects content + append in same call", async () => {
|
|
1580
|
+
const note = await store.createNote("body", { id: "n1" });
|
|
1581
|
+
const tools = generateMcpTools(store);
|
|
1582
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1583
|
+
|
|
1584
|
+
let err: any;
|
|
1585
|
+
try {
|
|
1586
|
+
await updateNote.execute({ id: note.id, content: "new", append: "more", force: true });
|
|
1587
|
+
} catch (e) {
|
|
1588
|
+
err = e;
|
|
1589
|
+
}
|
|
1590
|
+
expect(err?.message).toMatch(/mutually exclusive/);
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1593
|
+
it("update-note rejects content + content_edit in same call", async () => {
|
|
1594
|
+
const note = await store.createNote("hello world", { id: "n1" });
|
|
1595
|
+
const tools = generateMcpTools(store);
|
|
1596
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1597
|
+
|
|
1598
|
+
let err: any;
|
|
1599
|
+
try {
|
|
1600
|
+
await updateNote.execute({
|
|
1601
|
+
id: note.id,
|
|
1602
|
+
content: "replace",
|
|
1603
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
1604
|
+
force: true,
|
|
1605
|
+
});
|
|
1606
|
+
} catch (e) {
|
|
1607
|
+
err = e;
|
|
1608
|
+
}
|
|
1609
|
+
expect(err?.message).toMatch(/mutually exclusive/);
|
|
1610
|
+
});
|
|
1611
|
+
|
|
1612
|
+
it("update-note rejects append + content_edit in same call", async () => {
|
|
1613
|
+
const note = await store.createNote("hello world", { id: "n1" });
|
|
1614
|
+
const tools = generateMcpTools(store);
|
|
1615
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1616
|
+
|
|
1617
|
+
let err: any;
|
|
1618
|
+
try {
|
|
1619
|
+
await updateNote.execute({
|
|
1620
|
+
id: note.id,
|
|
1621
|
+
append: " more",
|
|
1622
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
1623
|
+
});
|
|
1624
|
+
} catch (e) {
|
|
1625
|
+
err = e;
|
|
1626
|
+
}
|
|
1627
|
+
expect(err?.message).toMatch(/mutually exclusive/);
|
|
1628
|
+
});
|
|
1629
|
+
|
|
1630
|
+
it("update-note append still requires precondition when combined with other fields", async () => {
|
|
1631
|
+
const note = await store.createNote("body", { id: "n1" });
|
|
1632
|
+
const tools = generateMcpTools(store);
|
|
1633
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1634
|
+
|
|
1635
|
+
// append + metadata is NOT precondition-exempt — metadata mutation
|
|
1636
|
+
// can lose data on a stale read, so the safety gate stays in.
|
|
1637
|
+
let err: any;
|
|
1638
|
+
try {
|
|
1639
|
+
await updateNote.execute({ id: note.id, append: " more", metadata: { x: 1 } });
|
|
1640
|
+
} catch (e) {
|
|
1641
|
+
err = e;
|
|
1642
|
+
}
|
|
1643
|
+
expect(err?.code).toBe("PRECONDITION_REQUIRED");
|
|
1644
|
+
});
|
|
1645
|
+
|
|
1646
|
+
it("update-note append is atomic under concurrent calls — both lands", async () => {
|
|
1647
|
+
const note = await store.createNote("seed:", { id: "n1" });
|
|
1648
|
+
const tools = generateMcpTools(store);
|
|
1649
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1650
|
+
|
|
1651
|
+
// Two concurrent appends. SQL-level concat means both contributions
|
|
1652
|
+
// land — neither overwrites the other.
|
|
1653
|
+
const results = await Promise.all([
|
|
1654
|
+
updateNote.execute({ id: note.id, append: " A" }),
|
|
1655
|
+
updateNote.execute({ id: note.id, append: " B" }),
|
|
1656
|
+
]);
|
|
1657
|
+
expect(results).toHaveLength(2);
|
|
1658
|
+
|
|
1659
|
+
const persisted = await store.getNote(note.id);
|
|
1660
|
+
// Final content is one of "seed: A B" or "seed: B A" — the order
|
|
1661
|
+
// depends on which write got the lock first, but both contributions
|
|
1662
|
+
// are present.
|
|
1663
|
+
expect(persisted!.content === "seed: A B" || persisted!.content === "seed: B A").toBe(true);
|
|
1664
|
+
});
|
|
1665
|
+
|
|
1666
|
+
it("update-note append updates updated_at and respects if_updated_at when supplied", async () => {
|
|
1667
|
+
const note = await store.createNote("seed", { id: "n1" });
|
|
1668
|
+
const tools = generateMcpTools(store);
|
|
1669
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1670
|
+
|
|
1671
|
+
// With if_updated_at — succeeds because we're using the right token.
|
|
1672
|
+
const ok = await updateNote.execute({ id: note.id, append: " A", if_updated_at: note.updatedAt }) as any;
|
|
1673
|
+
expect(ok.content).toBe("seed A");
|
|
1674
|
+
expect(ok.updatedAt).not.toBe(note.updatedAt);
|
|
1675
|
+
|
|
1676
|
+
// Stale token — conflict.
|
|
1677
|
+
let err: any;
|
|
1678
|
+
try {
|
|
1679
|
+
await updateNote.execute({ id: note.id, append: " B", if_updated_at: note.updatedAt });
|
|
1680
|
+
} catch (e) {
|
|
1681
|
+
err = e;
|
|
1682
|
+
}
|
|
1683
|
+
expect(err?.code).toBe("CONFLICT");
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
it("update-note append parses new wikilinks introduced via append", async () => {
|
|
1687
|
+
const target = await store.createNote("Alice's note", { id: "alice", path: "People/Alice" });
|
|
1688
|
+
const source = await store.createNote("intro\n", { id: "src" });
|
|
1689
|
+
const tools = generateMcpTools(store);
|
|
1690
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1691
|
+
|
|
1692
|
+
await updateNote.execute({ id: source.id, append: "see [[People/Alice]]" });
|
|
1693
|
+
|
|
1694
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
1695
|
+
expect(links.some((l) => l.targetId === target.id && l.relationship === "wikilink")).toBe(true);
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
it("update-note content_edit replaces a single occurrence", async () => {
|
|
1699
|
+
const note = await store.createNote("hello world", { id: "n1" });
|
|
1700
|
+
const tools = generateMcpTools(store);
|
|
1701
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1702
|
+
|
|
1703
|
+
const result = await updateNote.execute({
|
|
1704
|
+
id: note.id,
|
|
1705
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
1706
|
+
if_updated_at: note.updatedAt,
|
|
1707
|
+
}) as any;
|
|
1708
|
+
expect(result.content).toBe("hi world");
|
|
1709
|
+
});
|
|
1710
|
+
|
|
1711
|
+
it("update-note content_edit errors when old_text is not found", async () => {
|
|
1712
|
+
const note = await store.createNote("hello world", { id: "n1" });
|
|
1713
|
+
const tools = generateMcpTools(store);
|
|
1714
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1715
|
+
|
|
1716
|
+
let err: any;
|
|
1717
|
+
try {
|
|
1718
|
+
await updateNote.execute({
|
|
1719
|
+
id: note.id,
|
|
1720
|
+
content_edit: { old_text: "missing", new_text: "x" },
|
|
1721
|
+
if_updated_at: note.updatedAt,
|
|
1722
|
+
});
|
|
1723
|
+
} catch (e) {
|
|
1724
|
+
err = e;
|
|
1725
|
+
}
|
|
1726
|
+
expect(err?.message).toMatch(/not found/);
|
|
1727
|
+
// Note must be untouched.
|
|
1728
|
+
const persisted = await store.getNote(note.id);
|
|
1729
|
+
expect(persisted!.content).toBe("hello world");
|
|
1730
|
+
});
|
|
1731
|
+
|
|
1732
|
+
it("update-note content_edit errors when old_text matches multiple times", async () => {
|
|
1733
|
+
const note = await store.createNote("hello hello", { id: "n1" });
|
|
1734
|
+
const tools = generateMcpTools(store);
|
|
1735
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1736
|
+
|
|
1737
|
+
let err: any;
|
|
1738
|
+
try {
|
|
1739
|
+
await updateNote.execute({
|
|
1740
|
+
id: note.id,
|
|
1741
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
1742
|
+
if_updated_at: note.updatedAt,
|
|
1743
|
+
});
|
|
1744
|
+
} catch (e) {
|
|
1745
|
+
err = e;
|
|
1746
|
+
}
|
|
1747
|
+
expect(err?.message).toMatch(/matches multiple times|exactly once/);
|
|
1748
|
+
const persisted = await store.getNote(note.id);
|
|
1749
|
+
expect(persisted!.content).toBe("hello hello");
|
|
1750
|
+
});
|
|
1751
|
+
|
|
1752
|
+
it("update-note content_edit requires precondition by default", async () => {
|
|
1753
|
+
const note = await store.createNote("hello world", { id: "n1" });
|
|
1754
|
+
const tools = generateMcpTools(store);
|
|
1755
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1756
|
+
|
|
1757
|
+
let err: any;
|
|
1758
|
+
try {
|
|
1759
|
+
await updateNote.execute({
|
|
1760
|
+
id: note.id,
|
|
1761
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
1762
|
+
});
|
|
1763
|
+
} catch (e) {
|
|
1764
|
+
err = e;
|
|
1765
|
+
}
|
|
1766
|
+
expect(err?.code).toBe("PRECONDITION_REQUIRED");
|
|
1767
|
+
});
|
|
1768
|
+
|
|
1769
|
+
it("update-note content_edit conflicts when if_updated_at is stale", async () => {
|
|
1770
|
+
const note = await store.createNote("hello world", { id: "n1" });
|
|
1771
|
+
const tools = generateMcpTools(store);
|
|
1772
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1773
|
+
|
|
1774
|
+
// Bump the note so a stale token will conflict at the SQL layer.
|
|
1775
|
+
await updateNote.execute({ id: note.id, content: "hello world", force: true });
|
|
1776
|
+
|
|
1777
|
+
let err: any;
|
|
1778
|
+
try {
|
|
1779
|
+
await updateNote.execute({
|
|
1780
|
+
id: note.id,
|
|
1781
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
1782
|
+
if_updated_at: "2020-01-01T00:00:00.000Z",
|
|
1783
|
+
});
|
|
1784
|
+
} catch (e) {
|
|
1785
|
+
err = e;
|
|
1786
|
+
}
|
|
1787
|
+
expect(err?.code).toBe("CONFLICT");
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1270
1790
|
it("query-notes single note by id", async () => {
|
|
1271
1791
|
const note = await store.createNote("Hello", { path: "test/note" });
|
|
1272
1792
|
const tools = generateMcpTools(store);
|
|
@@ -1529,12 +2049,41 @@ describe("MCP tools", async () => {
|
|
|
1529
2049
|
expect(result[0].id).toBe("near");
|
|
1530
2050
|
});
|
|
1531
2051
|
|
|
1532
|
-
it("
|
|
1533
|
-
|
|
2052
|
+
it("query-notes near returns neighborhood even when limit is small and unrelated notes were created first (#130)", async () => {
|
|
2053
|
+
// Repro of #130: anchor + linked notes get crowded out by unrelated notes
|
|
2054
|
+
// when the query runs ORDER BY created_at LIMIT 5 BEFORE the
|
|
2055
|
+
// neighborhood filter. With the SQL-pushed ids filter, LIMIT applies to
|
|
2056
|
+
// the neighborhood, not the whole notes table.
|
|
2057
|
+
//
|
|
2058
|
+
// Seed: 10 unrelated notes created first, THEN the anchor + 2 linked
|
|
2059
|
+
// notes. With limit=5 and ORDER BY created_at ASC, the unrelated ten
|
|
2060
|
+
// would fill the slate and the in-neighborhood notes would never appear.
|
|
2061
|
+
for (let i = 0; i < 10; i++) {
|
|
2062
|
+
await store.createNote(`Unrelated ${i}`, { id: `unrelated-${i}` });
|
|
2063
|
+
}
|
|
2064
|
+
await store.createNote("Anchor", { id: "anchor" });
|
|
2065
|
+
await store.createNote("Outbound target", { id: "outbound" });
|
|
2066
|
+
await store.createNote("Inbound source", { id: "inbound" });
|
|
2067
|
+
await store.createLink("anchor", "outbound", "wikilink");
|
|
2068
|
+
await store.createLink("inbound", "anchor", "wikilink");
|
|
2069
|
+
|
|
1534
2070
|
const tools = generateMcpTools(store);
|
|
1535
|
-
const
|
|
1536
|
-
const result = await
|
|
1537
|
-
|
|
2071
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
2072
|
+
const result = await query.execute({
|
|
2073
|
+
near: { note_id: "anchor", depth: 2 },
|
|
2074
|
+
limit: 5,
|
|
2075
|
+
}) as any[];
|
|
2076
|
+
|
|
2077
|
+
const ids = result.map((n: any) => n.id).sort();
|
|
2078
|
+
expect(ids).toEqual(["anchor", "inbound", "outbound"]);
|
|
2079
|
+
});
|
|
2080
|
+
|
|
2081
|
+
it("delete-note accepts path", async () => {
|
|
2082
|
+
await store.createNote("To delete", { path: "Temp/note" });
|
|
2083
|
+
const tools = generateMcpTools(store);
|
|
2084
|
+
const deleteTool = tools.find((t) => t.name === "delete-note")!;
|
|
2085
|
+
const result = await deleteTool.execute({ id: "Temp/note" }) as any;
|
|
2086
|
+
expect(result.deleted).toBe(true);
|
|
1538
2087
|
expect(await store.getNoteByPath("Temp/note")).toBeNull();
|
|
1539
2088
|
});
|
|
1540
2089
|
|
|
@@ -1656,6 +2205,183 @@ describe("MCP tools", async () => {
|
|
|
1656
2205
|
expect(result.relationships).toEqual(["mentions", "related-to"]);
|
|
1657
2206
|
});
|
|
1658
2207
|
|
|
2208
|
+
// ---- synthesize-notes ----
|
|
2209
|
+
|
|
2210
|
+
it("synthesize-notes rejects when neither anchor nor query is supplied", async () => {
|
|
2211
|
+
const tools = generateMcpTools(store);
|
|
2212
|
+
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2213
|
+
const result = await synth.execute({}) as any;
|
|
2214
|
+
expect(result.error).toMatch(/at least one of `anchor` or `query`/);
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
it("synthesize-notes rejects an unknown anchor", async () => {
|
|
2218
|
+
const tools = generateMcpTools(store);
|
|
2219
|
+
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2220
|
+
const result = await synth.execute({ anchor: "does-not-exist" }) as any;
|
|
2221
|
+
expect(result.error).toMatch(/Anchor note not found/);
|
|
2222
|
+
});
|
|
2223
|
+
|
|
2224
|
+
it("synthesize-notes returns anchor + linked neighbors ranked", async () => {
|
|
2225
|
+
await store.createNote("Hub note", { id: "hub", tags: ["topic"] });
|
|
2226
|
+
await store.createNote("Direct neighbor", { id: "n1" });
|
|
2227
|
+
await store.createNote("Two-hop neighbor", { id: "n2" });
|
|
2228
|
+
await store.createNote("Unrelated", { id: "u1" });
|
|
2229
|
+
await store.createLink("hub", "n1", "mentions");
|
|
2230
|
+
await store.createLink("n1", "n2", "mentions");
|
|
2231
|
+
|
|
2232
|
+
const tools = generateMcpTools(store);
|
|
2233
|
+
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2234
|
+
const result = await synth.execute({ anchor: "hub", depth: 2 }) as any;
|
|
2235
|
+
|
|
2236
|
+
const ids = result.notes.map((n: any) => n.id);
|
|
2237
|
+
expect(ids).toContain("hub");
|
|
2238
|
+
expect(ids).toContain("n1");
|
|
2239
|
+
expect(ids).toContain("n2");
|
|
2240
|
+
expect(ids).not.toContain("u1");
|
|
2241
|
+
// Anchor ranks first; one-hop beats two-hop.
|
|
2242
|
+
expect(ids[0]).toBe("hub");
|
|
2243
|
+
expect(ids.indexOf("n1")).toBeLessThan(ids.indexOf("n2"));
|
|
2244
|
+
expect(result.notes[0].sources).toContain("anchor");
|
|
2245
|
+
expect(result.notes[0].distance).toBe(0);
|
|
2246
|
+
expect(result.topic.anchor).toEqual({ id: "hub", path: null });
|
|
2247
|
+
});
|
|
2248
|
+
|
|
2249
|
+
it("synthesize-notes returns FTS hits when only query is supplied", async () => {
|
|
2250
|
+
await store.createNote("Octopus thoughts: ink and color", { id: "o1" });
|
|
2251
|
+
await store.createNote("Squid thoughts: deep blue", { id: "s1" });
|
|
2252
|
+
await store.createNote("Recipe for octopus salad", { id: "o2" });
|
|
2253
|
+
|
|
2254
|
+
const tools = generateMcpTools(store);
|
|
2255
|
+
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2256
|
+
const result = await synth.execute({ query: "octopus" }) as any;
|
|
2257
|
+
|
|
2258
|
+
const ids = result.notes.map((n: any) => n.id);
|
|
2259
|
+
expect(ids).toContain("o1");
|
|
2260
|
+
expect(ids).toContain("o2");
|
|
2261
|
+
expect(ids).not.toContain("s1");
|
|
2262
|
+
expect(result.notes[0].sources).toContain("search");
|
|
2263
|
+
expect(result.notes[0]).toHaveProperty("fts_rank");
|
|
2264
|
+
expect(result.topic.query).toBe("octopus");
|
|
2265
|
+
});
|
|
2266
|
+
|
|
2267
|
+
it("synthesize-notes applies scope.tags filter (any-match)", async () => {
|
|
2268
|
+
await store.createNote("Anchor", { id: "a" });
|
|
2269
|
+
await store.createNote("Tagged neighbor", { id: "t1", tags: ["alpha"] });
|
|
2270
|
+
await store.createNote("Other neighbor", { id: "t2", tags: ["beta"] });
|
|
2271
|
+
await store.createLink("a", "t1", "mentions");
|
|
2272
|
+
await store.createLink("a", "t2", "mentions");
|
|
2273
|
+
|
|
2274
|
+
const tools = generateMcpTools(store);
|
|
2275
|
+
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2276
|
+
const result = await synth.execute({ anchor: "a", scope: { tags: ["alpha"] } }) as any;
|
|
2277
|
+
|
|
2278
|
+
const ids = result.notes.map((n: any) => n.id);
|
|
2279
|
+
expect(ids).toContain("t1");
|
|
2280
|
+
expect(ids).not.toContain("t2");
|
|
2281
|
+
expect(ids).not.toContain("a"); // anchor itself has no tags
|
|
2282
|
+
});
|
|
2283
|
+
|
|
2284
|
+
it("synthesize-notes applies scope.path prefix filter", async () => {
|
|
2285
|
+
await store.createNote("Anchor", { id: "a" });
|
|
2286
|
+
await store.createNote("In scope", { id: "p1", path: "Projects/Alpha/notes" });
|
|
2287
|
+
await store.createNote("Out of scope", { id: "p2", path: "People/Bob" });
|
|
2288
|
+
await store.createLink("a", "p1", "mentions");
|
|
2289
|
+
await store.createLink("a", "p2", "mentions");
|
|
2290
|
+
|
|
2291
|
+
const tools = generateMcpTools(store);
|
|
2292
|
+
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2293
|
+
const result = await synth.execute({ anchor: "a", scope: { path: "projects/" } }) as any;
|
|
2294
|
+
|
|
2295
|
+
const ids = result.notes.map((n: any) => n.id);
|
|
2296
|
+
expect(ids).toContain("p1");
|
|
2297
|
+
expect(ids).not.toContain("p2");
|
|
2298
|
+
});
|
|
2299
|
+
|
|
2300
|
+
it("synthesize-notes respects limit and sets truncated flag", async () => {
|
|
2301
|
+
await store.createNote("Anchor", { id: "anchor" });
|
|
2302
|
+
for (let i = 0; i < 5; i++) {
|
|
2303
|
+
await store.createNote(`Neighbor ${i}`, { id: `n${i}` });
|
|
2304
|
+
await store.createLink("anchor", `n${i}`, "mentions");
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
const tools = generateMcpTools(store);
|
|
2308
|
+
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2309
|
+
const result = await synth.execute({ anchor: "anchor", limit: 3 }) as any;
|
|
2310
|
+
|
|
2311
|
+
expect(result.notes).toHaveLength(3);
|
|
2312
|
+
expect(result.truncated).toBe(true);
|
|
2313
|
+
});
|
|
2314
|
+
|
|
2315
|
+
it("synthesize-notes connections include only links between returned notes", async () => {
|
|
2316
|
+
await store.createNote("Anchor", { id: "a" });
|
|
2317
|
+
await store.createNote("In set", { id: "b" });
|
|
2318
|
+
await store.createNote("Excluded", { id: "c" });
|
|
2319
|
+
await store.createLink("a", "b", "mentions");
|
|
2320
|
+
await store.createLink("a", "c", "mentions");
|
|
2321
|
+
await store.createLink("b", "c", "related-to");
|
|
2322
|
+
|
|
2323
|
+
const tools = generateMcpTools(store);
|
|
2324
|
+
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2325
|
+
const result = await synth.execute({ anchor: "a", depth: 1, limit: 2 }) as any;
|
|
2326
|
+
|
|
2327
|
+
const ids = new Set(result.notes.map((n: any) => n.id));
|
|
2328
|
+
for (const c of result.connections) {
|
|
2329
|
+
expect(ids.has(c.source)).toBe(true);
|
|
2330
|
+
expect(ids.has(c.target)).toBe(true);
|
|
2331
|
+
}
|
|
2332
|
+
expect(result.connections.some((c: any) => c.target === "c")).toBe(false);
|
|
2333
|
+
});
|
|
2334
|
+
|
|
2335
|
+
it("synthesize-notes timeline orders oldest → newest", async () => {
|
|
2336
|
+
await store.createNote("Old", { id: "old", created_at: "2024-01-01T00:00:00.000Z" });
|
|
2337
|
+
await store.createNote("Mid", { id: "mid", created_at: "2025-01-01T00:00:00.000Z" });
|
|
2338
|
+
await store.createNote("New", { id: "new", created_at: "2026-01-01T00:00:00.000Z" });
|
|
2339
|
+
await store.createLink("new", "mid", "mentions");
|
|
2340
|
+
await store.createLink("mid", "old", "mentions");
|
|
2341
|
+
|
|
2342
|
+
const tools = generateMcpTools(store);
|
|
2343
|
+
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2344
|
+
const result = await synth.execute({ anchor: "new", depth: 2 }) as any;
|
|
2345
|
+
|
|
2346
|
+
const ids = result.timeline.map((t: any) => t.id);
|
|
2347
|
+
expect(ids).toEqual(["old", "mid", "new"]);
|
|
2348
|
+
});
|
|
2349
|
+
|
|
2350
|
+
it("synthesize-notes tag distribution counts tags across results", async () => {
|
|
2351
|
+
await store.createNote("Anchor", { id: "a", tags: ["alpha", "beta"] });
|
|
2352
|
+
await store.createNote("N1", { id: "n1", tags: ["alpha"] });
|
|
2353
|
+
await store.createNote("N2", { id: "n2", tags: ["alpha", "gamma"] });
|
|
2354
|
+
await store.createLink("a", "n1", "mentions");
|
|
2355
|
+
await store.createLink("a", "n2", "mentions");
|
|
2356
|
+
|
|
2357
|
+
const tools = generateMcpTools(store);
|
|
2358
|
+
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2359
|
+
const result = await synth.execute({ anchor: "a" }) as any;
|
|
2360
|
+
|
|
2361
|
+
const tagMap = new Map(result.tags.map((t: any) => [t.name, t.count]));
|
|
2362
|
+
expect(tagMap.get("alpha")).toBe(3);
|
|
2363
|
+
expect(tagMap.get("beta")).toBe(1);
|
|
2364
|
+
expect(tagMap.get("gamma")).toBe(1);
|
|
2365
|
+
expect(result.tags[0].name).toBe("alpha");
|
|
2366
|
+
});
|
|
2367
|
+
|
|
2368
|
+
it("synthesize-notes include_content controls snippet vs full body", async () => {
|
|
2369
|
+
const longBody = "x".repeat(500);
|
|
2370
|
+
await store.createNote(longBody, { id: "long", path: "Long" });
|
|
2371
|
+
|
|
2372
|
+
const tools = generateMcpTools(store);
|
|
2373
|
+
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2374
|
+
|
|
2375
|
+
const snippetResult = await synth.execute({ anchor: "long" }) as any;
|
|
2376
|
+
expect(snippetResult.notes[0]).toHaveProperty("snippet");
|
|
2377
|
+
expect(snippetResult.notes[0]).not.toHaveProperty("content");
|
|
2378
|
+
expect(snippetResult.notes[0].snippet.length).toBeLessThanOrEqual(200);
|
|
2379
|
+
|
|
2380
|
+
const fullResult = await synth.execute({ anchor: "long", include_content: true }) as any;
|
|
2381
|
+
expect(fullResult.notes[0].content).toBe(longBody);
|
|
2382
|
+
expect(fullResult.notes[0]).not.toHaveProperty("snippet");
|
|
2383
|
+
});
|
|
2384
|
+
|
|
1659
2385
|
it("create-note via store triggers wikilink sync", async () => {
|
|
1660
2386
|
const tools = generateMcpTools(store);
|
|
1661
2387
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
@@ -1688,6 +2414,315 @@ describe("MCP tools", async () => {
|
|
|
1688
2414
|
expect(fresh.metadata.priority).toBe(0);
|
|
1689
2415
|
expect(fresh.metadata.status).toBe("active");
|
|
1690
2416
|
});
|
|
2417
|
+
|
|
2418
|
+
// ---- query-notes input-shape tolerance (vault#214) ----
|
|
2419
|
+
//
|
|
2420
|
+
// The MCP framework drops top-level keys not in the inputSchema without
|
|
2421
|
+
// raising — so an LLM caller passing the wrong field name gets a silent
|
|
2422
|
+
// no-op rather than an error. We accept canonical + camelCase + singular
|
|
2423
|
+
// aliases so the most common LLM mistakes still apply the filter, and
|
|
2424
|
+
// we mirror the `tag` param's string-or-array shape so a single excluded
|
|
2425
|
+
// tag doesn't need a wrapping array.
|
|
2426
|
+
|
|
2427
|
+
it("query-notes accepts `excludeTags` (camelCase alias)", async () => {
|
|
2428
|
+
await store.createNote("a", { tags: ["email"] });
|
|
2429
|
+
await store.createNote("b", { tags: ["email", "urgent"] });
|
|
2430
|
+
const tools = generateMcpTools(store);
|
|
2431
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2432
|
+
const r = await queryNotes.execute({ tag: "email", excludeTags: ["urgent"], include_content: true }) as any[];
|
|
2433
|
+
expect(r).toHaveLength(1);
|
|
2434
|
+
expect(r[0].content).toBe("a");
|
|
2435
|
+
});
|
|
2436
|
+
|
|
2437
|
+
it("query-notes accepts `exclude_tag` (singular alias)", async () => {
|
|
2438
|
+
await store.createNote("a", { tags: ["email"] });
|
|
2439
|
+
await store.createNote("b", { tags: ["email", "urgent"] });
|
|
2440
|
+
const tools = generateMcpTools(store);
|
|
2441
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2442
|
+
const r = await queryNotes.execute({ tag: "email", exclude_tag: "urgent", include_content: true }) as any[];
|
|
2443
|
+
expect(r).toHaveLength(1);
|
|
2444
|
+
expect(r[0].content).toBe("a");
|
|
2445
|
+
});
|
|
2446
|
+
|
|
2447
|
+
it("query-notes `exclude_tags` accepts a single string (mirrors `tag`)", async () => {
|
|
2448
|
+
await store.createNote("a", { tags: ["email"] });
|
|
2449
|
+
await store.createNote("b", { tags: ["email", "urgent"] });
|
|
2450
|
+
const tools = generateMcpTools(store);
|
|
2451
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2452
|
+
const r = await queryNotes.execute({ tag: "email", exclude_tags: "urgent", include_content: true }) as any[];
|
|
2453
|
+
expect(r).toHaveLength(1);
|
|
2454
|
+
expect(r[0].content).toBe("a");
|
|
2455
|
+
});
|
|
2456
|
+
|
|
2457
|
+
it("query-notes canonical `exclude_tags: [...]` still works (regression)", async () => {
|
|
2458
|
+
await store.createNote("a", { tags: ["email"] });
|
|
2459
|
+
await store.createNote("b", { tags: ["email", "urgent"] });
|
|
2460
|
+
const tools = generateMcpTools(store);
|
|
2461
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2462
|
+
const r = await queryNotes.execute({ tag: "email", exclude_tags: ["urgent"], include_content: true }) as any[];
|
|
2463
|
+
expect(r).toHaveLength(1);
|
|
2464
|
+
expect(r[0].content).toBe("a");
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
it("query-notes routes through store.queryNotes so tag-hierarchy expansion fires", async () => {
|
|
2468
|
+
// `voice` and `text` declare "manual" as their parent via the v14
|
|
2469
|
+
// tags.parent_names column. A query for `tag: "manual"` should match
|
|
2470
|
+
// notes tagged with either child — that expansion only happens when
|
|
2471
|
+
// the call goes through `store.queryNotes`, not `noteOps.queryNotes`
|
|
2472
|
+
// directly.
|
|
2473
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
2474
|
+
await store.upsertTagRecord("text", { parent_names: ["manual"] });
|
|
2475
|
+
await store.createNote("voice memo", { tags: ["voice"] });
|
|
2476
|
+
await store.createNote("text memo", { tags: ["text"] });
|
|
2477
|
+
await store.createNote("unrelated", { tags: ["other"] });
|
|
2478
|
+
|
|
2479
|
+
const tools = generateMcpTools(store);
|
|
2480
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2481
|
+
const r = await queryNotes.execute({ tag: "manual", include_content: true }) as any[];
|
|
2482
|
+
expect(r).toHaveLength(2);
|
|
2483
|
+
expect(r.map((n) => n.content).sort()).toEqual(["text memo", "voice memo"]);
|
|
2484
|
+
});
|
|
2485
|
+
|
|
2486
|
+
it("query-notes FTS path routes through store.searchNotes so tag-hierarchy expansion fires (vault#227)", async () => {
|
|
2487
|
+
// Same fixture shape as the structured-query hierarchy test above, but
|
|
2488
|
+
// exercising the search branch. Pre-fix the FTS path called
|
|
2489
|
+
// `noteOps.searchNotes` directly and silently dropped descendant matches —
|
|
2490
|
+
// `tag: "manual"` would only return notes literally tagged #manual, not
|
|
2491
|
+
// notes tagged with the declared children #voice / #text.
|
|
2492
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
2493
|
+
await store.upsertTagRecord("text", { parent_names: ["manual"] });
|
|
2494
|
+
await store.createNote("voice handoff notes", { tags: ["voice"] });
|
|
2495
|
+
await store.createNote("text handoff notes", { tags: ["text"] });
|
|
2496
|
+
await store.createNote("unrelated handoff", { tags: ["other"] });
|
|
2497
|
+
|
|
2498
|
+
const tools = generateMcpTools(store);
|
|
2499
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2500
|
+
const r = await queryNotes.execute({ search: "handoff", tag: "manual", include_content: true }) as any[];
|
|
2501
|
+
expect(r).toHaveLength(2);
|
|
2502
|
+
expect(r.map((n) => n.content).sort()).toEqual(["text handoff notes", "voice handoff notes"]);
|
|
2503
|
+
expect(r.map((n) => n.content)).not.toContain("unrelated handoff");
|
|
2504
|
+
});
|
|
2505
|
+
|
|
2506
|
+
it("query-notes does not mutate caller's params object across repeated calls", async () => {
|
|
2507
|
+
// normalizeTags returns a defensive copy of array inputs so the downstream
|
|
2508
|
+
// store layer can sort/dedupe without touching the caller's reference.
|
|
2509
|
+
// Without the copy, a caller reusing the same params object would see its
|
|
2510
|
+
// exclude_tags array reordered (or worse) on the second call.
|
|
2511
|
+
await store.createNote("a", { tags: ["email"] });
|
|
2512
|
+
await store.createNote("b", { tags: ["email", "urgent"] });
|
|
2513
|
+
await store.createNote("c", { tags: ["email", "spam"] });
|
|
2514
|
+
|
|
2515
|
+
const tools = generateMcpTools(store);
|
|
2516
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2517
|
+
const params = { tag: "email", exclude_tags: ["urgent", "spam"], include_content: true };
|
|
2518
|
+
|
|
2519
|
+
const r1 = await queryNotes.execute(params) as any[];
|
|
2520
|
+
expect(params.exclude_tags).toEqual(["urgent", "spam"]);
|
|
2521
|
+
|
|
2522
|
+
const r2 = await queryNotes.execute(params) as any[];
|
|
2523
|
+
expect(params.exclude_tags).toEqual(["urgent", "spam"]);
|
|
2524
|
+
|
|
2525
|
+
expect(r1.map((n) => n.content).sort()).toEqual(["a"]);
|
|
2526
|
+
expect(r2.map((n) => n.content).sort()).toEqual(["a"]);
|
|
2527
|
+
});
|
|
2528
|
+
|
|
2529
|
+
// ---- empty-note + batch-cap MCP regressions (#213) ----
|
|
2530
|
+
|
|
2531
|
+
it("create-note rejects bare empty content with no path (EMPTY_NOTE)", async () => {
|
|
2532
|
+
const tools = generateMcpTools(store);
|
|
2533
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2534
|
+
let err: any;
|
|
2535
|
+
try {
|
|
2536
|
+
await createNote.execute({ content: "" });
|
|
2537
|
+
} catch (e) {
|
|
2538
|
+
err = e;
|
|
2539
|
+
}
|
|
2540
|
+
expect(err?.code).toBe("EMPTY_NOTE");
|
|
2541
|
+
});
|
|
2542
|
+
|
|
2543
|
+
it("create-note batch rejects when any entry is empty content + no path (atomic, with item_index)", async () => {
|
|
2544
|
+
const tools = generateMcpTools(store);
|
|
2545
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2546
|
+
const beforeCount = (await store.queryNotes({ search: "atomic-marker" })).length;
|
|
2547
|
+
let err: any;
|
|
2548
|
+
try {
|
|
2549
|
+
await createNote.execute({
|
|
2550
|
+
notes: [
|
|
2551
|
+
{ content: "atomic-marker first" },
|
|
2552
|
+
{ content: "" },
|
|
2553
|
+
],
|
|
2554
|
+
});
|
|
2555
|
+
} catch (e) {
|
|
2556
|
+
err = e;
|
|
2557
|
+
}
|
|
2558
|
+
expect(err?.code).toBe("EMPTY_NOTE");
|
|
2559
|
+
// The first item must NOT have been created — pre-validation rolls
|
|
2560
|
+
// the whole batch back atomically. Partial-create would leak prefixes
|
|
2561
|
+
// on every runaway-client burst (#213).
|
|
2562
|
+
const afterCount = (await store.queryNotes({ search: "atomic-marker" })).length;
|
|
2563
|
+
expect(afterCount).toBe(beforeCount);
|
|
2564
|
+
// Parity with HTTP route: MCP callers with multi-item batches need to
|
|
2565
|
+
// know which entry triggered the rejection. The bad entry is at index 1.
|
|
2566
|
+
expect(err.item_index).toBe(1);
|
|
2567
|
+
});
|
|
2568
|
+
|
|
2569
|
+
it("create-note single empty has null item_index (not a batch position)", async () => {
|
|
2570
|
+
const tools = generateMcpTools(store);
|
|
2571
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2572
|
+
let err: any;
|
|
2573
|
+
try {
|
|
2574
|
+
await createNote.execute({ content: "" });
|
|
2575
|
+
} catch (e) {
|
|
2576
|
+
err = e;
|
|
2577
|
+
}
|
|
2578
|
+
expect(err?.code).toBe("EMPTY_NOTE");
|
|
2579
|
+
// Single-call (no `notes` array) — there's no batch position to report.
|
|
2580
|
+
expect(err.item_index).toBeNull();
|
|
2581
|
+
});
|
|
2582
|
+
|
|
2583
|
+
it("create-note batch over MAX_BATCH_SIZE rejects with BATCH_TOO_LARGE", async () => {
|
|
2584
|
+
const tools = generateMcpTools(store);
|
|
2585
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2586
|
+
const notes = Array.from({ length: 501 }, (_, i) => ({ content: `n${i}` }));
|
|
2587
|
+
let err: any;
|
|
2588
|
+
try {
|
|
2589
|
+
await createNote.execute({ notes });
|
|
2590
|
+
} catch (e) {
|
|
2591
|
+
err = e;
|
|
2592
|
+
}
|
|
2593
|
+
expect(err?.code).toBe("BATCH_TOO_LARGE");
|
|
2594
|
+
expect(err.limit).toBe(500);
|
|
2595
|
+
expect(err.got).toBe(501);
|
|
2596
|
+
});
|
|
2597
|
+
|
|
2598
|
+
it("update-note batch over MAX_BATCH_SIZE rejects with BATCH_TOO_LARGE", async () => {
|
|
2599
|
+
const tools = generateMcpTools(store);
|
|
2600
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2601
|
+
const notes = Array.from({ length: 501 }, (_, i) => ({
|
|
2602
|
+
id: `id${i}`,
|
|
2603
|
+
content: "x",
|
|
2604
|
+
force: true,
|
|
2605
|
+
}));
|
|
2606
|
+
let err: any;
|
|
2607
|
+
try {
|
|
2608
|
+
await updateNote.execute({ notes });
|
|
2609
|
+
} catch (e) {
|
|
2610
|
+
err = e;
|
|
2611
|
+
}
|
|
2612
|
+
expect(err?.code).toBe("BATCH_TOO_LARGE");
|
|
2613
|
+
expect(err.limit).toBe(500);
|
|
2614
|
+
expect(err.got).toBe(501);
|
|
2615
|
+
});
|
|
2616
|
+
|
|
2617
|
+
it("create-note batch where mid-item triggers PATH_CONFLICT rolls back prefix items (#236)", async () => {
|
|
2618
|
+
// The empty-note pre-walk (#213) catches `{}` before any DB write; a
|
|
2619
|
+
// path-conflict can only surface on the actual INSERT, mid-loop. Without
|
|
2620
|
+
// the BEGIN/COMMIT wrap the prefix items would have already landed.
|
|
2621
|
+
await store.createNote("seed", { path: "taken-236" });
|
|
2622
|
+
const tools = generateMcpTools(store);
|
|
2623
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2624
|
+
const beforeIds = (await store.queryNotes({})).map((n) => n.id).sort();
|
|
2625
|
+
|
|
2626
|
+
let err: any;
|
|
2627
|
+
try {
|
|
2628
|
+
await createNote.execute({
|
|
2629
|
+
notes: [
|
|
2630
|
+
{ content: "ok-1", path: "fresh-236-1" },
|
|
2631
|
+
{ content: "ok-2", path: "fresh-236-2" },
|
|
2632
|
+
{ content: "boom", path: "taken-236" },
|
|
2633
|
+
],
|
|
2634
|
+
});
|
|
2635
|
+
} catch (e) {
|
|
2636
|
+
err = e;
|
|
2637
|
+
}
|
|
2638
|
+
expect(err).toBeTruthy();
|
|
2639
|
+
|
|
2640
|
+
// The two prefix items must NOT have been created — atomic rollback.
|
|
2641
|
+
const afterIds = (await store.queryNotes({})).map((n) => n.id).sort();
|
|
2642
|
+
expect(afterIds).toEqual(beforeIds);
|
|
2643
|
+
expect(await store.queryNotes({ path: "fresh-236-1" })).toHaveLength(0);
|
|
2644
|
+
expect(await store.queryNotes({ path: "fresh-236-2" })).toHaveLength(0);
|
|
2645
|
+
});
|
|
2646
|
+
|
|
2647
|
+
it("update-note batch rolls back prefix tag mutation when a later item path-conflicts (#236)", async () => {
|
|
2648
|
+
await store.createNote("A", { id: "a236" });
|
|
2649
|
+
await store.createNote("B", { id: "b236" });
|
|
2650
|
+
await store.createNote("C", { id: "c236", path: "occupied-236" });
|
|
2651
|
+
const tools = generateMcpTools(store);
|
|
2652
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2653
|
+
|
|
2654
|
+
const aBefore = await store.getNote("a236");
|
|
2655
|
+
|
|
2656
|
+
let err: any;
|
|
2657
|
+
try {
|
|
2658
|
+
await updateNote.execute({
|
|
2659
|
+
notes: [
|
|
2660
|
+
// Item 0 mutates a236's content + adds a tag. force=true skips
|
|
2661
|
+
// the if_updated_at precondition.
|
|
2662
|
+
{ id: "a236", content: "A mutated", force: true, tags: { add: ["should-rollback"] } },
|
|
2663
|
+
// Item 1 tries to take a path already owned by c236 — PATH_CONFLICT.
|
|
2664
|
+
{ id: "b236", path: "occupied-236", force: true },
|
|
2665
|
+
],
|
|
2666
|
+
});
|
|
2667
|
+
} catch (e) {
|
|
2668
|
+
err = e;
|
|
2669
|
+
}
|
|
2670
|
+
expect(err?.code).toBe("PATH_CONFLICT");
|
|
2671
|
+
|
|
2672
|
+
// Item 0's tag-add + content change must be rolled back — the batch
|
|
2673
|
+
// transaction reverted them when item 1 path-conflicted (#236).
|
|
2674
|
+
const aAfter = await store.getNote("a236");
|
|
2675
|
+
expect(aAfter!.content).toBe(aBefore!.content);
|
|
2676
|
+
expect(aAfter!.tags ?? []).not.toContain("should-rollback");
|
|
2677
|
+
});
|
|
2678
|
+
|
|
2679
|
+
it("update-note batch rolls back prefix mutation when a later item if_updated_at-conflicts (#261)", async () => {
|
|
2680
|
+
// The companion to the PATH_CONFLICT test above. Item 1's stale
|
|
2681
|
+
// `if_updated_at` must surface as a ConflictError so the batch's
|
|
2682
|
+
// BEGIN/COMMIT wrap can roll back item 0's mutation.
|
|
2683
|
+
//
|
|
2684
|
+
// Pre-fix (vault#261): `noteOps.updateNote` checked `res.changes === 0`
|
|
2685
|
+
// to detect the precondition miss. Inside this multi-statement batch
|
|
2686
|
+
// transaction, `.changes` reported a stale non-zero value from prior
|
|
2687
|
+
// writes, so the conflict was silently missed and item 0's mutation
|
|
2688
|
+
// committed with item 1 ignored.
|
|
2689
|
+
//
|
|
2690
|
+
// Post-fix: the conditional UPDATE uses `RETURNING id` and detects the
|
|
2691
|
+
// miss directly from row presence. ConflictError fires; batch rolls back.
|
|
2692
|
+
//
|
|
2693
|
+
// Standalone bun:sqlite repro is pending — six attempted reductions
|
|
2694
|
+
// (basic txn, async/microtask, prepared-statement reuse, mcp-loop
|
|
2695
|
+
// mirror, hook-dispatch mirror, syncWikilinks-style writes) failed to
|
|
2696
|
+
// hit the .changes-stale path outside the full BunStore plumbing. The
|
|
2697
|
+
// bug surfaces only through `BunStore.updateNote` → hook dispatch.
|
|
2698
|
+
// See vault#261 for the audit trail.
|
|
2699
|
+
await store.createNote("A", { id: "a261" });
|
|
2700
|
+
await store.createNote("B", { id: "b261" });
|
|
2701
|
+
const tools = generateMcpTools(store);
|
|
2702
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2703
|
+
|
|
2704
|
+
const aBefore = await store.getNote("a261");
|
|
2705
|
+
|
|
2706
|
+
let err: any;
|
|
2707
|
+
try {
|
|
2708
|
+
await updateNote.execute({
|
|
2709
|
+
notes: [
|
|
2710
|
+
// Item 0 mutates a261's content + adds a tag (force, no precondition).
|
|
2711
|
+
{ id: "a261", content: "A mutated", force: true, tags: { add: ["should-rollback"] } },
|
|
2712
|
+
// Item 1 has a stale if_updated_at on b261 — should ConflictError.
|
|
2713
|
+
{ id: "b261", content: "B should-not-land", if_updated_at: "2020-01-01T00:00:00.000Z" },
|
|
2714
|
+
],
|
|
2715
|
+
});
|
|
2716
|
+
} catch (e) {
|
|
2717
|
+
err = e;
|
|
2718
|
+
}
|
|
2719
|
+
expect(err?.code).toBe("CONFLICT");
|
|
2720
|
+
|
|
2721
|
+
// Item 0's tag-add + content change must be rolled back.
|
|
2722
|
+
const aAfter = await store.getNote("a261");
|
|
2723
|
+
expect(aAfter!.content).toBe(aBefore!.content);
|
|
2724
|
+
expect(aAfter!.tags ?? []).not.toContain("should-rollback");
|
|
2725
|
+
});
|
|
1691
2726
|
});
|
|
1692
2727
|
|
|
1693
2728
|
// ---- query-notes link expansion ----
|
|
@@ -1978,3 +3013,1213 @@ describe("query-notes link expansion", async () => {
|
|
|
1978
3013
|
expect(result.content).not.toContain("unsummarized body");
|
|
1979
3014
|
});
|
|
1980
3015
|
});
|
|
3016
|
+
|
|
3017
|
+
// ---------------------------------------------------------------------------
|
|
3018
|
+
// Tag hierarchy via tags.parent_names (post-v14, patterns/tag-data-model.md)
|
|
3019
|
+
// ---------------------------------------------------------------------------
|
|
3020
|
+
|
|
3021
|
+
describe("tag hierarchy (tags.parent_names)", async () => {
|
|
3022
|
+
it("query for parent tag returns notes tagged with declared child", async () => {
|
|
3023
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3024
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3025
|
+
await store.createNote("text note", { tags: ["text"] });
|
|
3026
|
+
|
|
3027
|
+
const results = await store.queryNotes({ tags: ["manual"] });
|
|
3028
|
+
expect(results.length).toBe(1);
|
|
3029
|
+
expect(results[0]!.content).toBe("voice note");
|
|
3030
|
+
});
|
|
3031
|
+
|
|
3032
|
+
it("expands transitively across multiple levels", async () => {
|
|
3033
|
+
await store.upsertTagRecord("manual", { parent_names: ["note"] });
|
|
3034
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3035
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3036
|
+
await store.createNote("manual-only note", { tags: ["manual"] });
|
|
3037
|
+
await store.createNote("note-only note", { tags: ["note"] });
|
|
3038
|
+
|
|
3039
|
+
// #note matches all three (note + manual + voice).
|
|
3040
|
+
const noteResults = await store.queryNotes({ tags: ["note"] });
|
|
3041
|
+
expect(noteResults.length).toBe(3);
|
|
3042
|
+
|
|
3043
|
+
// #manual matches voice + manual-only, not note-only.
|
|
3044
|
+
const manualResults = await store.queryNotes({ tags: ["manual"] });
|
|
3045
|
+
expect(manualResults.map((n) => n.content).sort()).toEqual([
|
|
3046
|
+
"manual-only note",
|
|
3047
|
+
"voice note",
|
|
3048
|
+
]);
|
|
3049
|
+
});
|
|
3050
|
+
|
|
3051
|
+
it("query for child does not match parent-tagged notes", async () => {
|
|
3052
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3053
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3054
|
+
await store.createNote("manual-only note", { tags: ["manual"] });
|
|
3055
|
+
|
|
3056
|
+
const results = await store.queryNotes({ tags: ["voice"] });
|
|
3057
|
+
expect(results.length).toBe(1);
|
|
3058
|
+
expect(results[0]!.content).toBe("voice note");
|
|
3059
|
+
});
|
|
3060
|
+
|
|
3061
|
+
it("supports multiple parents (diamond inheritance)", async () => {
|
|
3062
|
+
await store.upsertTagRecord("voice-meeting", { parent_names: ["voice", "meeting"] });
|
|
3063
|
+
await store.createNote("vm", { tags: ["voice-meeting"] });
|
|
3064
|
+
await store.createNote("v", { tags: ["voice"] });
|
|
3065
|
+
await store.createNote("m", { tags: ["meeting"] });
|
|
3066
|
+
|
|
3067
|
+
expect((await store.queryNotes({ tags: ["voice"] })).length).toBe(2); // v + vm
|
|
3068
|
+
expect((await store.queryNotes({ tags: ["meeting"] })).length).toBe(2); // m + vm
|
|
3069
|
+
});
|
|
3070
|
+
|
|
3071
|
+
it("hierarchy is invalidated when parent_names is set", async () => {
|
|
3072
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3073
|
+
// Before the parents are declared, #manual matches nothing.
|
|
3074
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3075
|
+
|
|
3076
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3077
|
+
|
|
3078
|
+
// After upsert, the cache invalidates and the next query expands.
|
|
3079
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
3080
|
+
});
|
|
3081
|
+
|
|
3082
|
+
it("hierarchy is invalidated when parent_names is repointed", async () => {
|
|
3083
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3084
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3085
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
3086
|
+
|
|
3087
|
+
// Repoint the parent.
|
|
3088
|
+
await store.upsertTagRecord("voice", { parent_names: ["audio"] });
|
|
3089
|
+
|
|
3090
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3091
|
+
expect((await store.queryNotes({ tags: ["audio"] })).length).toBe(1);
|
|
3092
|
+
});
|
|
3093
|
+
|
|
3094
|
+
it("hierarchy is invalidated when parent_names is cleared", async () => {
|
|
3095
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3096
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3097
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
3098
|
+
|
|
3099
|
+
await store.upsertTagRecord("voice", { parent_names: null });
|
|
3100
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3101
|
+
});
|
|
3102
|
+
|
|
3103
|
+
it("hierarchy is invalidated when a parent tag is deleted", async () => {
|
|
3104
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3105
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3106
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
3107
|
+
|
|
3108
|
+
// Drop the child tag — the row holding the parent_names declaration
|
|
3109
|
+
// disappears, so the hierarchy edge goes with it.
|
|
3110
|
+
await store.deleteTag("voice");
|
|
3111
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3112
|
+
});
|
|
3113
|
+
|
|
3114
|
+
it("tagMatch=any flattens all expansions across input tags", async () => {
|
|
3115
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3116
|
+
await store.createNote("v", { tags: ["voice"] });
|
|
3117
|
+
await store.createNote("p", { tags: ["project"] });
|
|
3118
|
+
await store.createNote("o", { tags: ["other"] });
|
|
3119
|
+
|
|
3120
|
+
const results = await store.queryNotes({
|
|
3121
|
+
tags: ["manual", "project"],
|
|
3122
|
+
tagMatch: "any",
|
|
3123
|
+
});
|
|
3124
|
+
expect(results.map((n) => n.content).sort()).toEqual(["p", "v"]);
|
|
3125
|
+
});
|
|
3126
|
+
|
|
3127
|
+
it("tagMatch=all (default) requires each input tag's expanded set to be present", async () => {
|
|
3128
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3129
|
+
// Note has both #voice (which satisfies #manual via expansion) AND #project.
|
|
3130
|
+
await store.createNote("vp", { tags: ["voice", "project"] });
|
|
3131
|
+
await store.createNote("p-only", { tags: ["project"] });
|
|
3132
|
+
|
|
3133
|
+
const results = await store.queryNotes({
|
|
3134
|
+
tags: ["manual", "project"], // default tagMatch=all
|
|
3135
|
+
});
|
|
3136
|
+
expect(results.length).toBe(1);
|
|
3137
|
+
expect(results[0]!.content).toBe("vp");
|
|
3138
|
+
});
|
|
3139
|
+
|
|
3140
|
+
it("tolerates a cycle without infinite-looping", async () => {
|
|
3141
|
+
await store.upsertTagRecord("a", { parent_names: ["b"] });
|
|
3142
|
+
await store.upsertTagRecord("b", { parent_names: ["a"] });
|
|
3143
|
+
await store.createNote("note-a", { tags: ["a"] });
|
|
3144
|
+
|
|
3145
|
+
// Both a and b should resolve without hanging; both reach the same set {a, b}.
|
|
3146
|
+
expect((await store.queryNotes({ tags: ["a"] })).length).toBe(1);
|
|
3147
|
+
expect((await store.queryNotes({ tags: ["b"] })).length).toBe(1);
|
|
3148
|
+
});
|
|
3149
|
+
|
|
3150
|
+
it("malformed parent_names JSON is ignored silently", async () => {
|
|
3151
|
+
// Stuff a malformed value into the column directly to simulate an
|
|
3152
|
+
// out-of-band write. The resolver should drop it without throwing.
|
|
3153
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3154
|
+
(store as any).db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?")
|
|
3155
|
+
.run("not valid json {{{", "voice");
|
|
3156
|
+
// Force cache reload.
|
|
3157
|
+
(store as any)._tagHierarchy = null;
|
|
3158
|
+
await store.createNote("v", { tags: ["voice"] });
|
|
3159
|
+
|
|
3160
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3161
|
+
expect((await store.queryNotes({ tags: ["voice"] })).length).toBe(1);
|
|
3162
|
+
});
|
|
3163
|
+
|
|
3164
|
+
it("a tag with no parent_names is a hierarchy no-op", async () => {
|
|
3165
|
+
await store.upsertTagRecord("voice", { description: "voice notes" });
|
|
3166
|
+
await store.createNote("v", { tags: ["voice"] });
|
|
3167
|
+
await store.createNote("m", { tags: ["manual"] });
|
|
3168
|
+
|
|
3169
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
3170
|
+
expect((await store.queryNotes({ tags: ["voice"] })).length).toBe(1);
|
|
3171
|
+
});
|
|
3172
|
+
|
|
3173
|
+
it("legacy `_tags/<name>` notes left in place do not affect the hierarchy", async () => {
|
|
3174
|
+
// Post-v14, the resolver reads tags.parent_names — not notes. A leftover
|
|
3175
|
+
// `_tags/*` note from a pre-v14 vault is harmless historical record.
|
|
3176
|
+
await store.createNote("legacy", {
|
|
3177
|
+
path: "_tags/voice",
|
|
3178
|
+
metadata: { parents: ["manual"] },
|
|
3179
|
+
});
|
|
3180
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3181
|
+
|
|
3182
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3183
|
+
});
|
|
3184
|
+
});
|
|
3185
|
+
|
|
3186
|
+
// ---------------------------------------------------------------------------
|
|
3187
|
+
// Note schemas — table-driven (post-v15: `note_schemas` + `schema_mappings`)
|
|
3188
|
+
// ---------------------------------------------------------------------------
|
|
3189
|
+
// Originally written against the `_schemas/<name>` + `_schema_defaults`
|
|
3190
|
+
// notes-as-config convention (issue #177). Rewritten for vault#246: the
|
|
3191
|
+
// authoring surface is now `store.upsertNoteSchema` + `store.setSchemaMapping`
|
|
3192
|
+
// and the legacy notes are inert (left in place as audit trail).
|
|
3193
|
+
|
|
3194
|
+
describe("note schemas (note_schemas + schema_mappings)", async () => {
|
|
3195
|
+
it("returns no validation_status when no schemas are configured", async () => {
|
|
3196
|
+
const tools = generateMcpTools(store);
|
|
3197
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3198
|
+
const result = await create.execute({ content: "plain note" }) as any;
|
|
3199
|
+
expect(result.validation_status).toBeUndefined();
|
|
3200
|
+
});
|
|
3201
|
+
|
|
3202
|
+
it("attaches validation_status when a schema matches by tag", async () => {
|
|
3203
|
+
await store.upsertNoteSchema("task", {
|
|
3204
|
+
description: "A task",
|
|
3205
|
+
fields: { priority: { type: "string", enum: ["high", "medium", "low"] } },
|
|
3206
|
+
required: ["priority"],
|
|
3207
|
+
});
|
|
3208
|
+
await store.setSchemaMapping("task", "tag", "task");
|
|
3209
|
+
|
|
3210
|
+
const tools = generateMcpTools(store);
|
|
3211
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3212
|
+
const result = await create.execute({ content: "do thing", tags: ["task"] }) as any;
|
|
3213
|
+
|
|
3214
|
+
expect(result.validation_status).toBeTruthy();
|
|
3215
|
+
expect(result.validation_status.schemas).toEqual(["task"]);
|
|
3216
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3217
|
+
expect(result.validation_status.warnings[0].reason).toBe("missing_required");
|
|
3218
|
+
expect(result.validation_status.warnings[0].field).toBe("priority");
|
|
3219
|
+
});
|
|
3220
|
+
|
|
3221
|
+
it("attaches validation_status when a schema matches by path prefix", async () => {
|
|
3222
|
+
await store.upsertNoteSchema("journal-entry", {
|
|
3223
|
+
fields: { mood: { type: "string" } },
|
|
3224
|
+
required: ["mood"],
|
|
3225
|
+
});
|
|
3226
|
+
await store.setSchemaMapping("journal-entry", "path_prefix", "journal/");
|
|
3227
|
+
|
|
3228
|
+
const tools = generateMcpTools(store);
|
|
3229
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3230
|
+
const result = await create.execute({ content: "today", path: "journal/2026-04-29" }) as any;
|
|
3231
|
+
|
|
3232
|
+
expect(result.validation_status.schemas).toEqual(["journal-entry"]);
|
|
3233
|
+
expect(result.validation_status.warnings[0].reason).toBe("missing_required");
|
|
3234
|
+
});
|
|
3235
|
+
|
|
3236
|
+
it("validation passes (empty warnings) when required fields are present and types match", async () => {
|
|
3237
|
+
await store.upsertNoteSchema("task", {
|
|
3238
|
+
fields: {
|
|
3239
|
+
priority: { type: "string", enum: ["high", "medium", "low"] },
|
|
3240
|
+
done: { type: "boolean" },
|
|
3241
|
+
},
|
|
3242
|
+
required: ["priority"],
|
|
3243
|
+
});
|
|
3244
|
+
await store.setSchemaMapping("task", "tag", "task");
|
|
3245
|
+
|
|
3246
|
+
const tools = generateMcpTools(store);
|
|
3247
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3248
|
+
const result = await create.execute({
|
|
3249
|
+
content: "ok",
|
|
3250
|
+
tags: ["task"],
|
|
3251
|
+
metadata: { priority: "high", done: false },
|
|
3252
|
+
}) as any;
|
|
3253
|
+
|
|
3254
|
+
expect(result.validation_status.schemas).toEqual(["task"]);
|
|
3255
|
+
expect(result.validation_status.warnings).toEqual([]);
|
|
3256
|
+
});
|
|
3257
|
+
|
|
3258
|
+
it("type_mismatch warning when a field's value is the wrong type", async () => {
|
|
3259
|
+
await store.upsertNoteSchema("task", {
|
|
3260
|
+
fields: { priority: { type: "string" }, done: { type: "boolean" } },
|
|
3261
|
+
});
|
|
3262
|
+
await store.setSchemaMapping("task", "tag", "task");
|
|
3263
|
+
|
|
3264
|
+
const tools = generateMcpTools(store);
|
|
3265
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3266
|
+
const result = await create.execute({
|
|
3267
|
+
content: "x",
|
|
3268
|
+
tags: ["task"],
|
|
3269
|
+
metadata: { priority: "high", done: "yes" }, // wrong: should be boolean
|
|
3270
|
+
}) as any;
|
|
3271
|
+
|
|
3272
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3273
|
+
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
3274
|
+
expect(result.validation_status.warnings[0].field).toBe("done");
|
|
3275
|
+
});
|
|
3276
|
+
|
|
3277
|
+
it("enum_mismatch warning when a field's value is outside the declared enum", async () => {
|
|
3278
|
+
await store.upsertNoteSchema("task", {
|
|
3279
|
+
fields: { priority: { type: "string", enum: ["high", "medium", "low"] } },
|
|
3280
|
+
});
|
|
3281
|
+
await store.setSchemaMapping("task", "tag", "task");
|
|
3282
|
+
|
|
3283
|
+
const tools = generateMcpTools(store);
|
|
3284
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3285
|
+
const result = await create.execute({
|
|
3286
|
+
content: "x",
|
|
3287
|
+
tags: ["task"],
|
|
3288
|
+
metadata: { priority: "ULTRA" },
|
|
3289
|
+
}) as any;
|
|
3290
|
+
|
|
3291
|
+
expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
3292
|
+
});
|
|
3293
|
+
|
|
3294
|
+
it("validation never blocks the write — note exists with warnings attached", async () => {
|
|
3295
|
+
await store.upsertNoteSchema("task", { required: ["priority"] });
|
|
3296
|
+
await store.setSchemaMapping("task", "tag", "task");
|
|
3297
|
+
|
|
3298
|
+
const tools = generateMcpTools(store);
|
|
3299
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3300
|
+
const result = await create.execute({ content: "x", tags: ["task"] }) as any;
|
|
3301
|
+
|
|
3302
|
+
expect(result.id).toBeTruthy();
|
|
3303
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3304
|
+
|
|
3305
|
+
const fetched = await store.getNote(result.id);
|
|
3306
|
+
expect(fetched).not.toBeNull();
|
|
3307
|
+
expect(fetched!.content).toBe("x");
|
|
3308
|
+
});
|
|
3309
|
+
|
|
3310
|
+
it("update-note also surfaces validation_status", async () => {
|
|
3311
|
+
await store.upsertNoteSchema("task", {
|
|
3312
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
3313
|
+
required: ["priority"],
|
|
3314
|
+
});
|
|
3315
|
+
await store.setSchemaMapping("task", "tag", "task");
|
|
3316
|
+
const note = await store.createNote("body", { tags: ["task"], metadata: { priority: "high" } });
|
|
3317
|
+
|
|
3318
|
+
const tools = generateMcpTools(store);
|
|
3319
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
3320
|
+
const result = await update.execute({
|
|
3321
|
+
id: note.id,
|
|
3322
|
+
metadata: { priority: "ULTRA" },
|
|
3323
|
+
if_updated_at: note.updatedAt,
|
|
3324
|
+
}) as any;
|
|
3325
|
+
|
|
3326
|
+
expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
3327
|
+
});
|
|
3328
|
+
|
|
3329
|
+
it("cache invalidates when a schema mapping is removed", async () => {
|
|
3330
|
+
await store.upsertNoteSchema("task", { required: ["a"] });
|
|
3331
|
+
await store.setSchemaMapping("task", "tag", "task");
|
|
3332
|
+
|
|
3333
|
+
const tools = generateMcpTools(store);
|
|
3334
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3335
|
+
let result = await create.execute({ content: "x", tags: ["task"] }) as any;
|
|
3336
|
+
expect(result.validation_status.warnings[0].field).toBe("a");
|
|
3337
|
+
|
|
3338
|
+
await store.deleteSchemaMapping("task", "tag", "task");
|
|
3339
|
+
|
|
3340
|
+
result = await create.execute({ content: "y", tags: ["task"] }) as any;
|
|
3341
|
+
expect(result.validation_status).toBeUndefined();
|
|
3342
|
+
});
|
|
3343
|
+
|
|
3344
|
+
it("cache invalidates when a schema is updated", async () => {
|
|
3345
|
+
await store.upsertNoteSchema("task", { required: ["a"] });
|
|
3346
|
+
await store.setSchemaMapping("task", "tag", "task");
|
|
3347
|
+
|
|
3348
|
+
const tools = generateMcpTools(store);
|
|
3349
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3350
|
+
let result = await create.execute({ content: "x", tags: ["task"] }) as any;
|
|
3351
|
+
expect(result.validation_status.warnings[0].field).toBe("a");
|
|
3352
|
+
|
|
3353
|
+
// Re-declare with a different required field; cache must reflect the change.
|
|
3354
|
+
await store.upsertNoteSchema("task", { required: ["b"] });
|
|
3355
|
+
result = await create.execute({ content: "y", tags: ["task"] }) as any;
|
|
3356
|
+
expect(result.validation_status.warnings[0].field).toBe("b");
|
|
3357
|
+
});
|
|
3358
|
+
|
|
3359
|
+
it("cache invalidates when a schema is deleted (cascades mappings)", async () => {
|
|
3360
|
+
await store.upsertNoteSchema("task", { required: ["a"] });
|
|
3361
|
+
await store.setSchemaMapping("task", "tag", "task");
|
|
3362
|
+
|
|
3363
|
+
const tools = generateMcpTools(store);
|
|
3364
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3365
|
+
let result = await create.execute({ content: "x", tags: ["task"] }) as any;
|
|
3366
|
+
expect(result.validation_status.warnings[0].field).toBe("a");
|
|
3367
|
+
|
|
3368
|
+
await store.deleteNoteSchema("task");
|
|
3369
|
+
// FK CASCADE drops the mapping too.
|
|
3370
|
+
expect(await store.listSchemaMappings({ schema_name: "task" })).toEqual([]);
|
|
3371
|
+
|
|
3372
|
+
result = await create.execute({ content: "y", tags: ["task"] }) as any;
|
|
3373
|
+
expect(result.validation_status).toBeUndefined();
|
|
3374
|
+
});
|
|
3375
|
+
|
|
3376
|
+
it("longest path prefix wins when multiple match", async () => {
|
|
3377
|
+
await store.upsertNoteSchema("journal-day", { required: ["mood"] });
|
|
3378
|
+
await store.upsertNoteSchema("journal-broad", { required: ["topic"] });
|
|
3379
|
+
await store.setSchemaMapping("journal-broad", "path_prefix", "journal/");
|
|
3380
|
+
await store.setSchemaMapping("journal-day", "path_prefix", "journal/2026/");
|
|
3381
|
+
|
|
3382
|
+
const tools = generateMcpTools(store);
|
|
3383
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3384
|
+
const result = await create.execute({ content: "x", path: "journal/2026/april" }) as any;
|
|
3385
|
+
expect(result.validation_status.schemas).toEqual(["journal-day"]);
|
|
3386
|
+
});
|
|
3387
|
+
|
|
3388
|
+
it("multiple schemas can apply (path + tag combine warnings)", async () => {
|
|
3389
|
+
await store.upsertNoteSchema("journal-entry", { required: ["mood"] });
|
|
3390
|
+
await store.upsertNoteSchema("task", { required: ["priority"] });
|
|
3391
|
+
await store.setSchemaMapping("journal-entry", "path_prefix", "journal/");
|
|
3392
|
+
await store.setSchemaMapping("task", "tag", "task");
|
|
3393
|
+
|
|
3394
|
+
const tools = generateMcpTools(store);
|
|
3395
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3396
|
+
const result = await create.execute({
|
|
3397
|
+
content: "x",
|
|
3398
|
+
path: "journal/today",
|
|
3399
|
+
tags: ["task"],
|
|
3400
|
+
}) as any;
|
|
3401
|
+
|
|
3402
|
+
expect(result.validation_status.schemas.sort()).toEqual(["journal-entry", "task"]);
|
|
3403
|
+
expect(result.validation_status.warnings.length).toBe(2);
|
|
3404
|
+
});
|
|
3405
|
+
|
|
3406
|
+
it("a schema with no mappings is harmless (no validation)", async () => {
|
|
3407
|
+
await store.upsertNoteSchema("orphan", { required: ["x"] });
|
|
3408
|
+
|
|
3409
|
+
const tools = generateMcpTools(store);
|
|
3410
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3411
|
+
const result = await create.execute({ content: "anything" }) as any;
|
|
3412
|
+
expect(result.validation_status).toBeUndefined();
|
|
3413
|
+
});
|
|
3414
|
+
|
|
3415
|
+
it("legacy `_schemas/<name>` notes are inert post-v15", async () => {
|
|
3416
|
+
// The notes still write/read fine — they're just no longer interpreted
|
|
3417
|
+
// as schema config. Nothing in note_schemas/schema_mappings → no validation.
|
|
3418
|
+
await store.createNote("", {
|
|
3419
|
+
path: "_schemas/task",
|
|
3420
|
+
metadata: { required: ["priority"] },
|
|
3421
|
+
});
|
|
3422
|
+
await store.createNote("", {
|
|
3423
|
+
path: "_schema_defaults",
|
|
3424
|
+
metadata: { tags: { task: "task" } },
|
|
3425
|
+
});
|
|
3426
|
+
|
|
3427
|
+
const tools = generateMcpTools(store);
|
|
3428
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3429
|
+
const result = await create.execute({ content: "x", tags: ["task"] }) as any;
|
|
3430
|
+
expect(result.validation_status).toBeUndefined();
|
|
3431
|
+
});
|
|
3432
|
+
});
|
|
3433
|
+
|
|
3434
|
+
// ---------------------------------------------------------------------------
|
|
3435
|
+
// note_schemas + schema_mappings — direct CRUD (vault#246)
|
|
3436
|
+
// ---------------------------------------------------------------------------
|
|
3437
|
+
|
|
3438
|
+
describe("note_schemas / schema_mappings CRUD", async () => {
|
|
3439
|
+
it("upsertNoteSchema partial-merge: undefined preserves, null clears", async () => {
|
|
3440
|
+
await store.upsertNoteSchema("task", {
|
|
3441
|
+
description: "A task",
|
|
3442
|
+
fields: { priority: { type: "string" } },
|
|
3443
|
+
required: ["priority"],
|
|
3444
|
+
});
|
|
3445
|
+
|
|
3446
|
+
// description omitted → preserved
|
|
3447
|
+
await store.upsertNoteSchema("task", { required: ["priority", "due"] });
|
|
3448
|
+
let row = await store.getNoteSchema("task");
|
|
3449
|
+
expect(row?.description).toBe("A task");
|
|
3450
|
+
expect(row?.required).toEqual(["priority", "due"]);
|
|
3451
|
+
|
|
3452
|
+
// description null → cleared
|
|
3453
|
+
await store.upsertNoteSchema("task", { description: null });
|
|
3454
|
+
row = await store.getNoteSchema("task");
|
|
3455
|
+
expect(row?.description).toBeNull();
|
|
3456
|
+
expect(row?.required).toEqual(["priority", "due"]); // still preserved
|
|
3457
|
+
});
|
|
3458
|
+
|
|
3459
|
+
it("empty `required: []` collapses to null", async () => {
|
|
3460
|
+
await store.upsertNoteSchema("task", { required: ["a", "b"] });
|
|
3461
|
+
await store.upsertNoteSchema("task", { required: [] });
|
|
3462
|
+
const row = await store.getNoteSchema("task");
|
|
3463
|
+
expect(row?.required).toBeNull();
|
|
3464
|
+
});
|
|
3465
|
+
|
|
3466
|
+
it("setSchemaMapping is idempotent (composite PK)", async () => {
|
|
3467
|
+
await store.upsertNoteSchema("task", {});
|
|
3468
|
+
await store.setSchemaMapping("task", "tag", "task");
|
|
3469
|
+
await store.setSchemaMapping("task", "tag", "task"); // re-set same triple
|
|
3470
|
+
const mappings = await store.listSchemaMappings({ schema_name: "task" });
|
|
3471
|
+
expect(mappings.length).toBe(1);
|
|
3472
|
+
});
|
|
3473
|
+
|
|
3474
|
+
it("setSchemaMapping rejects unknown match_kind", async () => {
|
|
3475
|
+
await store.upsertNoteSchema("task", {});
|
|
3476
|
+
await expect(
|
|
3477
|
+
store.setSchemaMapping("task", "BOGUS" as any, "task"),
|
|
3478
|
+
).rejects.toThrow(/match_kind/);
|
|
3479
|
+
});
|
|
3480
|
+
|
|
3481
|
+
it("setSchemaMapping fails (FK) when schema doesn't exist", async () => {
|
|
3482
|
+
await expect(
|
|
3483
|
+
store.setSchemaMapping("missing", "tag", "x"),
|
|
3484
|
+
).rejects.toThrow();
|
|
3485
|
+
});
|
|
3486
|
+
|
|
3487
|
+
it("deleteNoteSchema cascades schema_mappings (FK ON DELETE CASCADE)", async () => {
|
|
3488
|
+
await store.upsertNoteSchema("task", {});
|
|
3489
|
+
await store.setSchemaMapping("task", "tag", "task");
|
|
3490
|
+
await store.setSchemaMapping("task", "path_prefix", "Tasks/");
|
|
3491
|
+
expect((await store.listSchemaMappings({ schema_name: "task" })).length).toBe(2);
|
|
3492
|
+
|
|
3493
|
+
expect(await store.deleteNoteSchema("task")).toBe(true);
|
|
3494
|
+
expect(await store.listSchemaMappings({ schema_name: "task" })).toEqual([]);
|
|
3495
|
+
expect(await store.deleteNoteSchema("task")).toBe(false); // already gone
|
|
3496
|
+
});
|
|
3497
|
+
|
|
3498
|
+
it("listSchemaMappings filters by schema_name and match_kind", async () => {
|
|
3499
|
+
await store.upsertNoteSchema("a", {});
|
|
3500
|
+
await store.upsertNoteSchema("b", {});
|
|
3501
|
+
await store.setSchemaMapping("a", "tag", "a-tag");
|
|
3502
|
+
await store.setSchemaMapping("a", "path_prefix", "A/");
|
|
3503
|
+
await store.setSchemaMapping("b", "tag", "b-tag");
|
|
3504
|
+
|
|
3505
|
+
expect((await store.listSchemaMappings({ schema_name: "a" })).length).toBe(2);
|
|
3506
|
+
expect((await store.listSchemaMappings({ match_kind: "tag" })).length).toBe(2);
|
|
3507
|
+
expect((await store.listSchemaMappings({ schema_name: "a", match_kind: "tag" })).length).toBe(1);
|
|
3508
|
+
});
|
|
3509
|
+
});
|
|
3510
|
+
|
|
3511
|
+
describe("expandTagsWithDescendants (tag-scoped tokens — patterns/tag-scoped-tokens.md)", async () => {
|
|
3512
|
+
it("returns the union of root + every descendant per tags.parent_names", async () => {
|
|
3513
|
+
await store.upsertTagRecord("health/food", { parent_names: ["health"] });
|
|
3514
|
+
await store.upsertTagRecord("health/food/breakfast", { parent_names: ["health/food"] });
|
|
3515
|
+
await store.upsertTagRecord("work", { description: "work things" });
|
|
3516
|
+
|
|
3517
|
+
const expanded = await store.expandTagsWithDescendants(["health"]);
|
|
3518
|
+
expect(expanded.has("health")).toBe(true);
|
|
3519
|
+
expect(expanded.has("health/food")).toBe(true);
|
|
3520
|
+
expect(expanded.has("health/food/breakfast")).toBe(true);
|
|
3521
|
+
expect(expanded.has("work")).toBe(false);
|
|
3522
|
+
});
|
|
3523
|
+
|
|
3524
|
+
it("returns an empty set for an empty input (no allowlist = nothing to expand)", async () => {
|
|
3525
|
+
const expanded = await store.expandTagsWithDescendants([]);
|
|
3526
|
+
expect(expanded.size).toBe(0);
|
|
3527
|
+
});
|
|
3528
|
+
|
|
3529
|
+
it("includes the root verbatim even when the tag has no declared descendants", async () => {
|
|
3530
|
+
await store.createNote("solo", { tags: ["loner"] });
|
|
3531
|
+
const expanded = await store.expandTagsWithDescendants(["loner"]);
|
|
3532
|
+
expect([...expanded]).toEqual(["loner"]);
|
|
3533
|
+
});
|
|
3534
|
+
|
|
3535
|
+
it("unions descendants from multiple roots", async () => {
|
|
3536
|
+
await store.upsertTagRecord("health/food", { parent_names: ["health"] });
|
|
3537
|
+
await store.upsertTagRecord("work/standup", { parent_names: ["work"] });
|
|
3538
|
+
const expanded = await store.expandTagsWithDescendants(["health", "work"]);
|
|
3539
|
+
expect(expanded.has("health")).toBe(true);
|
|
3540
|
+
expect(expanded.has("health/food")).toBe(true);
|
|
3541
|
+
expect(expanded.has("work")).toBe(true);
|
|
3542
|
+
expect(expanded.has("work/standup")).toBe(true);
|
|
3543
|
+
});
|
|
3544
|
+
});
|
|
3545
|
+
|
|
3546
|
+
// ---------------------------------------------------------------------------
|
|
3547
|
+
// Tag record API — patterns/tag-data-model.md
|
|
3548
|
+
// ---------------------------------------------------------------------------
|
|
3549
|
+
|
|
3550
|
+
describe("tag record API (patterns/tag-data-model.md)", async () => {
|
|
3551
|
+
it("upsertTagRecord persists description + fields + relationships + parent_names", async () => {
|
|
3552
|
+
await store.upsertTagRecord("project", {
|
|
3553
|
+
description: "long-running deliverable",
|
|
3554
|
+
fields: { status: { type: "string", enum: ["active", "shipped"] } },
|
|
3555
|
+
relationships: {
|
|
3556
|
+
owned_by: { target_tag: "person", cardinality: "one", description: "DRI" },
|
|
3557
|
+
},
|
|
3558
|
+
parent_names: ["work"],
|
|
3559
|
+
});
|
|
3560
|
+
const r = await store.getTagRecord("project");
|
|
3561
|
+
expect(r?.description).toBe("long-running deliverable");
|
|
3562
|
+
expect(r?.fields?.status?.type).toBe("string");
|
|
3563
|
+
expect(r?.relationships?.owned_by?.target_tag).toBe("person");
|
|
3564
|
+
expect(r?.relationships?.owned_by?.cardinality).toBe("one");
|
|
3565
|
+
expect(r?.parent_names).toEqual(["work"]);
|
|
3566
|
+
expect(r?.created_at).toBeDefined();
|
|
3567
|
+
expect(r?.updated_at).toBeDefined();
|
|
3568
|
+
});
|
|
3569
|
+
|
|
3570
|
+
it("upsertTagRecord preserves columns left undefined in the patch", async () => {
|
|
3571
|
+
await store.upsertTagRecord("project", {
|
|
3572
|
+
description: "first",
|
|
3573
|
+
fields: { status: { type: "string" } },
|
|
3574
|
+
parent_names: ["work"],
|
|
3575
|
+
});
|
|
3576
|
+
await store.upsertTagRecord("project", { description: "second" });
|
|
3577
|
+
const r = await store.getTagRecord("project");
|
|
3578
|
+
expect(r?.description).toBe("second");
|
|
3579
|
+
expect(r?.fields?.status?.type).toBe("string");
|
|
3580
|
+
expect(r?.parent_names).toEqual(["work"]);
|
|
3581
|
+
});
|
|
3582
|
+
|
|
3583
|
+
it("upsertTagRecord clears a column when patch passes null", async () => {
|
|
3584
|
+
await store.upsertTagRecord("project", {
|
|
3585
|
+
description: "deliverable",
|
|
3586
|
+
parent_names: ["work"],
|
|
3587
|
+
});
|
|
3588
|
+
await store.upsertTagRecord("project", { parent_names: null });
|
|
3589
|
+
const r = await store.getTagRecord("project");
|
|
3590
|
+
expect(r?.description).toBe("deliverable");
|
|
3591
|
+
expect(r?.parent_names).toBeUndefined();
|
|
3592
|
+
});
|
|
3593
|
+
|
|
3594
|
+
it("listTagRecords returns every tag row, sorted by name", async () => {
|
|
3595
|
+
await store.upsertTagRecord("zebra", { description: "z" });
|
|
3596
|
+
await store.upsertTagRecord("alpha", { description: "a" });
|
|
3597
|
+
const records = await store.listTagRecords();
|
|
3598
|
+
const names = records.map((r) => r.tag);
|
|
3599
|
+
const idxAlpha = names.indexOf("alpha");
|
|
3600
|
+
const idxZebra = names.indexOf("zebra");
|
|
3601
|
+
expect(idxAlpha).toBeGreaterThanOrEqual(0);
|
|
3602
|
+
expect(idxZebra).toBeGreaterThan(idxAlpha);
|
|
3603
|
+
});
|
|
3604
|
+
|
|
3605
|
+
it("update-tag MCP rejects an invalid cardinality", async () => {
|
|
3606
|
+
const tools = generateMcpTools(store);
|
|
3607
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
3608
|
+
await expect(
|
|
3609
|
+
update.execute({
|
|
3610
|
+
tag: "project",
|
|
3611
|
+
relationships: {
|
|
3612
|
+
owned_by: { target_tag: "person", cardinality: "bogus" },
|
|
3613
|
+
},
|
|
3614
|
+
}),
|
|
3615
|
+
).rejects.toThrow(/cardinality/);
|
|
3616
|
+
});
|
|
3617
|
+
|
|
3618
|
+
it("update-tag MCP accepts every cardinality in the named vocabulary", async () => {
|
|
3619
|
+
const tools = generateMcpTools(store);
|
|
3620
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
3621
|
+
for (const card of ["one", "optional", "many", "many-required"]) {
|
|
3622
|
+
await update.execute({
|
|
3623
|
+
tag: `tag-${card}`,
|
|
3624
|
+
relationships: {
|
|
3625
|
+
rel: { target_tag: "other", cardinality: card },
|
|
3626
|
+
},
|
|
3627
|
+
});
|
|
3628
|
+
const r = await store.getTagRecord(`tag-${card}`);
|
|
3629
|
+
expect(r?.relationships?.rel?.cardinality).toBe(card);
|
|
3630
|
+
}
|
|
3631
|
+
});
|
|
3632
|
+
|
|
3633
|
+
it("update-tag MCP rejects a relationship missing target_tag", async () => {
|
|
3634
|
+
const tools = generateMcpTools(store);
|
|
3635
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
3636
|
+
await expect(
|
|
3637
|
+
update.execute({
|
|
3638
|
+
tag: "project",
|
|
3639
|
+
relationships: { owned_by: { cardinality: "one" } },
|
|
3640
|
+
}),
|
|
3641
|
+
).rejects.toThrow(/target_tag/);
|
|
3642
|
+
});
|
|
3643
|
+
|
|
3644
|
+
it("update-tag MCP sets parent_names and the hierarchy invalidates", async () => {
|
|
3645
|
+
const tools = generateMcpTools(store);
|
|
3646
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
3647
|
+
|
|
3648
|
+
await store.createNote("v note", { tags: ["voice"] });
|
|
3649
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3650
|
+
|
|
3651
|
+
await update.execute({
|
|
3652
|
+
tag: "voice",
|
|
3653
|
+
parent_names: ["manual"],
|
|
3654
|
+
});
|
|
3655
|
+
|
|
3656
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
3657
|
+
});
|
|
3658
|
+
|
|
3659
|
+
it("update-tag MCP empty parent_names array clears the column", async () => {
|
|
3660
|
+
const tools = generateMcpTools(store);
|
|
3661
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
3662
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3663
|
+
await update.execute({ tag: "voice", parent_names: [] });
|
|
3664
|
+
const r = await store.getTagRecord("voice");
|
|
3665
|
+
expect(r?.parent_names).toBeUndefined();
|
|
3666
|
+
});
|
|
3667
|
+
|
|
3668
|
+
it("list-tags MCP single-tag detail includes relationships + parent_names", async () => {
|
|
3669
|
+
await store.upsertTagRecord("project", {
|
|
3670
|
+
description: "p",
|
|
3671
|
+
relationships: { owned_by: { target_tag: "person", cardinality: "one" } },
|
|
3672
|
+
parent_names: ["work"],
|
|
3673
|
+
});
|
|
3674
|
+
const tools = generateMcpTools(store);
|
|
3675
|
+
const listTags = tools.find((t) => t.name === "list-tags")!;
|
|
3676
|
+
const result = await listTags.execute({ tag: "project" }) as any;
|
|
3677
|
+
expect(result.relationships?.owned_by?.target_tag).toBe("person");
|
|
3678
|
+
expect(result.parent_names).toEqual(["work"]);
|
|
3679
|
+
expect(result.created_at).toBeDefined();
|
|
3680
|
+
});
|
|
3681
|
+
|
|
3682
|
+
it("list-tags MCP include_schema returns relationships + parent_names per tag", async () => {
|
|
3683
|
+
await store.upsertTagRecord("project", {
|
|
3684
|
+
relationships: { owned_by: { target_tag: "person", cardinality: "one" } },
|
|
3685
|
+
parent_names: ["work"],
|
|
3686
|
+
});
|
|
3687
|
+
await store.createNote("p note", { tags: ["project"] });
|
|
3688
|
+
const tools = generateMcpTools(store);
|
|
3689
|
+
const listTags = tools.find((t) => t.name === "list-tags")!;
|
|
3690
|
+
const all = await listTags.execute({ include_schema: true }) as any[];
|
|
3691
|
+
const project = all.find((t) => t.name === "project")!;
|
|
3692
|
+
expect(project.relationships?.owned_by?.target_tag).toBe("person");
|
|
3693
|
+
expect(project.parent_names).toEqual(["work"]);
|
|
3694
|
+
});
|
|
3695
|
+
|
|
3696
|
+
it("renameTag carries description + fields + relationships + parent_names onto the new row", async () => {
|
|
3697
|
+
await store.upsertTagRecord("old-name", {
|
|
3698
|
+
description: "before",
|
|
3699
|
+
fields: { status: { type: "string" } },
|
|
3700
|
+
relationships: { owned_by: { target_tag: "person", cardinality: "one" } },
|
|
3701
|
+
parent_names: ["work"],
|
|
3702
|
+
});
|
|
3703
|
+
const result = await store.renameTag("old-name", "new-name");
|
|
3704
|
+
expect("renamed" in result).toBe(true);
|
|
3705
|
+
|
|
3706
|
+
const renamed = await store.getTagRecord("new-name");
|
|
3707
|
+
expect(renamed?.description).toBe("before");
|
|
3708
|
+
expect(renamed?.fields?.status?.type).toBe("string");
|
|
3709
|
+
expect(renamed?.relationships?.owned_by?.target_tag).toBe("person");
|
|
3710
|
+
expect(renamed?.parent_names).toEqual(["work"]);
|
|
3711
|
+
|
|
3712
|
+
const old = await store.getTagRecord("old-name");
|
|
3713
|
+
expect(old).toBeNull();
|
|
3714
|
+
});
|
|
3715
|
+
|
|
3716
|
+
it("deleteTag drops the identity row + invalidates the hierarchy", async () => {
|
|
3717
|
+
await store.upsertTagRecord("voice", {
|
|
3718
|
+
description: "voice notes",
|
|
3719
|
+
parent_names: ["manual"],
|
|
3720
|
+
});
|
|
3721
|
+
await store.createNote("v", { tags: ["voice"] });
|
|
3722
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
3723
|
+
|
|
3724
|
+
await store.deleteTag("voice");
|
|
3725
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3726
|
+
expect(await store.getTagRecord("voice")).toBeNull();
|
|
3727
|
+
});
|
|
3728
|
+
});
|
|
3729
|
+
|
|
3730
|
+
// ---------------------------------------------------------------------------
|
|
3731
|
+
// Schema migration v13 → v14 — patterns/tag-data-model.md
|
|
3732
|
+
// ---------------------------------------------------------------------------
|
|
3733
|
+
|
|
3734
|
+
describe("schema migration v13 → v14", async () => {
|
|
3735
|
+
it("backfills tags.parent_names from `_tags/<name>` notes", async () => {
|
|
3736
|
+
// Simulate a pre-v14 vault by writing a `_tags/<name>` note + the
|
|
3737
|
+
// legacy tag_schemas row directly via a fresh DB at v13 shape.
|
|
3738
|
+
const { Database } = await import("bun:sqlite");
|
|
3739
|
+
const db = new Database(":memory:");
|
|
3740
|
+
|
|
3741
|
+
// Build the v13 shape inline: tags(name PK only), separate tag_schemas
|
|
3742
|
+
// table, plus a notes row at `_tags/voice`.
|
|
3743
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
3744
|
+
db.exec(`CREATE TABLE notes (
|
|
3745
|
+
id TEXT PRIMARY KEY, content TEXT DEFAULT '', path TEXT,
|
|
3746
|
+
metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT
|
|
3747
|
+
)`);
|
|
3748
|
+
db.exec(`CREATE TABLE tags (name TEXT PRIMARY KEY)`);
|
|
3749
|
+
db.exec(`CREATE TABLE tag_schemas (
|
|
3750
|
+
tag_name TEXT PRIMARY KEY REFERENCES tags(name) ON DELETE CASCADE,
|
|
3751
|
+
description TEXT, fields TEXT
|
|
3752
|
+
)`);
|
|
3753
|
+
|
|
3754
|
+
db.prepare("INSERT INTO tags (name) VALUES (?)").run("voice");
|
|
3755
|
+
db.prepare("INSERT INTO tag_schemas (tag_name, description, fields) VALUES (?, ?, ?)")
|
|
3756
|
+
.run("voice", "voice notes", '{"recorded_at":{"type":"string"}}');
|
|
3757
|
+
db.prepare(`INSERT INTO notes (id, path, metadata, created_at) VALUES (?, ?, ?, ?)`)
|
|
3758
|
+
.run("n1", "_tags/voice", JSON.stringify({ parents: ["manual"] }), new Date().toISOString());
|
|
3759
|
+
|
|
3760
|
+
// Now run initSchema — it should add the v14 columns, copy schema +
|
|
3761
|
+
// hierarchy data onto the tags row, and drop tag_schemas.
|
|
3762
|
+
const { initSchema } = await import("./schema.ts");
|
|
3763
|
+
initSchema(db);
|
|
3764
|
+
|
|
3765
|
+
// tag_schemas should be gone.
|
|
3766
|
+
const tableExists = db.prepare(
|
|
3767
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
|
|
3768
|
+
).get();
|
|
3769
|
+
expect(tableExists).toBeNull();
|
|
3770
|
+
|
|
3771
|
+
// tags row should carry the migrated fields.
|
|
3772
|
+
const row = db.prepare(
|
|
3773
|
+
"SELECT name, description, fields, parent_names FROM tags WHERE name = 'voice'",
|
|
3774
|
+
).get() as any;
|
|
3775
|
+
expect(row.description).toBe("voice notes");
|
|
3776
|
+
expect(JSON.parse(row.fields).recorded_at.type).toBe("string");
|
|
3777
|
+
expect(JSON.parse(row.parent_names)).toEqual(["manual"]);
|
|
3778
|
+
|
|
3779
|
+
// The `_tags/voice` note is left in place as harmless historical record.
|
|
3780
|
+
const note = db.prepare("SELECT id FROM notes WHERE path = '_tags/voice'").get();
|
|
3781
|
+
expect(note).toBeDefined();
|
|
3782
|
+
|
|
3783
|
+
db.close();
|
|
3784
|
+
});
|
|
3785
|
+
|
|
3786
|
+
it("is idempotent — running initSchema twice is a no-op the second time", async () => {
|
|
3787
|
+
const { Database } = await import("bun:sqlite");
|
|
3788
|
+
const { initSchema } = await import("./schema.ts");
|
|
3789
|
+
const db = new Database(":memory:");
|
|
3790
|
+
initSchema(db);
|
|
3791
|
+
db.prepare(`
|
|
3792
|
+
INSERT INTO tags (name, description, parent_names, created_at, updated_at)
|
|
3793
|
+
VALUES (?, ?, ?, ?, ?)
|
|
3794
|
+
`).run(
|
|
3795
|
+
"voice",
|
|
3796
|
+
"voice notes",
|
|
3797
|
+
JSON.stringify(["manual"]),
|
|
3798
|
+
new Date().toISOString(),
|
|
3799
|
+
new Date().toISOString(),
|
|
3800
|
+
);
|
|
3801
|
+
|
|
3802
|
+
// Second run must not throw, must not perturb the row, must not
|
|
3803
|
+
// reintroduce tag_schemas.
|
|
3804
|
+
initSchema(db);
|
|
3805
|
+
|
|
3806
|
+
const row = db.prepare("SELECT description, parent_names FROM tags WHERE name = 'voice'").get() as any;
|
|
3807
|
+
expect(row.description).toBe("voice notes");
|
|
3808
|
+
expect(JSON.parse(row.parent_names)).toEqual(["manual"]);
|
|
3809
|
+
|
|
3810
|
+
const tableExists = db.prepare(
|
|
3811
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
|
|
3812
|
+
).get();
|
|
3813
|
+
expect(tableExists).toBeNull();
|
|
3814
|
+
db.close();
|
|
3815
|
+
});
|
|
3816
|
+
|
|
3817
|
+
// vault#248 — the migration body is wrapped in BEGIN IMMEDIATE / COMMIT
|
|
3818
|
+
// with a try/catch ROLLBACK. A crash mid-migration must leave the DB in
|
|
3819
|
+
// its pre-migration state (NOT half-migrated), and a retry must converge
|
|
3820
|
+
// to the same final state as a clean run. The transaction wrap is what
|
|
3821
|
+
// makes that guarantee — the `hasColumn` / `hasTable` idempotency guards
|
|
3822
|
+
// are belt-and-suspenders, not load-bearing.
|
|
3823
|
+
it("crash mid-migration rolls back to pre-migration state, then retry succeeds", async () => {
|
|
3824
|
+
const { Database } = await import("bun:sqlite");
|
|
3825
|
+
const { initSchema } = await import("./schema.ts");
|
|
3826
|
+
|
|
3827
|
+
const db = new Database(":memory:");
|
|
3828
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
3829
|
+
db.exec(`CREATE TABLE notes (
|
|
3830
|
+
id TEXT PRIMARY KEY, content TEXT DEFAULT '', path TEXT,
|
|
3831
|
+
metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT
|
|
3832
|
+
)`);
|
|
3833
|
+
db.exec(`CREATE TABLE tags (name TEXT PRIMARY KEY)`);
|
|
3834
|
+
db.exec(`CREATE TABLE tag_schemas (
|
|
3835
|
+
tag_name TEXT PRIMARY KEY REFERENCES tags(name) ON DELETE CASCADE,
|
|
3836
|
+
description TEXT, fields TEXT
|
|
3837
|
+
)`);
|
|
3838
|
+
|
|
3839
|
+
db.prepare("INSERT INTO tags (name) VALUES (?)").run("voice");
|
|
3840
|
+
db.prepare("INSERT INTO tag_schemas (tag_name, description, fields) VALUES (?, ?, ?)")
|
|
3841
|
+
.run("voice", "voice notes", '{"recorded_at":{"type":"string"}}');
|
|
3842
|
+
db.prepare(`INSERT INTO notes (id, path, metadata, created_at) VALUES (?, ?, ?, ?)`)
|
|
3843
|
+
.run("n1", "_tags/voice", JSON.stringify({ parents: ["manual"] }), new Date().toISOString());
|
|
3844
|
+
|
|
3845
|
+
// Patch db.exec to simulate a crash on the final DROP TABLE step. That's
|
|
3846
|
+
// the right injection point: every ALTER + data copy has already landed
|
|
3847
|
+
// inside the transaction, so a successful rollback proves the wrap
|
|
3848
|
+
// covers the full migration body — not just the tail.
|
|
3849
|
+
const origExec = db.exec.bind(db);
|
|
3850
|
+
let crashOnDrop: boolean = true;
|
|
3851
|
+
(db as any).exec = function (sql: string) {
|
|
3852
|
+
if (crashOnDrop && sql.includes("DROP TABLE tag_schemas")) {
|
|
3853
|
+
throw new Error("simulated crash mid-migration");
|
|
3854
|
+
}
|
|
3855
|
+
return origExec(sql);
|
|
3856
|
+
};
|
|
3857
|
+
|
|
3858
|
+
expect(() => initSchema(db)).toThrow("simulated crash mid-migration");
|
|
3859
|
+
|
|
3860
|
+
// Pre-migration shape: tag_schemas table still exists with its row,
|
|
3861
|
+
// tags table back to (name) only — none of the v14 columns landed,
|
|
3862
|
+
// `_tags/voice` note untouched, schema_version row not yet written.
|
|
3863
|
+
const tagSchemasStill = db.prepare(
|
|
3864
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
|
|
3865
|
+
).get();
|
|
3866
|
+
expect(tagSchemasStill).toBeTruthy();
|
|
3867
|
+
const schemaRow = db.prepare(
|
|
3868
|
+
"SELECT description, fields FROM tag_schemas WHERE tag_name = 'voice'",
|
|
3869
|
+
).get() as any;
|
|
3870
|
+
expect(schemaRow.description).toBe("voice notes");
|
|
3871
|
+
|
|
3872
|
+
const tagsCols = db.prepare("PRAGMA table_info(tags)").all() as { name: string }[];
|
|
3873
|
+
const colNames = tagsCols.map((c) => c.name).sort();
|
|
3874
|
+
expect(colNames).toEqual(["name"]);
|
|
3875
|
+
|
|
3876
|
+
const note = db.prepare("SELECT path, metadata FROM notes WHERE id = 'n1'").get() as any;
|
|
3877
|
+
expect(note.path).toBe("_tags/voice");
|
|
3878
|
+
expect(JSON.parse(note.metadata).parents).toEqual(["manual"]);
|
|
3879
|
+
|
|
3880
|
+
// No lingering open transaction — a SELECT after rollback succeeds, and
|
|
3881
|
+
// a fresh BEGIN IMMEDIATE doesn't fail with "cannot start a transaction
|
|
3882
|
+
// within a transaction".
|
|
3883
|
+
db.exec("BEGIN IMMEDIATE");
|
|
3884
|
+
db.exec("ROLLBACK");
|
|
3885
|
+
|
|
3886
|
+
// Retry: drop the crash injection, run initSchema again. It must
|
|
3887
|
+
// converge to the same final post-v14 state as a clean run.
|
|
3888
|
+
crashOnDrop = false;
|
|
3889
|
+
initSchema(db);
|
|
3890
|
+
|
|
3891
|
+
const tableGone = db.prepare(
|
|
3892
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
|
|
3893
|
+
).get();
|
|
3894
|
+
expect(tableGone).toBeNull();
|
|
3895
|
+
const post = db.prepare(
|
|
3896
|
+
"SELECT description, fields, parent_names FROM tags WHERE name = 'voice'",
|
|
3897
|
+
).get() as any;
|
|
3898
|
+
expect(post.description).toBe("voice notes");
|
|
3899
|
+
expect(JSON.parse(post.fields).recorded_at.type).toBe("string");
|
|
3900
|
+
expect(JSON.parse(post.parent_names)).toEqual(["manual"]);
|
|
3901
|
+
|
|
3902
|
+
db.close();
|
|
3903
|
+
});
|
|
3904
|
+
});
|
|
3905
|
+
|
|
3906
|
+
// ---------------------------------------------------------------------------
|
|
3907
|
+
// Schema migration v14 → v15 — vault#246 (_schemas/* + _schema_defaults retired)
|
|
3908
|
+
// ---------------------------------------------------------------------------
|
|
3909
|
+
|
|
3910
|
+
describe("schema migration v14 → v15", async () => {
|
|
3911
|
+
// Build a v14-shape DB by:
|
|
3912
|
+
// 1. Calling initSchema (so we get the full v15 shape, including
|
|
3913
|
+
// note_schemas / schema_mappings tables).
|
|
3914
|
+
// 2. Manually wiping the new tables, then writing the legacy
|
|
3915
|
+
// `_schemas/<name>` and `_schema_defaults` notes.
|
|
3916
|
+
// 3. Re-running initSchema — the migration's short-circuit (empty
|
|
3917
|
+
// destination tables) should kick in and copy the data over.
|
|
3918
|
+
async function buildV14ShapeWithLegacyNotes(): Promise<Database> {
|
|
3919
|
+
const { Database } = await import("bun:sqlite");
|
|
3920
|
+
const { initSchema } = await import("./schema.ts");
|
|
3921
|
+
const db = new Database(":memory:");
|
|
3922
|
+
initSchema(db);
|
|
3923
|
+
|
|
3924
|
+
// Write the legacy notes that the v14 vaults stored config in.
|
|
3925
|
+
const now = new Date().toISOString();
|
|
3926
|
+
const insertNote = db.prepare(
|
|
3927
|
+
"INSERT INTO notes (id, content, path, metadata, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
3928
|
+
);
|
|
3929
|
+
insertNote.run(
|
|
3930
|
+
"s1",
|
|
3931
|
+
"",
|
|
3932
|
+
"_schemas/task",
|
|
3933
|
+
JSON.stringify({
|
|
3934
|
+
description: "A task",
|
|
3935
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
3936
|
+
required: ["priority"],
|
|
3937
|
+
}),
|
|
3938
|
+
now,
|
|
3939
|
+
);
|
|
3940
|
+
insertNote.run(
|
|
3941
|
+
"s2",
|
|
3942
|
+
"",
|
|
3943
|
+
"_schemas/journal-entry",
|
|
3944
|
+
JSON.stringify({ fields: { mood: { type: "string" } } }),
|
|
3945
|
+
now,
|
|
3946
|
+
);
|
|
3947
|
+
insertNote.run(
|
|
3948
|
+
"d1",
|
|
3949
|
+
"",
|
|
3950
|
+
"_schema_defaults",
|
|
3951
|
+
JSON.stringify({
|
|
3952
|
+
path_prefixes: { "journal/": "journal-entry" },
|
|
3953
|
+
tags: { task: "task", "follow-up": "task" },
|
|
3954
|
+
}),
|
|
3955
|
+
now,
|
|
3956
|
+
);
|
|
3957
|
+
|
|
3958
|
+
// Wipe the destination tables so the migration short-circuit doesn't
|
|
3959
|
+
// fire (otherwise initSchema treats them as already migrated).
|
|
3960
|
+
db.exec("DELETE FROM schema_mappings");
|
|
3961
|
+
db.exec("DELETE FROM note_schemas");
|
|
3962
|
+
|
|
3963
|
+
return db;
|
|
3964
|
+
}
|
|
3965
|
+
|
|
3966
|
+
it("copies `_schemas/<name>` notes → note_schemas with description / fields / required", async () => {
|
|
3967
|
+
const db = await buildV14ShapeWithLegacyNotes();
|
|
3968
|
+
const { initSchema } = await import("./schema.ts");
|
|
3969
|
+
initSchema(db);
|
|
3970
|
+
|
|
3971
|
+
const taskRow = db.prepare(
|
|
3972
|
+
"SELECT description, fields, required FROM note_schemas WHERE name = 'task'",
|
|
3973
|
+
).get() as any;
|
|
3974
|
+
expect(taskRow.description).toBe("A task");
|
|
3975
|
+
expect(JSON.parse(taskRow.fields).priority.enum).toEqual(["high", "low"]);
|
|
3976
|
+
expect(JSON.parse(taskRow.required)).toEqual(["priority"]);
|
|
3977
|
+
|
|
3978
|
+
const journalRow = db.prepare(
|
|
3979
|
+
"SELECT description, fields FROM note_schemas WHERE name = 'journal-entry'",
|
|
3980
|
+
).get() as any;
|
|
3981
|
+
expect(journalRow.description).toBeNull();
|
|
3982
|
+
expect(JSON.parse(journalRow.fields).mood.type).toBe("string");
|
|
3983
|
+
|
|
3984
|
+
db.close();
|
|
3985
|
+
});
|
|
3986
|
+
|
|
3987
|
+
it("copies `_schema_defaults` → schema_mappings (path_prefixes + tags)", async () => {
|
|
3988
|
+
const db = await buildV14ShapeWithLegacyNotes();
|
|
3989
|
+
const { initSchema } = await import("./schema.ts");
|
|
3990
|
+
initSchema(db);
|
|
3991
|
+
|
|
3992
|
+
const mappings = db.prepare(
|
|
3993
|
+
"SELECT schema_name, match_kind, match_value FROM schema_mappings ORDER BY match_kind, match_value",
|
|
3994
|
+
).all() as { schema_name: string; match_kind: string; match_value: string }[];
|
|
3995
|
+
expect(mappings).toEqual([
|
|
3996
|
+
{ schema_name: "journal-entry", match_kind: "path_prefix", match_value: "journal/" },
|
|
3997
|
+
{ schema_name: "task", match_kind: "tag", match_value: "follow-up" },
|
|
3998
|
+
{ schema_name: "task", match_kind: "tag", match_value: "task" },
|
|
3999
|
+
]);
|
|
4000
|
+
|
|
4001
|
+
db.close();
|
|
4002
|
+
});
|
|
4003
|
+
|
|
4004
|
+
it("auto-creates a stub schema row when a mapping references an undeclared schema (FK)", async () => {
|
|
4005
|
+
const { Database } = await import("bun:sqlite");
|
|
4006
|
+
const { initSchema } = await import("./schema.ts");
|
|
4007
|
+
const db = new Database(":memory:");
|
|
4008
|
+
initSchema(db);
|
|
4009
|
+
|
|
4010
|
+
// Only a defaults note — no `_schemas/orphan` definition.
|
|
4011
|
+
db.prepare(
|
|
4012
|
+
"INSERT INTO notes (id, content, path, metadata, created_at) VALUES (?, ?, ?, ?, ?)",
|
|
4013
|
+
).run(
|
|
4014
|
+
"d",
|
|
4015
|
+
"",
|
|
4016
|
+
"_schema_defaults",
|
|
4017
|
+
JSON.stringify({ tags: { orphan: "orphan" } }),
|
|
4018
|
+
new Date().toISOString(),
|
|
4019
|
+
);
|
|
4020
|
+
db.exec("DELETE FROM schema_mappings");
|
|
4021
|
+
db.exec("DELETE FROM note_schemas");
|
|
4022
|
+
|
|
4023
|
+
initSchema(db);
|
|
4024
|
+
|
|
4025
|
+
// Stub schema row was created so the FK on schema_mappings holds.
|
|
4026
|
+
const stub = db.prepare("SELECT name FROM note_schemas WHERE name = 'orphan'").get();
|
|
4027
|
+
expect(stub).toBeTruthy();
|
|
4028
|
+
const mapping = db.prepare(
|
|
4029
|
+
"SELECT match_kind, match_value FROM schema_mappings WHERE schema_name = 'orphan'",
|
|
4030
|
+
).get() as any;
|
|
4031
|
+
expect(mapping).toEqual({ match_kind: "tag", match_value: "orphan" });
|
|
4032
|
+
|
|
4033
|
+
db.close();
|
|
4034
|
+
});
|
|
4035
|
+
|
|
4036
|
+
it("legacy `_schemas/*` and `_schema_defaults` notes are left in place (audit trail)", async () => {
|
|
4037
|
+
const db = await buildV14ShapeWithLegacyNotes();
|
|
4038
|
+
const { initSchema } = await import("./schema.ts");
|
|
4039
|
+
initSchema(db);
|
|
4040
|
+
|
|
4041
|
+
const schemaNote = db.prepare("SELECT id FROM notes WHERE path = '_schemas/task'").get();
|
|
4042
|
+
expect(schemaNote).toBeTruthy();
|
|
4043
|
+
const defaultsNote = db.prepare("SELECT id FROM notes WHERE path = '_schema_defaults'").get();
|
|
4044
|
+
expect(defaultsNote).toBeTruthy();
|
|
4045
|
+
|
|
4046
|
+
db.close();
|
|
4047
|
+
});
|
|
4048
|
+
|
|
4049
|
+
it("is idempotent — second initSchema doesn't re-copy or duplicate rows", async () => {
|
|
4050
|
+
const db = await buildV14ShapeWithLegacyNotes();
|
|
4051
|
+
const { initSchema } = await import("./schema.ts");
|
|
4052
|
+
initSchema(db);
|
|
4053
|
+
|
|
4054
|
+
const schemaCount1 = (db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c;
|
|
4055
|
+
const mappingCount1 = (db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c;
|
|
4056
|
+
|
|
4057
|
+
initSchema(db);
|
|
4058
|
+
|
|
4059
|
+
const schemaCount2 = (db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c;
|
|
4060
|
+
const mappingCount2 = (db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c;
|
|
4061
|
+
expect(schemaCount2).toBe(schemaCount1);
|
|
4062
|
+
expect(mappingCount2).toBe(mappingCount1);
|
|
4063
|
+
|
|
4064
|
+
db.close();
|
|
4065
|
+
});
|
|
4066
|
+
|
|
4067
|
+
it("no legacy notes → migration is a no-op (empty tables)", async () => {
|
|
4068
|
+
const { Database } = await import("bun:sqlite");
|
|
4069
|
+
const { initSchema } = await import("./schema.ts");
|
|
4070
|
+
const db = new Database(":memory:");
|
|
4071
|
+
initSchema(db);
|
|
4072
|
+
|
|
4073
|
+
expect((db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c).toBe(0);
|
|
4074
|
+
expect((db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c).toBe(0);
|
|
4075
|
+
|
|
4076
|
+
db.close();
|
|
4077
|
+
});
|
|
4078
|
+
|
|
4079
|
+
// Short-circuit uses `||` not `&&`: a vault with schemas-but-no-mappings
|
|
4080
|
+
// is a valid post-v15 state. Legacy `_schemas/*` notes left around from
|
|
4081
|
+
// a prior import shouldn't get re-folded on every boot. (The mirror case —
|
|
4082
|
+
// mappings-but-no-schemas — is structurally impossible because the
|
|
4083
|
+
// schema_mappings FK to note_schemas has ON DELETE CASCADE.)
|
|
4084
|
+
it("doesn't re-run when only one destination table is non-empty", async () => {
|
|
4085
|
+
const db = await buildV14ShapeWithLegacyNotes();
|
|
4086
|
+
const { initSchema } = await import("./schema.ts");
|
|
4087
|
+
initSchema(db);
|
|
4088
|
+
|
|
4089
|
+
// After first run: tables populated, legacy notes still in `notes`.
|
|
4090
|
+
expect((db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c).toBeGreaterThan(0);
|
|
4091
|
+
expect((db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c).toBeGreaterThan(0);
|
|
4092
|
+
expect((db.prepare("SELECT COUNT(*) as c FROM notes WHERE path GLOB '_schemas/*'").get() as any).c).toBeGreaterThan(0);
|
|
4093
|
+
|
|
4094
|
+
// Wipe mappings only; schemas remain non-empty. With the buggy `&&`
|
|
4095
|
+
// short-circuit, the migration would re-scan `_schema_defaults` and
|
|
4096
|
+
// rebuild the mappings table. With `||` it correctly no-ops.
|
|
4097
|
+
db.exec("DELETE FROM schema_mappings");
|
|
4098
|
+
initSchema(db);
|
|
4099
|
+
expect((db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c).toBe(0);
|
|
4100
|
+
|
|
4101
|
+
db.close();
|
|
4102
|
+
});
|
|
4103
|
+
});
|
|
4104
|
+
|
|
4105
|
+
// ---------------------------------------------------------------------------
|
|
4106
|
+
// Schema migration v15 → v16 — vault#257 (tokens.vault_name binding)
|
|
4107
|
+
// ---------------------------------------------------------------------------
|
|
4108
|
+
|
|
4109
|
+
describe("schema migration v15 → v16", async () => {
|
|
4110
|
+
// vault#257 — the v16 migration body (ALTER TABLE ADD COLUMN + CREATE
|
|
4111
|
+
// INDEX) is wrapped in BEGIN IMMEDIATE / COMMIT with a try/catch
|
|
4112
|
+
// ROLLBACK, mirroring the v14/v15 wrap shape from vault#251. The
|
|
4113
|
+
// individual statements are atomic in SQLite, so the wrap is mostly
|
|
4114
|
+
// belt-and-suspenders for THIS migration — but the test mirrors v14's
|
|
4115
|
+
// crash-rollback shape so anyone touching migrations finds the same
|
|
4116
|
+
// regression-pin pattern across versions.
|
|
4117
|
+
it("crash mid-migration rolls back to pre-v16 state, then retry succeeds", async () => {
|
|
4118
|
+
const { Database } = await import("bun:sqlite");
|
|
4119
|
+
const { initSchema } = await import("./schema.ts");
|
|
4120
|
+
|
|
4121
|
+
// Build the full post-v16 shape, plant a row, then drop the v16
|
|
4122
|
+
// additions so initSchema's migrateToV16 fires on the next call.
|
|
4123
|
+
const db = new Database(":memory:");
|
|
4124
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
4125
|
+
initSchema(db);
|
|
4126
|
+
|
|
4127
|
+
const now = new Date().toISOString();
|
|
4128
|
+
db.prepare(
|
|
4129
|
+
"INSERT INTO tokens (token_hash, label, created_at, vault_name) VALUES (?, ?, ?, ?)",
|
|
4130
|
+
).run("sha256:abc123def456", "pre-existing", now, "work");
|
|
4131
|
+
|
|
4132
|
+
db.exec("DROP INDEX IF EXISTS idx_tokens_vault_name");
|
|
4133
|
+
db.exec("ALTER TABLE tokens DROP COLUMN vault_name");
|
|
4134
|
+
|
|
4135
|
+
// Pre-condition: the column is gone but the row is still there
|
|
4136
|
+
// (DROP COLUMN strips the column from existing rows).
|
|
4137
|
+
const preCols = db.prepare("PRAGMA table_info(tokens)").all() as { name: string }[];
|
|
4138
|
+
expect(preCols.map((c) => c.name)).not.toContain("vault_name");
|
|
4139
|
+
const preRow = db.prepare("SELECT label FROM tokens WHERE token_hash = ?")
|
|
4140
|
+
.get("sha256:abc123def456") as { label: string } | null;
|
|
4141
|
+
expect(preRow?.label).toBe("pre-existing");
|
|
4142
|
+
|
|
4143
|
+
// Patch db.exec to crash on CREATE INDEX — the second statement inside
|
|
4144
|
+
// the v16 transaction body. Crashing here proves the wrap covers the
|
|
4145
|
+
// post-ALTER state, not just the tail. The injection deliberately
|
|
4146
|
+
// doesn't match BEGIN/COMMIT/ROLLBACK so the catch's ROLLBACK still
|
|
4147
|
+
// runs through the patched exec.
|
|
4148
|
+
const origExec = db.exec.bind(db);
|
|
4149
|
+
let crashOnIndex: boolean = true;
|
|
4150
|
+
(db as any).exec = function (sql: string) {
|
|
4151
|
+
if (crashOnIndex && sql.includes("CREATE INDEX") && sql.includes("idx_tokens_vault_name")) {
|
|
4152
|
+
throw new Error("simulated crash mid-v16-migration");
|
|
4153
|
+
}
|
|
4154
|
+
return origExec(sql);
|
|
4155
|
+
};
|
|
4156
|
+
|
|
4157
|
+
expect(() => initSchema(db)).toThrow("simulated crash mid-v16-migration");
|
|
4158
|
+
|
|
4159
|
+
// Pre-v16 shape after rollback: vault_name column must not exist; the
|
|
4160
|
+
// pre-existing row must be untouched.
|
|
4161
|
+
const colsAfterRollback = db.prepare("PRAGMA table_info(tokens)").all() as { name: string }[];
|
|
4162
|
+
expect(colsAfterRollback.map((c) => c.name)).not.toContain("vault_name");
|
|
4163
|
+
const idxAfterRollback = db.prepare(
|
|
4164
|
+
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_tokens_vault_name'",
|
|
4165
|
+
).get();
|
|
4166
|
+
expect(idxAfterRollback).toBeNull();
|
|
4167
|
+
const rowAfterRollback = db.prepare("SELECT label FROM tokens WHERE token_hash = ?")
|
|
4168
|
+
.get("sha256:abc123def456") as { label: string } | null;
|
|
4169
|
+
expect(rowAfterRollback?.label).toBe("pre-existing");
|
|
4170
|
+
|
|
4171
|
+
// No lingering open transaction — a fresh BEGIN IMMEDIATE + ROLLBACK
|
|
4172
|
+
// doesn't fail with "cannot start a transaction within a transaction".
|
|
4173
|
+
db.exec("BEGIN IMMEDIATE");
|
|
4174
|
+
db.exec("ROLLBACK");
|
|
4175
|
+
|
|
4176
|
+
// Retry: drop the crash injection, run initSchema again. Must converge
|
|
4177
|
+
// to post-v16 shape (column added, index created, lenient NULL backfill
|
|
4178
|
+
// on the pre-existing row per the migration spec).
|
|
4179
|
+
crashOnIndex = false;
|
|
4180
|
+
initSchema(db);
|
|
4181
|
+
|
|
4182
|
+
const colsPost = db.prepare("PRAGMA table_info(tokens)").all() as { name: string }[];
|
|
4183
|
+
expect(colsPost.map((c) => c.name)).toContain("vault_name");
|
|
4184
|
+
const idxPost = db.prepare(
|
|
4185
|
+
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_tokens_vault_name'",
|
|
4186
|
+
).get();
|
|
4187
|
+
expect(idxPost).toBeTruthy();
|
|
4188
|
+
const rowPost = db.prepare("SELECT label, vault_name FROM tokens WHERE token_hash = ?")
|
|
4189
|
+
.get("sha256:abc123def456") as { label: string; vault_name: string | null };
|
|
4190
|
+
expect(rowPost.label).toBe("pre-existing");
|
|
4191
|
+
expect(rowPost.vault_name).toBeNull();
|
|
4192
|
+
|
|
4193
|
+
db.close();
|
|
4194
|
+
});
|
|
4195
|
+
});
|
|
4196
|
+
|
|
4197
|
+
// ---------------------------------------------------------------------------
|
|
4198
|
+
// Tag-scope auth post-v14 — patterns/tag-scoped-tokens.md
|
|
4199
|
+
// ---------------------------------------------------------------------------
|
|
4200
|
+
|
|
4201
|
+
describe("tag-scope auth (post-v14 hierarchy)", async () => {
|
|
4202
|
+
it("token allowlisted for `health` matches descendants declared via parent_names", async () => {
|
|
4203
|
+
await store.upsertTagRecord("health/food", { parent_names: ["health"] });
|
|
4204
|
+
await store.upsertTagRecord("health/food/breakfast", { parent_names: ["health/food"] });
|
|
4205
|
+
|
|
4206
|
+
const expanded = await store.expandTagsWithDescendants(["health"]);
|
|
4207
|
+
expect(expanded.has("health")).toBe(true);
|
|
4208
|
+
expect(expanded.has("health/food")).toBe(true);
|
|
4209
|
+
expect(expanded.has("health/food/breakfast")).toBe(true);
|
|
4210
|
+
});
|
|
4211
|
+
|
|
4212
|
+
it("orphan sub-tag fallback: token for `health` still sees `#health/food` even with no declared hierarchy", async () => {
|
|
4213
|
+
// Per patterns/tag-scoped-tokens.md §Storage details, the auth check
|
|
4214
|
+
// also splits on '/' and matches the root verbatim against the raw
|
|
4215
|
+
// allowlist. This survives the v14 source-of-truth swap because the
|
|
4216
|
+
// fallback lives in src/tag-scope.ts, not in the resolver.
|
|
4217
|
+
const { noteWithinTagScope } = await import("../../src/tag-scope.ts");
|
|
4218
|
+
const note = { id: "x", content: "", createdAt: "", tags: ["health/food"] };
|
|
4219
|
+
const allowed = await store.expandTagsWithDescendants(["health"]);
|
|
4220
|
+
// No declared hierarchy — the expansion returns just `health`.
|
|
4221
|
+
expect(allowed.has("health/food")).toBe(false);
|
|
4222
|
+
// But the string-form fallback still matches.
|
|
4223
|
+
expect(noteWithinTagScope(note, allowed, ["health"])).toBe(true);
|
|
4224
|
+
});
|
|
4225
|
+
});
|