@openparachute/vault 0.4.9-rc.4 → 0.4.9-rc.5
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/core/src/hooks.test.ts +320 -1
- package/core/src/hooks.ts +243 -38
- package/core/src/portable-md.test.ts +252 -1
- package/core/src/portable-md.ts +370 -2
- package/core/src/store.ts +68 -2
- package/package.json +1 -1
- package/src/mirror-config.test.ts +125 -14
- package/src/mirror-config.ts +168 -21
- package/src/mirror-deps.ts +42 -1
- package/src/mirror-manager.test.ts +319 -12
- package/src/mirror-manager.ts +351 -63
- package/src/mirror-routes.test.ts +9 -9
- package/src/mirror-routes.ts +6 -3
- package/src/server.ts +21 -8
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/web/ui/dist/assets/index-CEbbVPIQ.js +60 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-BJX47k5V.js +0 -60
package/core/src/hooks.test.ts
CHANGED
|
@@ -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
|
+
});
|