@openparachute/vault 0.3.3 → 0.4.3
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 +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- 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 +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- 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 +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- 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 +6 -5
- package/src/routes.ts +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -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 +727 -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 +1626 -183
- 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 ----
|
|
@@ -246,7 +357,7 @@ describe("renameTag", async () => {
|
|
|
246
357
|
const n2 = await store.createNote("B", { tags: ["voice", "keeper"] });
|
|
247
358
|
|
|
248
359
|
const result = await store.renameTag("voice", "memo");
|
|
249
|
-
expect(result).
|
|
360
|
+
expect(result).toMatchObject({ renamed: 2, sub_tags_renamed: 0 });
|
|
250
361
|
|
|
251
362
|
expect((await store.getNote(n1.id))!.tags).toEqual(["memo"]);
|
|
252
363
|
expect((await store.getNote(n2.id))!.tags?.sort()).toEqual(["keeper", "memo"]);
|
|
@@ -275,7 +386,7 @@ describe("renameTag", async () => {
|
|
|
275
386
|
await store.untagNote((await store.queryNotes({}))[0].id, ["doomed"]);
|
|
276
387
|
|
|
277
388
|
const result = await store.renameTag("doomed", "archived");
|
|
278
|
-
expect(result).
|
|
389
|
+
expect(result).toMatchObject({ renamed: 0, sub_tags_renamed: 0 });
|
|
279
390
|
const tags = await store.listTags();
|
|
280
391
|
expect(tags.some((t) => t.name === "doomed")).toBe(false);
|
|
281
392
|
expect(tags.some((t) => t.name === "archived")).toBe(true);
|
|
@@ -286,7 +397,7 @@ describe("renameTag", async () => {
|
|
|
286
397
|
await store.createNote("B", { tags: ["new"] });
|
|
287
398
|
|
|
288
399
|
const result = await store.renameTag("old", "new");
|
|
289
|
-
expect(result).
|
|
400
|
+
expect(result).toMatchObject({ error: "target_exists", conflicting: ["new"] });
|
|
290
401
|
|
|
291
402
|
// No bleed — both tags still present with their original counts.
|
|
292
403
|
const tags = await store.listTags();
|
|
@@ -302,11 +413,325 @@ describe("renameTag", async () => {
|
|
|
302
413
|
it("same-name rename is a no-op on an existing tag", async () => {
|
|
303
414
|
await store.createNote("A", { tags: ["voice"] });
|
|
304
415
|
const result = await store.renameTag("voice", "voice");
|
|
305
|
-
expect(result).
|
|
416
|
+
expect(result).toMatchObject({ renamed: 0, sub_tags_renamed: 0 });
|
|
306
417
|
expect((await store.listTags()).find((t) => t.name === "voice")!.count).toBe(1);
|
|
307
418
|
});
|
|
308
419
|
});
|
|
309
420
|
|
|
421
|
+
// ---- Tag rename cascade (vault#240 + #247) ----
|
|
422
|
+
|
|
423
|
+
describe("renameTag cascade (vault#240 + #247)", async () => {
|
|
424
|
+
it("1. rewrites note bodies with #tag references", async () => {
|
|
425
|
+
const note = await store.createNote(
|
|
426
|
+
"Today's #task is important. Also see #task/work and the #other tag.",
|
|
427
|
+
{ tags: ["task"] },
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
const result = await store.renameTag("task", "todo");
|
|
431
|
+
expect(result).toMatchObject({ renamed: 1, notes_rewritten: 1 });
|
|
432
|
+
|
|
433
|
+
const fresh = await store.getNote(note.id);
|
|
434
|
+
expect(fresh!.content).toContain("#todo");
|
|
435
|
+
expect(fresh!.content).not.toContain("#task ");
|
|
436
|
+
expect(fresh!.content).toContain("#other"); // untouched
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("2. cascades sub-tags recursively (task → todo, task/work → todo/work, task/work/client → todo/work/client)", async () => {
|
|
440
|
+
await store.createNote("a", { tags: ["task"] });
|
|
441
|
+
await store.createNote("b", { tags: ["task/work"] });
|
|
442
|
+
await store.createNote("c", { tags: ["task/work/client"] });
|
|
443
|
+
|
|
444
|
+
const result = await store.renameTag("task", "todo");
|
|
445
|
+
expect(result).toMatchObject({ renamed: 3, sub_tags_renamed: 2 });
|
|
446
|
+
|
|
447
|
+
const tags = (await store.listTags()).map((t) => t.name).sort();
|
|
448
|
+
expect(tags).toContain("todo");
|
|
449
|
+
expect(tags).toContain("todo/work");
|
|
450
|
+
expect(tags).toContain("todo/work/client");
|
|
451
|
+
expect(tags.some((t) => t.startsWith("task"))).toBe(false);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("3. rewrites parent_names refs in OTHER tag rows (closes #247)", async () => {
|
|
455
|
+
await store.upsertTagRecord("task", { description: "tasks" });
|
|
456
|
+
await store.upsertTagRecord("voice", {
|
|
457
|
+
parent_names: ["manual", "task"],
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
const result = await store.renameTag("task", "todo");
|
|
461
|
+
expect(result).toMatchObject({ renamed: 0, parent_refs_updated: 1 });
|
|
462
|
+
|
|
463
|
+
const voice = await store.getTagRecord("voice");
|
|
464
|
+
expect(voice?.parent_names).toEqual(["manual", "todo"]);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it("4. rewrites tokens.scoped_tags JSON arrays", async () => {
|
|
468
|
+
await store.upsertTagRecord("task", {});
|
|
469
|
+
await store.upsertTagRecord("project", {});
|
|
470
|
+
|
|
471
|
+
const now = new Date().toISOString();
|
|
472
|
+
db.prepare(
|
|
473
|
+
`INSERT INTO tokens (token_hash, label, permission, scopes, scoped_tags, created_at)
|
|
474
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
475
|
+
).run("h_one", "tok-1", "full", "vault:read", JSON.stringify(["task"]), now);
|
|
476
|
+
db.prepare(
|
|
477
|
+
`INSERT INTO tokens (token_hash, label, permission, scopes, scoped_tags, created_at)
|
|
478
|
+
VALUES (?, ?, ?, ?, ?, ?)`,
|
|
479
|
+
).run("h_two", "tok-2", "full", "vault:read", JSON.stringify(["project"]), now);
|
|
480
|
+
|
|
481
|
+
const result = await store.renameTag("task", "todo");
|
|
482
|
+
expect(result).toMatchObject({ tokens_updated: 1 });
|
|
483
|
+
|
|
484
|
+
const refreshed = db
|
|
485
|
+
.prepare("SELECT token_hash, scoped_tags FROM tokens ORDER BY token_hash")
|
|
486
|
+
.all() as { token_hash: string; scoped_tags: string }[];
|
|
487
|
+
expect(JSON.parse(refreshed[0]!.scoped_tags)).toEqual(["todo"]);
|
|
488
|
+
// Untouched.
|
|
489
|
+
expect(JSON.parse(refreshed[1]!.scoped_tags)).toEqual(["project"]);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it("5. rewrites #tag and [[_tags/...]] references in note bodies (incl. sub-tags)", async () => {
|
|
493
|
+
await store.upsertTagRecord("task/work", {});
|
|
494
|
+
await store.upsertTagRecord("task", {});
|
|
495
|
+
const a = await store.createNote(
|
|
496
|
+
"#task is important #task/work and a wikilink: [[_tags/task]]",
|
|
497
|
+
{ tags: ["task"] },
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
const result = await store.renameTag("task", "todo");
|
|
501
|
+
expect(result.renamed).toBeGreaterThan(0);
|
|
502
|
+
if ("notes_rewritten" in result) expect(result.notes_rewritten).toBe(1);
|
|
503
|
+
|
|
504
|
+
const fresh = await store.getNote(a.id);
|
|
505
|
+
expect(fresh!.content).toContain("#todo is important");
|
|
506
|
+
expect(fresh!.content).toContain("#todo/work");
|
|
507
|
+
expect(fresh!.content).toContain("[[_tags/todo]]");
|
|
508
|
+
expect(fresh!.content).not.toContain("#task ");
|
|
509
|
+
expect(fresh!.content).not.toContain("[[_tags/task]");
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
it("6. pre-flight collision aborts without mutation when target exists", async () => {
|
|
513
|
+
await store.createNote("t", { tags: ["task"] });
|
|
514
|
+
await store.createNote("p", { tags: ["project"] });
|
|
515
|
+
|
|
516
|
+
const result = await store.renameTag("task", "project");
|
|
517
|
+
expect(result).toMatchObject({ error: "target_exists", conflicting: ["project"] });
|
|
518
|
+
|
|
519
|
+
// Both tags still present, untouched counts.
|
|
520
|
+
const tags = await store.listTags();
|
|
521
|
+
expect(tags.find((t) => t.name === "task")?.count).toBe(1);
|
|
522
|
+
expect(tags.find((t) => t.name === "project")?.count).toBe(1);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
it("7. transactional rollback leaves the original state intact on mid-cascade failure", async () => {
|
|
526
|
+
await store.createNote("a", { tags: ["task"] });
|
|
527
|
+
await store.createNote("b", { tags: ["task/work"] });
|
|
528
|
+
|
|
529
|
+
// Inject failure: drop the tags table mid-transaction by intercepting
|
|
530
|
+
// the JSON cascade pass. Easiest reliable hook: corrupt the
|
|
531
|
+
// tokens.scoped_tags column with a value that will fail the JSON
|
|
532
|
+
// cascade's UPDATE (use a token_hash that violates a constraint when
|
|
533
|
+
// the cascade rewrites it). Simpler: spy on noteOps via monkey-patch.
|
|
534
|
+
//
|
|
535
|
+
// We use the simplest reliable approach: a row lock conflict. Two
|
|
536
|
+
// statements writing the same row in a deferred transaction would
|
|
537
|
+
// require two connections; instead we drop a required table at the
|
|
538
|
+
// SQL layer to force a SQL error on a downstream UPDATE inside the
|
|
539
|
+
// cascade. Restore after the test.
|
|
540
|
+
const originalDeleteTag = (db as any).prepare;
|
|
541
|
+
let dropOnce = false;
|
|
542
|
+
(db as any).prepare = function (sql: string) {
|
|
543
|
+
// Force the tag-row pass to fail on the DELETE step by dropping
|
|
544
|
+
// the tags table out from under it after the first INSERT.
|
|
545
|
+
if (!dropOnce && sql.startsWith("DELETE FROM tags WHERE name = ?")) {
|
|
546
|
+
dropOnce = true;
|
|
547
|
+
const stmt = originalDeleteTag.call(this, sql);
|
|
548
|
+
const wrapped = {
|
|
549
|
+
run: (...args: any[]) => {
|
|
550
|
+
(db as any).prepare = originalDeleteTag;
|
|
551
|
+
throw new Error("synthetic mid-cascade failure");
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
return wrapped;
|
|
555
|
+
}
|
|
556
|
+
return originalDeleteTag.call(this, sql);
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
let threw = false;
|
|
560
|
+
try {
|
|
561
|
+
await store.renameTag("task", "todo");
|
|
562
|
+
} catch {
|
|
563
|
+
threw = true;
|
|
564
|
+
}
|
|
565
|
+
(db as any).prepare = originalDeleteTag;
|
|
566
|
+
expect(threw).toBe(true);
|
|
567
|
+
|
|
568
|
+
// Original state intact: task tags still present, todo absent.
|
|
569
|
+
const tags = (await store.listTags()).map((t) => t.name);
|
|
570
|
+
expect(tags).toContain("task");
|
|
571
|
+
expect(tags).toContain("task/work");
|
|
572
|
+
expect(tags).not.toContain("todo");
|
|
573
|
+
expect(tags).not.toContain("todo/work");
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
it("8. invalidates hierarchy + schema caches after rename", async () => {
|
|
577
|
+
await store.upsertTagRecord("task", {
|
|
578
|
+
fields: { status: { type: "string" } },
|
|
579
|
+
});
|
|
580
|
+
await store.upsertTagRecord("task/work", { parent_names: ["task"] });
|
|
581
|
+
await store.createNote("a", { tags: ["task/work"] });
|
|
582
|
+
|
|
583
|
+
// Prime the caches by querying via the hierarchy-aware path.
|
|
584
|
+
await store.queryNotes({ tags: ["task"] });
|
|
585
|
+
|
|
586
|
+
await store.renameTag("task", "todo");
|
|
587
|
+
|
|
588
|
+
// queryNotes via the new tag must find the note that's now tagged
|
|
589
|
+
// todo/work (descendant of todo via parent_names rewrite).
|
|
590
|
+
const found = await store.queryNotes({ tags: ["todo"] });
|
|
591
|
+
expect(found.length).toBe(1);
|
|
592
|
+
|
|
593
|
+
// validateNoteAgainstSchemas must surface fields under the new tag —
|
|
594
|
+
// proves the schema-config cache was busted (otherwise the resolver
|
|
595
|
+
// would still be keyed on `task`).
|
|
596
|
+
const status = store.validateNoteAgainstSchemas({
|
|
597
|
+
tags: ["todo"],
|
|
598
|
+
metadata: { status: 123 },
|
|
599
|
+
});
|
|
600
|
+
expect(status?.warnings.some((w) => w.reason === "type_mismatch")).toBe(true);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("9. self-rename is a structured no-op when the source exists", async () => {
|
|
604
|
+
await store.createNote("a", { tags: ["task"] });
|
|
605
|
+
const result = await store.renameTag("task", "task");
|
|
606
|
+
expect(result).toMatchObject({ renamed: 0, sub_tags_renamed: 0 });
|
|
607
|
+
expect((await store.listTags()).find((t) => t.name === "task")?.count).toBe(1);
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
it("10. preserves transitive inheritance through the cascade (manual extends note; voice extends manual; renaming manual → instruction keeps voice's effective fields)", async () => {
|
|
611
|
+
await store.upsertTagRecord("note", {
|
|
612
|
+
fields: { topic: { type: "string" } },
|
|
613
|
+
});
|
|
614
|
+
await store.upsertTagRecord("manual", {
|
|
615
|
+
fields: { author: { type: "string" } },
|
|
616
|
+
parent_names: ["note"],
|
|
617
|
+
});
|
|
618
|
+
await store.upsertTagRecord("voice", {
|
|
619
|
+
parent_names: ["manual"],
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
const result = await store.renameTag("manual", "instruction");
|
|
623
|
+
expect(result.renamed).toBe(0); // no notes
|
|
624
|
+
if ("parent_refs_updated" in result) expect(result.parent_refs_updated).toBe(1);
|
|
625
|
+
|
|
626
|
+
// Voice's parent_names now references `instruction` (the renamed parent).
|
|
627
|
+
const voice = await store.getTagRecord("voice");
|
|
628
|
+
expect(voice?.parent_names).toEqual(["instruction"]);
|
|
629
|
+
|
|
630
|
+
// Voice's effective fields still inherit through instruction → note.
|
|
631
|
+
const status = store.validateNoteAgainstSchemas({
|
|
632
|
+
tags: ["voice"],
|
|
633
|
+
metadata: { topic: 123, author: "ok" },
|
|
634
|
+
});
|
|
635
|
+
expect(status?.warnings.some((w) => w.field === "topic" && w.reason === "type_mismatch")).toBe(
|
|
636
|
+
true,
|
|
637
|
+
);
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
it("11. rewrites indexed_fields.declarer_tags JSON arrays (#275 fold N3)", async () => {
|
|
641
|
+
// Drive through the update-tag MCP tool — it owns indexed-field
|
|
642
|
+
// lifecycle (see mcp.ts §update-tag); store.upsertTagRecord does
|
|
643
|
+
// not populate the `indexed_fields` table.
|
|
644
|
+
const tools = generateMcpTools(store);
|
|
645
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
646
|
+
await updateTag.execute({
|
|
647
|
+
tag: "task",
|
|
648
|
+
fields: { status: { type: "string", indexed: true } },
|
|
649
|
+
});
|
|
650
|
+
await updateTag.execute({
|
|
651
|
+
tag: "project",
|
|
652
|
+
fields: { status: { type: "string", indexed: true } },
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
const beforeRow = db
|
|
656
|
+
.prepare("SELECT declarer_tags FROM indexed_fields WHERE field = ?")
|
|
657
|
+
.get("status") as { declarer_tags: string };
|
|
658
|
+
expect(JSON.parse(beforeRow.declarer_tags).sort()).toEqual(["project", "task"]);
|
|
659
|
+
|
|
660
|
+
const result = await store.renameTag("task", "todo");
|
|
661
|
+
expect(result).toMatchObject({ indexed_field_declarers_updated: 1 });
|
|
662
|
+
|
|
663
|
+
const afterRow = db
|
|
664
|
+
.prepare("SELECT declarer_tags FROM indexed_fields WHERE field = ?")
|
|
665
|
+
.get("status") as { declarer_tags: string };
|
|
666
|
+
const declarers = JSON.parse(afterRow.declarer_tags) as string[];
|
|
667
|
+
expect(declarers).toContain("todo");
|
|
668
|
+
expect(declarers).toContain("project");
|
|
669
|
+
expect(declarers).not.toContain("task");
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it("12. rewrites `_tags/<path>` config-note paths via rewriteTagConfigPath (#275 fold N3)", async () => {
|
|
673
|
+
await store.upsertTagRecord("old", {});
|
|
674
|
+
const root = await store.createNote("root config", { path: "_tags/old" });
|
|
675
|
+
const sub = await store.createNote("sub config", { path: "_tags/old/nested/leaf" });
|
|
676
|
+
|
|
677
|
+
const result = await store.renameTag("old", "new");
|
|
678
|
+
expect(result).toMatchObject({ paths_renamed: 2 });
|
|
679
|
+
|
|
680
|
+
const rootFresh = await store.getNote(root.id);
|
|
681
|
+
expect(rootFresh?.path).toBe("_tags/new");
|
|
682
|
+
|
|
683
|
+
const subFresh = await store.getNote(sub.id);
|
|
684
|
+
expect(subFresh?.path).toBe("_tags/new/nested/leaf");
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it("14. sub-tag discovery escapes LIKE wildcards — `task_` rename doesn't pull `taskX/sub` into the cascade (#275 re-review)", async () => {
|
|
688
|
+
// Pre-fold: the discovery query was `LIKE 'task_/%'` which matches
|
|
689
|
+
// `taskX/sub` because `_` is a single-char wildcard. `taskX/sub`
|
|
690
|
+
// would then enter `renames` and get rewritten to `<new>/sub` — a
|
|
691
|
+
// write the caller never asked for.
|
|
692
|
+
await store.upsertTagRecord("task_", {});
|
|
693
|
+
await store.upsertTagRecord("taskX/sub", {});
|
|
694
|
+
const stray = await store.createNote("stray", { tags: ["taskX/sub"] });
|
|
695
|
+
|
|
696
|
+
const result = await store.renameTag("task_", "todo_");
|
|
697
|
+
// Only the actual root rename — no spurious sub-tag pulled in.
|
|
698
|
+
expect(result).toMatchObject({ sub_tags_renamed: 0 });
|
|
699
|
+
|
|
700
|
+
expect(await store.getTagRecord("task_")).toBeNull();
|
|
701
|
+
expect(await store.getTagRecord("todo_")).toBeTruthy();
|
|
702
|
+
|
|
703
|
+
// `taskX/sub` is untouched: row still present, the note tagged with
|
|
704
|
+
// it still carries the original tag.
|
|
705
|
+
expect(await store.getTagRecord("taskX/sub")).toBeTruthy();
|
|
706
|
+
expect((await store.getNote(stray.id))!.tags).toEqual(["taskX/sub"]);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
it("13. LIKE wildcard escape — a tag literally named `task_` doesn't false-match `taskX` (#275 fold N1)", async () => {
|
|
710
|
+
// `task_` and `taskX` are unrelated tags. Pre-fold N1 the LIKE
|
|
711
|
+
// pre-filter would have considered `taskX` rows as candidates for
|
|
712
|
+
// `task_`'s rewrite (LIKE `%"task_"%` matches `"taskX"` in JSON
|
|
713
|
+
// because `_` is a single-char wildcard). The cascade's downstream
|
|
714
|
+
// remapJsonArray would have rejected the row, so no data
|
|
715
|
+
// corruption — but the wasted scan + bad hygiene is what fold N1
|
|
716
|
+
// closes. This test pins the behavior end-to-end.
|
|
717
|
+
await store.upsertTagRecord("task_", {});
|
|
718
|
+
await store.upsertTagRecord("taskX", {});
|
|
719
|
+
await store.upsertTagRecord("voice", {
|
|
720
|
+
parent_names: ["taskX"],
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
await store.renameTag("task_", "renamed_task");
|
|
724
|
+
|
|
725
|
+
// taskX-rooted parent_names must be untouched — the escape stops
|
|
726
|
+
// taskX from being a candidate for the `task_` rewrite.
|
|
727
|
+
const voice = await store.getTagRecord("voice");
|
|
728
|
+
expect(voice?.parent_names).toEqual(["taskX"]);
|
|
729
|
+
// Sanity: the actual rename did happen.
|
|
730
|
+
expect(await store.getTagRecord("task_")).toBeNull();
|
|
731
|
+
expect(await store.getTagRecord("renamed_task")).toBeTruthy();
|
|
732
|
+
});
|
|
733
|
+
});
|
|
734
|
+
|
|
310
735
|
describe("mergeTags", async () => {
|
|
311
736
|
it("retags every note from every source onto target and drops sources", async () => {
|
|
312
737
|
const n1 = await store.createNote("A", { tags: ["v1"] });
|
|
@@ -476,12 +901,24 @@ describe("vault stats", async () => {
|
|
|
476
901
|
const result = await store.getVaultStats();
|
|
477
902
|
expect(result.totalNotes).toBe(2);
|
|
478
903
|
expect(result.tagCount).toBe(2);
|
|
904
|
+
expect(result.attachmentCount).toBe(0);
|
|
479
905
|
expect(result.topTags[0].tag).toBe("x");
|
|
480
906
|
expect(result.topTags[0].count).toBe(2);
|
|
481
907
|
expect(result.notesByMonth).toHaveLength(2);
|
|
482
908
|
expect(result.earliestNote!.createdAt).toBe("2025-05-01T00:00:00.000Z");
|
|
483
909
|
expect(result.latestNote!.createdAt).toBe("2025-06-01T00:00:00.000Z");
|
|
484
910
|
});
|
|
911
|
+
|
|
912
|
+
it("getVaultStats counts attachments", async () => {
|
|
913
|
+
const n1 = await store.createNote("one");
|
|
914
|
+
const n2 = await store.createNote("two");
|
|
915
|
+
await store.addAttachment(n1.id, "/tmp/a1.mp3", "audio/mp3");
|
|
916
|
+
await store.addAttachment(n1.id, "/tmp/i1.png", "image/png");
|
|
917
|
+
await store.addAttachment(n2.id, "/tmp/a2.mp3", "audio/mp3");
|
|
918
|
+
|
|
919
|
+
const result = await store.getVaultStats();
|
|
920
|
+
expect(result.attachmentCount).toBe(3);
|
|
921
|
+
});
|
|
485
922
|
});
|
|
486
923
|
|
|
487
924
|
// ---- Query ----
|
|
@@ -534,6 +971,178 @@ describe("queryNotes", async () => {
|
|
|
534
971
|
expect(results.length).toBeGreaterThan(0);
|
|
535
972
|
});
|
|
536
973
|
|
|
974
|
+
// ---- Generalized date_filter (vault#215) ----
|
|
975
|
+
//
|
|
976
|
+
// The legacy `dateFrom` / `dateTo` always filter on `n.created_at` (vault
|
|
977
|
+
// ingestion time). The new `dateFilter: { field, from, to }` shape lets a
|
|
978
|
+
// caller filter on any *content* date — an email's received date, a
|
|
979
|
+
// meeting's scheduled date — by pointing `field` at an indexed metadata
|
|
980
|
+
// field. `field` defaults to `created_at`, in which case the SQL is
|
|
981
|
+
// identical to the legacy path.
|
|
982
|
+
describe("dateFilter (generalized)", () => {
|
|
983
|
+
async function declareEmailDate() {
|
|
984
|
+
const { declareField } = await import("./indexed-fields.js");
|
|
985
|
+
declareField(db, "email_date", "TEXT", "email");
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
it("dateFilter with no field defaults to created_at (matches the legacy shorthand)", async () => {
|
|
989
|
+
await store.createNote("A", { created_at: "2026-01-15T00:00:00.000Z" });
|
|
990
|
+
await store.createNote("B", { created_at: "2026-02-15T00:00:00.000Z" });
|
|
991
|
+
await store.createNote("C", { created_at: "2026-03-15T00:00:00.000Z" });
|
|
992
|
+
|
|
993
|
+
const results = await store.queryNotes({
|
|
994
|
+
dateFilter: { from: "2026-02-01", to: "2026-03-01" },
|
|
995
|
+
});
|
|
996
|
+
expect(results.map((n) => n.content)).toEqual(["B"]);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
it("dateFilter on an indexed metadata field filters on content date, not ingestion date", async () => {
|
|
1000
|
+
await declareEmailDate();
|
|
1001
|
+
// Ingestion order doesn't match email_date order — that's the whole
|
|
1002
|
+
// point: the bug was that `dateFrom` returned rows by ingestion time.
|
|
1003
|
+
await store.createNote("recently-synced old email", {
|
|
1004
|
+
metadata: { email_date: "2025-12-01T00:00:00.000Z" },
|
|
1005
|
+
});
|
|
1006
|
+
await store.createNote("recently-synced new email", {
|
|
1007
|
+
metadata: { email_date: "2026-04-25T00:00:00.000Z" },
|
|
1008
|
+
});
|
|
1009
|
+
await store.createNote("recently-synced ancient", {
|
|
1010
|
+
metadata: { email_date: "2024-08-15T00:00:00.000Z" },
|
|
1011
|
+
});
|
|
1012
|
+
|
|
1013
|
+
const results = await store.queryNotes({
|
|
1014
|
+
dateFilter: { field: "email_date", from: "2026-04-01", to: "2026-05-01" },
|
|
1015
|
+
});
|
|
1016
|
+
expect(results.map((n) => n.content)).toEqual(["recently-synced new email"]);
|
|
1017
|
+
});
|
|
1018
|
+
|
|
1019
|
+
it("dateFilter on a non-indexed field rejects with FIELD_NOT_INDEXED", async () => {
|
|
1020
|
+
await store.createNote("X", { metadata: { meeting_date: "2026-04-25T00:00:00.000Z" } });
|
|
1021
|
+
// Note: not declared via declareField, so the field has no generated
|
|
1022
|
+
// column. The error mirrors the metadata-operator + order_by gate.
|
|
1023
|
+
try {
|
|
1024
|
+
await store.queryNotes({
|
|
1025
|
+
dateFilter: { field: "meeting_date", from: "2026-04-01" },
|
|
1026
|
+
});
|
|
1027
|
+
throw new Error("expected QueryError");
|
|
1028
|
+
} catch (err: any) {
|
|
1029
|
+
expect(err.name).toBe("QueryError");
|
|
1030
|
+
expect(err.code).toBe("FIELD_NOT_INDEXED");
|
|
1031
|
+
expect(err.message).toContain("meeting_date");
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
|
|
1035
|
+
it("dateFilter combined with top-level dateFrom rejects with INVALID_QUERY", async () => {
|
|
1036
|
+
await declareEmailDate();
|
|
1037
|
+
try {
|
|
1038
|
+
await store.queryNotes({
|
|
1039
|
+
dateFrom: "2026-01-01",
|
|
1040
|
+
dateFilter: { field: "email_date", from: "2026-04-01" },
|
|
1041
|
+
});
|
|
1042
|
+
throw new Error("expected QueryError");
|
|
1043
|
+
} catch (err: any) {
|
|
1044
|
+
expect(err.name).toBe("QueryError");
|
|
1045
|
+
expect(err.code).toBe("INVALID_QUERY");
|
|
1046
|
+
expect(err.message).toMatch(/cannot combine/i);
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it("dateFilter with only `from` is open-ended on the upper bound", async () => {
|
|
1051
|
+
await declareEmailDate();
|
|
1052
|
+
await store.createNote("old", { metadata: { email_date: "2025-01-01T00:00:00.000Z" } });
|
|
1053
|
+
await store.createNote("middle", { metadata: { email_date: "2026-04-15T00:00:00.000Z" } });
|
|
1054
|
+
await store.createNote("new", { metadata: { email_date: "2026-05-01T00:00:00.000Z" } });
|
|
1055
|
+
|
|
1056
|
+
const results = await store.queryNotes({
|
|
1057
|
+
dateFilter: { field: "email_date", from: "2026-04-01" },
|
|
1058
|
+
});
|
|
1059
|
+
expect(results.map((n) => n.content).sort()).toEqual(["middle", "new"]);
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
it("dateFilter with explicit field='created_at' routes to the legacy SQL path", async () => {
|
|
1063
|
+
// The implicit-default case is covered above; this asserts the explicit
|
|
1064
|
+
// form behaves identically — no indexed-field gate, same n.created_at SQL.
|
|
1065
|
+
await store.createNote("A", { created_at: "2026-01-15T00:00:00.000Z" });
|
|
1066
|
+
await store.createNote("B", { created_at: "2026-02-15T00:00:00.000Z" });
|
|
1067
|
+
await store.createNote("C", { created_at: "2026-03-15T00:00:00.000Z" });
|
|
1068
|
+
|
|
1069
|
+
const results = await store.queryNotes({
|
|
1070
|
+
dateFilter: { field: "created_at", from: "2026-02-01", to: "2026-03-01" },
|
|
1071
|
+
});
|
|
1072
|
+
expect(results.map((n) => n.content)).toEqual(["B"]);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it("query-notes accepts date_filter on an indexed metadata field (vault#215)", async () => {
|
|
1076
|
+
await declareEmailDate();
|
|
1077
|
+
await store.createNote("old email", {
|
|
1078
|
+
metadata: { email_date: "2025-12-01T00:00:00.000Z" },
|
|
1079
|
+
});
|
|
1080
|
+
await store.createNote("recent email", {
|
|
1081
|
+
metadata: { email_date: "2026-04-25T00:00:00.000Z" },
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
const tools = generateMcpTools(store);
|
|
1085
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
1086
|
+
const results = await query.execute({
|
|
1087
|
+
date_filter: { field: "email_date", from: "2026-04-01", to: "2026-05-01" },
|
|
1088
|
+
include_content: true,
|
|
1089
|
+
}) as any[];
|
|
1090
|
+
expect(results.map((n) => n.content)).toEqual(["recent email"]);
|
|
1091
|
+
});
|
|
1092
|
+
|
|
1093
|
+
// ---- updated_at filter (vault#285 friction point 1.5) ----
|
|
1094
|
+
//
|
|
1095
|
+
// Incremental-rebuild flows ask "what changed since X." Like `created_at`,
|
|
1096
|
+
// `updated_at` is a real column on `notes` (no indexed-field gate), but
|
|
1097
|
+
// it tracks the *last write* rather than ingestion time. SSGs paginate
|
|
1098
|
+
// against it; sync clients use it as a high-watermark cursor.
|
|
1099
|
+
it("dateFilter on updated_at routes to the n.updated_at column (vault#285 1.5)", async () => {
|
|
1100
|
+
// Two notes; only one is later modified. The filter should pick up the
|
|
1101
|
+
// modification time, not the original creation time.
|
|
1102
|
+
const a = await store.createNote("untouched", { created_at: "2026-01-15T00:00:00.000Z" });
|
|
1103
|
+
const b = await store.createNote("modified-later", { created_at: "2026-01-20T00:00:00.000Z" });
|
|
1104
|
+
// Mutate b to bump its updated_at into a window that excludes a.
|
|
1105
|
+
await store.updateNote(b.id, { append: " edit" });
|
|
1106
|
+
|
|
1107
|
+
// Pin each note's updated_at deterministically so the assertion isn't
|
|
1108
|
+
// racing real wall-clock writes from the test harness.
|
|
1109
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1110
|
+
.run("2026-01-15T00:00:00.000Z", a.id);
|
|
1111
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1112
|
+
.run("2026-04-25T00:00:00.000Z", b.id);
|
|
1113
|
+
|
|
1114
|
+
const results = await store.queryNotes({
|
|
1115
|
+
dateFilter: { field: "updated_at", from: "2026-04-01" },
|
|
1116
|
+
});
|
|
1117
|
+
expect(results.map((n) => n.content)).toEqual(["modified-later edit"]);
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
it("dateFilter on updated_at requires no indexed-field declaration", async () => {
|
|
1121
|
+
// `updated_at` is a recognized real column — must not hit the
|
|
1122
|
+
// requireIndexedField gate that fires for arbitrary metadata fields.
|
|
1123
|
+
await store.createNote("x");
|
|
1124
|
+
// Should not throw.
|
|
1125
|
+
const results = await store.queryNotes({
|
|
1126
|
+
dateFilter: { field: "updated_at", from: "1970-01-01" },
|
|
1127
|
+
});
|
|
1128
|
+
expect(Array.isArray(results)).toBe(true);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
it("dateFilter on updated_at honors the upper-bound exclusive `to`", async () => {
|
|
1132
|
+
const a = await store.createNote("inside-window");
|
|
1133
|
+
const b = await store.createNote("after-window");
|
|
1134
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1135
|
+
.run("2026-04-25T00:00:00.000Z", a.id);
|
|
1136
|
+
db.prepare("UPDATE notes SET updated_at = ? WHERE id = ?")
|
|
1137
|
+
.run("2026-05-15T00:00:00.000Z", b.id);
|
|
1138
|
+
|
|
1139
|
+
const results = await store.queryNotes({
|
|
1140
|
+
dateFilter: { field: "updated_at", from: "2026-04-01", to: "2026-05-01" },
|
|
1141
|
+
});
|
|
1142
|
+
expect(results.map((n) => n.content)).toEqual(["inside-window"]);
|
|
1143
|
+
});
|
|
1144
|
+
});
|
|
1145
|
+
|
|
537
1146
|
it("sorts ascending and descending", async () => {
|
|
538
1147
|
await store.createNote("First", { id: "first" });
|
|
539
1148
|
await store.createNote("Second", { id: "second" });
|
|
@@ -907,7 +1516,7 @@ describe("attachments", async () => {
|
|
|
907
1516
|
// ---- MCP Tools ----
|
|
908
1517
|
|
|
909
1518
|
describe("MCP tools", async () => {
|
|
910
|
-
it("generates
|
|
1519
|
+
it("generates the consolidated tool set", () => {
|
|
911
1520
|
const tools = generateMcpTools(store);
|
|
912
1521
|
const names = tools.map((t) => t.name);
|
|
913
1522
|
|
|
@@ -920,6 +1529,19 @@ describe("MCP tools", async () => {
|
|
|
920
1529
|
expect(names).toContain("delete-tag");
|
|
921
1530
|
expect(names).toContain("find-path");
|
|
922
1531
|
expect(names).toContain("vault-info");
|
|
1532
|
+
// Six note-schema tools (list/update/delete-note-schema +
|
|
1533
|
+
// list/set/delete-schema-mapping) retired in v17 — the standalone
|
|
1534
|
+
// note_schemas + schema_mappings subsystem was a parallel path to
|
|
1535
|
+
// tags.fields with zero operator usage. See vault#267.
|
|
1536
|
+
expect(names).not.toContain("list-note-schemas");
|
|
1537
|
+
expect(names).not.toContain("update-note-schema");
|
|
1538
|
+
expect(names).not.toContain("delete-note-schema");
|
|
1539
|
+
expect(names).not.toContain("list-schema-mappings");
|
|
1540
|
+
expect(names).not.toContain("set-schema-mapping");
|
|
1541
|
+
expect(names).not.toContain("delete-schema-mapping");
|
|
1542
|
+
// synthesize-notes retired in v17 — replicable with query-notes(near=) +
|
|
1543
|
+
// find-path + agent-side aggregation. See vault#268.
|
|
1544
|
+
expect(names).not.toContain("synthesize-notes");
|
|
923
1545
|
expect(tools).toHaveLength(9);
|
|
924
1546
|
});
|
|
925
1547
|
|
|
@@ -1141,6 +1763,96 @@ describe("MCP tools", async () => {
|
|
|
1141
1763
|
expect((await store.getNote(note.id))!.content).toBe("Test");
|
|
1142
1764
|
});
|
|
1143
1765
|
|
|
1766
|
+
// ---- include_content response-shape opt-out (vault#285 friction point 2.response) ----
|
|
1767
|
+
//
|
|
1768
|
+
// Default behavior is unchanged: full Note is returned with `content`.
|
|
1769
|
+
// Setting `include_content: false` swaps in the lean NoteIndex shape
|
|
1770
|
+
// (drops content, adds byteSize + preview). Cuts the response cost on
|
|
1771
|
+
// small-edit / large-note workflows.
|
|
1772
|
+
describe("update-note include_content", () => {
|
|
1773
|
+
it("defaults to full Note (back-compat)", async () => {
|
|
1774
|
+
const note = await store.createNote("Original body", { path: "x" });
|
|
1775
|
+
const tools = generateMcpTools(store);
|
|
1776
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1777
|
+
const result = await updateNote.execute({
|
|
1778
|
+
id: note.id,
|
|
1779
|
+
content: "Replaced body",
|
|
1780
|
+
force: true,
|
|
1781
|
+
}) as any;
|
|
1782
|
+
expect(result.content).toBe("Replaced body");
|
|
1783
|
+
// Index-only fields must NOT appear on the back-compat shape.
|
|
1784
|
+
expect(result.byteSize).toBeUndefined();
|
|
1785
|
+
expect(result.preview).toBeUndefined();
|
|
1786
|
+
});
|
|
1787
|
+
|
|
1788
|
+
it("include_content: false returns the lean NoteIndex shape", async () => {
|
|
1789
|
+
const longBody = "a".repeat(5_000);
|
|
1790
|
+
const note = await store.createNote(longBody, { path: "big-note" });
|
|
1791
|
+
const tools = generateMcpTools(store);
|
|
1792
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1793
|
+
const result = await updateNote.execute({
|
|
1794
|
+
id: note.id,
|
|
1795
|
+
append: " edit",
|
|
1796
|
+
include_content: false,
|
|
1797
|
+
}) as any;
|
|
1798
|
+
// No content payload — that's the whole point of the opt-out.
|
|
1799
|
+
expect(result.content).toBeUndefined();
|
|
1800
|
+
// Index fields present.
|
|
1801
|
+
expect(typeof result.byteSize).toBe("number");
|
|
1802
|
+
expect(result.byteSize).toBe(5_000 + 5); // original + " edit"
|
|
1803
|
+
expect(typeof result.preview).toBe("string");
|
|
1804
|
+
expect(result.preview.length).toBeGreaterThan(0);
|
|
1805
|
+
expect(result.id).toBe(note.id);
|
|
1806
|
+
expect(result.path).toBe("big-note");
|
|
1807
|
+
expect(result.updatedAt).toBeTruthy();
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
it("include_content: false applies uniformly across batch responses", async () => {
|
|
1811
|
+
await store.createNote("A", { id: "a", path: "a" });
|
|
1812
|
+
await store.createNote("B", { id: "b", path: "b" });
|
|
1813
|
+
const tools = generateMcpTools(store);
|
|
1814
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1815
|
+
const result = await updateNote.execute({
|
|
1816
|
+
include_content: false,
|
|
1817
|
+
notes: [
|
|
1818
|
+
{ id: "a", content: "A v2", force: true },
|
|
1819
|
+
{ id: "b", append: " v2" },
|
|
1820
|
+
],
|
|
1821
|
+
}) as any[];
|
|
1822
|
+
expect(result).toHaveLength(2);
|
|
1823
|
+
for (const item of result) {
|
|
1824
|
+
expect(item.content).toBeUndefined();
|
|
1825
|
+
expect(typeof item.byteSize).toBe("number");
|
|
1826
|
+
expect(typeof item.preview).toBe("string");
|
|
1827
|
+
}
|
|
1828
|
+
});
|
|
1829
|
+
|
|
1830
|
+
it("include_content: false preserves validation_status when present", async () => {
|
|
1831
|
+
// Declare a tag schema with an indexed `priority` field that constrains
|
|
1832
|
+
// values; then write a note whose metadata violates the schema, so
|
|
1833
|
+
// attachValidationStatus has something to surface.
|
|
1834
|
+
await store.upsertTagSchema("task", {
|
|
1835
|
+
description: "tasks",
|
|
1836
|
+
fields: {
|
|
1837
|
+
priority: { type: "string", enum: ["low", "med", "high"], indexed: false },
|
|
1838
|
+
},
|
|
1839
|
+
});
|
|
1840
|
+
await store.createNote("a task", { id: "t1", tags: ["task"] });
|
|
1841
|
+
const tools = generateMcpTools(store);
|
|
1842
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1843
|
+
const result = await updateNote.execute({
|
|
1844
|
+
id: "t1",
|
|
1845
|
+
metadata: { priority: "URGENT" }, // not in enum — generates a warning
|
|
1846
|
+
include_content: false,
|
|
1847
|
+
force: true,
|
|
1848
|
+
}) as any;
|
|
1849
|
+
expect(result.content).toBeUndefined();
|
|
1850
|
+
expect(result.validation_status).toBeTruthy();
|
|
1851
|
+
expect(Array.isArray(result.validation_status.warnings)).toBe(true);
|
|
1852
|
+
expect(result.validation_status.warnings.length).toBeGreaterThan(0);
|
|
1853
|
+
});
|
|
1854
|
+
});
|
|
1855
|
+
|
|
1144
1856
|
it("update-note force:true bypasses precondition and mutates unconditionally", async () => {
|
|
1145
1857
|
const note = await store.createNote("First");
|
|
1146
1858
|
const tools = generateMcpTools(store);
|
|
@@ -1267,78 +1979,348 @@ describe("MCP tools", async () => {
|
|
|
1267
1979
|
expect((await store.getNote("source"))!.content).toBe("See [[People/Alice]] for details");
|
|
1268
1980
|
});
|
|
1269
1981
|
|
|
1270
|
-
it("
|
|
1271
|
-
const note = await store.createNote("
|
|
1982
|
+
it("update-note append concatenates to end without precondition", async () => {
|
|
1983
|
+
const note = await store.createNote("first line\n", { id: "n1" });
|
|
1272
1984
|
const tools = generateMcpTools(store);
|
|
1273
|
-
const
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
expect(
|
|
1281
|
-
expect(result.updatedAt).toBe(note.updatedAt);
|
|
1985
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1986
|
+
|
|
1987
|
+
// No if_updated_at and no force — append-only is precondition-exempt.
|
|
1988
|
+
const result = await updateNote.execute({ id: note.id, append: "second line\n" }) as any;
|
|
1989
|
+
expect(result.content).toBe("first line\nsecond line\n");
|
|
1990
|
+
|
|
1991
|
+
const persisted = await store.getNote(note.id);
|
|
1992
|
+
expect(persisted!.content).toBe("first line\nsecond line\n");
|
|
1282
1993
|
});
|
|
1283
1994
|
|
|
1284
|
-
it("
|
|
1285
|
-
await store.createNote("
|
|
1995
|
+
it("update-note prepend concatenates to start without precondition", async () => {
|
|
1996
|
+
const note = await store.createNote("body", { id: "n1" });
|
|
1286
1997
|
const tools = generateMcpTools(store);
|
|
1287
|
-
const
|
|
1288
|
-
|
|
1289
|
-
|
|
1998
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1999
|
+
|
|
2000
|
+
const result = await updateNote.execute({ id: note.id, prepend: "header\n" }) as any;
|
|
2001
|
+
expect(result.content).toBe("header\nbody");
|
|
1290
2002
|
});
|
|
1291
2003
|
|
|
1292
|
-
it("
|
|
1293
|
-
|
|
2004
|
+
it("update-note prepend on frontmatter-led content injects after closing --- (#203)", async () => {
|
|
2005
|
+
const original = "---\ntitle: Foo\ntags: [bar]\n---\nbody line 1\n";
|
|
2006
|
+
const note = await store.createNote(original, { id: "n1" });
|
|
1294
2007
|
const tools = generateMcpTools(store);
|
|
1295
|
-
const
|
|
1296
|
-
|
|
1297
|
-
|
|
2008
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2009
|
+
|
|
2010
|
+
const result = await updateNote.execute({ id: note.id, prepend: "preamble\n" }) as any;
|
|
2011
|
+
// Frontmatter still at byte 0 — parsers expecting `---\n` will find it.
|
|
2012
|
+
expect(result.content.startsWith("---\ntitle: Foo\ntags: [bar]\n---\n")).toBe(true);
|
|
2013
|
+
// Prepend lands immediately after the closing fence, before the body.
|
|
2014
|
+
expect(result.content).toBe(
|
|
2015
|
+
"---\ntitle: Foo\ntags: [bar]\n---\npreamble\nbody line 1\n",
|
|
2016
|
+
);
|
|
1298
2017
|
});
|
|
1299
2018
|
|
|
1300
|
-
it("
|
|
1301
|
-
await store.createNote("
|
|
1302
|
-
await store.createNote("Plain");
|
|
2019
|
+
it("update-note prepend on content lacking frontmatter injects at byte 0", async () => {
|
|
2020
|
+
const note = await store.createNote("# Heading\nbody\n", { id: "n1" });
|
|
1303
2021
|
const tools = generateMcpTools(store);
|
|
1304
|
-
const
|
|
1305
|
-
const result = await query.execute({ has_tags: false, include_content: true }) as any[];
|
|
1306
|
-
expect(result.map((n) => n.content)).toEqual(["Plain"]);
|
|
1307
|
-
});
|
|
2022
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1308
2023
|
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
await store.createNote("Orphan", { id: "mq-o" });
|
|
1313
|
-
await store.createLink(a.id, b.id, "mentions");
|
|
2024
|
+
const result = await updateNote.execute({ id: note.id, prepend: "preamble\n" }) as any;
|
|
2025
|
+
expect(result.content).toBe("preamble\n# Heading\nbody\n");
|
|
2026
|
+
});
|
|
1314
2027
|
|
|
2028
|
+
it("update-note append+prepend in one call lands both contributions", async () => {
|
|
2029
|
+
const note = await store.createNote("middle", { id: "n1" });
|
|
1315
2030
|
const tools = generateMcpTools(store);
|
|
1316
|
-
const
|
|
1317
|
-
const result = await query.execute({ has_links: false, include_content: true }) as any[];
|
|
1318
|
-
expect(result.map((n) => n.content)).toEqual(["Orphan"]);
|
|
1319
|
-
});
|
|
2031
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
1320
2032
|
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
2033
|
+
const result = await updateNote.execute({
|
|
2034
|
+
id: note.id,
|
|
2035
|
+
prepend: "[start] ",
|
|
2036
|
+
append: " [end]",
|
|
2037
|
+
}) as any;
|
|
2038
|
+
expect(result.content).toBe("[start] middle [end]");
|
|
2039
|
+
});
|
|
1327
2040
|
|
|
2041
|
+
it("update-note rejects content + append in same call", async () => {
|
|
2042
|
+
const note = await store.createNote("body", { id: "n1" });
|
|
1328
2043
|
const tools = generateMcpTools(store);
|
|
1329
|
-
const
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
2044
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2045
|
+
|
|
2046
|
+
let err: any;
|
|
2047
|
+
try {
|
|
2048
|
+
await updateNote.execute({ id: note.id, content: "new", append: "more", force: true });
|
|
2049
|
+
} catch (e) {
|
|
2050
|
+
err = e;
|
|
2051
|
+
}
|
|
2052
|
+
expect(err?.message).toMatch(/mutually exclusive/);
|
|
1335
2053
|
});
|
|
1336
2054
|
|
|
1337
|
-
it("
|
|
1338
|
-
const
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
2055
|
+
it("update-note rejects content + content_edit in same call", async () => {
|
|
2056
|
+
const note = await store.createNote("hello world", { id: "n1" });
|
|
2057
|
+
const tools = generateMcpTools(store);
|
|
2058
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2059
|
+
|
|
2060
|
+
let err: any;
|
|
2061
|
+
try {
|
|
2062
|
+
await updateNote.execute({
|
|
2063
|
+
id: note.id,
|
|
2064
|
+
content: "replace",
|
|
2065
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
2066
|
+
force: true,
|
|
2067
|
+
});
|
|
2068
|
+
} catch (e) {
|
|
2069
|
+
err = e;
|
|
2070
|
+
}
|
|
2071
|
+
expect(err?.message).toMatch(/mutually exclusive/);
|
|
2072
|
+
});
|
|
2073
|
+
|
|
2074
|
+
it("update-note rejects append + content_edit in same call", async () => {
|
|
2075
|
+
const note = await store.createNote("hello world", { id: "n1" });
|
|
2076
|
+
const tools = generateMcpTools(store);
|
|
2077
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2078
|
+
|
|
2079
|
+
let err: any;
|
|
2080
|
+
try {
|
|
2081
|
+
await updateNote.execute({
|
|
2082
|
+
id: note.id,
|
|
2083
|
+
append: " more",
|
|
2084
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
2085
|
+
});
|
|
2086
|
+
} catch (e) {
|
|
2087
|
+
err = e;
|
|
2088
|
+
}
|
|
2089
|
+
expect(err?.message).toMatch(/mutually exclusive/);
|
|
2090
|
+
});
|
|
2091
|
+
|
|
2092
|
+
it("update-note append still requires precondition when combined with other fields", async () => {
|
|
2093
|
+
const note = await store.createNote("body", { id: "n1" });
|
|
2094
|
+
const tools = generateMcpTools(store);
|
|
2095
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2096
|
+
|
|
2097
|
+
// append + metadata is NOT precondition-exempt — metadata mutation
|
|
2098
|
+
// can lose data on a stale read, so the safety gate stays in.
|
|
2099
|
+
let err: any;
|
|
2100
|
+
try {
|
|
2101
|
+
await updateNote.execute({ id: note.id, append: " more", metadata: { x: 1 } });
|
|
2102
|
+
} catch (e) {
|
|
2103
|
+
err = e;
|
|
2104
|
+
}
|
|
2105
|
+
expect(err?.code).toBe("PRECONDITION_REQUIRED");
|
|
2106
|
+
});
|
|
2107
|
+
|
|
2108
|
+
it("update-note append is atomic under concurrent calls — both lands", async () => {
|
|
2109
|
+
const note = await store.createNote("seed:", { id: "n1" });
|
|
2110
|
+
const tools = generateMcpTools(store);
|
|
2111
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2112
|
+
|
|
2113
|
+
// Two concurrent appends. SQL-level concat means both contributions
|
|
2114
|
+
// land — neither overwrites the other.
|
|
2115
|
+
const results = await Promise.all([
|
|
2116
|
+
updateNote.execute({ id: note.id, append: " A" }),
|
|
2117
|
+
updateNote.execute({ id: note.id, append: " B" }),
|
|
2118
|
+
]);
|
|
2119
|
+
expect(results).toHaveLength(2);
|
|
2120
|
+
|
|
2121
|
+
const persisted = await store.getNote(note.id);
|
|
2122
|
+
// Final content is one of "seed: A B" or "seed: B A" — the order
|
|
2123
|
+
// depends on which write got the lock first, but both contributions
|
|
2124
|
+
// are present.
|
|
2125
|
+
expect(persisted!.content === "seed: A B" || persisted!.content === "seed: B A").toBe(true);
|
|
2126
|
+
});
|
|
2127
|
+
|
|
2128
|
+
it("update-note append updates updated_at and respects if_updated_at when supplied", async () => {
|
|
2129
|
+
const note = await store.createNote("seed", { id: "n1" });
|
|
2130
|
+
const tools = generateMcpTools(store);
|
|
2131
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2132
|
+
|
|
2133
|
+
// With if_updated_at — succeeds because we're using the right token.
|
|
2134
|
+
const ok = await updateNote.execute({ id: note.id, append: " A", if_updated_at: note.updatedAt }) as any;
|
|
2135
|
+
expect(ok.content).toBe("seed A");
|
|
2136
|
+
expect(ok.updatedAt).not.toBe(note.updatedAt);
|
|
2137
|
+
|
|
2138
|
+
// Stale token — conflict.
|
|
2139
|
+
let err: any;
|
|
2140
|
+
try {
|
|
2141
|
+
await updateNote.execute({ id: note.id, append: " B", if_updated_at: note.updatedAt });
|
|
2142
|
+
} catch (e) {
|
|
2143
|
+
err = e;
|
|
2144
|
+
}
|
|
2145
|
+
expect(err?.code).toBe("CONFLICT");
|
|
2146
|
+
});
|
|
2147
|
+
|
|
2148
|
+
it("update-note append parses new wikilinks introduced via append", async () => {
|
|
2149
|
+
const target = await store.createNote("Alice's note", { id: "alice", path: "People/Alice" });
|
|
2150
|
+
const source = await store.createNote("intro\n", { id: "src" });
|
|
2151
|
+
const tools = generateMcpTools(store);
|
|
2152
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2153
|
+
|
|
2154
|
+
await updateNote.execute({ id: source.id, append: "see [[People/Alice]]" });
|
|
2155
|
+
|
|
2156
|
+
const links = await store.getLinks(source.id, { direction: "outbound" });
|
|
2157
|
+
expect(links.some((l) => l.targetId === target.id && l.relationship === "wikilink")).toBe(true);
|
|
2158
|
+
});
|
|
2159
|
+
|
|
2160
|
+
it("update-note content_edit replaces a single occurrence", async () => {
|
|
2161
|
+
const note = await store.createNote("hello world", { id: "n1" });
|
|
2162
|
+
const tools = generateMcpTools(store);
|
|
2163
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2164
|
+
|
|
2165
|
+
const result = await updateNote.execute({
|
|
2166
|
+
id: note.id,
|
|
2167
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
2168
|
+
if_updated_at: note.updatedAt,
|
|
2169
|
+
}) as any;
|
|
2170
|
+
expect(result.content).toBe("hi world");
|
|
2171
|
+
});
|
|
2172
|
+
|
|
2173
|
+
it("update-note content_edit errors when old_text is not found", async () => {
|
|
2174
|
+
const note = await store.createNote("hello world", { id: "n1" });
|
|
2175
|
+
const tools = generateMcpTools(store);
|
|
2176
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2177
|
+
|
|
2178
|
+
let err: any;
|
|
2179
|
+
try {
|
|
2180
|
+
await updateNote.execute({
|
|
2181
|
+
id: note.id,
|
|
2182
|
+
content_edit: { old_text: "missing", new_text: "x" },
|
|
2183
|
+
if_updated_at: note.updatedAt,
|
|
2184
|
+
});
|
|
2185
|
+
} catch (e) {
|
|
2186
|
+
err = e;
|
|
2187
|
+
}
|
|
2188
|
+
expect(err?.message).toMatch(/not found/);
|
|
2189
|
+
// Note must be untouched.
|
|
2190
|
+
const persisted = await store.getNote(note.id);
|
|
2191
|
+
expect(persisted!.content).toBe("hello world");
|
|
2192
|
+
});
|
|
2193
|
+
|
|
2194
|
+
it("update-note content_edit errors when old_text matches multiple times", async () => {
|
|
2195
|
+
const note = await store.createNote("hello hello", { id: "n1" });
|
|
2196
|
+
const tools = generateMcpTools(store);
|
|
2197
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2198
|
+
|
|
2199
|
+
let err: any;
|
|
2200
|
+
try {
|
|
2201
|
+
await updateNote.execute({
|
|
2202
|
+
id: note.id,
|
|
2203
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
2204
|
+
if_updated_at: note.updatedAt,
|
|
2205
|
+
});
|
|
2206
|
+
} catch (e) {
|
|
2207
|
+
err = e;
|
|
2208
|
+
}
|
|
2209
|
+
expect(err?.message).toMatch(/matches multiple times|exactly once/);
|
|
2210
|
+
const persisted = await store.getNote(note.id);
|
|
2211
|
+
expect(persisted!.content).toBe("hello hello");
|
|
2212
|
+
});
|
|
2213
|
+
|
|
2214
|
+
it("update-note content_edit requires precondition by default", async () => {
|
|
2215
|
+
const note = await store.createNote("hello world", { id: "n1" });
|
|
2216
|
+
const tools = generateMcpTools(store);
|
|
2217
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2218
|
+
|
|
2219
|
+
let err: any;
|
|
2220
|
+
try {
|
|
2221
|
+
await updateNote.execute({
|
|
2222
|
+
id: note.id,
|
|
2223
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
2224
|
+
});
|
|
2225
|
+
} catch (e) {
|
|
2226
|
+
err = e;
|
|
2227
|
+
}
|
|
2228
|
+
expect(err?.code).toBe("PRECONDITION_REQUIRED");
|
|
2229
|
+
});
|
|
2230
|
+
|
|
2231
|
+
it("update-note content_edit conflicts when if_updated_at is stale", async () => {
|
|
2232
|
+
const note = await store.createNote("hello world", { id: "n1" });
|
|
2233
|
+
const tools = generateMcpTools(store);
|
|
2234
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2235
|
+
|
|
2236
|
+
// Bump the note so a stale token will conflict at the SQL layer.
|
|
2237
|
+
await updateNote.execute({ id: note.id, content: "hello world", force: true });
|
|
2238
|
+
|
|
2239
|
+
let err: any;
|
|
2240
|
+
try {
|
|
2241
|
+
await updateNote.execute({
|
|
2242
|
+
id: note.id,
|
|
2243
|
+
content_edit: { old_text: "hello", new_text: "hi" },
|
|
2244
|
+
if_updated_at: "2020-01-01T00:00:00.000Z",
|
|
2245
|
+
});
|
|
2246
|
+
} catch (e) {
|
|
2247
|
+
err = e;
|
|
2248
|
+
}
|
|
2249
|
+
expect(err?.code).toBe("CONFLICT");
|
|
2250
|
+
});
|
|
2251
|
+
|
|
2252
|
+
it("query-notes single note by id", async () => {
|
|
2253
|
+
const note = await store.createNote("Hello", { path: "test/note" });
|
|
2254
|
+
const tools = generateMcpTools(store);
|
|
2255
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
2256
|
+
const result = await query.execute({ id: note.id }) as any;
|
|
2257
|
+
expect(result.content).toBe("Hello");
|
|
2258
|
+
expect(result.path).toBe("test/note");
|
|
2259
|
+
// updatedAt is the optimistic-concurrency token. Callers can't arm a
|
|
2260
|
+
// followup update without it, so it must always come back from a
|
|
2261
|
+
// single-note fetch.
|
|
2262
|
+
expect(result.updatedAt).toBeTruthy();
|
|
2263
|
+
expect(result.updatedAt).toBe(note.updatedAt);
|
|
2264
|
+
});
|
|
2265
|
+
|
|
2266
|
+
it("query-notes single note by path", async () => {
|
|
2267
|
+
await store.createNote("By Path", { path: "Projects/README" });
|
|
2268
|
+
const tools = generateMcpTools(store);
|
|
2269
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
2270
|
+
const result = await query.execute({ id: "Projects/README" }) as any;
|
|
2271
|
+
expect(result.content).toBe("By Path");
|
|
2272
|
+
});
|
|
2273
|
+
|
|
2274
|
+
it("query-notes by tag", async () => {
|
|
2275
|
+
await store.createNote("Test", { tags: ["daily"] });
|
|
2276
|
+
const tools = generateMcpTools(store);
|
|
2277
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
2278
|
+
const result = await query.execute({ tag: ["daily"] }) as any[];
|
|
2279
|
+
expect(result).toHaveLength(1);
|
|
2280
|
+
});
|
|
2281
|
+
|
|
2282
|
+
it("query-notes has_tags=false surfaces untagged notes", async () => {
|
|
2283
|
+
await store.createNote("Tagged", { tags: ["daily"] });
|
|
2284
|
+
await store.createNote("Plain");
|
|
2285
|
+
const tools = generateMcpTools(store);
|
|
2286
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
2287
|
+
const result = await query.execute({ has_tags: false, include_content: true }) as any[];
|
|
2288
|
+
expect(result.map((n) => n.content)).toEqual(["Plain"]);
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
it("query-notes has_links=false surfaces orphaned notes", async () => {
|
|
2292
|
+
const a = await store.createNote("Source", { id: "mq-a" });
|
|
2293
|
+
const b = await store.createNote("Target", { id: "mq-b" });
|
|
2294
|
+
await store.createNote("Orphan", { id: "mq-o" });
|
|
2295
|
+
await store.createLink(a.id, b.id, "mentions");
|
|
2296
|
+
|
|
2297
|
+
const tools = generateMcpTools(store);
|
|
2298
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
2299
|
+
const result = await query.execute({ has_links: false, include_content: true }) as any[];
|
|
2300
|
+
expect(result.map((n) => n.content)).toEqual(["Orphan"]);
|
|
2301
|
+
});
|
|
2302
|
+
|
|
2303
|
+
it("query-notes metadata operator query routes through the indexed column", async () => {
|
|
2304
|
+
const { declareField } = await import("./indexed-fields.js");
|
|
2305
|
+
declareField(db, "priority", "INTEGER", "project");
|
|
2306
|
+
await store.createNote("high", { metadata: { priority: 5 } });
|
|
2307
|
+
await store.createNote("mid", { metadata: { priority: 3 } });
|
|
2308
|
+
await store.createNote("low", { metadata: { priority: 1 } });
|
|
2309
|
+
|
|
2310
|
+
const tools = generateMcpTools(store);
|
|
2311
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
2312
|
+
const result = await query.execute({
|
|
2313
|
+
metadata: { priority: { gte: 3 } },
|
|
2314
|
+
include_content: true,
|
|
2315
|
+
}) as any[];
|
|
2316
|
+
expect(result.map((n) => n.content).sort()).toEqual(["high", "mid"]);
|
|
2317
|
+
});
|
|
2318
|
+
|
|
2319
|
+
it("query-notes order_by + sort=desc surfaces highest-priority first", async () => {
|
|
2320
|
+
const { declareField } = await import("./indexed-fields.js");
|
|
2321
|
+
declareField(db, "priority", "INTEGER", "project");
|
|
2322
|
+
await store.createNote("p2", { metadata: { priority: 2 } });
|
|
2323
|
+
await store.createNote("p5", { metadata: { priority: 5 } });
|
|
1342
2324
|
await store.createNote("p1", { metadata: { priority: 1 } });
|
|
1343
2325
|
|
|
1344
2326
|
const tools = generateMcpTools(store);
|
|
@@ -1529,6 +2511,35 @@ describe("MCP tools", async () => {
|
|
|
1529
2511
|
expect(result[0].id).toBe("near");
|
|
1530
2512
|
});
|
|
1531
2513
|
|
|
2514
|
+
it("query-notes near returns neighborhood even when limit is small and unrelated notes were created first (#130)", async () => {
|
|
2515
|
+
// Repro of #130: anchor + linked notes get crowded out by unrelated notes
|
|
2516
|
+
// when the query runs ORDER BY created_at LIMIT 5 BEFORE the
|
|
2517
|
+
// neighborhood filter. With the SQL-pushed ids filter, LIMIT applies to
|
|
2518
|
+
// the neighborhood, not the whole notes table.
|
|
2519
|
+
//
|
|
2520
|
+
// Seed: 10 unrelated notes created first, THEN the anchor + 2 linked
|
|
2521
|
+
// notes. With limit=5 and ORDER BY created_at ASC, the unrelated ten
|
|
2522
|
+
// would fill the slate and the in-neighborhood notes would never appear.
|
|
2523
|
+
for (let i = 0; i < 10; i++) {
|
|
2524
|
+
await store.createNote(`Unrelated ${i}`, { id: `unrelated-${i}` });
|
|
2525
|
+
}
|
|
2526
|
+
await store.createNote("Anchor", { id: "anchor" });
|
|
2527
|
+
await store.createNote("Outbound target", { id: "outbound" });
|
|
2528
|
+
await store.createNote("Inbound source", { id: "inbound" });
|
|
2529
|
+
await store.createLink("anchor", "outbound", "wikilink");
|
|
2530
|
+
await store.createLink("inbound", "anchor", "wikilink");
|
|
2531
|
+
|
|
2532
|
+
const tools = generateMcpTools(store);
|
|
2533
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
2534
|
+
const result = await query.execute({
|
|
2535
|
+
near: { note_id: "anchor", depth: 2 },
|
|
2536
|
+
limit: 5,
|
|
2537
|
+
}) as any[];
|
|
2538
|
+
|
|
2539
|
+
const ids = result.map((n: any) => n.id).sort();
|
|
2540
|
+
expect(ids).toEqual(["anchor", "inbound", "outbound"]);
|
|
2541
|
+
});
|
|
2542
|
+
|
|
1532
2543
|
it("delete-note accepts path", async () => {
|
|
1533
2544
|
await store.createNote("To delete", { path: "Temp/note" });
|
|
1534
2545
|
const tools = generateMcpTools(store);
|
|
@@ -1688,51 +2699,360 @@ describe("MCP tools", async () => {
|
|
|
1688
2699
|
expect(fresh.metadata.priority).toBe(0);
|
|
1689
2700
|
expect(fresh.metadata.status).toBe("active");
|
|
1690
2701
|
});
|
|
1691
|
-
});
|
|
1692
2702
|
|
|
1693
|
-
// ---- query-notes
|
|
2703
|
+
// ---- query-notes input-shape tolerance (vault#214) ----
|
|
2704
|
+
//
|
|
2705
|
+
// The MCP framework drops top-level keys not in the inputSchema without
|
|
2706
|
+
// raising — so an LLM caller passing the wrong field name gets a silent
|
|
2707
|
+
// no-op rather than an error. We accept canonical + camelCase + singular
|
|
2708
|
+
// aliases so the most common LLM mistakes still apply the filter, and
|
|
2709
|
+
// we mirror the `tag` param's string-or-array shape so a single excluded
|
|
2710
|
+
// tag doesn't need a wrapping array.
|
|
1694
2711
|
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
await store.createNote("
|
|
1698
|
-
await store.createNote(
|
|
1699
|
-
"Canon:\nSee [[Statements/Who]] for identity.",
|
|
1700
|
-
{ path: "Canon" },
|
|
1701
|
-
);
|
|
2712
|
+
it("query-notes accepts `excludeTags` (camelCase alias)", async () => {
|
|
2713
|
+
await store.createNote("a", { tags: ["email"] });
|
|
2714
|
+
await store.createNote("b", { tags: ["email", "urgent"] });
|
|
1702
2715
|
const tools = generateMcpTools(store);
|
|
1703
|
-
const
|
|
2716
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2717
|
+
const r = await queryNotes.execute({ tag: "email", excludeTags: ["urgent"], include_content: true }) as any[];
|
|
2718
|
+
expect(r).toHaveLength(1);
|
|
2719
|
+
expect(r[0].content).toBe("a");
|
|
2720
|
+
});
|
|
1704
2721
|
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
2722
|
+
it("query-notes accepts `exclude_tag` (singular alias)", async () => {
|
|
2723
|
+
await store.createNote("a", { tags: ["email"] });
|
|
2724
|
+
await store.createNote("b", { tags: ["email", "urgent"] });
|
|
2725
|
+
const tools = generateMcpTools(store);
|
|
2726
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2727
|
+
const r = await queryNotes.execute({ tag: "email", exclude_tag: "urgent", include_content: true }) as any[];
|
|
2728
|
+
expect(r).toHaveLength(1);
|
|
2729
|
+
expect(r[0].content).toBe("a");
|
|
2730
|
+
});
|
|
1709
2731
|
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
2732
|
+
it("query-notes `exclude_tags` accepts a single string (mirrors `tag`)", async () => {
|
|
2733
|
+
await store.createNote("a", { tags: ["email"] });
|
|
2734
|
+
await store.createNote("b", { tags: ["email", "urgent"] });
|
|
2735
|
+
const tools = generateMcpTools(store);
|
|
2736
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2737
|
+
const r = await queryNotes.execute({ tag: "email", exclude_tags: "urgent", include_content: true }) as any[];
|
|
2738
|
+
expect(r).toHaveLength(1);
|
|
2739
|
+
expect(r[0].content).toBe("a");
|
|
1713
2740
|
});
|
|
1714
2741
|
|
|
1715
|
-
it("
|
|
1716
|
-
await store.createNote(
|
|
1717
|
-
|
|
1718
|
-
{ path: "Statements/Philosophy", metadata: { summary: "Unforced / wu wei." } },
|
|
1719
|
-
);
|
|
1720
|
-
await store.createNote("Overview: [[Statements/Philosophy]]", { path: "Index" });
|
|
2742
|
+
it("query-notes canonical `exclude_tags: [...]` still works (regression)", async () => {
|
|
2743
|
+
await store.createNote("a", { tags: ["email"] });
|
|
2744
|
+
await store.createNote("b", { tags: ["email", "urgent"] });
|
|
1721
2745
|
const tools = generateMcpTools(store);
|
|
1722
|
-
const
|
|
2746
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2747
|
+
const r = await queryNotes.execute({ tag: "email", exclude_tags: ["urgent"], include_content: true }) as any[];
|
|
2748
|
+
expect(r).toHaveLength(1);
|
|
2749
|
+
expect(r[0].content).toBe("a");
|
|
2750
|
+
});
|
|
2751
|
+
|
|
2752
|
+
it("query-notes routes through store.queryNotes so tag-hierarchy expansion fires", async () => {
|
|
2753
|
+
// `voice` and `text` declare "manual" as their parent via the v14
|
|
2754
|
+
// tags.parent_names column. A query for `tag: "manual"` should match
|
|
2755
|
+
// notes tagged with either child — that expansion only happens when
|
|
2756
|
+
// the call goes through `store.queryNotes`, not `noteOps.queryNotes`
|
|
2757
|
+
// directly.
|
|
2758
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
2759
|
+
await store.upsertTagRecord("text", { parent_names: ["manual"] });
|
|
2760
|
+
await store.createNote("voice memo", { tags: ["voice"] });
|
|
2761
|
+
await store.createNote("text memo", { tags: ["text"] });
|
|
2762
|
+
await store.createNote("unrelated", { tags: ["other"] });
|
|
1723
2763
|
|
|
1724
|
-
const
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
2764
|
+
const tools = generateMcpTools(store);
|
|
2765
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2766
|
+
const r = await queryNotes.execute({ tag: "manual", include_content: true }) as any[];
|
|
2767
|
+
expect(r).toHaveLength(2);
|
|
2768
|
+
expect(r.map((n) => n.content).sort()).toEqual(["text memo", "voice memo"]);
|
|
2769
|
+
});
|
|
2770
|
+
|
|
2771
|
+
it("query-notes FTS path routes through store.searchNotes so tag-hierarchy expansion fires (vault#227)", async () => {
|
|
2772
|
+
// Same fixture shape as the structured-query hierarchy test above, but
|
|
2773
|
+
// exercising the search branch. Pre-fix the FTS path called
|
|
2774
|
+
// `noteOps.searchNotes` directly and silently dropped descendant matches —
|
|
2775
|
+
// `tag: "manual"` would only return notes literally tagged #manual, not
|
|
2776
|
+
// notes tagged with the declared children #voice / #text.
|
|
2777
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
2778
|
+
await store.upsertTagRecord("text", { parent_names: ["manual"] });
|
|
2779
|
+
await store.createNote("voice handoff notes", { tags: ["voice"] });
|
|
2780
|
+
await store.createNote("text handoff notes", { tags: ["text"] });
|
|
2781
|
+
await store.createNote("unrelated handoff", { tags: ["other"] });
|
|
1729
2782
|
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
2783
|
+
const tools = generateMcpTools(store);
|
|
2784
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2785
|
+
const r = await queryNotes.execute({ search: "handoff", tag: "manual", include_content: true }) as any[];
|
|
2786
|
+
expect(r).toHaveLength(2);
|
|
2787
|
+
expect(r.map((n) => n.content).sort()).toEqual(["text handoff notes", "voice handoff notes"]);
|
|
2788
|
+
expect(r.map((n) => n.content)).not.toContain("unrelated handoff");
|
|
2789
|
+
});
|
|
2790
|
+
|
|
2791
|
+
it("query-notes does not mutate caller's params object across repeated calls", async () => {
|
|
2792
|
+
// normalizeTags returns a defensive copy of array inputs so the downstream
|
|
2793
|
+
// store layer can sort/dedupe without touching the caller's reference.
|
|
2794
|
+
// Without the copy, a caller reusing the same params object would see its
|
|
2795
|
+
// exclude_tags array reordered (or worse) on the second call.
|
|
2796
|
+
await store.createNote("a", { tags: ["email"] });
|
|
2797
|
+
await store.createNote("b", { tags: ["email", "urgent"] });
|
|
2798
|
+
await store.createNote("c", { tags: ["email", "spam"] });
|
|
2799
|
+
|
|
2800
|
+
const tools = generateMcpTools(store);
|
|
2801
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
2802
|
+
const params = { tag: "email", exclude_tags: ["urgent", "spam"], include_content: true };
|
|
2803
|
+
|
|
2804
|
+
const r1 = await queryNotes.execute(params) as any[];
|
|
2805
|
+
expect(params.exclude_tags).toEqual(["urgent", "spam"]);
|
|
2806
|
+
|
|
2807
|
+
const r2 = await queryNotes.execute(params) as any[];
|
|
2808
|
+
expect(params.exclude_tags).toEqual(["urgent", "spam"]);
|
|
2809
|
+
|
|
2810
|
+
expect(r1.map((n) => n.content).sort()).toEqual(["a"]);
|
|
2811
|
+
expect(r2.map((n) => n.content).sort()).toEqual(["a"]);
|
|
1733
2812
|
});
|
|
1734
2813
|
|
|
1735
|
-
|
|
2814
|
+
// ---- empty-note + batch-cap MCP regressions (#213) ----
|
|
2815
|
+
|
|
2816
|
+
it("create-note rejects bare empty content with no path (EMPTY_NOTE)", async () => {
|
|
2817
|
+
const tools = generateMcpTools(store);
|
|
2818
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2819
|
+
let err: any;
|
|
2820
|
+
try {
|
|
2821
|
+
await createNote.execute({ content: "" });
|
|
2822
|
+
} catch (e) {
|
|
2823
|
+
err = e;
|
|
2824
|
+
}
|
|
2825
|
+
expect(err?.code).toBe("EMPTY_NOTE");
|
|
2826
|
+
});
|
|
2827
|
+
|
|
2828
|
+
it("create-note batch rejects when any entry is empty content + no path (atomic, with item_index)", async () => {
|
|
2829
|
+
const tools = generateMcpTools(store);
|
|
2830
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2831
|
+
const beforeCount = (await store.queryNotes({ search: "atomic-marker" })).length;
|
|
2832
|
+
let err: any;
|
|
2833
|
+
try {
|
|
2834
|
+
await createNote.execute({
|
|
2835
|
+
notes: [
|
|
2836
|
+
{ content: "atomic-marker first" },
|
|
2837
|
+
{ content: "" },
|
|
2838
|
+
],
|
|
2839
|
+
});
|
|
2840
|
+
} catch (e) {
|
|
2841
|
+
err = e;
|
|
2842
|
+
}
|
|
2843
|
+
expect(err?.code).toBe("EMPTY_NOTE");
|
|
2844
|
+
// The first item must NOT have been created — pre-validation rolls
|
|
2845
|
+
// the whole batch back atomically. Partial-create would leak prefixes
|
|
2846
|
+
// on every runaway-client burst (#213).
|
|
2847
|
+
const afterCount = (await store.queryNotes({ search: "atomic-marker" })).length;
|
|
2848
|
+
expect(afterCount).toBe(beforeCount);
|
|
2849
|
+
// Parity with HTTP route: MCP callers with multi-item batches need to
|
|
2850
|
+
// know which entry triggered the rejection. The bad entry is at index 1.
|
|
2851
|
+
expect(err.item_index).toBe(1);
|
|
2852
|
+
});
|
|
2853
|
+
|
|
2854
|
+
it("create-note single empty has null item_index (not a batch position)", async () => {
|
|
2855
|
+
const tools = generateMcpTools(store);
|
|
2856
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2857
|
+
let err: any;
|
|
2858
|
+
try {
|
|
2859
|
+
await createNote.execute({ content: "" });
|
|
2860
|
+
} catch (e) {
|
|
2861
|
+
err = e;
|
|
2862
|
+
}
|
|
2863
|
+
expect(err?.code).toBe("EMPTY_NOTE");
|
|
2864
|
+
// Single-call (no `notes` array) — there's no batch position to report.
|
|
2865
|
+
expect(err.item_index).toBeNull();
|
|
2866
|
+
});
|
|
2867
|
+
|
|
2868
|
+
it("create-note batch over MAX_BATCH_SIZE rejects with BATCH_TOO_LARGE", async () => {
|
|
2869
|
+
const tools = generateMcpTools(store);
|
|
2870
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2871
|
+
const notes = Array.from({ length: 501 }, (_, i) => ({ content: `n${i}` }));
|
|
2872
|
+
let err: any;
|
|
2873
|
+
try {
|
|
2874
|
+
await createNote.execute({ notes });
|
|
2875
|
+
} catch (e) {
|
|
2876
|
+
err = e;
|
|
2877
|
+
}
|
|
2878
|
+
expect(err?.code).toBe("BATCH_TOO_LARGE");
|
|
2879
|
+
expect(err.limit).toBe(500);
|
|
2880
|
+
expect(err.got).toBe(501);
|
|
2881
|
+
});
|
|
2882
|
+
|
|
2883
|
+
it("update-note batch over MAX_BATCH_SIZE rejects with BATCH_TOO_LARGE", async () => {
|
|
2884
|
+
const tools = generateMcpTools(store);
|
|
2885
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2886
|
+
const notes = Array.from({ length: 501 }, (_, i) => ({
|
|
2887
|
+
id: `id${i}`,
|
|
2888
|
+
content: "x",
|
|
2889
|
+
force: true,
|
|
2890
|
+
}));
|
|
2891
|
+
let err: any;
|
|
2892
|
+
try {
|
|
2893
|
+
await updateNote.execute({ notes });
|
|
2894
|
+
} catch (e) {
|
|
2895
|
+
err = e;
|
|
2896
|
+
}
|
|
2897
|
+
expect(err?.code).toBe("BATCH_TOO_LARGE");
|
|
2898
|
+
expect(err.limit).toBe(500);
|
|
2899
|
+
expect(err.got).toBe(501);
|
|
2900
|
+
});
|
|
2901
|
+
|
|
2902
|
+
it("create-note batch where mid-item triggers PATH_CONFLICT rolls back prefix items (#236)", async () => {
|
|
2903
|
+
// The empty-note pre-walk (#213) catches `{}` before any DB write; a
|
|
2904
|
+
// path-conflict can only surface on the actual INSERT, mid-loop. Without
|
|
2905
|
+
// the BEGIN/COMMIT wrap the prefix items would have already landed.
|
|
2906
|
+
await store.createNote("seed", { path: "taken-236" });
|
|
2907
|
+
const tools = generateMcpTools(store);
|
|
2908
|
+
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
2909
|
+
const beforeIds = (await store.queryNotes({})).map((n) => n.id).sort();
|
|
2910
|
+
|
|
2911
|
+
let err: any;
|
|
2912
|
+
try {
|
|
2913
|
+
await createNote.execute({
|
|
2914
|
+
notes: [
|
|
2915
|
+
{ content: "ok-1", path: "fresh-236-1" },
|
|
2916
|
+
{ content: "ok-2", path: "fresh-236-2" },
|
|
2917
|
+
{ content: "boom", path: "taken-236" },
|
|
2918
|
+
],
|
|
2919
|
+
});
|
|
2920
|
+
} catch (e) {
|
|
2921
|
+
err = e;
|
|
2922
|
+
}
|
|
2923
|
+
expect(err).toBeTruthy();
|
|
2924
|
+
|
|
2925
|
+
// The two prefix items must NOT have been created — atomic rollback.
|
|
2926
|
+
const afterIds = (await store.queryNotes({})).map((n) => n.id).sort();
|
|
2927
|
+
expect(afterIds).toEqual(beforeIds);
|
|
2928
|
+
expect(await store.queryNotes({ path: "fresh-236-1" })).toHaveLength(0);
|
|
2929
|
+
expect(await store.queryNotes({ path: "fresh-236-2" })).toHaveLength(0);
|
|
2930
|
+
});
|
|
2931
|
+
|
|
2932
|
+
it("update-note batch rolls back prefix tag mutation when a later item path-conflicts (#236)", async () => {
|
|
2933
|
+
await store.createNote("A", { id: "a236" });
|
|
2934
|
+
await store.createNote("B", { id: "b236" });
|
|
2935
|
+
await store.createNote("C", { id: "c236", path: "occupied-236" });
|
|
2936
|
+
const tools = generateMcpTools(store);
|
|
2937
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2938
|
+
|
|
2939
|
+
const aBefore = await store.getNote("a236");
|
|
2940
|
+
|
|
2941
|
+
let err: any;
|
|
2942
|
+
try {
|
|
2943
|
+
await updateNote.execute({
|
|
2944
|
+
notes: [
|
|
2945
|
+
// Item 0 mutates a236's content + adds a tag. force=true skips
|
|
2946
|
+
// the if_updated_at precondition.
|
|
2947
|
+
{ id: "a236", content: "A mutated", force: true, tags: { add: ["should-rollback"] } },
|
|
2948
|
+
// Item 1 tries to take a path already owned by c236 — PATH_CONFLICT.
|
|
2949
|
+
{ id: "b236", path: "occupied-236", force: true },
|
|
2950
|
+
],
|
|
2951
|
+
});
|
|
2952
|
+
} catch (e) {
|
|
2953
|
+
err = e;
|
|
2954
|
+
}
|
|
2955
|
+
expect(err?.code).toBe("PATH_CONFLICT");
|
|
2956
|
+
|
|
2957
|
+
// Item 0's tag-add + content change must be rolled back — the batch
|
|
2958
|
+
// transaction reverted them when item 1 path-conflicted (#236).
|
|
2959
|
+
const aAfter = await store.getNote("a236");
|
|
2960
|
+
expect(aAfter!.content).toBe(aBefore!.content);
|
|
2961
|
+
expect(aAfter!.tags ?? []).not.toContain("should-rollback");
|
|
2962
|
+
});
|
|
2963
|
+
|
|
2964
|
+
it("update-note batch rolls back prefix mutation when a later item if_updated_at-conflicts (#261)", async () => {
|
|
2965
|
+
// The companion to the PATH_CONFLICT test above. Item 1's stale
|
|
2966
|
+
// `if_updated_at` must surface as a ConflictError so the batch's
|
|
2967
|
+
// BEGIN/COMMIT wrap can roll back item 0's mutation.
|
|
2968
|
+
//
|
|
2969
|
+
// Pre-fix (vault#261): `noteOps.updateNote` checked `res.changes === 0`
|
|
2970
|
+
// to detect the precondition miss. Inside this multi-statement batch
|
|
2971
|
+
// transaction, `.changes` reported a stale non-zero value from prior
|
|
2972
|
+
// writes, so the conflict was silently missed and item 0's mutation
|
|
2973
|
+
// committed with item 1 ignored.
|
|
2974
|
+
//
|
|
2975
|
+
// Post-fix: the conditional UPDATE uses `RETURNING id` and detects the
|
|
2976
|
+
// miss directly from row presence. ConflictError fires; batch rolls back.
|
|
2977
|
+
//
|
|
2978
|
+
// Standalone bun:sqlite repro is pending — six attempted reductions
|
|
2979
|
+
// (basic txn, async/microtask, prepared-statement reuse, mcp-loop
|
|
2980
|
+
// mirror, hook-dispatch mirror, syncWikilinks-style writes) failed to
|
|
2981
|
+
// hit the .changes-stale path outside the full BunStore plumbing. The
|
|
2982
|
+
// bug surfaces only through `BunStore.updateNote` → hook dispatch.
|
|
2983
|
+
// See vault#261 for the audit trail.
|
|
2984
|
+
await store.createNote("A", { id: "a261" });
|
|
2985
|
+
await store.createNote("B", { id: "b261" });
|
|
2986
|
+
const tools = generateMcpTools(store);
|
|
2987
|
+
const updateNote = tools.find((t) => t.name === "update-note")!;
|
|
2988
|
+
|
|
2989
|
+
const aBefore = await store.getNote("a261");
|
|
2990
|
+
|
|
2991
|
+
let err: any;
|
|
2992
|
+
try {
|
|
2993
|
+
await updateNote.execute({
|
|
2994
|
+
notes: [
|
|
2995
|
+
// Item 0 mutates a261's content + adds a tag (force, no precondition).
|
|
2996
|
+
{ id: "a261", content: "A mutated", force: true, tags: { add: ["should-rollback"] } },
|
|
2997
|
+
// Item 1 has a stale if_updated_at on b261 — should ConflictError.
|
|
2998
|
+
{ id: "b261", content: "B should-not-land", if_updated_at: "2020-01-01T00:00:00.000Z" },
|
|
2999
|
+
],
|
|
3000
|
+
});
|
|
3001
|
+
} catch (e) {
|
|
3002
|
+
err = e;
|
|
3003
|
+
}
|
|
3004
|
+
expect(err?.code).toBe("CONFLICT");
|
|
3005
|
+
|
|
3006
|
+
// Item 0's tag-add + content change must be rolled back.
|
|
3007
|
+
const aAfter = await store.getNote("a261");
|
|
3008
|
+
expect(aAfter!.content).toBe(aBefore!.content);
|
|
3009
|
+
expect(aAfter!.tags ?? []).not.toContain("should-rollback");
|
|
3010
|
+
});
|
|
3011
|
+
});
|
|
3012
|
+
|
|
3013
|
+
// ---- query-notes link expansion ----
|
|
3014
|
+
|
|
3015
|
+
describe("query-notes link expansion", async () => {
|
|
3016
|
+
it("expands a single [[wikilink]] inline in full mode by default", async () => {
|
|
3017
|
+
await store.createNote("# Who I Am\nI teach Taiji.", { path: "Statements/Who" });
|
|
3018
|
+
await store.createNote(
|
|
3019
|
+
"Canon:\nSee [[Statements/Who]] for identity.",
|
|
3020
|
+
{ path: "Canon" },
|
|
3021
|
+
);
|
|
3022
|
+
const tools = generateMcpTools(store);
|
|
3023
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3024
|
+
|
|
3025
|
+
const result = await query.execute({
|
|
3026
|
+
id: "Canon",
|
|
3027
|
+
expand_links: true,
|
|
3028
|
+
}) as any;
|
|
3029
|
+
|
|
3030
|
+
expect(result.content).toContain('<expanded path="Statements/Who" mode="full">');
|
|
3031
|
+
expect(result.content).toContain("I teach Taiji.");
|
|
3032
|
+
expect(result.content).toContain("</expanded>");
|
|
3033
|
+
});
|
|
3034
|
+
|
|
3035
|
+
it("summary mode inlines only metadata.summary, not full content", async () => {
|
|
3036
|
+
await store.createNote(
|
|
3037
|
+
"# Long canonical statement\n\n(Many paragraphs of detail follow...)",
|
|
3038
|
+
{ path: "Statements/Philosophy", metadata: { summary: "Unforced / wu wei." } },
|
|
3039
|
+
);
|
|
3040
|
+
await store.createNote("Overview: [[Statements/Philosophy]]", { path: "Index" });
|
|
3041
|
+
const tools = generateMcpTools(store);
|
|
3042
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3043
|
+
|
|
3044
|
+
const result = await query.execute({
|
|
3045
|
+
id: "Index",
|
|
3046
|
+
expand_links: true,
|
|
3047
|
+
expand_mode: "summary",
|
|
3048
|
+
}) as any;
|
|
3049
|
+
|
|
3050
|
+
expect(result.content).toContain('mode="summary"');
|
|
3051
|
+
expect(result.content).toContain("Unforced / wu wei.");
|
|
3052
|
+
expect(result.content).not.toContain("Many paragraphs of detail");
|
|
3053
|
+
});
|
|
3054
|
+
|
|
3055
|
+
it("deduplicates: a linked note expanded once, subsequent references marked", async () => {
|
|
1736
3056
|
await store.createNote("target body", { path: "Target" });
|
|
1737
3057
|
await store.createNote(
|
|
1738
3058
|
"First [[Target]], then [[Target]] again.",
|
|
@@ -1978,3 +3298,1581 @@ describe("query-notes link expansion", async () => {
|
|
|
1978
3298
|
expect(result.content).not.toContain("unsummarized body");
|
|
1979
3299
|
});
|
|
1980
3300
|
});
|
|
3301
|
+
|
|
3302
|
+
// ---------------------------------------------------------------------------
|
|
3303
|
+
// Tag hierarchy via tags.parent_names (post-v14, patterns/tag-data-model.md)
|
|
3304
|
+
// ---------------------------------------------------------------------------
|
|
3305
|
+
|
|
3306
|
+
describe("tag hierarchy (tags.parent_names)", async () => {
|
|
3307
|
+
it("query for parent tag returns notes tagged with declared child", async () => {
|
|
3308
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3309
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3310
|
+
await store.createNote("text note", { tags: ["text"] });
|
|
3311
|
+
|
|
3312
|
+
const results = await store.queryNotes({ tags: ["manual"] });
|
|
3313
|
+
expect(results.length).toBe(1);
|
|
3314
|
+
expect(results[0]!.content).toBe("voice note");
|
|
3315
|
+
});
|
|
3316
|
+
|
|
3317
|
+
it("expands transitively across multiple levels", async () => {
|
|
3318
|
+
await store.upsertTagRecord("manual", { parent_names: ["note"] });
|
|
3319
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3320
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3321
|
+
await store.createNote("manual-only note", { tags: ["manual"] });
|
|
3322
|
+
await store.createNote("note-only note", { tags: ["note"] });
|
|
3323
|
+
|
|
3324
|
+
// #note matches all three (note + manual + voice).
|
|
3325
|
+
const noteResults = await store.queryNotes({ tags: ["note"] });
|
|
3326
|
+
expect(noteResults.length).toBe(3);
|
|
3327
|
+
|
|
3328
|
+
// #manual matches voice + manual-only, not note-only.
|
|
3329
|
+
const manualResults = await store.queryNotes({ tags: ["manual"] });
|
|
3330
|
+
expect(manualResults.map((n) => n.content).sort()).toEqual([
|
|
3331
|
+
"manual-only note",
|
|
3332
|
+
"voice note",
|
|
3333
|
+
]);
|
|
3334
|
+
});
|
|
3335
|
+
|
|
3336
|
+
it("query for child does not match parent-tagged notes", async () => {
|
|
3337
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3338
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3339
|
+
await store.createNote("manual-only note", { tags: ["manual"] });
|
|
3340
|
+
|
|
3341
|
+
const results = await store.queryNotes({ tags: ["voice"] });
|
|
3342
|
+
expect(results.length).toBe(1);
|
|
3343
|
+
expect(results[0]!.content).toBe("voice note");
|
|
3344
|
+
});
|
|
3345
|
+
|
|
3346
|
+
it("supports multiple parents (diamond inheritance)", async () => {
|
|
3347
|
+
await store.upsertTagRecord("voice-meeting", { parent_names: ["voice", "meeting"] });
|
|
3348
|
+
await store.createNote("vm", { tags: ["voice-meeting"] });
|
|
3349
|
+
await store.createNote("v", { tags: ["voice"] });
|
|
3350
|
+
await store.createNote("m", { tags: ["meeting"] });
|
|
3351
|
+
|
|
3352
|
+
expect((await store.queryNotes({ tags: ["voice"] })).length).toBe(2); // v + vm
|
|
3353
|
+
expect((await store.queryNotes({ tags: ["meeting"] })).length).toBe(2); // m + vm
|
|
3354
|
+
});
|
|
3355
|
+
|
|
3356
|
+
it("hierarchy is invalidated when parent_names is set", async () => {
|
|
3357
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3358
|
+
// Before the parents are declared, #manual matches nothing.
|
|
3359
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3360
|
+
|
|
3361
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3362
|
+
|
|
3363
|
+
// After upsert, the cache invalidates and the next query expands.
|
|
3364
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
3365
|
+
});
|
|
3366
|
+
|
|
3367
|
+
it("hierarchy is invalidated when parent_names is repointed", async () => {
|
|
3368
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3369
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3370
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
3371
|
+
|
|
3372
|
+
// Repoint the parent.
|
|
3373
|
+
await store.upsertTagRecord("voice", { parent_names: ["audio"] });
|
|
3374
|
+
|
|
3375
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3376
|
+
expect((await store.queryNotes({ tags: ["audio"] })).length).toBe(1);
|
|
3377
|
+
});
|
|
3378
|
+
|
|
3379
|
+
it("hierarchy is invalidated when parent_names is cleared", async () => {
|
|
3380
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3381
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3382
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
3383
|
+
|
|
3384
|
+
await store.upsertTagRecord("voice", { parent_names: null });
|
|
3385
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3386
|
+
});
|
|
3387
|
+
|
|
3388
|
+
it("hierarchy is invalidated when a parent tag is deleted", async () => {
|
|
3389
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3390
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3391
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
3392
|
+
|
|
3393
|
+
// Drop the child tag — the row holding the parent_names declaration
|
|
3394
|
+
// disappears, so the hierarchy edge goes with it.
|
|
3395
|
+
await store.deleteTag("voice");
|
|
3396
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3397
|
+
});
|
|
3398
|
+
|
|
3399
|
+
it("tagMatch=any flattens all expansions across input tags", async () => {
|
|
3400
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3401
|
+
await store.createNote("v", { tags: ["voice"] });
|
|
3402
|
+
await store.createNote("p", { tags: ["project"] });
|
|
3403
|
+
await store.createNote("o", { tags: ["other"] });
|
|
3404
|
+
|
|
3405
|
+
const results = await store.queryNotes({
|
|
3406
|
+
tags: ["manual", "project"],
|
|
3407
|
+
tagMatch: "any",
|
|
3408
|
+
});
|
|
3409
|
+
expect(results.map((n) => n.content).sort()).toEqual(["p", "v"]);
|
|
3410
|
+
});
|
|
3411
|
+
|
|
3412
|
+
it("tagMatch=all (default) requires each input tag's expanded set to be present", async () => {
|
|
3413
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3414
|
+
// Note has both #voice (which satisfies #manual via expansion) AND #project.
|
|
3415
|
+
await store.createNote("vp", { tags: ["voice", "project"] });
|
|
3416
|
+
await store.createNote("p-only", { tags: ["project"] });
|
|
3417
|
+
|
|
3418
|
+
const results = await store.queryNotes({
|
|
3419
|
+
tags: ["manual", "project"], // default tagMatch=all
|
|
3420
|
+
});
|
|
3421
|
+
expect(results.length).toBe(1);
|
|
3422
|
+
expect(results[0]!.content).toBe("vp");
|
|
3423
|
+
});
|
|
3424
|
+
|
|
3425
|
+
it("tolerates a cycle without infinite-looping", async () => {
|
|
3426
|
+
await store.upsertTagRecord("a", { parent_names: ["b"] });
|
|
3427
|
+
await store.upsertTagRecord("b", { parent_names: ["a"] });
|
|
3428
|
+
await store.createNote("note-a", { tags: ["a"] });
|
|
3429
|
+
|
|
3430
|
+
// Both a and b should resolve without hanging; both reach the same set {a, b}.
|
|
3431
|
+
expect((await store.queryNotes({ tags: ["a"] })).length).toBe(1);
|
|
3432
|
+
expect((await store.queryNotes({ tags: ["b"] })).length).toBe(1);
|
|
3433
|
+
});
|
|
3434
|
+
|
|
3435
|
+
it("malformed parent_names JSON is ignored silently", async () => {
|
|
3436
|
+
// Stuff a malformed value into the column directly to simulate an
|
|
3437
|
+
// out-of-band write. The resolver should drop it without throwing.
|
|
3438
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
3439
|
+
(store as any).db.prepare("UPDATE tags SET parent_names = ? WHERE name = ?")
|
|
3440
|
+
.run("not valid json {{{", "voice");
|
|
3441
|
+
// Force cache reload.
|
|
3442
|
+
(store as any)._tagHierarchy = null;
|
|
3443
|
+
await store.createNote("v", { tags: ["voice"] });
|
|
3444
|
+
|
|
3445
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3446
|
+
expect((await store.queryNotes({ tags: ["voice"] })).length).toBe(1);
|
|
3447
|
+
});
|
|
3448
|
+
|
|
3449
|
+
it("a tag with no parent_names is a hierarchy no-op", async () => {
|
|
3450
|
+
await store.upsertTagRecord("voice", { description: "voice notes" });
|
|
3451
|
+
await store.createNote("v", { tags: ["voice"] });
|
|
3452
|
+
await store.createNote("m", { tags: ["manual"] });
|
|
3453
|
+
|
|
3454
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
3455
|
+
expect((await store.queryNotes({ tags: ["voice"] })).length).toBe(1);
|
|
3456
|
+
});
|
|
3457
|
+
|
|
3458
|
+
it("legacy `_tags/<name>` notes left in place do not affect the hierarchy", async () => {
|
|
3459
|
+
// Post-v14, the resolver reads tags.parent_names — not notes. A leftover
|
|
3460
|
+
// `_tags/*` note from a pre-v14 vault is harmless historical record.
|
|
3461
|
+
await store.createNote("legacy", {
|
|
3462
|
+
path: "_tags/voice",
|
|
3463
|
+
metadata: { parents: ["manual"] },
|
|
3464
|
+
});
|
|
3465
|
+
await store.createNote("voice note", { tags: ["voice"] });
|
|
3466
|
+
|
|
3467
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
3468
|
+
});
|
|
3469
|
+
});
|
|
3470
|
+
|
|
3471
|
+
// ---------------------------------------------------------------------------
|
|
3472
|
+
// Schema validation — driven by `tags.fields` (post-v17, vault#267)
|
|
3473
|
+
// ---------------------------------------------------------------------------
|
|
3474
|
+
// Originally written against the `_schemas/<name>` + `_schema_defaults`
|
|
3475
|
+
// notes-as-config convention (issue #177); rewritten for vault#246 against
|
|
3476
|
+
// the standalone `note_schemas` + `schema_mappings` tables; rewritten again
|
|
3477
|
+
// for vault#267 against `tags.fields` after the standalone subsystem retired.
|
|
3478
|
+
// The validation surface is intentionally smaller now — schemas are tag-axis
|
|
3479
|
+
// only (no path_prefix) and advisory only (no `required` concept).
|
|
3480
|
+
|
|
3481
|
+
describe("schema validation (tags.fields)", async () => {
|
|
3482
|
+
it("returns no validation_status when no tag declares fields", async () => {
|
|
3483
|
+
const tools = generateMcpTools(store);
|
|
3484
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3485
|
+
const result = await create.execute({ content: "plain note" }) as any;
|
|
3486
|
+
expect(result.validation_status).toBeUndefined();
|
|
3487
|
+
});
|
|
3488
|
+
|
|
3489
|
+
it("returns no validation_status when no tag on the note declares fields", async () => {
|
|
3490
|
+
// A different tag has fields, but the note isn't tagged with it.
|
|
3491
|
+
await store.upsertTagSchema("task", {
|
|
3492
|
+
fields: { priority: { type: "string" } },
|
|
3493
|
+
});
|
|
3494
|
+
|
|
3495
|
+
const tools = generateMcpTools(store);
|
|
3496
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3497
|
+
const result = await create.execute({ content: "plain note", tags: ["other"] }) as any;
|
|
3498
|
+
expect(result.validation_status).toBeUndefined();
|
|
3499
|
+
});
|
|
3500
|
+
|
|
3501
|
+
it("validation passes (empty warnings) when fields match types", async () => {
|
|
3502
|
+
await store.upsertTagSchema("task", {
|
|
3503
|
+
fields: {
|
|
3504
|
+
priority: { type: "string", enum: ["high", "medium", "low"] },
|
|
3505
|
+
done: { type: "boolean" },
|
|
3506
|
+
},
|
|
3507
|
+
});
|
|
3508
|
+
|
|
3509
|
+
const tools = generateMcpTools(store);
|
|
3510
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3511
|
+
const result = await create.execute({
|
|
3512
|
+
content: "ok",
|
|
3513
|
+
tags: ["task"],
|
|
3514
|
+
metadata: { priority: "high", done: false },
|
|
3515
|
+
}) as any;
|
|
3516
|
+
|
|
3517
|
+
expect(result.validation_status.schemas).toEqual(["task"]);
|
|
3518
|
+
expect(result.validation_status.warnings).toEqual([]);
|
|
3519
|
+
});
|
|
3520
|
+
|
|
3521
|
+
it("type_mismatch warning when a field's value is the wrong type", async () => {
|
|
3522
|
+
await store.upsertTagSchema("task", {
|
|
3523
|
+
fields: { priority: { type: "string" }, done: { type: "boolean" } },
|
|
3524
|
+
});
|
|
3525
|
+
|
|
3526
|
+
const tools = generateMcpTools(store);
|
|
3527
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3528
|
+
const result = await create.execute({
|
|
3529
|
+
content: "x",
|
|
3530
|
+
tags: ["task"],
|
|
3531
|
+
metadata: { priority: "high", done: "yes" }, // wrong: should be boolean
|
|
3532
|
+
}) as any;
|
|
3533
|
+
|
|
3534
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3535
|
+
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
3536
|
+
expect(result.validation_status.warnings[0].field).toBe("done");
|
|
3537
|
+
});
|
|
3538
|
+
|
|
3539
|
+
it("enum_mismatch warning when a field's value is outside the declared enum", async () => {
|
|
3540
|
+
await store.upsertTagSchema("task", {
|
|
3541
|
+
fields: { priority: { type: "string", enum: ["high", "medium", "low"] } },
|
|
3542
|
+
});
|
|
3543
|
+
|
|
3544
|
+
const tools = generateMcpTools(store);
|
|
3545
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3546
|
+
const result = await create.execute({
|
|
3547
|
+
content: "x",
|
|
3548
|
+
tags: ["task"],
|
|
3549
|
+
metadata: { priority: "ULTRA" },
|
|
3550
|
+
}) as any;
|
|
3551
|
+
|
|
3552
|
+
expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
3553
|
+
});
|
|
3554
|
+
|
|
3555
|
+
it("validation never blocks the write — note exists with warnings attached", async () => {
|
|
3556
|
+
await store.upsertTagSchema("task", {
|
|
3557
|
+
fields: { priority: { type: "boolean" } },
|
|
3558
|
+
});
|
|
3559
|
+
|
|
3560
|
+
const tools = generateMcpTools(store);
|
|
3561
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3562
|
+
const result = await create.execute({
|
|
3563
|
+
content: "x",
|
|
3564
|
+
tags: ["task"],
|
|
3565
|
+
metadata: { priority: "high" }, // wrong type, but the write still lands
|
|
3566
|
+
}) as any;
|
|
3567
|
+
|
|
3568
|
+
expect(result.id).toBeTruthy();
|
|
3569
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3570
|
+
|
|
3571
|
+
const fetched = await store.getNote(result.id);
|
|
3572
|
+
expect(fetched).not.toBeNull();
|
|
3573
|
+
expect(fetched!.content).toBe("x");
|
|
3574
|
+
});
|
|
3575
|
+
|
|
3576
|
+
it("update-note also surfaces validation_status", async () => {
|
|
3577
|
+
await store.upsertTagSchema("task", {
|
|
3578
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
3579
|
+
});
|
|
3580
|
+
const note = await store.createNote("body", { tags: ["task"], metadata: { priority: "high" } });
|
|
3581
|
+
|
|
3582
|
+
const tools = generateMcpTools(store);
|
|
3583
|
+
const update = tools.find((t) => t.name === "update-note")!;
|
|
3584
|
+
const result = await update.execute({
|
|
3585
|
+
id: note.id,
|
|
3586
|
+
metadata: { priority: "ULTRA" },
|
|
3587
|
+
if_updated_at: note.updatedAt,
|
|
3588
|
+
}) as any;
|
|
3589
|
+
|
|
3590
|
+
expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
3591
|
+
});
|
|
3592
|
+
|
|
3593
|
+
it("cache invalidates when a tag schema is updated", async () => {
|
|
3594
|
+
await store.upsertTagSchema("task", {
|
|
3595
|
+
fields: { priority: { type: "boolean" } },
|
|
3596
|
+
});
|
|
3597
|
+
|
|
3598
|
+
const tools = generateMcpTools(store);
|
|
3599
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3600
|
+
let result = await create.execute({
|
|
3601
|
+
content: "x",
|
|
3602
|
+
tags: ["task"],
|
|
3603
|
+
metadata: { priority: "high" },
|
|
3604
|
+
}) as any;
|
|
3605
|
+
expect(result.validation_status.warnings[0].field).toBe("priority");
|
|
3606
|
+
expect(result.validation_status.warnings[0].reason).toBe("type_mismatch");
|
|
3607
|
+
|
|
3608
|
+
// Re-declare with a string type; cache must reflect the change.
|
|
3609
|
+
await store.upsertTagSchema("task", {
|
|
3610
|
+
fields: { priority: { type: "string" } },
|
|
3611
|
+
});
|
|
3612
|
+
result = await create.execute({
|
|
3613
|
+
content: "y",
|
|
3614
|
+
tags: ["task"],
|
|
3615
|
+
metadata: { priority: "high" },
|
|
3616
|
+
}) as any;
|
|
3617
|
+
expect(result.validation_status.warnings).toEqual([]);
|
|
3618
|
+
});
|
|
3619
|
+
|
|
3620
|
+
it("cache invalidates when a tag schema is deleted", async () => {
|
|
3621
|
+
await store.upsertTagSchema("task", {
|
|
3622
|
+
fields: { priority: { type: "boolean" } },
|
|
3623
|
+
});
|
|
3624
|
+
|
|
3625
|
+
const tools = generateMcpTools(store);
|
|
3626
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3627
|
+
let result = await create.execute({
|
|
3628
|
+
content: "x",
|
|
3629
|
+
tags: ["task"],
|
|
3630
|
+
metadata: { priority: "high" },
|
|
3631
|
+
}) as any;
|
|
3632
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3633
|
+
|
|
3634
|
+
await store.deleteTagSchema("task");
|
|
3635
|
+
|
|
3636
|
+
result = await create.execute({
|
|
3637
|
+
content: "y",
|
|
3638
|
+
tags: ["task"],
|
|
3639
|
+
metadata: { priority: "high" },
|
|
3640
|
+
}) as any;
|
|
3641
|
+
expect(result.validation_status).toBeUndefined();
|
|
3642
|
+
});
|
|
3643
|
+
|
|
3644
|
+
it("multiple tag schemas combine warnings", async () => {
|
|
3645
|
+
await store.upsertTagSchema("task", {
|
|
3646
|
+
fields: { priority: { type: "string" } },
|
|
3647
|
+
});
|
|
3648
|
+
await store.upsertTagSchema("project", {
|
|
3649
|
+
fields: { status: { type: "string", enum: ["active", "done"] } },
|
|
3650
|
+
});
|
|
3651
|
+
|
|
3652
|
+
const tools = generateMcpTools(store);
|
|
3653
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3654
|
+
const result = await create.execute({
|
|
3655
|
+
content: "x",
|
|
3656
|
+
tags: ["task", "project"],
|
|
3657
|
+
metadata: { priority: 7, status: "WIP" }, // bad: wrong type, bad enum
|
|
3658
|
+
}) as any;
|
|
3659
|
+
|
|
3660
|
+
expect(result.validation_status.schemas.sort()).toEqual(["project", "task"]);
|
|
3661
|
+
expect(result.validation_status.warnings.length).toBe(2);
|
|
3662
|
+
});
|
|
3663
|
+
|
|
3664
|
+
it("legacy `_schemas/<name>` notes are inert post-v17", async () => {
|
|
3665
|
+
// The notes still write/read fine — they're just no longer interpreted
|
|
3666
|
+
// as schema config. Nothing in tags.fields → no validation.
|
|
3667
|
+
await store.createNote("", {
|
|
3668
|
+
path: "_schemas/task",
|
|
3669
|
+
metadata: { fields: { priority: { type: "string" } } },
|
|
3670
|
+
});
|
|
3671
|
+
await store.createNote("", {
|
|
3672
|
+
path: "_schema_defaults",
|
|
3673
|
+
metadata: { tags: { task: "task" } },
|
|
3674
|
+
});
|
|
3675
|
+
|
|
3676
|
+
const tools = generateMcpTools(store);
|
|
3677
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3678
|
+
const result = await create.execute({ content: "x", tags: ["task"] }) as any;
|
|
3679
|
+
expect(result.validation_status).toBeUndefined();
|
|
3680
|
+
});
|
|
3681
|
+
});
|
|
3682
|
+
|
|
3683
|
+
// ---------------------------------------------------------------------------
|
|
3684
|
+
// Schema inheritance via parent_names + `_default` universal parent — vault#270
|
|
3685
|
+
// ---------------------------------------------------------------------------
|
|
3686
|
+
|
|
3687
|
+
describe("schema inheritance via parent_names (vault#270)", async () => {
|
|
3688
|
+
it("single-parent: child inherits parent's fields", async () => {
|
|
3689
|
+
await store.upsertTagRecord("work", {
|
|
3690
|
+
fields: { project: { type: "string" } },
|
|
3691
|
+
});
|
|
3692
|
+
await store.upsertTagRecord("task", {
|
|
3693
|
+
parent_names: ["work"],
|
|
3694
|
+
fields: { priority: { type: "string" } },
|
|
3695
|
+
});
|
|
3696
|
+
|
|
3697
|
+
const tools = generateMcpTools(store);
|
|
3698
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3699
|
+
const result = await create.execute({
|
|
3700
|
+
content: "x",
|
|
3701
|
+
tags: ["task"],
|
|
3702
|
+
metadata: { priority: 7, project: 42 }, // both wrong type
|
|
3703
|
+
}) as any;
|
|
3704
|
+
|
|
3705
|
+
expect(result.validation_status.warnings.length).toBe(2);
|
|
3706
|
+
const fields = result.validation_status.warnings.map((w: any) => w.field).sort();
|
|
3707
|
+
expect(fields).toEqual(["priority", "project"]);
|
|
3708
|
+
});
|
|
3709
|
+
|
|
3710
|
+
it("multi-parent: child gets union of parents' fields", async () => {
|
|
3711
|
+
await store.upsertTagRecord("work", {
|
|
3712
|
+
fields: { project: { type: "string" } },
|
|
3713
|
+
});
|
|
3714
|
+
await store.upsertTagRecord("publication", {
|
|
3715
|
+
fields: { venue: { type: "string" } },
|
|
3716
|
+
});
|
|
3717
|
+
await store.upsertTagRecord("paper", {
|
|
3718
|
+
parent_names: ["work", "publication"],
|
|
3719
|
+
fields: { title: { type: "string" } },
|
|
3720
|
+
});
|
|
3721
|
+
|
|
3722
|
+
const tools = generateMcpTools(store);
|
|
3723
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3724
|
+
const result = await create.execute({
|
|
3725
|
+
content: "x",
|
|
3726
|
+
tags: ["paper"],
|
|
3727
|
+
metadata: { title: 1, project: 2, venue: 3 }, // all wrong type
|
|
3728
|
+
}) as any;
|
|
3729
|
+
|
|
3730
|
+
expect(result.validation_status.warnings.length).toBe(3);
|
|
3731
|
+
const fields = result.validation_status.warnings.map((w: any) => w.field).sort();
|
|
3732
|
+
expect(fields).toEqual(["project", "title", "venue"]);
|
|
3733
|
+
});
|
|
3734
|
+
|
|
3735
|
+
it("diamond: A→B, A→C, B→D, C→D — D's field appears once", async () => {
|
|
3736
|
+
await store.upsertTagRecord("D", {
|
|
3737
|
+
fields: { d_field: { type: "string" } },
|
|
3738
|
+
});
|
|
3739
|
+
await store.upsertTagRecord("B", { parent_names: ["D"] });
|
|
3740
|
+
await store.upsertTagRecord("C", { parent_names: ["D"] });
|
|
3741
|
+
await store.upsertTagRecord("A", { parent_names: ["B", "C"] });
|
|
3742
|
+
|
|
3743
|
+
const tools = generateMcpTools(store);
|
|
3744
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3745
|
+
const result = await create.execute({
|
|
3746
|
+
content: "x",
|
|
3747
|
+
tags: ["A"],
|
|
3748
|
+
metadata: { d_field: 999 }, // wrong type
|
|
3749
|
+
}) as any;
|
|
3750
|
+
|
|
3751
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3752
|
+
expect(result.validation_status.warnings[0].field).toBe("d_field");
|
|
3753
|
+
expect(result.validation_status.warnings[0].schema).toBe("D");
|
|
3754
|
+
});
|
|
3755
|
+
|
|
3756
|
+
it("cycle: A→B, B→A — no infinite loop, both fields visible", async () => {
|
|
3757
|
+
await store.upsertTagRecord("A", {
|
|
3758
|
+
parent_names: ["B"],
|
|
3759
|
+
fields: { a_field: { type: "string" } },
|
|
3760
|
+
});
|
|
3761
|
+
await store.upsertTagRecord("B", {
|
|
3762
|
+
parent_names: ["A"],
|
|
3763
|
+
fields: { b_field: { type: "string" } },
|
|
3764
|
+
});
|
|
3765
|
+
|
|
3766
|
+
const tools = generateMcpTools(store);
|
|
3767
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3768
|
+
const result = await create.execute({
|
|
3769
|
+
content: "x",
|
|
3770
|
+
tags: ["A"],
|
|
3771
|
+
metadata: { a_field: 1, b_field: 2 }, // both wrong type
|
|
3772
|
+
}) as any;
|
|
3773
|
+
|
|
3774
|
+
expect(result.validation_status.warnings.length).toBe(2);
|
|
3775
|
+
const fields = result.validation_status.warnings.map((w: any) => w.field).sort();
|
|
3776
|
+
expect(fields).toEqual(["a_field", "b_field"]);
|
|
3777
|
+
});
|
|
3778
|
+
|
|
3779
|
+
it("override: child's spec wins over parent's for the same field name", async () => {
|
|
3780
|
+
await store.upsertTagRecord("parent_tag", {
|
|
3781
|
+
fields: { status: { type: "string", enum: ["a", "b"] } },
|
|
3782
|
+
});
|
|
3783
|
+
await store.upsertTagRecord("child_tag", {
|
|
3784
|
+
parent_names: ["parent_tag"],
|
|
3785
|
+
fields: { status: { type: "string", enum: ["x", "y"] } },
|
|
3786
|
+
});
|
|
3787
|
+
|
|
3788
|
+
const tools = generateMcpTools(store);
|
|
3789
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3790
|
+
const result = await create.execute({
|
|
3791
|
+
content: "x",
|
|
3792
|
+
tags: ["child_tag"],
|
|
3793
|
+
metadata: { status: "x" }, // valid under child, invalid under parent
|
|
3794
|
+
}) as any;
|
|
3795
|
+
|
|
3796
|
+
// Child's spec wins → "x" passes the enum check.
|
|
3797
|
+
const enumWarnings = result.validation_status.warnings.filter(
|
|
3798
|
+
(w: any) => w.reason === "enum_mismatch",
|
|
3799
|
+
);
|
|
3800
|
+
expect(enumWarnings.length).toBe(0);
|
|
3801
|
+
// The conflict surfaces as a schema_conflict warning whose `schema` field
|
|
3802
|
+
// names the *winning* tag (child).
|
|
3803
|
+
const conflict = result.validation_status.warnings.find(
|
|
3804
|
+
(w: any) => w.reason === "schema_conflict",
|
|
3805
|
+
);
|
|
3806
|
+
expect(conflict).toBeDefined();
|
|
3807
|
+
expect(conflict.field).toBe("status");
|
|
3808
|
+
expect(conflict.schema).toBe("child_tag");
|
|
3809
|
+
});
|
|
3810
|
+
|
|
3811
|
+
it("conflict warning: two parents declare same field with different specs, first wins", async () => {
|
|
3812
|
+
await store.upsertTagRecord("task", {
|
|
3813
|
+
fields: { status: { type: "string", enum: ["todo", "doing", "done"] } },
|
|
3814
|
+
});
|
|
3815
|
+
await store.upsertTagRecord("publication", {
|
|
3816
|
+
fields: { status: { type: "string", enum: ["draft", "published"] } },
|
|
3817
|
+
});
|
|
3818
|
+
// parent_names order = ["task", "publication"] → task wins.
|
|
3819
|
+
await store.upsertTagRecord("article_task", {
|
|
3820
|
+
parent_names: ["task", "publication"],
|
|
3821
|
+
});
|
|
3822
|
+
|
|
3823
|
+
const tools = generateMcpTools(store);
|
|
3824
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3825
|
+
const result = await create.execute({
|
|
3826
|
+
content: "x",
|
|
3827
|
+
tags: ["article_task"],
|
|
3828
|
+
metadata: { status: "todo" }, // valid under task (winner), invalid under publication
|
|
3829
|
+
}) as any;
|
|
3830
|
+
|
|
3831
|
+
const conflict = result.validation_status.warnings.find(
|
|
3832
|
+
(w: any) => w.reason === "schema_conflict",
|
|
3833
|
+
);
|
|
3834
|
+
expect(conflict).toBeDefined();
|
|
3835
|
+
expect(conflict.field).toBe("status");
|
|
3836
|
+
expect(conflict.schema).toBe("task"); // winner
|
|
3837
|
+
// No enum_mismatch — the value is valid under task's enum.
|
|
3838
|
+
const enumMismatch = result.validation_status.warnings.find(
|
|
3839
|
+
(w: any) => w.reason === "enum_mismatch",
|
|
3840
|
+
);
|
|
3841
|
+
expect(enumMismatch).toBeUndefined();
|
|
3842
|
+
});
|
|
3843
|
+
|
|
3844
|
+
it("`_default` universal parent: untagged note picks up `_default`'s schema", async () => {
|
|
3845
|
+
await store.upsertTagRecord("_default", {
|
|
3846
|
+
fields: { author: { type: "string" } },
|
|
3847
|
+
});
|
|
3848
|
+
|
|
3849
|
+
const tools = generateMcpTools(store);
|
|
3850
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3851
|
+
const result = await create.execute({
|
|
3852
|
+
content: "untagged note",
|
|
3853
|
+
metadata: { author: 42 }, // wrong type
|
|
3854
|
+
}) as any;
|
|
3855
|
+
|
|
3856
|
+
expect(result.validation_status.schemas).toEqual(["_default"]);
|
|
3857
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3858
|
+
expect(result.validation_status.warnings[0].field).toBe("author");
|
|
3859
|
+
});
|
|
3860
|
+
|
|
3861
|
+
it("`_default` universal parent: tagged note gets `_default` + its tag schema", async () => {
|
|
3862
|
+
await store.upsertTagRecord("_default", {
|
|
3863
|
+
fields: { author: { type: "string" } },
|
|
3864
|
+
});
|
|
3865
|
+
await store.upsertTagRecord("task", {
|
|
3866
|
+
fields: { priority: { type: "string" } },
|
|
3867
|
+
});
|
|
3868
|
+
|
|
3869
|
+
const tools = generateMcpTools(store);
|
|
3870
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3871
|
+
const result = await create.execute({
|
|
3872
|
+
content: "x",
|
|
3873
|
+
tags: ["task"],
|
|
3874
|
+
metadata: { author: 1, priority: 2 }, // both wrong type
|
|
3875
|
+
}) as any;
|
|
3876
|
+
|
|
3877
|
+
expect(result.validation_status.warnings.length).toBe(2);
|
|
3878
|
+
const schemas = new Set(
|
|
3879
|
+
result.validation_status.warnings.map((w: any) => w.schema),
|
|
3880
|
+
);
|
|
3881
|
+
expect(schemas.has("_default")).toBe(true);
|
|
3882
|
+
expect(schemas.has("task")).toBe(true);
|
|
3883
|
+
});
|
|
3884
|
+
|
|
3885
|
+
it("`_default` query expansion: query-notes { tag: '_default' } returns every note", async () => {
|
|
3886
|
+
await store.upsertTagRecord("_default", { description: "universal parent" });
|
|
3887
|
+
const a = await store.createNote("alpha", { tags: ["task"] });
|
|
3888
|
+
const b = await store.createNote("beta", { tags: ["project"] });
|
|
3889
|
+
const g = await store.createNote("gamma"); // untagged
|
|
3890
|
+
|
|
3891
|
+
const tools = generateMcpTools(store);
|
|
3892
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
3893
|
+
const result = await query.execute({ tag: "_default" }) as any;
|
|
3894
|
+
|
|
3895
|
+
expect(result.length).toBe(3);
|
|
3896
|
+
const ids = (result as { id: string }[]).map((n) => n.id).sort();
|
|
3897
|
+
expect(ids).toEqual([a.id, b.id, g.id].sort());
|
|
3898
|
+
});
|
|
3899
|
+
|
|
3900
|
+
it("missing parent: non-existent name in parent_names is silently skipped", async () => {
|
|
3901
|
+
await store.upsertTagRecord("task", {
|
|
3902
|
+
parent_names: ["nonexistent_tag"],
|
|
3903
|
+
fields: { priority: { type: "string" } },
|
|
3904
|
+
});
|
|
3905
|
+
|
|
3906
|
+
const tools = generateMcpTools(store);
|
|
3907
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3908
|
+
const result = await create.execute({
|
|
3909
|
+
content: "x",
|
|
3910
|
+
tags: ["task"],
|
|
3911
|
+
metadata: { priority: 999 }, // wrong type
|
|
3912
|
+
}) as any;
|
|
3913
|
+
|
|
3914
|
+
// No error. task's own field still validates.
|
|
3915
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3916
|
+
expect(result.validation_status.warnings[0].field).toBe("priority");
|
|
3917
|
+
});
|
|
3918
|
+
|
|
3919
|
+
it("`_default` deleted mid-session: cache invalidates, default behavior goes away", async () => {
|
|
3920
|
+
await store.upsertTagRecord("_default", {
|
|
3921
|
+
fields: { author: { type: "string" } },
|
|
3922
|
+
});
|
|
3923
|
+
|
|
3924
|
+
const tools = generateMcpTools(store);
|
|
3925
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3926
|
+
let result = await create.execute({
|
|
3927
|
+
content: "x",
|
|
3928
|
+
metadata: { author: 42 }, // wrong type
|
|
3929
|
+
}) as any;
|
|
3930
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
3931
|
+
|
|
3932
|
+
await store.deleteTag("_default");
|
|
3933
|
+
|
|
3934
|
+
result = await create.execute({
|
|
3935
|
+
content: "y",
|
|
3936
|
+
metadata: { author: 42 },
|
|
3937
|
+
}) as any;
|
|
3938
|
+
expect(result.validation_status).toBeUndefined();
|
|
3939
|
+
});
|
|
3940
|
+
|
|
3941
|
+
it("`_default` + `tagMatch: 'any'` drops the tag filter entirely (every note matches)", async () => {
|
|
3942
|
+
// Folded from PR #272 review (N1). With OR-semantics, `_default` matches
|
|
3943
|
+
// everything → the disjunction collapses regardless of what else is in
|
|
3944
|
+
// the list. Pre-fold this would have narrowed to `task`-tagged notes.
|
|
3945
|
+
await store.upsertTagRecord("_default", { description: "universal parent" });
|
|
3946
|
+
const a = await store.createNote("alpha", { tags: ["task"] });
|
|
3947
|
+
const b = await store.createNote("beta", { tags: ["project"] });
|
|
3948
|
+
const g = await store.createNote("gamma"); // untagged
|
|
3949
|
+
|
|
3950
|
+
const results = await store.queryNotes({ tags: ["_default", "task"], tagMatch: "any" });
|
|
3951
|
+
const ids = results.map((n) => n.id).sort();
|
|
3952
|
+
expect(ids).toEqual([a.id, b.id, g.id].sort());
|
|
3953
|
+
});
|
|
3954
|
+
|
|
3955
|
+
it("`_default` + `tagMatch: 'all'` drops only `_default` from the AND-set", async () => {
|
|
3956
|
+
// Symmetric guard for N1: AND-semantics should NOT collapse — `_default`
|
|
3957
|
+
// is universally satisfied so it can be dropped, but other tags still
|
|
3958
|
+
// narrow the result set.
|
|
3959
|
+
await store.upsertTagRecord("_default", { description: "universal parent" });
|
|
3960
|
+
const a = await store.createNote("alpha", { tags: ["task"] });
|
|
3961
|
+
await store.createNote("beta", { tags: ["project"] });
|
|
3962
|
+
await store.createNote("gamma"); // untagged
|
|
3963
|
+
|
|
3964
|
+
const results = await store.queryNotes({ tags: ["_default", "task"], tagMatch: "all" });
|
|
3965
|
+
expect(results.length).toBe(1);
|
|
3966
|
+
expect(results[0].id).toBe(a.id);
|
|
3967
|
+
});
|
|
3968
|
+
|
|
3969
|
+
it("`searchNotes` with `_default` returns matches from every note (including untagged)", async () => {
|
|
3970
|
+
// Folded from PR #272 review (N2). FTS-backed search now short-circuits
|
|
3971
|
+
// the tag filter when `_default` is requested, matching `queryNotes`.
|
|
3972
|
+
await store.upsertTagRecord("_default", { description: "universal parent" });
|
|
3973
|
+
const a = await store.createNote("findme alpha", { tags: ["task"] });
|
|
3974
|
+
const b = await store.createNote("findme beta"); // untagged
|
|
3975
|
+
|
|
3976
|
+
const results = await store.searchNotes("findme", { tags: ["_default"] });
|
|
3977
|
+
const ids = results.map((n) => n.id).sort();
|
|
3978
|
+
expect(ids).toEqual([a.id, b.id].sort());
|
|
3979
|
+
});
|
|
3980
|
+
|
|
3981
|
+
it("`schema_conflict` warning carries structured `loser_schema`", async () => {
|
|
3982
|
+
// Folded from PR #272 review (N3). Agents shouldn't have to regex
|
|
3983
|
+
// `message` to find the overridden tag — surface it structurally.
|
|
3984
|
+
await store.upsertTagRecord("task", {
|
|
3985
|
+
fields: { status: { type: "string", enum: ["todo", "done"] } },
|
|
3986
|
+
});
|
|
3987
|
+
await store.upsertTagRecord("publication", {
|
|
3988
|
+
fields: { status: { type: "string", enum: ["draft", "published"] } },
|
|
3989
|
+
});
|
|
3990
|
+
await store.upsertTagRecord("article_task", {
|
|
3991
|
+
parent_names: ["task", "publication"],
|
|
3992
|
+
});
|
|
3993
|
+
|
|
3994
|
+
const tools = generateMcpTools(store);
|
|
3995
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
3996
|
+
const result = await create.execute({
|
|
3997
|
+
content: "x",
|
|
3998
|
+
tags: ["article_task"],
|
|
3999
|
+
metadata: { status: "todo" },
|
|
4000
|
+
}) as any;
|
|
4001
|
+
|
|
4002
|
+
const conflict = result.validation_status.warnings.find(
|
|
4003
|
+
(w: any) => w.reason === "schema_conflict",
|
|
4004
|
+
);
|
|
4005
|
+
expect(conflict).toBeDefined();
|
|
4006
|
+
expect(conflict.schema).toBe("task"); // winner
|
|
4007
|
+
expect(conflict.loser_schema).toBe("publication"); // overridden
|
|
4008
|
+
});
|
|
4009
|
+
|
|
4010
|
+
it("non-conflict warnings (type/enum mismatch) don't carry `loser_schema`", async () => {
|
|
4011
|
+
// Symmetric guard for N3: `loser_schema` is only meaningful for
|
|
4012
|
+
// schema_conflict; absent on type/enum mismatches.
|
|
4013
|
+
await store.upsertTagSchema("task", {
|
|
4014
|
+
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
4015
|
+
});
|
|
4016
|
+
|
|
4017
|
+
const tools = generateMcpTools(store);
|
|
4018
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
4019
|
+
const result = await create.execute({
|
|
4020
|
+
content: "x",
|
|
4021
|
+
tags: ["task"],
|
|
4022
|
+
metadata: { priority: "ULTRA" },
|
|
4023
|
+
}) as any;
|
|
4024
|
+
|
|
4025
|
+
expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
4026
|
+
expect(result.validation_status.warnings[0].loser_schema).toBeUndefined();
|
|
4027
|
+
});
|
|
4028
|
+
|
|
4029
|
+
it("invalidates schema cache when only parent_names changes (no fields touched)", async () => {
|
|
4030
|
+
// Regression guard: pre-vault#270, parent_names changes only invalidated
|
|
4031
|
+
// the hierarchy cache. Now they must also invalidate the schema cache,
|
|
4032
|
+
// since inheritance walks parent chains at validation time.
|
|
4033
|
+
await store.upsertTagRecord("base", {
|
|
4034
|
+
fields: { tier: { type: "string" } },
|
|
4035
|
+
});
|
|
4036
|
+
await store.upsertTagRecord("derived", { description: "starts orphaned" });
|
|
4037
|
+
|
|
4038
|
+
const tools = generateMcpTools(store);
|
|
4039
|
+
const create = tools.find((t) => t.name === "create-note")!;
|
|
4040
|
+
let result = await create.execute({
|
|
4041
|
+
content: "x",
|
|
4042
|
+
tags: ["derived"],
|
|
4043
|
+
metadata: { tier: 1 },
|
|
4044
|
+
}) as any;
|
|
4045
|
+
expect(result.validation_status).toBeUndefined();
|
|
4046
|
+
|
|
4047
|
+
// Wire up inheritance — fields *not* touched, only parent_names.
|
|
4048
|
+
await store.upsertTagRecord("derived", { parent_names: ["base"] });
|
|
4049
|
+
|
|
4050
|
+
result = await create.execute({
|
|
4051
|
+
content: "y",
|
|
4052
|
+
tags: ["derived"],
|
|
4053
|
+
metadata: { tier: 1 }, // wrong type per base.tier
|
|
4054
|
+
}) as any;
|
|
4055
|
+
expect(result.validation_status.warnings.length).toBe(1);
|
|
4056
|
+
expect(result.validation_status.warnings[0].field).toBe("tier");
|
|
4057
|
+
expect(result.validation_status.warnings[0].schema).toBe("base");
|
|
4058
|
+
});
|
|
4059
|
+
});
|
|
4060
|
+
|
|
4061
|
+
describe("expandTagsWithDescendants (tag-scoped tokens — patterns/tag-scoped-tokens.md)", async () => {
|
|
4062
|
+
it("returns the union of root + every descendant per tags.parent_names", async () => {
|
|
4063
|
+
await store.upsertTagRecord("health/food", { parent_names: ["health"] });
|
|
4064
|
+
await store.upsertTagRecord("health/food/breakfast", { parent_names: ["health/food"] });
|
|
4065
|
+
await store.upsertTagRecord("work", { description: "work things" });
|
|
4066
|
+
|
|
4067
|
+
const expanded = await store.expandTagsWithDescendants(["health"]);
|
|
4068
|
+
expect(expanded.has("health")).toBe(true);
|
|
4069
|
+
expect(expanded.has("health/food")).toBe(true);
|
|
4070
|
+
expect(expanded.has("health/food/breakfast")).toBe(true);
|
|
4071
|
+
expect(expanded.has("work")).toBe(false);
|
|
4072
|
+
});
|
|
4073
|
+
|
|
4074
|
+
it("returns an empty set for an empty input (no allowlist = nothing to expand)", async () => {
|
|
4075
|
+
const expanded = await store.expandTagsWithDescendants([]);
|
|
4076
|
+
expect(expanded.size).toBe(0);
|
|
4077
|
+
});
|
|
4078
|
+
|
|
4079
|
+
it("includes the root verbatim even when the tag has no declared descendants", async () => {
|
|
4080
|
+
await store.createNote("solo", { tags: ["loner"] });
|
|
4081
|
+
const expanded = await store.expandTagsWithDescendants(["loner"]);
|
|
4082
|
+
expect([...expanded]).toEqual(["loner"]);
|
|
4083
|
+
});
|
|
4084
|
+
|
|
4085
|
+
it("unions descendants from multiple roots", async () => {
|
|
4086
|
+
await store.upsertTagRecord("health/food", { parent_names: ["health"] });
|
|
4087
|
+
await store.upsertTagRecord("work/standup", { parent_names: ["work"] });
|
|
4088
|
+
const expanded = await store.expandTagsWithDescendants(["health", "work"]);
|
|
4089
|
+
expect(expanded.has("health")).toBe(true);
|
|
4090
|
+
expect(expanded.has("health/food")).toBe(true);
|
|
4091
|
+
expect(expanded.has("work")).toBe(true);
|
|
4092
|
+
expect(expanded.has("work/standup")).toBe(true);
|
|
4093
|
+
});
|
|
4094
|
+
});
|
|
4095
|
+
|
|
4096
|
+
// ---------------------------------------------------------------------------
|
|
4097
|
+
// Tag record API — patterns/tag-data-model.md
|
|
4098
|
+
// ---------------------------------------------------------------------------
|
|
4099
|
+
|
|
4100
|
+
describe("tag record API (patterns/tag-data-model.md)", async () => {
|
|
4101
|
+
it("upsertTagRecord persists description + fields + relationships + parent_names", async () => {
|
|
4102
|
+
await store.upsertTagRecord("project", {
|
|
4103
|
+
description: "long-running deliverable",
|
|
4104
|
+
fields: { status: { type: "string", enum: ["active", "shipped"] } },
|
|
4105
|
+
relationships: {
|
|
4106
|
+
owned_by: { target_tag: "person", cardinality: "one", description: "DRI" },
|
|
4107
|
+
},
|
|
4108
|
+
parent_names: ["work"],
|
|
4109
|
+
});
|
|
4110
|
+
const r = await store.getTagRecord("project");
|
|
4111
|
+
expect(r?.description).toBe("long-running deliverable");
|
|
4112
|
+
expect(r?.fields?.status?.type).toBe("string");
|
|
4113
|
+
expect(r?.relationships?.owned_by?.target_tag).toBe("person");
|
|
4114
|
+
expect(r?.relationships?.owned_by?.cardinality).toBe("one");
|
|
4115
|
+
expect(r?.parent_names).toEqual(["work"]);
|
|
4116
|
+
expect(r?.created_at).toBeDefined();
|
|
4117
|
+
expect(r?.updated_at).toBeDefined();
|
|
4118
|
+
});
|
|
4119
|
+
|
|
4120
|
+
it("upsertTagRecord preserves columns left undefined in the patch", async () => {
|
|
4121
|
+
await store.upsertTagRecord("project", {
|
|
4122
|
+
description: "first",
|
|
4123
|
+
fields: { status: { type: "string" } },
|
|
4124
|
+
parent_names: ["work"],
|
|
4125
|
+
});
|
|
4126
|
+
await store.upsertTagRecord("project", { description: "second" });
|
|
4127
|
+
const r = await store.getTagRecord("project");
|
|
4128
|
+
expect(r?.description).toBe("second");
|
|
4129
|
+
expect(r?.fields?.status?.type).toBe("string");
|
|
4130
|
+
expect(r?.parent_names).toEqual(["work"]);
|
|
4131
|
+
});
|
|
4132
|
+
|
|
4133
|
+
it("upsertTagRecord clears a column when patch passes null", async () => {
|
|
4134
|
+
await store.upsertTagRecord("project", {
|
|
4135
|
+
description: "deliverable",
|
|
4136
|
+
parent_names: ["work"],
|
|
4137
|
+
});
|
|
4138
|
+
await store.upsertTagRecord("project", { parent_names: null });
|
|
4139
|
+
const r = await store.getTagRecord("project");
|
|
4140
|
+
expect(r?.description).toBe("deliverable");
|
|
4141
|
+
expect(r?.parent_names).toBeUndefined();
|
|
4142
|
+
});
|
|
4143
|
+
|
|
4144
|
+
it("listTagRecords returns every tag row, sorted by name", async () => {
|
|
4145
|
+
await store.upsertTagRecord("zebra", { description: "z" });
|
|
4146
|
+
await store.upsertTagRecord("alpha", { description: "a" });
|
|
4147
|
+
const records = await store.listTagRecords();
|
|
4148
|
+
const names = records.map((r) => r.tag);
|
|
4149
|
+
const idxAlpha = names.indexOf("alpha");
|
|
4150
|
+
const idxZebra = names.indexOf("zebra");
|
|
4151
|
+
expect(idxAlpha).toBeGreaterThanOrEqual(0);
|
|
4152
|
+
expect(idxZebra).toBeGreaterThan(idxAlpha);
|
|
4153
|
+
});
|
|
4154
|
+
|
|
4155
|
+
it("update-tag MCP rejects an invalid cardinality", async () => {
|
|
4156
|
+
const tools = generateMcpTools(store);
|
|
4157
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
4158
|
+
await expect(
|
|
4159
|
+
update.execute({
|
|
4160
|
+
tag: "project",
|
|
4161
|
+
relationships: {
|
|
4162
|
+
owned_by: { target_tag: "person", cardinality: "bogus" },
|
|
4163
|
+
},
|
|
4164
|
+
}),
|
|
4165
|
+
).rejects.toThrow(/cardinality/);
|
|
4166
|
+
});
|
|
4167
|
+
|
|
4168
|
+
it("update-tag MCP accepts every cardinality in the named vocabulary", async () => {
|
|
4169
|
+
const tools = generateMcpTools(store);
|
|
4170
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
4171
|
+
for (const card of ["one", "optional", "many", "many-required"]) {
|
|
4172
|
+
await update.execute({
|
|
4173
|
+
tag: `tag-${card}`,
|
|
4174
|
+
relationships: {
|
|
4175
|
+
rel: { target_tag: "other", cardinality: card },
|
|
4176
|
+
},
|
|
4177
|
+
});
|
|
4178
|
+
const r = await store.getTagRecord(`tag-${card}`);
|
|
4179
|
+
expect(r?.relationships?.rel?.cardinality).toBe(card);
|
|
4180
|
+
}
|
|
4181
|
+
});
|
|
4182
|
+
|
|
4183
|
+
it("update-tag MCP rejects a relationship missing target_tag", async () => {
|
|
4184
|
+
const tools = generateMcpTools(store);
|
|
4185
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
4186
|
+
await expect(
|
|
4187
|
+
update.execute({
|
|
4188
|
+
tag: "project",
|
|
4189
|
+
relationships: { owned_by: { cardinality: "one" } },
|
|
4190
|
+
}),
|
|
4191
|
+
).rejects.toThrow(/target_tag/);
|
|
4192
|
+
});
|
|
4193
|
+
|
|
4194
|
+
it("update-tag MCP sets parent_names and the hierarchy invalidates", async () => {
|
|
4195
|
+
const tools = generateMcpTools(store);
|
|
4196
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
4197
|
+
|
|
4198
|
+
await store.createNote("v note", { tags: ["voice"] });
|
|
4199
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
4200
|
+
|
|
4201
|
+
await update.execute({
|
|
4202
|
+
tag: "voice",
|
|
4203
|
+
parent_names: ["manual"],
|
|
4204
|
+
});
|
|
4205
|
+
|
|
4206
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
4207
|
+
});
|
|
4208
|
+
|
|
4209
|
+
it("update-tag MCP empty parent_names array clears the column", async () => {
|
|
4210
|
+
const tools = generateMcpTools(store);
|
|
4211
|
+
const update = tools.find((t) => t.name === "update-tag")!;
|
|
4212
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
4213
|
+
await update.execute({ tag: "voice", parent_names: [] });
|
|
4214
|
+
const r = await store.getTagRecord("voice");
|
|
4215
|
+
expect(r?.parent_names).toBeUndefined();
|
|
4216
|
+
});
|
|
4217
|
+
|
|
4218
|
+
it("list-tags MCP single-tag detail includes relationships + parent_names", async () => {
|
|
4219
|
+
await store.upsertTagRecord("project", {
|
|
4220
|
+
description: "p",
|
|
4221
|
+
relationships: { owned_by: { target_tag: "person", cardinality: "one" } },
|
|
4222
|
+
parent_names: ["work"],
|
|
4223
|
+
});
|
|
4224
|
+
const tools = generateMcpTools(store);
|
|
4225
|
+
const listTags = tools.find((t) => t.name === "list-tags")!;
|
|
4226
|
+
const result = await listTags.execute({ tag: "project" }) as any;
|
|
4227
|
+
expect(result.relationships?.owned_by?.target_tag).toBe("person");
|
|
4228
|
+
expect(result.parent_names).toEqual(["work"]);
|
|
4229
|
+
expect(result.created_at).toBeDefined();
|
|
4230
|
+
});
|
|
4231
|
+
|
|
4232
|
+
it("list-tags MCP include_schema returns relationships + parent_names per tag", async () => {
|
|
4233
|
+
await store.upsertTagRecord("project", {
|
|
4234
|
+
relationships: { owned_by: { target_tag: "person", cardinality: "one" } },
|
|
4235
|
+
parent_names: ["work"],
|
|
4236
|
+
});
|
|
4237
|
+
await store.createNote("p note", { tags: ["project"] });
|
|
4238
|
+
const tools = generateMcpTools(store);
|
|
4239
|
+
const listTags = tools.find((t) => t.name === "list-tags")!;
|
|
4240
|
+
const all = await listTags.execute({ include_schema: true }) as any[];
|
|
4241
|
+
const project = all.find((t) => t.name === "project")!;
|
|
4242
|
+
expect(project.relationships?.owned_by?.target_tag).toBe("person");
|
|
4243
|
+
expect(project.parent_names).toEqual(["work"]);
|
|
4244
|
+
});
|
|
4245
|
+
|
|
4246
|
+
it("renameTag carries description + fields + relationships + parent_names onto the new row", async () => {
|
|
4247
|
+
await store.upsertTagRecord("old-name", {
|
|
4248
|
+
description: "before",
|
|
4249
|
+
fields: { status: { type: "string" } },
|
|
4250
|
+
relationships: { owned_by: { target_tag: "person", cardinality: "one" } },
|
|
4251
|
+
parent_names: ["work"],
|
|
4252
|
+
});
|
|
4253
|
+
const result = await store.renameTag("old-name", "new-name");
|
|
4254
|
+
expect("renamed" in result).toBe(true);
|
|
4255
|
+
|
|
4256
|
+
const renamed = await store.getTagRecord("new-name");
|
|
4257
|
+
expect(renamed?.description).toBe("before");
|
|
4258
|
+
expect(renamed?.fields?.status?.type).toBe("string");
|
|
4259
|
+
expect(renamed?.relationships?.owned_by?.target_tag).toBe("person");
|
|
4260
|
+
expect(renamed?.parent_names).toEqual(["work"]);
|
|
4261
|
+
|
|
4262
|
+
const old = await store.getTagRecord("old-name");
|
|
4263
|
+
expect(old).toBeNull();
|
|
4264
|
+
});
|
|
4265
|
+
|
|
4266
|
+
it("deleteTag drops the identity row + invalidates the hierarchy", async () => {
|
|
4267
|
+
await store.upsertTagRecord("voice", {
|
|
4268
|
+
description: "voice notes",
|
|
4269
|
+
parent_names: ["manual"],
|
|
4270
|
+
});
|
|
4271
|
+
await store.createNote("v", { tags: ["voice"] });
|
|
4272
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(1);
|
|
4273
|
+
|
|
4274
|
+
await store.deleteTag("voice");
|
|
4275
|
+
expect((await store.queryNotes({ tags: ["manual"] })).length).toBe(0);
|
|
4276
|
+
expect(await store.getTagRecord("voice")).toBeNull();
|
|
4277
|
+
});
|
|
4278
|
+
});
|
|
4279
|
+
|
|
4280
|
+
// ---------------------------------------------------------------------------
|
|
4281
|
+
// Schema migration v13 → v14 — patterns/tag-data-model.md
|
|
4282
|
+
// ---------------------------------------------------------------------------
|
|
4283
|
+
|
|
4284
|
+
describe("schema migration v13 → v14", async () => {
|
|
4285
|
+
it("backfills tags.parent_names from `_tags/<name>` notes", async () => {
|
|
4286
|
+
// Simulate a pre-v14 vault by writing a `_tags/<name>` note + the
|
|
4287
|
+
// legacy tag_schemas row directly via a fresh DB at v13 shape.
|
|
4288
|
+
const { Database } = await import("bun:sqlite");
|
|
4289
|
+
const db = new Database(":memory:");
|
|
4290
|
+
|
|
4291
|
+
// Build the v13 shape inline: tags(name PK only), separate tag_schemas
|
|
4292
|
+
// table, plus a notes row at `_tags/voice`.
|
|
4293
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
4294
|
+
db.exec(`CREATE TABLE notes (
|
|
4295
|
+
id TEXT PRIMARY KEY, content TEXT DEFAULT '', path TEXT,
|
|
4296
|
+
metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT
|
|
4297
|
+
)`);
|
|
4298
|
+
db.exec(`CREATE TABLE tags (name TEXT PRIMARY KEY)`);
|
|
4299
|
+
db.exec(`CREATE TABLE tag_schemas (
|
|
4300
|
+
tag_name TEXT PRIMARY KEY REFERENCES tags(name) ON DELETE CASCADE,
|
|
4301
|
+
description TEXT, fields TEXT
|
|
4302
|
+
)`);
|
|
4303
|
+
|
|
4304
|
+
db.prepare("INSERT INTO tags (name) VALUES (?)").run("voice");
|
|
4305
|
+
db.prepare("INSERT INTO tag_schemas (tag_name, description, fields) VALUES (?, ?, ?)")
|
|
4306
|
+
.run("voice", "voice notes", '{"recorded_at":{"type":"string"}}');
|
|
4307
|
+
db.prepare(`INSERT INTO notes (id, path, metadata, created_at) VALUES (?, ?, ?, ?)`)
|
|
4308
|
+
.run("n1", "_tags/voice", JSON.stringify({ parents: ["manual"] }), new Date().toISOString());
|
|
4309
|
+
|
|
4310
|
+
// Now run initSchema — it should add the v14 columns, copy schema +
|
|
4311
|
+
// hierarchy data onto the tags row, and drop tag_schemas.
|
|
4312
|
+
const { initSchema } = await import("./schema.ts");
|
|
4313
|
+
initSchema(db);
|
|
4314
|
+
|
|
4315
|
+
// tag_schemas should be gone.
|
|
4316
|
+
const tableExists = db.prepare(
|
|
4317
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
|
|
4318
|
+
).get();
|
|
4319
|
+
expect(tableExists).toBeNull();
|
|
4320
|
+
|
|
4321
|
+
// tags row should carry the migrated fields.
|
|
4322
|
+
const row = db.prepare(
|
|
4323
|
+
"SELECT name, description, fields, parent_names FROM tags WHERE name = 'voice'",
|
|
4324
|
+
).get() as any;
|
|
4325
|
+
expect(row.description).toBe("voice notes");
|
|
4326
|
+
expect(JSON.parse(row.fields).recorded_at.type).toBe("string");
|
|
4327
|
+
expect(JSON.parse(row.parent_names)).toEqual(["manual"]);
|
|
4328
|
+
|
|
4329
|
+
// The `_tags/voice` note is left in place as harmless historical record.
|
|
4330
|
+
const note = db.prepare("SELECT id FROM notes WHERE path = '_tags/voice'").get();
|
|
4331
|
+
expect(note).toBeDefined();
|
|
4332
|
+
|
|
4333
|
+
db.close();
|
|
4334
|
+
});
|
|
4335
|
+
|
|
4336
|
+
it("is idempotent — running initSchema twice is a no-op the second time", async () => {
|
|
4337
|
+
const { Database } = await import("bun:sqlite");
|
|
4338
|
+
const { initSchema } = await import("./schema.ts");
|
|
4339
|
+
const db = new Database(":memory:");
|
|
4340
|
+
initSchema(db);
|
|
4341
|
+
db.prepare(`
|
|
4342
|
+
INSERT INTO tags (name, description, parent_names, created_at, updated_at)
|
|
4343
|
+
VALUES (?, ?, ?, ?, ?)
|
|
4344
|
+
`).run(
|
|
4345
|
+
"voice",
|
|
4346
|
+
"voice notes",
|
|
4347
|
+
JSON.stringify(["manual"]),
|
|
4348
|
+
new Date().toISOString(),
|
|
4349
|
+
new Date().toISOString(),
|
|
4350
|
+
);
|
|
4351
|
+
|
|
4352
|
+
// Second run must not throw, must not perturb the row, must not
|
|
4353
|
+
// reintroduce tag_schemas.
|
|
4354
|
+
initSchema(db);
|
|
4355
|
+
|
|
4356
|
+
const row = db.prepare("SELECT description, parent_names FROM tags WHERE name = 'voice'").get() as any;
|
|
4357
|
+
expect(row.description).toBe("voice notes");
|
|
4358
|
+
expect(JSON.parse(row.parent_names)).toEqual(["manual"]);
|
|
4359
|
+
|
|
4360
|
+
const tableExists = db.prepare(
|
|
4361
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
|
|
4362
|
+
).get();
|
|
4363
|
+
expect(tableExists).toBeNull();
|
|
4364
|
+
db.close();
|
|
4365
|
+
});
|
|
4366
|
+
|
|
4367
|
+
// vault#248 — the migration body is wrapped in BEGIN IMMEDIATE / COMMIT
|
|
4368
|
+
// with a try/catch ROLLBACK. A crash mid-migration must leave the DB in
|
|
4369
|
+
// its pre-migration state (NOT half-migrated), and a retry must converge
|
|
4370
|
+
// to the same final state as a clean run. The transaction wrap is what
|
|
4371
|
+
// makes that guarantee — the `hasColumn` / `hasTable` idempotency guards
|
|
4372
|
+
// are belt-and-suspenders, not load-bearing.
|
|
4373
|
+
it("crash mid-migration rolls back to pre-migration state, then retry succeeds", async () => {
|
|
4374
|
+
const { Database } = await import("bun:sqlite");
|
|
4375
|
+
const { initSchema } = await import("./schema.ts");
|
|
4376
|
+
|
|
4377
|
+
const db = new Database(":memory:");
|
|
4378
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
4379
|
+
db.exec(`CREATE TABLE notes (
|
|
4380
|
+
id TEXT PRIMARY KEY, content TEXT DEFAULT '', path TEXT,
|
|
4381
|
+
metadata TEXT DEFAULT '{}', created_at TEXT NOT NULL, updated_at TEXT
|
|
4382
|
+
)`);
|
|
4383
|
+
db.exec(`CREATE TABLE tags (name TEXT PRIMARY KEY)`);
|
|
4384
|
+
db.exec(`CREATE TABLE tag_schemas (
|
|
4385
|
+
tag_name TEXT PRIMARY KEY REFERENCES tags(name) ON DELETE CASCADE,
|
|
4386
|
+
description TEXT, fields TEXT
|
|
4387
|
+
)`);
|
|
4388
|
+
|
|
4389
|
+
db.prepare("INSERT INTO tags (name) VALUES (?)").run("voice");
|
|
4390
|
+
db.prepare("INSERT INTO tag_schemas (tag_name, description, fields) VALUES (?, ?, ?)")
|
|
4391
|
+
.run("voice", "voice notes", '{"recorded_at":{"type":"string"}}');
|
|
4392
|
+
db.prepare(`INSERT INTO notes (id, path, metadata, created_at) VALUES (?, ?, ?, ?)`)
|
|
4393
|
+
.run("n1", "_tags/voice", JSON.stringify({ parents: ["manual"] }), new Date().toISOString());
|
|
4394
|
+
|
|
4395
|
+
// Patch db.exec to simulate a crash on the final DROP TABLE step. That's
|
|
4396
|
+
// the right injection point: every ALTER + data copy has already landed
|
|
4397
|
+
// inside the transaction, so a successful rollback proves the wrap
|
|
4398
|
+
// covers the full migration body — not just the tail.
|
|
4399
|
+
const origExec = db.exec.bind(db);
|
|
4400
|
+
let crashOnDrop: boolean = true;
|
|
4401
|
+
(db as any).exec = function (sql: string) {
|
|
4402
|
+
if (crashOnDrop && sql.includes("DROP TABLE tag_schemas")) {
|
|
4403
|
+
throw new Error("simulated crash mid-migration");
|
|
4404
|
+
}
|
|
4405
|
+
return origExec(sql);
|
|
4406
|
+
};
|
|
4407
|
+
|
|
4408
|
+
expect(() => initSchema(db)).toThrow("simulated crash mid-migration");
|
|
4409
|
+
|
|
4410
|
+
// Pre-migration shape: tag_schemas table still exists with its row,
|
|
4411
|
+
// tags table back to (name) only — none of the v14 columns landed,
|
|
4412
|
+
// `_tags/voice` note untouched, schema_version row not yet written.
|
|
4413
|
+
const tagSchemasStill = db.prepare(
|
|
4414
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
|
|
4415
|
+
).get();
|
|
4416
|
+
expect(tagSchemasStill).toBeTruthy();
|
|
4417
|
+
const schemaRow = db.prepare(
|
|
4418
|
+
"SELECT description, fields FROM tag_schemas WHERE tag_name = 'voice'",
|
|
4419
|
+
).get() as any;
|
|
4420
|
+
expect(schemaRow.description).toBe("voice notes");
|
|
4421
|
+
|
|
4422
|
+
const tagsCols = db.prepare("PRAGMA table_info(tags)").all() as { name: string }[];
|
|
4423
|
+
const colNames = tagsCols.map((c) => c.name).sort();
|
|
4424
|
+
expect(colNames).toEqual(["name"]);
|
|
4425
|
+
|
|
4426
|
+
const note = db.prepare("SELECT path, metadata FROM notes WHERE id = 'n1'").get() as any;
|
|
4427
|
+
expect(note.path).toBe("_tags/voice");
|
|
4428
|
+
expect(JSON.parse(note.metadata).parents).toEqual(["manual"]);
|
|
4429
|
+
|
|
4430
|
+
// No lingering open transaction — a SELECT after rollback succeeds, and
|
|
4431
|
+
// a fresh BEGIN IMMEDIATE doesn't fail with "cannot start a transaction
|
|
4432
|
+
// within a transaction".
|
|
4433
|
+
db.exec("BEGIN IMMEDIATE");
|
|
4434
|
+
db.exec("ROLLBACK");
|
|
4435
|
+
|
|
4436
|
+
// Retry: drop the crash injection, run initSchema again. It must
|
|
4437
|
+
// converge to the same final post-v14 state as a clean run.
|
|
4438
|
+
crashOnDrop = false;
|
|
4439
|
+
initSchema(db);
|
|
4440
|
+
|
|
4441
|
+
const tableGone = db.prepare(
|
|
4442
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='tag_schemas'",
|
|
4443
|
+
).get();
|
|
4444
|
+
expect(tableGone).toBeNull();
|
|
4445
|
+
const post = db.prepare(
|
|
4446
|
+
"SELECT description, fields, parent_names FROM tags WHERE name = 'voice'",
|
|
4447
|
+
).get() as any;
|
|
4448
|
+
expect(post.description).toBe("voice notes");
|
|
4449
|
+
expect(JSON.parse(post.fields).recorded_at.type).toBe("string");
|
|
4450
|
+
expect(JSON.parse(post.parent_names)).toEqual(["manual"]);
|
|
4451
|
+
|
|
4452
|
+
db.close();
|
|
4453
|
+
});
|
|
4454
|
+
});
|
|
4455
|
+
|
|
4456
|
+
// ---------------------------------------------------------------------------
|
|
4457
|
+
// Schema migration v16 → v17 — vault#267 (note_schemas + schema_mappings rip)
|
|
4458
|
+
// ---------------------------------------------------------------------------
|
|
4459
|
+
|
|
4460
|
+
describe("schema migration v16 → v17", async () => {
|
|
4461
|
+
// Build a v16-shape DB with the standalone note_schemas + schema_mappings
|
|
4462
|
+
// tables and a couple of rows, then run initSchema again. The v17 migration
|
|
4463
|
+
// should drop both tables.
|
|
4464
|
+
async function buildV16ShapeWithLegacyTables(): Promise<Database> {
|
|
4465
|
+
const { Database } = await import("bun:sqlite");
|
|
4466
|
+
const db = new Database(":memory:");
|
|
4467
|
+
|
|
4468
|
+
// Create the v16-era schema fragments by hand. We can't call the post-v17
|
|
4469
|
+
// initSchema to do this — SCHEMA_SQL no longer creates the dropped
|
|
4470
|
+
// tables. Build them manually here so the migration test exercises the
|
|
4471
|
+
// "upgrading from v16" path.
|
|
4472
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
4473
|
+
db.exec("PRAGMA foreign_keys = ON");
|
|
4474
|
+
db.exec(`CREATE TABLE note_schemas (
|
|
4475
|
+
name TEXT PRIMARY KEY,
|
|
4476
|
+
description TEXT,
|
|
4477
|
+
fields TEXT,
|
|
4478
|
+
required TEXT,
|
|
4479
|
+
created_at TEXT,
|
|
4480
|
+
updated_at TEXT
|
|
4481
|
+
)`);
|
|
4482
|
+
db.exec(`CREATE TABLE schema_mappings (
|
|
4483
|
+
schema_name TEXT NOT NULL REFERENCES note_schemas(name) ON DELETE CASCADE,
|
|
4484
|
+
match_kind TEXT NOT NULL CHECK (match_kind IN ('path_prefix', 'tag')),
|
|
4485
|
+
match_value TEXT NOT NULL,
|
|
4486
|
+
PRIMARY KEY (schema_name, match_kind, match_value)
|
|
4487
|
+
)`);
|
|
4488
|
+
db.exec("CREATE INDEX idx_schema_mappings_match ON schema_mappings(match_kind, match_value)");
|
|
4489
|
+
|
|
4490
|
+
const now = new Date().toISOString();
|
|
4491
|
+
db.prepare(
|
|
4492
|
+
"INSERT INTO note_schemas (name, description, fields, required, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
4493
|
+
).run("task", "A task", '{"priority":{"type":"string"}}', '["priority"]', now, now);
|
|
4494
|
+
db.prepare(
|
|
4495
|
+
"INSERT INTO note_schemas (name, created_at, updated_at) VALUES (?, ?, ?)",
|
|
4496
|
+
).run("journal-entry", now, now);
|
|
4497
|
+
db.prepare(
|
|
4498
|
+
"INSERT INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
|
|
4499
|
+
).run("task", "tag", "task");
|
|
4500
|
+
db.prepare(
|
|
4501
|
+
"INSERT INTO schema_mappings (schema_name, match_kind, match_value) VALUES (?, ?, ?)",
|
|
4502
|
+
).run("journal-entry", "path_prefix", "journal/");
|
|
4503
|
+
|
|
4504
|
+
return db;
|
|
4505
|
+
}
|
|
4506
|
+
|
|
4507
|
+
it("drops note_schemas + schema_mappings tables on upgrade", async () => {
|
|
4508
|
+
const db = await buildV16ShapeWithLegacyTables();
|
|
4509
|
+
const { initSchema } = await import("./schema.ts");
|
|
4510
|
+
initSchema(db);
|
|
4511
|
+
|
|
4512
|
+
const tables = db.prepare(
|
|
4513
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_schemas','schema_mappings')",
|
|
4514
|
+
).all() as { name: string }[];
|
|
4515
|
+
expect(tables).toEqual([]);
|
|
4516
|
+
|
|
4517
|
+
db.close();
|
|
4518
|
+
});
|
|
4519
|
+
|
|
4520
|
+
it("idempotent — running on an already-v17 vault is a no-op", async () => {
|
|
4521
|
+
const { Database } = await import("bun:sqlite");
|
|
4522
|
+
const { initSchema } = await import("./schema.ts");
|
|
4523
|
+
const db = new Database(":memory:");
|
|
4524
|
+
initSchema(db); // First run: fresh v17 shape.
|
|
4525
|
+
|
|
4526
|
+
// Sanity: the dropped tables don't exist on a fresh vault.
|
|
4527
|
+
let tables = db.prepare(
|
|
4528
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_schemas','schema_mappings')",
|
|
4529
|
+
).all() as { name: string }[];
|
|
4530
|
+
expect(tables).toEqual([]);
|
|
4531
|
+
|
|
4532
|
+
// Second run shouldn't crash and the tables stay absent.
|
|
4533
|
+
initSchema(db);
|
|
4534
|
+
tables = db.prepare(
|
|
4535
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name IN ('note_schemas','schema_mappings')",
|
|
4536
|
+
).all() as { name: string }[];
|
|
4537
|
+
expect(tables).toEqual([]);
|
|
4538
|
+
|
|
4539
|
+
db.close();
|
|
4540
|
+
});
|
|
4541
|
+
|
|
4542
|
+
it("preserves notes + tags + tokens across the rip", async () => {
|
|
4543
|
+
const db = await buildV16ShapeWithLegacyTables();
|
|
4544
|
+
|
|
4545
|
+
// Bring the rest of the schema up to v16 baseline so notes/tags/tokens
|
|
4546
|
+
// exist, then re-run initSchema (which finishes the v17 migration).
|
|
4547
|
+
const { initSchema } = await import("./schema.ts");
|
|
4548
|
+
initSchema(db);
|
|
4549
|
+
|
|
4550
|
+
// Populate some unrelated state and re-run; nothing else should move.
|
|
4551
|
+
await import("./store.ts").then(async ({ SqliteStore }) => {
|
|
4552
|
+
const s = new SqliteStore(db);
|
|
4553
|
+
await s.createNote("hello", { id: "n1", tags: ["task"] });
|
|
4554
|
+
await s.upsertTagRecord("task", { description: "still here" });
|
|
4555
|
+
});
|
|
4556
|
+
initSchema(db);
|
|
4557
|
+
|
|
4558
|
+
const note = db.prepare("SELECT id, content FROM notes WHERE id = ?").get("n1") as any;
|
|
4559
|
+
expect(note?.content).toBe("hello");
|
|
4560
|
+
const tag = db.prepare("SELECT description FROM tags WHERE name = ?").get("task") as any;
|
|
4561
|
+
expect(tag?.description).toBe("still here");
|
|
4562
|
+
|
|
4563
|
+
db.close();
|
|
4564
|
+
});
|
|
4565
|
+
});
|
|
4566
|
+
|
|
4567
|
+
// ---------------------------------------------------------------------------
|
|
4568
|
+
// Schema migration v15 → v16 — vault#257 (tokens.vault_name binding)
|
|
4569
|
+
// ---------------------------------------------------------------------------
|
|
4570
|
+
|
|
4571
|
+
describe("schema migration v15 → v16", async () => {
|
|
4572
|
+
// vault#257 — the v16 migration body (ALTER TABLE ADD COLUMN + CREATE
|
|
4573
|
+
// INDEX) is wrapped in BEGIN IMMEDIATE / COMMIT with a try/catch
|
|
4574
|
+
// ROLLBACK, mirroring the v14/v15 wrap shape from vault#251. The
|
|
4575
|
+
// individual statements are atomic in SQLite, so the wrap is mostly
|
|
4576
|
+
// belt-and-suspenders for THIS migration — but the test mirrors v14's
|
|
4577
|
+
// crash-rollback shape so anyone touching migrations finds the same
|
|
4578
|
+
// regression-pin pattern across versions.
|
|
4579
|
+
it("crash mid-migration rolls back to pre-v16 state, then retry succeeds", async () => {
|
|
4580
|
+
const { Database } = await import("bun:sqlite");
|
|
4581
|
+
const { initSchema } = await import("./schema.ts");
|
|
4582
|
+
|
|
4583
|
+
// Build the full post-v16 shape, plant a row, then drop the v16
|
|
4584
|
+
// additions so initSchema's migrateToV16 fires on the next call.
|
|
4585
|
+
const db = new Database(":memory:");
|
|
4586
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
4587
|
+
initSchema(db);
|
|
4588
|
+
|
|
4589
|
+
const now = new Date().toISOString();
|
|
4590
|
+
db.prepare(
|
|
4591
|
+
"INSERT INTO tokens (token_hash, label, created_at, vault_name) VALUES (?, ?, ?, ?)",
|
|
4592
|
+
).run("sha256:abc123def456", "pre-existing", now, "work");
|
|
4593
|
+
|
|
4594
|
+
db.exec("DROP INDEX IF EXISTS idx_tokens_vault_name");
|
|
4595
|
+
db.exec("ALTER TABLE tokens DROP COLUMN vault_name");
|
|
4596
|
+
|
|
4597
|
+
// Pre-condition: the column is gone but the row is still there
|
|
4598
|
+
// (DROP COLUMN strips the column from existing rows).
|
|
4599
|
+
const preCols = db.prepare("PRAGMA table_info(tokens)").all() as { name: string }[];
|
|
4600
|
+
expect(preCols.map((c) => c.name)).not.toContain("vault_name");
|
|
4601
|
+
const preRow = db.prepare("SELECT label FROM tokens WHERE token_hash = ?")
|
|
4602
|
+
.get("sha256:abc123def456") as { label: string } | null;
|
|
4603
|
+
expect(preRow?.label).toBe("pre-existing");
|
|
4604
|
+
|
|
4605
|
+
// Patch db.exec to crash on CREATE INDEX — the second statement inside
|
|
4606
|
+
// the v16 transaction body. Crashing here proves the wrap covers the
|
|
4607
|
+
// post-ALTER state, not just the tail. The injection deliberately
|
|
4608
|
+
// doesn't match BEGIN/COMMIT/ROLLBACK so the catch's ROLLBACK still
|
|
4609
|
+
// runs through the patched exec.
|
|
4610
|
+
const origExec = db.exec.bind(db);
|
|
4611
|
+
let crashOnIndex: boolean = true;
|
|
4612
|
+
(db as any).exec = function (sql: string) {
|
|
4613
|
+
if (crashOnIndex && sql.includes("CREATE INDEX") && sql.includes("idx_tokens_vault_name")) {
|
|
4614
|
+
throw new Error("simulated crash mid-v16-migration");
|
|
4615
|
+
}
|
|
4616
|
+
return origExec(sql);
|
|
4617
|
+
};
|
|
4618
|
+
|
|
4619
|
+
expect(() => initSchema(db)).toThrow("simulated crash mid-v16-migration");
|
|
4620
|
+
|
|
4621
|
+
// Pre-v16 shape after rollback: vault_name column must not exist; the
|
|
4622
|
+
// pre-existing row must be untouched.
|
|
4623
|
+
const colsAfterRollback = db.prepare("PRAGMA table_info(tokens)").all() as { name: string }[];
|
|
4624
|
+
expect(colsAfterRollback.map((c) => c.name)).not.toContain("vault_name");
|
|
4625
|
+
const idxAfterRollback = db.prepare(
|
|
4626
|
+
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_tokens_vault_name'",
|
|
4627
|
+
).get();
|
|
4628
|
+
expect(idxAfterRollback).toBeNull();
|
|
4629
|
+
const rowAfterRollback = db.prepare("SELECT label FROM tokens WHERE token_hash = ?")
|
|
4630
|
+
.get("sha256:abc123def456") as { label: string } | null;
|
|
4631
|
+
expect(rowAfterRollback?.label).toBe("pre-existing");
|
|
4632
|
+
|
|
4633
|
+
// No lingering open transaction — a fresh BEGIN IMMEDIATE + ROLLBACK
|
|
4634
|
+
// doesn't fail with "cannot start a transaction within a transaction".
|
|
4635
|
+
db.exec("BEGIN IMMEDIATE");
|
|
4636
|
+
db.exec("ROLLBACK");
|
|
4637
|
+
|
|
4638
|
+
// Retry: drop the crash injection, run initSchema again. Must converge
|
|
4639
|
+
// to post-v16 shape (column added, index created, lenient NULL backfill
|
|
4640
|
+
// on the pre-existing row per the migration spec).
|
|
4641
|
+
crashOnIndex = false;
|
|
4642
|
+
initSchema(db);
|
|
4643
|
+
|
|
4644
|
+
const colsPost = db.prepare("PRAGMA table_info(tokens)").all() as { name: string }[];
|
|
4645
|
+
expect(colsPost.map((c) => c.name)).toContain("vault_name");
|
|
4646
|
+
const idxPost = db.prepare(
|
|
4647
|
+
"SELECT name FROM sqlite_master WHERE type='index' AND name='idx_tokens_vault_name'",
|
|
4648
|
+
).get();
|
|
4649
|
+
expect(idxPost).toBeTruthy();
|
|
4650
|
+
const rowPost = db.prepare("SELECT label, vault_name FROM tokens WHERE token_hash = ?")
|
|
4651
|
+
.get("sha256:abc123def456") as { label: string; vault_name: string | null };
|
|
4652
|
+
expect(rowPost.label).toBe("pre-existing");
|
|
4653
|
+
expect(rowPost.vault_name).toBeNull();
|
|
4654
|
+
|
|
4655
|
+
db.close();
|
|
4656
|
+
});
|
|
4657
|
+
});
|
|
4658
|
+
|
|
4659
|
+
// ---------------------------------------------------------------------------
|
|
4660
|
+
// Tag-scope auth post-v14 — patterns/tag-scoped-tokens.md
|
|
4661
|
+
// ---------------------------------------------------------------------------
|
|
4662
|
+
|
|
4663
|
+
describe("tag-scope auth (post-v14 hierarchy)", async () => {
|
|
4664
|
+
it("token allowlisted for `health` matches descendants declared via parent_names", async () => {
|
|
4665
|
+
await store.upsertTagRecord("health/food", { parent_names: ["health"] });
|
|
4666
|
+
await store.upsertTagRecord("health/food/breakfast", { parent_names: ["health/food"] });
|
|
4667
|
+
|
|
4668
|
+
const expanded = await store.expandTagsWithDescendants(["health"]);
|
|
4669
|
+
expect(expanded.has("health")).toBe(true);
|
|
4670
|
+
expect(expanded.has("health/food")).toBe(true);
|
|
4671
|
+
expect(expanded.has("health/food/breakfast")).toBe(true);
|
|
4672
|
+
});
|
|
4673
|
+
|
|
4674
|
+
it("orphan sub-tag fallback: token for `health` still sees `#health/food` even with no declared hierarchy", async () => {
|
|
4675
|
+
// Per patterns/tag-scoped-tokens.md §Storage details, the auth check
|
|
4676
|
+
// also splits on '/' and matches the root verbatim against the raw
|
|
4677
|
+
// allowlist. This survives the v14 source-of-truth swap because the
|
|
4678
|
+
// fallback lives in src/tag-scope.ts, not in the resolver.
|
|
4679
|
+
const { noteWithinTagScope } = await import("../../src/tag-scope.ts");
|
|
4680
|
+
const note = { id: "x", content: "", createdAt: "", tags: ["health/food"] };
|
|
4681
|
+
const allowed = await store.expandTagsWithDescendants(["health"]);
|
|
4682
|
+
// No declared hierarchy — the expansion returns just `health`.
|
|
4683
|
+
expect(allowed.has("health/food")).toBe(false);
|
|
4684
|
+
// But the string-form fallback still matches.
|
|
4685
|
+
expect(noteWithinTagScope(note, allowed, ["health"])).toBe(true);
|
|
4686
|
+
});
|
|
4687
|
+
});
|
|
4688
|
+
|
|
4689
|
+
// ---- Vault projection (vault#271) ----
|
|
4690
|
+
|
|
4691
|
+
describe("vault projection (vault#271)", async () => {
|
|
4692
|
+
it("projects tags-with-schemas with effective inheritance", async () => {
|
|
4693
|
+
const { buildVaultProjection } = await import("./vault-projection.ts");
|
|
4694
|
+
|
|
4695
|
+
// Universal `_default` parent declares `created_by`.
|
|
4696
|
+
await store.upsertTagRecord("_default", {
|
|
4697
|
+
fields: { created_by: { type: "string", description: "Origin agent" } },
|
|
4698
|
+
});
|
|
4699
|
+
// `person` declares `email` and inherits `created_by`.
|
|
4700
|
+
await store.upsertTagRecord("person", {
|
|
4701
|
+
description: "A person",
|
|
4702
|
+
fields: { email: { type: "string", indexed: true } },
|
|
4703
|
+
});
|
|
4704
|
+
// `employee` extends `person` — should inherit BOTH `email` and `created_by`.
|
|
4705
|
+
await store.upsertTagRecord("employee", {
|
|
4706
|
+
description: "Person who works here",
|
|
4707
|
+
fields: { title: { type: "string" } },
|
|
4708
|
+
parent_names: ["person"],
|
|
4709
|
+
});
|
|
4710
|
+
|
|
4711
|
+
const projection = buildVaultProjection(db);
|
|
4712
|
+
|
|
4713
|
+
const byName = Object.fromEntries(projection.tags.map((t) => [t.name, t]));
|
|
4714
|
+
|
|
4715
|
+
// _default appears (has fields).
|
|
4716
|
+
expect(byName._default).toBeTruthy();
|
|
4717
|
+
expect(byName._default.parents).toEqual([]);
|
|
4718
|
+
expect(byName._default.effective_parents).toEqual([]);
|
|
4719
|
+
|
|
4720
|
+
// person inherits _default's universal field.
|
|
4721
|
+
expect(byName.person.parents).toEqual([]);
|
|
4722
|
+
expect(byName.person.effective_parents).toEqual(["_default"]);
|
|
4723
|
+
expect(Object.keys(byName.person.effective_fields).sort()).toEqual([
|
|
4724
|
+
"created_by",
|
|
4725
|
+
"email",
|
|
4726
|
+
]);
|
|
4727
|
+
// own fields stay separate
|
|
4728
|
+
expect(Object.keys(byName.person.fields ?? {})).toEqual(["email"]);
|
|
4729
|
+
|
|
4730
|
+
// employee walks person → _default.
|
|
4731
|
+
expect(byName.employee.parents).toEqual(["person"]);
|
|
4732
|
+
expect(byName.employee.effective_parents).toEqual(["person", "_default"]);
|
|
4733
|
+
expect(Object.keys(byName.employee.effective_fields).sort()).toEqual([
|
|
4734
|
+
"created_by",
|
|
4735
|
+
"email",
|
|
4736
|
+
"title",
|
|
4737
|
+
]);
|
|
4738
|
+
});
|
|
4739
|
+
|
|
4740
|
+
it("catalogs indexed fields across declarers", async () => {
|
|
4741
|
+
const { buildVaultProjection } = await import("./vault-projection.ts");
|
|
4742
|
+
|
|
4743
|
+
// Indexed-field lifecycle is owned by the update-tag MCP tool, not
|
|
4744
|
+
// store.upsertTagRecord — go through the tool so the indexed_fields
|
|
4745
|
+
// table actually gets populated.
|
|
4746
|
+
const tools = generateMcpTools(store);
|
|
4747
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
4748
|
+
await updateTag.execute({
|
|
4749
|
+
tag: "task",
|
|
4750
|
+
fields: { status: { type: "string", indexed: true } },
|
|
4751
|
+
});
|
|
4752
|
+
await updateTag.execute({
|
|
4753
|
+
tag: "project",
|
|
4754
|
+
fields: {
|
|
4755
|
+
status: { type: "string", indexed: true },
|
|
4756
|
+
priority: { type: "integer", indexed: true },
|
|
4757
|
+
},
|
|
4758
|
+
});
|
|
4759
|
+
|
|
4760
|
+
const projection = buildVaultProjection(db);
|
|
4761
|
+
const byName = Object.fromEntries(projection.indexed_fields.map((f) => [f.name, f]));
|
|
4762
|
+
|
|
4763
|
+
expect(byName.status).toBeTruthy();
|
|
4764
|
+
expect(byName.status.type).toBe("string");
|
|
4765
|
+
expect(byName.status.tags.sort()).toEqual(["project", "task"]);
|
|
4766
|
+
|
|
4767
|
+
expect(byName.priority).toBeTruthy();
|
|
4768
|
+
expect(byName.priority.type).toBe("integer");
|
|
4769
|
+
expect(byName.priority.tags).toEqual(["project"]);
|
|
4770
|
+
});
|
|
4771
|
+
|
|
4772
|
+
it("includes the static query-hint catalog", async () => {
|
|
4773
|
+
const { buildVaultProjection, QUERY_HINTS } = await import("./vault-projection.ts");
|
|
4774
|
+
const projection = buildVaultProjection(db);
|
|
4775
|
+
expect(projection.query_hints.length).toBe(QUERY_HINTS.length);
|
|
4776
|
+
expect(projection.query_hints.some((h) => h.startsWith("query-notes { tag:"))).toBe(true);
|
|
4777
|
+
expect(projection.query_hints.some((h) => h.includes("near:"))).toBe(true);
|
|
4778
|
+
});
|
|
4779
|
+
|
|
4780
|
+
it("includes stats only when requested", async () => {
|
|
4781
|
+
const { buildVaultProjection } = await import("./vault-projection.ts");
|
|
4782
|
+
await store.createNote("a", { tags: ["x"] });
|
|
4783
|
+
await store.createNote("b", { tags: ["x", "y"] });
|
|
4784
|
+
|
|
4785
|
+
const without = buildVaultProjection(db);
|
|
4786
|
+
expect(without.stats).toBeUndefined();
|
|
4787
|
+
|
|
4788
|
+
const withStats = buildVaultProjection(db, { includeStats: true });
|
|
4789
|
+
expect(withStats.stats).toBeTruthy();
|
|
4790
|
+
expect(withStats.stats!.totalNotes).toBe(2);
|
|
4791
|
+
expect(withStats.stats!.tagCount).toBe(2);
|
|
4792
|
+
});
|
|
4793
|
+
|
|
4794
|
+
it("degrades gracefully on an empty vault", async () => {
|
|
4795
|
+
const { buildVaultProjection } = await import("./vault-projection.ts");
|
|
4796
|
+
const projection = buildVaultProjection(db);
|
|
4797
|
+
expect(projection.tags).toEqual([]);
|
|
4798
|
+
expect(projection.indexed_fields).toEqual([]);
|
|
4799
|
+
// Query hints are static — present even on a blank vault.
|
|
4800
|
+
expect(projection.query_hints.length).toBeGreaterThan(0);
|
|
4801
|
+
});
|
|
4802
|
+
|
|
4803
|
+
it("renders a markdown brief listing tags-with-schemas and indexed fields", async () => {
|
|
4804
|
+
const { buildVaultProjection, projectionToMarkdown } = await import(
|
|
4805
|
+
"./vault-projection.ts"
|
|
4806
|
+
);
|
|
4807
|
+
|
|
4808
|
+
await store.createNote("a", { tags: ["person"] });
|
|
4809
|
+
const tools = generateMcpTools(store);
|
|
4810
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
4811
|
+
await updateTag.execute({
|
|
4812
|
+
tag: "person",
|
|
4813
|
+
description: "A person",
|
|
4814
|
+
fields: { email: { type: "string", indexed: true } },
|
|
4815
|
+
});
|
|
4816
|
+
|
|
4817
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
4818
|
+
const md = projectionToMarkdown({
|
|
4819
|
+
vaultName: "test",
|
|
4820
|
+
description: "My vault",
|
|
4821
|
+
projection,
|
|
4822
|
+
});
|
|
4823
|
+
|
|
4824
|
+
expect(md).toContain('You are connected to Parachute Vault "test"');
|
|
4825
|
+
expect(md).toContain("My vault");
|
|
4826
|
+
expect(md).toContain("1 tag with schemas: person");
|
|
4827
|
+
expect(md).toContain("Indexed metadata fields");
|
|
4828
|
+
expect(md).toContain("email");
|
|
4829
|
+
expect(md).toContain("#person");
|
|
4830
|
+
expect(md).toContain("vault-info");
|
|
4831
|
+
expect(md).toContain("list-tags { include_schema: true }");
|
|
4832
|
+
});
|
|
4833
|
+
|
|
4834
|
+
it("markdown brief degrades gracefully when no schemas declared", async () => {
|
|
4835
|
+
const { buildVaultProjection, projectionToMarkdown } = await import(
|
|
4836
|
+
"./vault-projection.ts"
|
|
4837
|
+
);
|
|
4838
|
+
|
|
4839
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
4840
|
+
const md = projectionToMarkdown({
|
|
4841
|
+
vaultName: "fresh",
|
|
4842
|
+
description: null,
|
|
4843
|
+
projection,
|
|
4844
|
+
});
|
|
4845
|
+
|
|
4846
|
+
expect(md).toContain('Parachute Vault "fresh"');
|
|
4847
|
+
expect(md).toContain("No tag schemas declared");
|
|
4848
|
+
expect(md).toContain("No indexed metadata fields");
|
|
4849
|
+
expect(md).toContain("Querying");
|
|
4850
|
+
});
|
|
4851
|
+
|
|
4852
|
+
it("markdown brief stays under ~5K tokens for a 50-tags-with-schemas vault", async () => {
|
|
4853
|
+
const { buildVaultProjection, projectionToMarkdown } = await import(
|
|
4854
|
+
"./vault-projection.ts"
|
|
4855
|
+
);
|
|
4856
|
+
|
|
4857
|
+
for (let i = 0; i < 50; i++) {
|
|
4858
|
+
await store.upsertTagRecord(`schema_tag_${i}`, {
|
|
4859
|
+
description: `Description for tag ${i} — covers what this tag is used for in the vault.`,
|
|
4860
|
+
fields: {
|
|
4861
|
+
[`field_${i}_a`]: { type: "string", indexed: i % 3 === 0 },
|
|
4862
|
+
[`field_${i}_b`]: { type: "integer" },
|
|
4863
|
+
},
|
|
4864
|
+
});
|
|
4865
|
+
}
|
|
4866
|
+
|
|
4867
|
+
const projection = buildVaultProjection(db, { includeStats: true });
|
|
4868
|
+
const md = projectionToMarkdown({
|
|
4869
|
+
vaultName: "big",
|
|
4870
|
+
description: "Big test vault",
|
|
4871
|
+
projection,
|
|
4872
|
+
});
|
|
4873
|
+
|
|
4874
|
+
// Rough token approximation: 1 token ≈ 4 chars. Budget: 5K tokens.
|
|
4875
|
+
const approxTokens = md.length / 4;
|
|
4876
|
+
expect(approxTokens).toBeLessThan(5000);
|
|
4877
|
+
});
|
|
4878
|
+
});
|