@openparachute/vault 0.4.0 → 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/README.md +133 -0
- package/core/src/core.test.ts +1171 -518
- package/core/src/mcp.ts +37 -426
- package/core/src/notes.ts +405 -32
- package/core/src/schema-defaults.ts +214 -170
- package/core/src/schema.ts +104 -32
- package/core/src/store.ts +90 -78
- package/core/src/tag-hierarchy.ts +36 -2
- package/core/src/types.ts +37 -42
- package/core/src/vault-projection.ts +309 -0
- package/package.json +2 -2
- package/src/auth-hub-jwt.test.ts +142 -13
- package/src/auth.ts +29 -0
- package/src/hub-jwt.test.ts +16 -5
- package/src/hub-jwt.ts +9 -0
- package/src/mcp-http.ts +4 -2
- package/src/mcp-tools.ts +101 -90
- package/src/routes.ts +313 -206
- package/src/routing.test.ts +12 -12
- package/src/routing.ts +0 -2
- package/src/tokens-routes.test.ts +11 -4
- package/src/vault.test.ts +875 -297
- package/core/src/note-schemas.ts +0 -232
package/core/src/core.test.ts
CHANGED
|
@@ -357,7 +357,7 @@ describe("renameTag", async () => {
|
|
|
357
357
|
const n2 = await store.createNote("B", { tags: ["voice", "keeper"] });
|
|
358
358
|
|
|
359
359
|
const result = await store.renameTag("voice", "memo");
|
|
360
|
-
expect(result).
|
|
360
|
+
expect(result).toMatchObject({ renamed: 2, sub_tags_renamed: 0 });
|
|
361
361
|
|
|
362
362
|
expect((await store.getNote(n1.id))!.tags).toEqual(["memo"]);
|
|
363
363
|
expect((await store.getNote(n2.id))!.tags?.sort()).toEqual(["keeper", "memo"]);
|
|
@@ -386,7 +386,7 @@ describe("renameTag", async () => {
|
|
|
386
386
|
await store.untagNote((await store.queryNotes({}))[0].id, ["doomed"]);
|
|
387
387
|
|
|
388
388
|
const result = await store.renameTag("doomed", "archived");
|
|
389
|
-
expect(result).
|
|
389
|
+
expect(result).toMatchObject({ renamed: 0, sub_tags_renamed: 0 });
|
|
390
390
|
const tags = await store.listTags();
|
|
391
391
|
expect(tags.some((t) => t.name === "doomed")).toBe(false);
|
|
392
392
|
expect(tags.some((t) => t.name === "archived")).toBe(true);
|
|
@@ -397,7 +397,7 @@ describe("renameTag", async () => {
|
|
|
397
397
|
await store.createNote("B", { tags: ["new"] });
|
|
398
398
|
|
|
399
399
|
const result = await store.renameTag("old", "new");
|
|
400
|
-
expect(result).
|
|
400
|
+
expect(result).toMatchObject({ error: "target_exists", conflicting: ["new"] });
|
|
401
401
|
|
|
402
402
|
// No bleed — both tags still present with their original counts.
|
|
403
403
|
const tags = await store.listTags();
|
|
@@ -413,11 +413,325 @@ describe("renameTag", async () => {
|
|
|
413
413
|
it("same-name rename is a no-op on an existing tag", async () => {
|
|
414
414
|
await store.createNote("A", { tags: ["voice"] });
|
|
415
415
|
const result = await store.renameTag("voice", "voice");
|
|
416
|
-
expect(result).
|
|
416
|
+
expect(result).toMatchObject({ renamed: 0, sub_tags_renamed: 0 });
|
|
417
417
|
expect((await store.listTags()).find((t) => t.name === "voice")!.count).toBe(1);
|
|
418
418
|
});
|
|
419
419
|
});
|
|
420
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
|
+
|
|
421
735
|
describe("mergeTags", async () => {
|
|
422
736
|
it("retags every note from every source onto target and drops sources", async () => {
|
|
423
737
|
const n1 = await store.createNote("A", { tags: ["v1"] });
|
|
@@ -775,6 +1089,58 @@ describe("queryNotes", async () => {
|
|
|
775
1089
|
}) as any[];
|
|
776
1090
|
expect(results.map((n) => n.content)).toEqual(["recent email"]);
|
|
777
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
|
+
});
|
|
778
1144
|
});
|
|
779
1145
|
|
|
780
1146
|
it("sorts ascending and descending", async () => {
|
|
@@ -1161,16 +1527,22 @@ describe("MCP tools", async () => {
|
|
|
1161
1527
|
expect(names).toContain("list-tags");
|
|
1162
1528
|
expect(names).toContain("update-tag");
|
|
1163
1529
|
expect(names).toContain("delete-tag");
|
|
1164
|
-
expect(names).toContain("list-note-schemas");
|
|
1165
|
-
expect(names).toContain("update-note-schema");
|
|
1166
|
-
expect(names).toContain("delete-note-schema");
|
|
1167
|
-
expect(names).toContain("list-schema-mappings");
|
|
1168
|
-
expect(names).toContain("set-schema-mapping");
|
|
1169
|
-
expect(names).toContain("delete-schema-mapping");
|
|
1170
1530
|
expect(names).toContain("find-path");
|
|
1171
|
-
expect(names).toContain("synthesize-notes");
|
|
1172
1531
|
expect(names).toContain("vault-info");
|
|
1173
|
-
|
|
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");
|
|
1545
|
+
expect(tools).toHaveLength(9);
|
|
1174
1546
|
});
|
|
1175
1547
|
|
|
1176
1548
|
it("create-note tool works", async () => {
|
|
@@ -1391,6 +1763,96 @@ describe("MCP tools", async () => {
|
|
|
1391
1763
|
expect((await store.getNote(note.id))!.content).toBe("Test");
|
|
1392
1764
|
});
|
|
1393
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
|
+
|
|
1394
1856
|
it("update-note force:true bypasses precondition and mutates unconditionally", async () => {
|
|
1395
1857
|
const note = await store.createNote("First");
|
|
1396
1858
|
const tools = generateMcpTools(store);
|
|
@@ -2205,183 +2667,6 @@ describe("MCP tools", async () => {
|
|
|
2205
2667
|
expect(result.relationships).toEqual(["mentions", "related-to"]);
|
|
2206
2668
|
});
|
|
2207
2669
|
|
|
2208
|
-
// ---- synthesize-notes ----
|
|
2209
|
-
|
|
2210
|
-
it("synthesize-notes rejects when neither anchor nor query is supplied", async () => {
|
|
2211
|
-
const tools = generateMcpTools(store);
|
|
2212
|
-
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2213
|
-
const result = await synth.execute({}) as any;
|
|
2214
|
-
expect(result.error).toMatch(/at least one of `anchor` or `query`/);
|
|
2215
|
-
});
|
|
2216
|
-
|
|
2217
|
-
it("synthesize-notes rejects an unknown anchor", async () => {
|
|
2218
|
-
const tools = generateMcpTools(store);
|
|
2219
|
-
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2220
|
-
const result = await synth.execute({ anchor: "does-not-exist" }) as any;
|
|
2221
|
-
expect(result.error).toMatch(/Anchor note not found/);
|
|
2222
|
-
});
|
|
2223
|
-
|
|
2224
|
-
it("synthesize-notes returns anchor + linked neighbors ranked", async () => {
|
|
2225
|
-
await store.createNote("Hub note", { id: "hub", tags: ["topic"] });
|
|
2226
|
-
await store.createNote("Direct neighbor", { id: "n1" });
|
|
2227
|
-
await store.createNote("Two-hop neighbor", { id: "n2" });
|
|
2228
|
-
await store.createNote("Unrelated", { id: "u1" });
|
|
2229
|
-
await store.createLink("hub", "n1", "mentions");
|
|
2230
|
-
await store.createLink("n1", "n2", "mentions");
|
|
2231
|
-
|
|
2232
|
-
const tools = generateMcpTools(store);
|
|
2233
|
-
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2234
|
-
const result = await synth.execute({ anchor: "hub", depth: 2 }) as any;
|
|
2235
|
-
|
|
2236
|
-
const ids = result.notes.map((n: any) => n.id);
|
|
2237
|
-
expect(ids).toContain("hub");
|
|
2238
|
-
expect(ids).toContain("n1");
|
|
2239
|
-
expect(ids).toContain("n2");
|
|
2240
|
-
expect(ids).not.toContain("u1");
|
|
2241
|
-
// Anchor ranks first; one-hop beats two-hop.
|
|
2242
|
-
expect(ids[0]).toBe("hub");
|
|
2243
|
-
expect(ids.indexOf("n1")).toBeLessThan(ids.indexOf("n2"));
|
|
2244
|
-
expect(result.notes[0].sources).toContain("anchor");
|
|
2245
|
-
expect(result.notes[0].distance).toBe(0);
|
|
2246
|
-
expect(result.topic.anchor).toEqual({ id: "hub", path: null });
|
|
2247
|
-
});
|
|
2248
|
-
|
|
2249
|
-
it("synthesize-notes returns FTS hits when only query is supplied", async () => {
|
|
2250
|
-
await store.createNote("Octopus thoughts: ink and color", { id: "o1" });
|
|
2251
|
-
await store.createNote("Squid thoughts: deep blue", { id: "s1" });
|
|
2252
|
-
await store.createNote("Recipe for octopus salad", { id: "o2" });
|
|
2253
|
-
|
|
2254
|
-
const tools = generateMcpTools(store);
|
|
2255
|
-
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2256
|
-
const result = await synth.execute({ query: "octopus" }) as any;
|
|
2257
|
-
|
|
2258
|
-
const ids = result.notes.map((n: any) => n.id);
|
|
2259
|
-
expect(ids).toContain("o1");
|
|
2260
|
-
expect(ids).toContain("o2");
|
|
2261
|
-
expect(ids).not.toContain("s1");
|
|
2262
|
-
expect(result.notes[0].sources).toContain("search");
|
|
2263
|
-
expect(result.notes[0]).toHaveProperty("fts_rank");
|
|
2264
|
-
expect(result.topic.query).toBe("octopus");
|
|
2265
|
-
});
|
|
2266
|
-
|
|
2267
|
-
it("synthesize-notes applies scope.tags filter (any-match)", async () => {
|
|
2268
|
-
await store.createNote("Anchor", { id: "a" });
|
|
2269
|
-
await store.createNote("Tagged neighbor", { id: "t1", tags: ["alpha"] });
|
|
2270
|
-
await store.createNote("Other neighbor", { id: "t2", tags: ["beta"] });
|
|
2271
|
-
await store.createLink("a", "t1", "mentions");
|
|
2272
|
-
await store.createLink("a", "t2", "mentions");
|
|
2273
|
-
|
|
2274
|
-
const tools = generateMcpTools(store);
|
|
2275
|
-
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2276
|
-
const result = await synth.execute({ anchor: "a", scope: { tags: ["alpha"] } }) as any;
|
|
2277
|
-
|
|
2278
|
-
const ids = result.notes.map((n: any) => n.id);
|
|
2279
|
-
expect(ids).toContain("t1");
|
|
2280
|
-
expect(ids).not.toContain("t2");
|
|
2281
|
-
expect(ids).not.toContain("a"); // anchor itself has no tags
|
|
2282
|
-
});
|
|
2283
|
-
|
|
2284
|
-
it("synthesize-notes applies scope.path prefix filter", async () => {
|
|
2285
|
-
await store.createNote("Anchor", { id: "a" });
|
|
2286
|
-
await store.createNote("In scope", { id: "p1", path: "Projects/Alpha/notes" });
|
|
2287
|
-
await store.createNote("Out of scope", { id: "p2", path: "People/Bob" });
|
|
2288
|
-
await store.createLink("a", "p1", "mentions");
|
|
2289
|
-
await store.createLink("a", "p2", "mentions");
|
|
2290
|
-
|
|
2291
|
-
const tools = generateMcpTools(store);
|
|
2292
|
-
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2293
|
-
const result = await synth.execute({ anchor: "a", scope: { path: "projects/" } }) as any;
|
|
2294
|
-
|
|
2295
|
-
const ids = result.notes.map((n: any) => n.id);
|
|
2296
|
-
expect(ids).toContain("p1");
|
|
2297
|
-
expect(ids).not.toContain("p2");
|
|
2298
|
-
});
|
|
2299
|
-
|
|
2300
|
-
it("synthesize-notes respects limit and sets truncated flag", async () => {
|
|
2301
|
-
await store.createNote("Anchor", { id: "anchor" });
|
|
2302
|
-
for (let i = 0; i < 5; i++) {
|
|
2303
|
-
await store.createNote(`Neighbor ${i}`, { id: `n${i}` });
|
|
2304
|
-
await store.createLink("anchor", `n${i}`, "mentions");
|
|
2305
|
-
}
|
|
2306
|
-
|
|
2307
|
-
const tools = generateMcpTools(store);
|
|
2308
|
-
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2309
|
-
const result = await synth.execute({ anchor: "anchor", limit: 3 }) as any;
|
|
2310
|
-
|
|
2311
|
-
expect(result.notes).toHaveLength(3);
|
|
2312
|
-
expect(result.truncated).toBe(true);
|
|
2313
|
-
});
|
|
2314
|
-
|
|
2315
|
-
it("synthesize-notes connections include only links between returned notes", async () => {
|
|
2316
|
-
await store.createNote("Anchor", { id: "a" });
|
|
2317
|
-
await store.createNote("In set", { id: "b" });
|
|
2318
|
-
await store.createNote("Excluded", { id: "c" });
|
|
2319
|
-
await store.createLink("a", "b", "mentions");
|
|
2320
|
-
await store.createLink("a", "c", "mentions");
|
|
2321
|
-
await store.createLink("b", "c", "related-to");
|
|
2322
|
-
|
|
2323
|
-
const tools = generateMcpTools(store);
|
|
2324
|
-
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2325
|
-
const result = await synth.execute({ anchor: "a", depth: 1, limit: 2 }) as any;
|
|
2326
|
-
|
|
2327
|
-
const ids = new Set(result.notes.map((n: any) => n.id));
|
|
2328
|
-
for (const c of result.connections) {
|
|
2329
|
-
expect(ids.has(c.source)).toBe(true);
|
|
2330
|
-
expect(ids.has(c.target)).toBe(true);
|
|
2331
|
-
}
|
|
2332
|
-
expect(result.connections.some((c: any) => c.target === "c")).toBe(false);
|
|
2333
|
-
});
|
|
2334
|
-
|
|
2335
|
-
it("synthesize-notes timeline orders oldest → newest", async () => {
|
|
2336
|
-
await store.createNote("Old", { id: "old", created_at: "2024-01-01T00:00:00.000Z" });
|
|
2337
|
-
await store.createNote("Mid", { id: "mid", created_at: "2025-01-01T00:00:00.000Z" });
|
|
2338
|
-
await store.createNote("New", { id: "new", created_at: "2026-01-01T00:00:00.000Z" });
|
|
2339
|
-
await store.createLink("new", "mid", "mentions");
|
|
2340
|
-
await store.createLink("mid", "old", "mentions");
|
|
2341
|
-
|
|
2342
|
-
const tools = generateMcpTools(store);
|
|
2343
|
-
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2344
|
-
const result = await synth.execute({ anchor: "new", depth: 2 }) as any;
|
|
2345
|
-
|
|
2346
|
-
const ids = result.timeline.map((t: any) => t.id);
|
|
2347
|
-
expect(ids).toEqual(["old", "mid", "new"]);
|
|
2348
|
-
});
|
|
2349
|
-
|
|
2350
|
-
it("synthesize-notes tag distribution counts tags across results", async () => {
|
|
2351
|
-
await store.createNote("Anchor", { id: "a", tags: ["alpha", "beta"] });
|
|
2352
|
-
await store.createNote("N1", { id: "n1", tags: ["alpha"] });
|
|
2353
|
-
await store.createNote("N2", { id: "n2", tags: ["alpha", "gamma"] });
|
|
2354
|
-
await store.createLink("a", "n1", "mentions");
|
|
2355
|
-
await store.createLink("a", "n2", "mentions");
|
|
2356
|
-
|
|
2357
|
-
const tools = generateMcpTools(store);
|
|
2358
|
-
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2359
|
-
const result = await synth.execute({ anchor: "a" }) as any;
|
|
2360
|
-
|
|
2361
|
-
const tagMap = new Map(result.tags.map((t: any) => [t.name, t.count]));
|
|
2362
|
-
expect(tagMap.get("alpha")).toBe(3);
|
|
2363
|
-
expect(tagMap.get("beta")).toBe(1);
|
|
2364
|
-
expect(tagMap.get("gamma")).toBe(1);
|
|
2365
|
-
expect(result.tags[0].name).toBe("alpha");
|
|
2366
|
-
});
|
|
2367
|
-
|
|
2368
|
-
it("synthesize-notes include_content controls snippet vs full body", async () => {
|
|
2369
|
-
const longBody = "x".repeat(500);
|
|
2370
|
-
await store.createNote(longBody, { id: "long", path: "Long" });
|
|
2371
|
-
|
|
2372
|
-
const tools = generateMcpTools(store);
|
|
2373
|
-
const synth = tools.find((t) => t.name === "synthesize-notes")!;
|
|
2374
|
-
|
|
2375
|
-
const snippetResult = await synth.execute({ anchor: "long" }) as any;
|
|
2376
|
-
expect(snippetResult.notes[0]).toHaveProperty("snippet");
|
|
2377
|
-
expect(snippetResult.notes[0]).not.toHaveProperty("content");
|
|
2378
|
-
expect(snippetResult.notes[0].snippet.length).toBeLessThanOrEqual(200);
|
|
2379
|
-
|
|
2380
|
-
const fullResult = await synth.execute({ anchor: "long", include_content: true }) as any;
|
|
2381
|
-
expect(fullResult.notes[0].content).toBe(longBody);
|
|
2382
|
-
expect(fullResult.notes[0]).not.toHaveProperty("snippet");
|
|
2383
|
-
});
|
|
2384
|
-
|
|
2385
2670
|
it("create-note via store triggers wikilink sync", async () => {
|
|
2386
2671
|
const tools = generateMcpTools(store);
|
|
2387
2672
|
const createNote = tools.find((t) => t.name === "create-note")!;
|
|
@@ -3184,64 +3469,42 @@ describe("tag hierarchy (tags.parent_names)", async () => {
|
|
|
3184
3469
|
});
|
|
3185
3470
|
|
|
3186
3471
|
// ---------------------------------------------------------------------------
|
|
3187
|
-
//
|
|
3472
|
+
// Schema validation — driven by `tags.fields` (post-v17, vault#267)
|
|
3188
3473
|
// ---------------------------------------------------------------------------
|
|
3189
3474
|
// Originally written against the `_schemas/<name>` + `_schema_defaults`
|
|
3190
|
-
// notes-as-config convention (issue #177)
|
|
3191
|
-
//
|
|
3192
|
-
//
|
|
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).
|
|
3193
3480
|
|
|
3194
|
-
describe("
|
|
3195
|
-
it("returns no validation_status when no
|
|
3481
|
+
describe("schema validation (tags.fields)", async () => {
|
|
3482
|
+
it("returns no validation_status when no tag declares fields", async () => {
|
|
3196
3483
|
const tools = generateMcpTools(store);
|
|
3197
3484
|
const create = tools.find((t) => t.name === "create-note")!;
|
|
3198
3485
|
const result = await create.execute({ content: "plain note" }) as any;
|
|
3199
3486
|
expect(result.validation_status).toBeUndefined();
|
|
3200
3487
|
});
|
|
3201
3488
|
|
|
3202
|
-
it("
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
fields: { priority: { type: "string"
|
|
3206
|
-
required: ["priority"],
|
|
3207
|
-
});
|
|
3208
|
-
await store.setSchemaMapping("task", "tag", "task");
|
|
3209
|
-
|
|
3210
|
-
const tools = generateMcpTools(store);
|
|
3211
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
3212
|
-
const result = await create.execute({ content: "do thing", tags: ["task"] }) as any;
|
|
3213
|
-
|
|
3214
|
-
expect(result.validation_status).toBeTruthy();
|
|
3215
|
-
expect(result.validation_status.schemas).toEqual(["task"]);
|
|
3216
|
-
expect(result.validation_status.warnings.length).toBe(1);
|
|
3217
|
-
expect(result.validation_status.warnings[0].reason).toBe("missing_required");
|
|
3218
|
-
expect(result.validation_status.warnings[0].field).toBe("priority");
|
|
3219
|
-
});
|
|
3220
|
-
|
|
3221
|
-
it("attaches validation_status when a schema matches by path prefix", async () => {
|
|
3222
|
-
await store.upsertNoteSchema("journal-entry", {
|
|
3223
|
-
fields: { mood: { type: "string" } },
|
|
3224
|
-
required: ["mood"],
|
|
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" } },
|
|
3225
3493
|
});
|
|
3226
|
-
await store.setSchemaMapping("journal-entry", "path_prefix", "journal/");
|
|
3227
3494
|
|
|
3228
3495
|
const tools = generateMcpTools(store);
|
|
3229
3496
|
const create = tools.find((t) => t.name === "create-note")!;
|
|
3230
|
-
const result = await create.execute({ content: "
|
|
3231
|
-
|
|
3232
|
-
expect(result.validation_status.schemas).toEqual(["journal-entry"]);
|
|
3233
|
-
expect(result.validation_status.warnings[0].reason).toBe("missing_required");
|
|
3497
|
+
const result = await create.execute({ content: "plain note", tags: ["other"] }) as any;
|
|
3498
|
+
expect(result.validation_status).toBeUndefined();
|
|
3234
3499
|
});
|
|
3235
3500
|
|
|
3236
|
-
it("validation passes (empty warnings) when
|
|
3237
|
-
await store.
|
|
3501
|
+
it("validation passes (empty warnings) when fields match types", async () => {
|
|
3502
|
+
await store.upsertTagSchema("task", {
|
|
3238
3503
|
fields: {
|
|
3239
3504
|
priority: { type: "string", enum: ["high", "medium", "low"] },
|
|
3240
3505
|
done: { type: "boolean" },
|
|
3241
3506
|
},
|
|
3242
|
-
required: ["priority"],
|
|
3243
3507
|
});
|
|
3244
|
-
await store.setSchemaMapping("task", "tag", "task");
|
|
3245
3508
|
|
|
3246
3509
|
const tools = generateMcpTools(store);
|
|
3247
3510
|
const create = tools.find((t) => t.name === "create-note")!;
|
|
@@ -3256,10 +3519,9 @@ describe("note schemas (note_schemas + schema_mappings)", async () => {
|
|
|
3256
3519
|
});
|
|
3257
3520
|
|
|
3258
3521
|
it("type_mismatch warning when a field's value is the wrong type", async () => {
|
|
3259
|
-
await store.
|
|
3522
|
+
await store.upsertTagSchema("task", {
|
|
3260
3523
|
fields: { priority: { type: "string" }, done: { type: "boolean" } },
|
|
3261
3524
|
});
|
|
3262
|
-
await store.setSchemaMapping("task", "tag", "task");
|
|
3263
3525
|
|
|
3264
3526
|
const tools = generateMcpTools(store);
|
|
3265
3527
|
const create = tools.find((t) => t.name === "create-note")!;
|
|
@@ -3275,10 +3537,9 @@ describe("note schemas (note_schemas + schema_mappings)", async () => {
|
|
|
3275
3537
|
});
|
|
3276
3538
|
|
|
3277
3539
|
it("enum_mismatch warning when a field's value is outside the declared enum", async () => {
|
|
3278
|
-
await store.
|
|
3540
|
+
await store.upsertTagSchema("task", {
|
|
3279
3541
|
fields: { priority: { type: "string", enum: ["high", "medium", "low"] } },
|
|
3280
3542
|
});
|
|
3281
|
-
await store.setSchemaMapping("task", "tag", "task");
|
|
3282
3543
|
|
|
3283
3544
|
const tools = generateMcpTools(store);
|
|
3284
3545
|
const create = tools.find((t) => t.name === "create-note")!;
|
|
@@ -3292,12 +3553,17 @@ describe("note schemas (note_schemas + schema_mappings)", async () => {
|
|
|
3292
3553
|
});
|
|
3293
3554
|
|
|
3294
3555
|
it("validation never blocks the write — note exists with warnings attached", async () => {
|
|
3295
|
-
await store.
|
|
3296
|
-
|
|
3556
|
+
await store.upsertTagSchema("task", {
|
|
3557
|
+
fields: { priority: { type: "boolean" } },
|
|
3558
|
+
});
|
|
3297
3559
|
|
|
3298
3560
|
const tools = generateMcpTools(store);
|
|
3299
3561
|
const create = tools.find((t) => t.name === "create-note")!;
|
|
3300
|
-
const result = await create.execute({
|
|
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;
|
|
3301
3567
|
|
|
3302
3568
|
expect(result.id).toBeTruthy();
|
|
3303
3569
|
expect(result.validation_status.warnings.length).toBe(1);
|
|
@@ -3308,11 +3574,9 @@ describe("note schemas (note_schemas + schema_mappings)", async () => {
|
|
|
3308
3574
|
});
|
|
3309
3575
|
|
|
3310
3576
|
it("update-note also surfaces validation_status", async () => {
|
|
3311
|
-
await store.
|
|
3577
|
+
await store.upsertTagSchema("task", {
|
|
3312
3578
|
fields: { priority: { type: "string", enum: ["high", "low"] } },
|
|
3313
|
-
required: ["priority"],
|
|
3314
3579
|
});
|
|
3315
|
-
await store.setSchemaMapping("task", "tag", "task");
|
|
3316
3580
|
const note = await store.createNote("body", { tags: ["task"], metadata: { priority: "high" } });
|
|
3317
3581
|
|
|
3318
3582
|
const tools = generateMcpTools(store);
|
|
@@ -3326,98 +3590,83 @@ describe("note schemas (note_schemas + schema_mappings)", async () => {
|
|
|
3326
3590
|
expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
3327
3591
|
});
|
|
3328
3592
|
|
|
3329
|
-
it("cache invalidates when a schema
|
|
3330
|
-
await store.
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
const tools = generateMcpTools(store);
|
|
3334
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
3335
|
-
let result = await create.execute({ content: "x", tags: ["task"] }) as any;
|
|
3336
|
-
expect(result.validation_status.warnings[0].field).toBe("a");
|
|
3337
|
-
|
|
3338
|
-
await store.deleteSchemaMapping("task", "tag", "task");
|
|
3339
|
-
|
|
3340
|
-
result = await create.execute({ content: "y", tags: ["task"] }) as any;
|
|
3341
|
-
expect(result.validation_status).toBeUndefined();
|
|
3342
|
-
});
|
|
3343
|
-
|
|
3344
|
-
it("cache invalidates when a schema is updated", async () => {
|
|
3345
|
-
await store.upsertNoteSchema("task", { required: ["a"] });
|
|
3346
|
-
await store.setSchemaMapping("task", "tag", "task");
|
|
3593
|
+
it("cache invalidates when a tag schema is updated", async () => {
|
|
3594
|
+
await store.upsertTagSchema("task", {
|
|
3595
|
+
fields: { priority: { type: "boolean" } },
|
|
3596
|
+
});
|
|
3347
3597
|
|
|
3348
3598
|
const tools = generateMcpTools(store);
|
|
3349
3599
|
const create = tools.find((t) => t.name === "create-note")!;
|
|
3350
|
-
let result = await create.execute({
|
|
3351
|
-
|
|
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");
|
|
3352
3607
|
|
|
3353
|
-
// Re-declare with a
|
|
3354
|
-
await store.
|
|
3355
|
-
|
|
3356
|
-
|
|
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([]);
|
|
3357
3618
|
});
|
|
3358
3619
|
|
|
3359
|
-
it("cache invalidates when a schema is deleted
|
|
3360
|
-
await store.
|
|
3361
|
-
|
|
3620
|
+
it("cache invalidates when a tag schema is deleted", async () => {
|
|
3621
|
+
await store.upsertTagSchema("task", {
|
|
3622
|
+
fields: { priority: { type: "boolean" } },
|
|
3623
|
+
});
|
|
3362
3624
|
|
|
3363
3625
|
const tools = generateMcpTools(store);
|
|
3364
3626
|
const create = tools.find((t) => t.name === "create-note")!;
|
|
3365
|
-
let result = await create.execute({
|
|
3366
|
-
|
|
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);
|
|
3367
3633
|
|
|
3368
|
-
await store.
|
|
3369
|
-
// FK CASCADE drops the mapping too.
|
|
3370
|
-
expect(await store.listSchemaMappings({ schema_name: "task" })).toEqual([]);
|
|
3634
|
+
await store.deleteTagSchema("task");
|
|
3371
3635
|
|
|
3372
|
-
result = await create.execute({
|
|
3636
|
+
result = await create.execute({
|
|
3637
|
+
content: "y",
|
|
3638
|
+
tags: ["task"],
|
|
3639
|
+
metadata: { priority: "high" },
|
|
3640
|
+
}) as any;
|
|
3373
3641
|
expect(result.validation_status).toBeUndefined();
|
|
3374
3642
|
});
|
|
3375
3643
|
|
|
3376
|
-
it("
|
|
3377
|
-
await store.
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
await store.
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
3384
|
-
const result = await create.execute({ content: "x", path: "journal/2026/april" }) as any;
|
|
3385
|
-
expect(result.validation_status.schemas).toEqual(["journal-day"]);
|
|
3386
|
-
});
|
|
3387
|
-
|
|
3388
|
-
it("multiple schemas can apply (path + tag combine warnings)", async () => {
|
|
3389
|
-
await store.upsertNoteSchema("journal-entry", { required: ["mood"] });
|
|
3390
|
-
await store.upsertNoteSchema("task", { required: ["priority"] });
|
|
3391
|
-
await store.setSchemaMapping("journal-entry", "path_prefix", "journal/");
|
|
3392
|
-
await store.setSchemaMapping("task", "tag", "task");
|
|
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
|
+
});
|
|
3393
3651
|
|
|
3394
3652
|
const tools = generateMcpTools(store);
|
|
3395
3653
|
const create = tools.find((t) => t.name === "create-note")!;
|
|
3396
3654
|
const result = await create.execute({
|
|
3397
3655
|
content: "x",
|
|
3398
|
-
|
|
3399
|
-
|
|
3656
|
+
tags: ["task", "project"],
|
|
3657
|
+
metadata: { priority: 7, status: "WIP" }, // bad: wrong type, bad enum
|
|
3400
3658
|
}) as any;
|
|
3401
3659
|
|
|
3402
|
-
expect(result.validation_status.schemas.sort()).toEqual(["
|
|
3660
|
+
expect(result.validation_status.schemas.sort()).toEqual(["project", "task"]);
|
|
3403
3661
|
expect(result.validation_status.warnings.length).toBe(2);
|
|
3404
3662
|
});
|
|
3405
3663
|
|
|
3406
|
-
it("
|
|
3407
|
-
await store.upsertNoteSchema("orphan", { required: ["x"] });
|
|
3408
|
-
|
|
3409
|
-
const tools = generateMcpTools(store);
|
|
3410
|
-
const create = tools.find((t) => t.name === "create-note")!;
|
|
3411
|
-
const result = await create.execute({ content: "anything" }) as any;
|
|
3412
|
-
expect(result.validation_status).toBeUndefined();
|
|
3413
|
-
});
|
|
3414
|
-
|
|
3415
|
-
it("legacy `_schemas/<name>` notes are inert post-v15", async () => {
|
|
3664
|
+
it("legacy `_schemas/<name>` notes are inert post-v17", async () => {
|
|
3416
3665
|
// The notes still write/read fine — they're just no longer interpreted
|
|
3417
|
-
// as schema config. Nothing in
|
|
3666
|
+
// as schema config. Nothing in tags.fields → no validation.
|
|
3418
3667
|
await store.createNote("", {
|
|
3419
3668
|
path: "_schemas/task",
|
|
3420
|
-
metadata: {
|
|
3669
|
+
metadata: { fields: { priority: { type: "string" } } },
|
|
3421
3670
|
});
|
|
3422
3671
|
await store.createNote("", {
|
|
3423
3672
|
path: "_schema_defaults",
|
|
@@ -3432,79 +3681,380 @@ describe("note schemas (note_schemas + schema_mappings)", async () => {
|
|
|
3432
3681
|
});
|
|
3433
3682
|
|
|
3434
3683
|
// ---------------------------------------------------------------------------
|
|
3435
|
-
//
|
|
3684
|
+
// Schema inheritance via parent_names + `_default` universal parent — vault#270
|
|
3436
3685
|
// ---------------------------------------------------------------------------
|
|
3437
3686
|
|
|
3438
|
-
describe("
|
|
3439
|
-
it("
|
|
3440
|
-
await store.
|
|
3441
|
-
|
|
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"],
|
|
3442
3694
|
fields: { priority: { type: "string" } },
|
|
3443
|
-
required: ["priority"],
|
|
3444
3695
|
});
|
|
3445
3696
|
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
await store.upsertNoteSchema("task", { description: null });
|
|
3454
|
-
row = await store.getNoteSchema("task");
|
|
3455
|
-
expect(row?.description).toBeNull();
|
|
3456
|
-
expect(row?.required).toEqual(["priority", "due"]); // still preserved
|
|
3457
|
-
});
|
|
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;
|
|
3458
3704
|
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
const row = await store.getNoteSchema("task");
|
|
3463
|
-
expect(row?.required).toBeNull();
|
|
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"]);
|
|
3464
3708
|
});
|
|
3465
3709
|
|
|
3466
|
-
it("
|
|
3467
|
-
await store.
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
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
|
+
});
|
|
3473
3721
|
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
await
|
|
3477
|
-
|
|
3478
|
-
|
|
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"]);
|
|
3479
3733
|
});
|
|
3480
3734
|
|
|
3481
|
-
it("
|
|
3482
|
-
await
|
|
3483
|
-
|
|
3484
|
-
)
|
|
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());
|
|
3485
3979
|
});
|
|
3486
3980
|
|
|
3487
|
-
it("
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
await store.
|
|
3491
|
-
|
|
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;
|
|
3492
4024
|
|
|
3493
|
-
expect(
|
|
3494
|
-
expect(
|
|
3495
|
-
expect(await store.deleteNoteSchema("task")).toBe(false); // already gone
|
|
4025
|
+
expect(result.validation_status.warnings[0].reason).toBe("enum_mismatch");
|
|
4026
|
+
expect(result.validation_status.warnings[0].loser_schema).toBeUndefined();
|
|
3496
4027
|
});
|
|
3497
4028
|
|
|
3498
|
-
it("
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
await store.
|
|
3503
|
-
|
|
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();
|
|
3504
4046
|
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
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");
|
|
3508
4058
|
});
|
|
3509
4059
|
});
|
|
3510
4060
|
|
|
@@ -3904,199 +4454,111 @@ describe("schema migration v13 → v14", async () => {
|
|
|
3904
4454
|
});
|
|
3905
4455
|
|
|
3906
4456
|
// ---------------------------------------------------------------------------
|
|
3907
|
-
// Schema migration
|
|
4457
|
+
// Schema migration v16 → v17 — vault#267 (note_schemas + schema_mappings rip)
|
|
3908
4458
|
// ---------------------------------------------------------------------------
|
|
3909
4459
|
|
|
3910
|
-
describe("schema migration
|
|
3911
|
-
// Build a
|
|
3912
|
-
//
|
|
3913
|
-
//
|
|
3914
|
-
|
|
3915
|
-
// `_schemas/<name>` and `_schema_defaults` notes.
|
|
3916
|
-
// 3. Re-running initSchema — the migration's short-circuit (empty
|
|
3917
|
-
// destination tables) should kick in and copy the data over.
|
|
3918
|
-
async function buildV14ShapeWithLegacyNotes(): Promise<Database> {
|
|
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> {
|
|
3919
4465
|
const { Database } = await import("bun:sqlite");
|
|
3920
|
-
const { initSchema } = await import("./schema.ts");
|
|
3921
4466
|
const db = new Database(":memory:");
|
|
3922
|
-
initSchema(db);
|
|
3923
4467
|
|
|
3924
|
-
//
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
);
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
now,
|
|
3946
|
-
);
|
|
3947
|
-
insertNote.run(
|
|
3948
|
-
"d1",
|
|
3949
|
-
"",
|
|
3950
|
-
"_schema_defaults",
|
|
3951
|
-
JSON.stringify({
|
|
3952
|
-
path_prefixes: { "journal/": "journal-entry" },
|
|
3953
|
-
tags: { task: "task", "follow-up": "task" },
|
|
3954
|
-
}),
|
|
3955
|
-
now,
|
|
3956
|
-
);
|
|
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)");
|
|
3957
4489
|
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
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/");
|
|
3962
4503
|
|
|
3963
4504
|
return db;
|
|
3964
4505
|
}
|
|
3965
4506
|
|
|
3966
|
-
it("
|
|
3967
|
-
const db = await
|
|
3968
|
-
const { initSchema } = await import("./schema.ts");
|
|
3969
|
-
initSchema(db);
|
|
3970
|
-
|
|
3971
|
-
const taskRow = db.prepare(
|
|
3972
|
-
"SELECT description, fields, required FROM note_schemas WHERE name = 'task'",
|
|
3973
|
-
).get() as any;
|
|
3974
|
-
expect(taskRow.description).toBe("A task");
|
|
3975
|
-
expect(JSON.parse(taskRow.fields).priority.enum).toEqual(["high", "low"]);
|
|
3976
|
-
expect(JSON.parse(taskRow.required)).toEqual(["priority"]);
|
|
3977
|
-
|
|
3978
|
-
const journalRow = db.prepare(
|
|
3979
|
-
"SELECT description, fields FROM note_schemas WHERE name = 'journal-entry'",
|
|
3980
|
-
).get() as any;
|
|
3981
|
-
expect(journalRow.description).toBeNull();
|
|
3982
|
-
expect(JSON.parse(journalRow.fields).mood.type).toBe("string");
|
|
3983
|
-
|
|
3984
|
-
db.close();
|
|
3985
|
-
});
|
|
3986
|
-
|
|
3987
|
-
it("copies `_schema_defaults` → schema_mappings (path_prefixes + tags)", async () => {
|
|
3988
|
-
const db = await buildV14ShapeWithLegacyNotes();
|
|
4507
|
+
it("drops note_schemas + schema_mappings tables on upgrade", async () => {
|
|
4508
|
+
const db = await buildV16ShapeWithLegacyTables();
|
|
3989
4509
|
const { initSchema } = await import("./schema.ts");
|
|
3990
4510
|
initSchema(db);
|
|
3991
4511
|
|
|
3992
|
-
const
|
|
3993
|
-
"SELECT
|
|
3994
|
-
).all() as {
|
|
3995
|
-
expect(
|
|
3996
|
-
{ schema_name: "journal-entry", match_kind: "path_prefix", match_value: "journal/" },
|
|
3997
|
-
{ schema_name: "task", match_kind: "tag", match_value: "follow-up" },
|
|
3998
|
-
{ schema_name: "task", match_kind: "tag", match_value: "task" },
|
|
3999
|
-
]);
|
|
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([]);
|
|
4000
4516
|
|
|
4001
4517
|
db.close();
|
|
4002
4518
|
});
|
|
4003
4519
|
|
|
4004
|
-
it("
|
|
4520
|
+
it("idempotent — running on an already-v17 vault is a no-op", async () => {
|
|
4005
4521
|
const { Database } = await import("bun:sqlite");
|
|
4006
4522
|
const { initSchema } = await import("./schema.ts");
|
|
4007
4523
|
const db = new Database(":memory:");
|
|
4008
|
-
initSchema(db);
|
|
4524
|
+
initSchema(db); // First run: fresh v17 shape.
|
|
4009
4525
|
|
|
4010
|
-
//
|
|
4011
|
-
db.prepare(
|
|
4012
|
-
"
|
|
4013
|
-
).
|
|
4014
|
-
|
|
4015
|
-
"",
|
|
4016
|
-
"_schema_defaults",
|
|
4017
|
-
JSON.stringify({ tags: { orphan: "orphan" } }),
|
|
4018
|
-
new Date().toISOString(),
|
|
4019
|
-
);
|
|
4020
|
-
db.exec("DELETE FROM schema_mappings");
|
|
4021
|
-
db.exec("DELETE FROM note_schemas");
|
|
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([]);
|
|
4022
4531
|
|
|
4532
|
+
// Second run shouldn't crash and the tables stay absent.
|
|
4023
4533
|
initSchema(db);
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
expect(
|
|
4028
|
-
const mapping = db.prepare(
|
|
4029
|
-
"SELECT match_kind, match_value FROM schema_mappings WHERE schema_name = 'orphan'",
|
|
4030
|
-
).get() as any;
|
|
4031
|
-
expect(mapping).toEqual({ match_kind: "tag", match_value: "orphan" });
|
|
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([]);
|
|
4032
4538
|
|
|
4033
4539
|
db.close();
|
|
4034
4540
|
});
|
|
4035
4541
|
|
|
4036
|
-
it("
|
|
4037
|
-
const db = await
|
|
4038
|
-
const { initSchema } = await import("./schema.ts");
|
|
4039
|
-
initSchema(db);
|
|
4542
|
+
it("preserves notes + tags + tokens across the rip", async () => {
|
|
4543
|
+
const db = await buildV16ShapeWithLegacyTables();
|
|
4040
4544
|
|
|
4041
|
-
|
|
4042
|
-
|
|
4043
|
-
const defaultsNote = db.prepare("SELECT id FROM notes WHERE path = '_schema_defaults'").get();
|
|
4044
|
-
expect(defaultsNote).toBeTruthy();
|
|
4045
|
-
|
|
4046
|
-
db.close();
|
|
4047
|
-
});
|
|
4048
|
-
|
|
4049
|
-
it("is idempotent — second initSchema doesn't re-copy or duplicate rows", async () => {
|
|
4050
|
-
const db = await buildV14ShapeWithLegacyNotes();
|
|
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).
|
|
4051
4547
|
const { initSchema } = await import("./schema.ts");
|
|
4052
4548
|
initSchema(db);
|
|
4053
4549
|
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
const mappingCount2 = (db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c;
|
|
4061
|
-
expect(schemaCount2).toBe(schemaCount1);
|
|
4062
|
-
expect(mappingCount2).toBe(mappingCount1);
|
|
4063
|
-
|
|
4064
|
-
db.close();
|
|
4065
|
-
});
|
|
4066
|
-
|
|
4067
|
-
it("no legacy notes → migration is a no-op (empty tables)", async () => {
|
|
4068
|
-
const { Database } = await import("bun:sqlite");
|
|
4069
|
-
const { initSchema } = await import("./schema.ts");
|
|
4070
|
-
const db = new Database(":memory:");
|
|
4071
|
-
initSchema(db);
|
|
4072
|
-
|
|
4073
|
-
expect((db.prepare("SELECT COUNT(*) as c FROM note_schemas").get() as any).c).toBe(0);
|
|
4074
|
-
expect((db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c).toBe(0);
|
|
4075
|
-
|
|
4076
|
-
db.close();
|
|
4077
|
-
});
|
|
4078
|
-
|
|
4079
|
-
// Short-circuit uses `||` not `&&`: a vault with schemas-but-no-mappings
|
|
4080
|
-
// is a valid post-v15 state. Legacy `_schemas/*` notes left around from
|
|
4081
|
-
// a prior import shouldn't get re-folded on every boot. (The mirror case —
|
|
4082
|
-
// mappings-but-no-schemas — is structurally impossible because the
|
|
4083
|
-
// schema_mappings FK to note_schemas has ON DELETE CASCADE.)
|
|
4084
|
-
it("doesn't re-run when only one destination table is non-empty", async () => {
|
|
4085
|
-
const db = await buildV14ShapeWithLegacyNotes();
|
|
4086
|
-
const { initSchema } = await import("./schema.ts");
|
|
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
|
+
});
|
|
4087
4556
|
initSchema(db);
|
|
4088
4557
|
|
|
4089
|
-
|
|
4090
|
-
expect(
|
|
4091
|
-
|
|
4092
|
-
expect(
|
|
4093
|
-
|
|
4094
|
-
// Wipe mappings only; schemas remain non-empty. With the buggy `&&`
|
|
4095
|
-
// short-circuit, the migration would re-scan `_schema_defaults` and
|
|
4096
|
-
// rebuild the mappings table. With `||` it correctly no-ops.
|
|
4097
|
-
db.exec("DELETE FROM schema_mappings");
|
|
4098
|
-
initSchema(db);
|
|
4099
|
-
expect((db.prepare("SELECT COUNT(*) as c FROM schema_mappings").get() as any).c).toBe(0);
|
|
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");
|
|
4100
4562
|
|
|
4101
4563
|
db.close();
|
|
4102
4564
|
});
|
|
@@ -4223,3 +4685,194 @@ describe("tag-scope auth (post-v14 hierarchy)", async () => {
|
|
|
4223
4685
|
expect(noteWithinTagScope(note, allowed, ["health"])).toBe(true);
|
|
4224
4686
|
});
|
|
4225
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
|
+
});
|