@openparachute/vault 0.4.8 → 0.4.9-rc.11

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.
Files changed (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -1944,6 +1944,9 @@ describe("MCP tools", async () => {
1944
1944
  expect(names).toContain("delete-tag");
1945
1945
  expect(names).toContain("find-path");
1946
1946
  expect(names).toContain("vault-info");
1947
+ // prune-schema (admin) — drops orphaned indexed-field columns whose
1948
+ // declaring tags are gone. The gitcoin orphaned-fields fix.
1949
+ expect(names).toContain("prune-schema");
1947
1950
  // Six note-schema tools (list/update/delete-note-schema +
1948
1951
  // list/set/delete-schema-mapping) retired in v17 — the standalone
1949
1952
  // note_schemas + schema_mappings subsystem was a parallel path to
@@ -1957,7 +1960,7 @@ describe("MCP tools", async () => {
1957
1960
  // synthesize-notes retired in v17 — replicable with query-notes(near=) +
1958
1961
  // find-path + agent-side aggregation. See vault#268.
1959
1962
  expect(names).not.toContain("synthesize-notes");
1960
- expect(tools).toHaveLength(9);
1963
+ expect(tools).toHaveLength(10);
1961
1964
  });
1962
1965
 
1963
1966
  it("create-note tool works", async () => {
@@ -1,7 +1,7 @@
1
1
  import { describe, it, expect, beforeEach } from "bun:test";
2
2
  import { Database } from "bun:sqlite";
3
3
  import { SqliteStore } from "./store.js";
4
- import { HookRegistry } from "./hooks.js";
4
+ import { HookRegistry, type DeletedNoteRef, type DeletedAttachmentRef } from "./hooks.js";
5
5
  import type { Note } from "./types.js";
6
6
 
7
7
  let db: Database;
@@ -359,3 +359,322 @@ describe("HookRegistry — HOOK_CONCURRENCY env var parsing", async () => {
359
359
  restore();
360
360
  });
361
361
  });
362
+
363
+ // ---------------------------------------------------------------------------
364
+ // New event types — deleted notes, tag mutations, deleted attachments
365
+ // (vault#382 — event-driven git-mirror foundation)
366
+ // ---------------------------------------------------------------------------
367
+
368
+ describe("HookRegistry — deleted note events", () => {
369
+ let db: Database;
370
+ let hooks: HookRegistry;
371
+ let store: SqliteStore;
372
+ beforeEach(() => {
373
+ db = new Database(":memory:");
374
+ hooks = new HookRegistry({ concurrency: 4, logger: silentLogger });
375
+ store = new SqliteStore(db, { hooks });
376
+ });
377
+
378
+ async function settle(): Promise<void> {
379
+ await Promise.resolve();
380
+ await Promise.resolve();
381
+ await hooks.drain();
382
+ }
383
+
384
+ it("default subscription doesn't include 'deleted'", async () => {
385
+ // Existing hooks pre-deleted-event don't suddenly start receiving
386
+ // delete shapes they weren't typed for.
387
+ const fired: string[] = [];
388
+ hooks.onNote({
389
+ handler: (note) => {
390
+ fired.push(note.id);
391
+ },
392
+ });
393
+
394
+ const note = await store.createNote("hi");
395
+ await settle();
396
+ fired.length = 0;
397
+ await store.deleteNote(note.id);
398
+ await settle();
399
+ expect(fired).toEqual([]);
400
+ });
401
+
402
+ it("explicit 'deleted' subscription receives DeletedNoteRef on store.deleteNote", async () => {
403
+ const seen: Array<{ event: string; id: string; path?: string }> = [];
404
+ hooks.onNote({
405
+ event: "deleted",
406
+ handler: (payload, _store, event) => {
407
+ // payload here is a DeletedNoteRef (we subscribed to deleted only).
408
+ const ref = payload as DeletedNoteRef;
409
+ seen.push({ event: event ?? "deleted", id: ref.id, path: ref.path });
410
+ },
411
+ });
412
+
413
+ const note = await store.createNote("doomed", { path: "to-delete" });
414
+ await settle();
415
+ await store.deleteNote(note.id);
416
+ await settle();
417
+ expect(seen).toHaveLength(1);
418
+ expect(seen[0]!.event).toBe("deleted");
419
+ expect(seen[0]!.id).toBe(note.id);
420
+ expect(seen[0]!.path).toBe("to-delete");
421
+ });
422
+
423
+ it("DeletedNoteRef has no metadata/tags/content (predicate authors take note)", async () => {
424
+ const observedPayloads: unknown[] = [];
425
+ hooks.onNote({
426
+ event: "deleted",
427
+ handler: (payload) => {
428
+ observedPayloads.push(payload);
429
+ },
430
+ });
431
+ const n = await store.createNote("with tags", { tags: ["one", "two"], metadata: { color: "blue" } });
432
+ await settle();
433
+ await store.deleteNote(n.id);
434
+ await settle();
435
+ expect(observedPayloads).toHaveLength(1);
436
+ const p = observedPayloads[0] as Record<string, unknown>;
437
+ expect(p.id).toBe(n.id);
438
+ // Strictly minimal shape — no full Note fields leak through.
439
+ expect(p.content).toBeUndefined();
440
+ expect(p.metadata).toBeUndefined();
441
+ expect(p.tags).toBeUndefined();
442
+ });
443
+
444
+ it("array events allow subscribing to created+updated+deleted in one hook", async () => {
445
+ const events: string[] = [];
446
+ hooks.onNote({
447
+ event: ["created", "updated", "deleted"],
448
+ handler: (_n, _s, ev) => {
449
+ events.push(ev ?? "?");
450
+ },
451
+ });
452
+ const n = await store.createNote("lifecycle");
453
+ await settle();
454
+ await store.updateNote(n.id, { content: "v2" });
455
+ await settle();
456
+ await store.deleteNote(n.id);
457
+ await settle();
458
+ expect(events).toEqual(["created", "updated", "deleted"]);
459
+ });
460
+
461
+ it("predicate on a deleted event receives DeletedNoteRef and can filter on path", async () => {
462
+ const fired: string[] = [];
463
+ hooks.onNote({
464
+ event: "deleted",
465
+ when: (payload) => {
466
+ const ref = payload as DeletedNoteRef;
467
+ return ref.path?.startsWith("keep/") === true;
468
+ },
469
+ handler: (payload) => {
470
+ fired.push((payload as DeletedNoteRef).id);
471
+ },
472
+ });
473
+ const a = await store.createNote("a", { path: "keep/one" });
474
+ const b = await store.createNote("b", { path: "skip/two" });
475
+ await settle();
476
+ await store.deleteNote(a.id);
477
+ await store.deleteNote(b.id);
478
+ await settle();
479
+ expect(fired).toEqual([a.id]);
480
+ });
481
+ });
482
+
483
+ describe("HookRegistry — tag events", () => {
484
+ let db: Database;
485
+ let hooks: HookRegistry;
486
+ let store: SqliteStore;
487
+ beforeEach(() => {
488
+ db = new Database(":memory:");
489
+ hooks = new HookRegistry({ concurrency: 4, logger: silentLogger });
490
+ store = new SqliteStore(db, { hooks });
491
+ });
492
+
493
+ async function settle(): Promise<void> {
494
+ await Promise.resolve();
495
+ await Promise.resolve();
496
+ await hooks.drain();
497
+ }
498
+
499
+ it("upsertTagRecord dispatches 'upserted'", async () => {
500
+ const seen: Array<{ tag: string; event: string }> = [];
501
+ hooks.onTag({
502
+ handler: (tag, _store, event) => {
503
+ seen.push({ tag, event: event ?? "?" });
504
+ },
505
+ });
506
+ await store.upsertTagRecord("project", { description: "a project" });
507
+ await settle();
508
+ expect(seen).toEqual([{ tag: "project", event: "upserted" }]);
509
+ });
510
+
511
+ it("upsertTagSchema dispatches 'upserted'", async () => {
512
+ const seen: string[] = [];
513
+ hooks.onTag({
514
+ event: "upserted",
515
+ handler: (tag) => {
516
+ seen.push(tag);
517
+ },
518
+ });
519
+ await store.upsertTagSchema("meeting", {
520
+ fields: { duration: { type: "number" } },
521
+ });
522
+ await settle();
523
+ expect(seen).toEqual(["meeting"]);
524
+ });
525
+
526
+ it("deleteTag dispatches 'deleted' only when the tag actually existed", async () => {
527
+ const seen: string[] = [];
528
+ hooks.onTag({
529
+ event: "deleted",
530
+ handler: (tag) => {
531
+ seen.push(tag);
532
+ },
533
+ });
534
+ const note = await store.createNote("tagged", { tags: ["mytag"] });
535
+ await settle();
536
+
537
+ // Delete the tag — fires.
538
+ await store.deleteTag("mytag");
539
+ await settle();
540
+ expect(seen).toEqual(["mytag"]);
541
+
542
+ // Delete a tag that doesn't exist — no fire.
543
+ await store.deleteTag("never-existed");
544
+ await settle();
545
+ expect(seen).toEqual(["mytag"]);
546
+ void note; // silence unused
547
+ });
548
+
549
+ it("renameTag dispatches deleted(old) + upserted(new)", async () => {
550
+ const events: Array<{ tag: string; event: string }> = [];
551
+ hooks.onTag({
552
+ handler: (tag, _store, event) => {
553
+ events.push({ tag, event: event ?? "?" });
554
+ },
555
+ });
556
+ await store.createNote("tagged", { tags: ["old-name"] });
557
+ await settle();
558
+ await store.renameTag("old-name", "new-name");
559
+ await settle();
560
+ // Order is implementation-defined; assert the set instead.
561
+ expect(events).toContainEqual({ tag: "old-name", event: "deleted" });
562
+ expect(events).toContainEqual({ tag: "new-name", event: "upserted" });
563
+ });
564
+
565
+ it("mergeTags dispatches deleted for each source + upserted for target", async () => {
566
+ const events: Array<{ tag: string; event: string }> = [];
567
+ hooks.onTag({
568
+ handler: (tag, _store, event) => {
569
+ events.push({ tag, event: event ?? "?" });
570
+ },
571
+ });
572
+ await store.createNote("a", { tags: ["src1"] });
573
+ await store.createNote("b", { tags: ["src2"] });
574
+ await settle();
575
+ events.length = 0;
576
+ await store.mergeTags(["src1", "src2"], "target");
577
+ await settle();
578
+ expect(events).toContainEqual({ tag: "src1", event: "deleted" });
579
+ expect(events).toContainEqual({ tag: "src2", event: "deleted" });
580
+ expect(events).toContainEqual({ tag: "target", event: "upserted" });
581
+ });
582
+
583
+ it("deleteTagSchema fires 'deleted' for the schema slot", async () => {
584
+ const seen: string[] = [];
585
+ hooks.onTag({
586
+ event: "deleted",
587
+ handler: (tag) => {
588
+ seen.push(tag);
589
+ },
590
+ });
591
+ await store.upsertTagSchema("foo", { description: "x" });
592
+ await settle();
593
+ const ok = await store.deleteTagSchema("foo");
594
+ await settle();
595
+ expect(ok).toBe(true);
596
+ expect(seen).toEqual(["foo"]);
597
+ });
598
+
599
+ it("predicate filters tag events by name", async () => {
600
+ const seen: string[] = [];
601
+ hooks.onTag({
602
+ when: (tag) => tag.startsWith("_"),
603
+ handler: (tag) => {
604
+ seen.push(tag);
605
+ },
606
+ });
607
+ await store.upsertTagRecord("public-tag", { description: "x" });
608
+ await store.upsertTagRecord("_private-tag", { description: "y" });
609
+ await settle();
610
+ expect(seen).toEqual(["_private-tag"]);
611
+ });
612
+ });
613
+
614
+ describe("HookRegistry — deleted attachment events", () => {
615
+ let db: Database;
616
+ let hooks: HookRegistry;
617
+ let store: SqliteStore;
618
+ beforeEach(() => {
619
+ db = new Database(":memory:");
620
+ hooks = new HookRegistry({ concurrency: 4, logger: silentLogger });
621
+ store = new SqliteStore(db, { hooks });
622
+ });
623
+
624
+ async function settle(): Promise<void> {
625
+ await Promise.resolve();
626
+ await Promise.resolve();
627
+ await hooks.drain();
628
+ }
629
+
630
+ it("deleteAttachment dispatches 'deleted' with DeletedAttachmentRef", async () => {
631
+ const seen: DeletedAttachmentRef[] = [];
632
+ hooks.onAttachment({
633
+ event: "deleted",
634
+ handler: (payload) => {
635
+ seen.push(payload as DeletedAttachmentRef);
636
+ },
637
+ });
638
+ const note = await store.createNote("with-att");
639
+ const att = await store.addAttachment(note.id, "audio/voice.m4a", "audio/mp4");
640
+ await settle();
641
+ await store.deleteAttachment(note.id, att.id);
642
+ await settle();
643
+ expect(seen).toHaveLength(1);
644
+ expect(seen[0]!.id).toBe(att.id);
645
+ expect(seen[0]!.noteId).toBe(note.id);
646
+ expect(seen[0]!.path).toBe("audio/voice.m4a");
647
+ });
648
+
649
+ it("default attachment subscription doesn't include 'deleted' (back-compat)", async () => {
650
+ const fired: string[] = [];
651
+ hooks.onAttachment({
652
+ handler: (att) => {
653
+ // payload is Attachment | DeletedAttachmentRef; we know default
654
+ // events doesn't include deleted, so this only sees created.
655
+ fired.push(att.id);
656
+ },
657
+ });
658
+ const note = await store.createNote("hi");
659
+ const att = await store.addAttachment(note.id, "f.txt", "text/plain");
660
+ await settle();
661
+ fired.length = 0;
662
+ await store.deleteAttachment(note.id, att.id);
663
+ await settle();
664
+ expect(fired).toEqual([]);
665
+ });
666
+
667
+ it("non-existent attachment delete does not dispatch", async () => {
668
+ const fired: string[] = [];
669
+ hooks.onAttachment({
670
+ event: "deleted",
671
+ handler: (a) => fired.push(a.id),
672
+ });
673
+ const note = await store.createNote("hi");
674
+ await settle();
675
+ const result = await store.deleteAttachment(note.id, "never-existed-id");
676
+ await settle();
677
+ expect(result.deleted).toBe(false);
678
+ expect(fired).toEqual([]);
679
+ });
680
+ });