@openparachute/vault 0.4.8 → 0.4.9-rc.10
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/mcp.ts +35 -0
- package/core/src/portable-md.test.ts +252 -1
- package/core/src/portable-md.ts +370 -2
- package/core/src/schema.ts +51 -2
- package/core/src/store.ts +68 -2
- package/package.json +1 -1
- package/src/auth.ts +29 -1
- package/src/auto-transcribe.test.ts +7 -2
- package/src/auto-transcribe.ts +6 -2
- package/src/export-watch.test.ts +74 -0
- package/src/export-watch.ts +108 -7
- package/src/github-device-flow.test.ts +404 -0
- package/src/github-device-flow.ts +415 -0
- package/src/mcp-http.ts +24 -36
- package/src/mcp-tools.ts +286 -2
- package/src/mirror-config.test.ts +184 -14
- package/src/mirror-config.ts +220 -24
- package/src/mirror-credentials.test.ts +450 -0
- package/src/mirror-credentials.ts +577 -0
- package/src/mirror-deps.ts +42 -1
- package/src/mirror-import.test.ts +550 -0
- package/src/mirror-import.ts +484 -0
- package/src/mirror-manager.test.ts +423 -12
- package/src/mirror-manager.ts +579 -62
- package/src/mirror-routes.test.ts +966 -10
- package/src/mirror-routes.ts +1096 -5
- package/src/module-config.ts +11 -5
- package/src/routing.test.ts +92 -1
- package/src/routing.ts +165 -1
- package/src/server.ts +21 -8
- package/src/token-store.ts +158 -5
- package/src/transcription-worker.ts +9 -4
- package/src/triggers.ts +16 -3
- package/src/vault.test.ts +380 -1
- package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
- package/web/ui/dist/assets/index-DE18QJMx.js +60 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
type MirrorDeps,
|
|
22
22
|
} from "./mirror-manager.ts";
|
|
23
23
|
import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
|
|
24
|
+
import { HookRegistry } from "../core/src/hooks.ts";
|
|
24
25
|
|
|
25
26
|
// Snapshot HOME + PARACHUTE_HOME at module load; restore after every test
|
|
26
27
|
// so the `process.env.HOME = ...` rewrite in `makeFakeDeps` doesn't leak
|
|
@@ -82,8 +83,18 @@ function makeFakeDeps(opts: {
|
|
|
82
83
|
initialConfig?: MirrorConfig | undefined;
|
|
83
84
|
/** Optional override for runExport — return note count per call. */
|
|
84
85
|
runExport?: (call: { outDir: string; sinceCursor?: string }) => Promise<{ notes: number }>;
|
|
86
|
+
/** Optional override for runPrune. */
|
|
87
|
+
runPrune?: (call: { outDir: string }) => Promise<{
|
|
88
|
+
notes_removed: number;
|
|
89
|
+
sidecars_removed: number;
|
|
90
|
+
schemas_removed: number;
|
|
91
|
+
attachment_dirs_removed: number;
|
|
92
|
+
}>;
|
|
93
|
+
/** Hook registry for the event-driven path. Tests that exercise hooks pass one. */
|
|
94
|
+
hooks?: HookRegistry;
|
|
85
95
|
}): MirrorDeps & {
|
|
86
96
|
exportCalls: Array<{ outDir: string; sinceCursor: string | undefined }>;
|
|
97
|
+
pruneCalls: Array<{ outDir: string }>;
|
|
87
98
|
storedConfig: MirrorConfig | undefined;
|
|
88
99
|
} {
|
|
89
100
|
process.env.PARACHUTE_HOME = opts.parachuteHome;
|
|
@@ -92,9 +103,11 @@ function makeFakeDeps(opts: {
|
|
|
92
103
|
const state: {
|
|
93
104
|
config: MirrorConfig | undefined;
|
|
94
105
|
exportCalls: Array<{ outDir: string; sinceCursor: string | undefined }>;
|
|
106
|
+
pruneCalls: Array<{ outDir: string }>;
|
|
95
107
|
} = {
|
|
96
108
|
config: opts.initialConfig,
|
|
97
109
|
exportCalls: [],
|
|
110
|
+
pruneCalls: [],
|
|
98
111
|
};
|
|
99
112
|
|
|
100
113
|
const base: MirrorDeps = {
|
|
@@ -107,11 +120,17 @@ function makeFakeDeps(opts: {
|
|
|
107
120
|
if (opts.runExport) return opts.runExport(call);
|
|
108
121
|
return { notes: 1 };
|
|
109
122
|
},
|
|
123
|
+
runPrune: async (call: { outDir: string }) => {
|
|
124
|
+
state.pruneCalls.push({ outDir: call.outDir });
|
|
125
|
+
if (opts.runPrune) return opts.runPrune(call);
|
|
126
|
+
return { notes_removed: 0, sidecars_removed: 0, schemas_removed: 0, attachment_dirs_removed: 0 };
|
|
127
|
+
},
|
|
110
128
|
firstChangedNoteTitle: async () => "Inbox/fake",
|
|
111
129
|
readMirrorConfig: () => state.config,
|
|
112
130
|
writeMirrorConfig: (c: MirrorConfig) => {
|
|
113
131
|
state.config = c;
|
|
114
132
|
},
|
|
133
|
+
hooks: opts.hooks,
|
|
115
134
|
};
|
|
116
135
|
// Defining the test-visible getters via defineProperty so the getter
|
|
117
136
|
// bodies run on every access — otherwise an Object.assign snapshots
|
|
@@ -120,12 +139,17 @@ function makeFakeDeps(opts: {
|
|
|
120
139
|
get: () => state.exportCalls,
|
|
121
140
|
enumerable: true,
|
|
122
141
|
});
|
|
142
|
+
Object.defineProperty(base, "pruneCalls", {
|
|
143
|
+
get: () => state.pruneCalls,
|
|
144
|
+
enumerable: true,
|
|
145
|
+
});
|
|
123
146
|
Object.defineProperty(base, "storedConfig", {
|
|
124
147
|
get: () => state.config,
|
|
125
148
|
enumerable: true,
|
|
126
149
|
});
|
|
127
150
|
return base as MirrorDeps & {
|
|
128
151
|
exportCalls: Array<{ outDir: string; sinceCursor: string | undefined }>;
|
|
152
|
+
pruneCalls: Array<{ outDir: string }>;
|
|
129
153
|
storedConfig: MirrorConfig | undefined;
|
|
130
154
|
};
|
|
131
155
|
}
|
|
@@ -240,7 +264,7 @@ describe("MirrorManager.start — lifecycle matrix", () => {
|
|
|
240
264
|
...defaultMirrorConfig(),
|
|
241
265
|
enabled: true,
|
|
242
266
|
location: "internal",
|
|
243
|
-
|
|
267
|
+
sync_mode: "manual",
|
|
244
268
|
auto_commit: false, // skip commit cycle for this unit
|
|
245
269
|
},
|
|
246
270
|
});
|
|
@@ -268,9 +292,9 @@ describe("MirrorManager.start — lifecycle matrix", () => {
|
|
|
268
292
|
...defaultMirrorConfig(),
|
|
269
293
|
enabled: true,
|
|
270
294
|
location: "internal",
|
|
271
|
-
|
|
295
|
+
sync_mode: "events",
|
|
272
296
|
auto_commit: false,
|
|
273
|
-
|
|
297
|
+
safety_net_seconds: 60,
|
|
274
298
|
},
|
|
275
299
|
});
|
|
276
300
|
const mgr = new MirrorManager(deps);
|
|
@@ -296,9 +320,9 @@ describe("MirrorManager.start — lifecycle matrix", () => {
|
|
|
296
320
|
enabled: true,
|
|
297
321
|
location: "external",
|
|
298
322
|
external_path: external,
|
|
299
|
-
|
|
323
|
+
sync_mode: "events",
|
|
300
324
|
auto_commit: false,
|
|
301
|
-
|
|
325
|
+
safety_net_seconds: 60,
|
|
302
326
|
},
|
|
303
327
|
});
|
|
304
328
|
const mgr = new MirrorManager(deps);
|
|
@@ -322,7 +346,7 @@ describe("MirrorManager.start — lifecycle matrix", () => {
|
|
|
322
346
|
enabled: true,
|
|
323
347
|
location: "external",
|
|
324
348
|
external_path: "/definitely/not/a/path/here",
|
|
325
|
-
|
|
349
|
+
sync_mode: "events",
|
|
326
350
|
},
|
|
327
351
|
});
|
|
328
352
|
const mgr = new MirrorManager(deps);
|
|
@@ -365,7 +389,7 @@ describe("MirrorManager.start — lifecycle matrix", () => {
|
|
|
365
389
|
...defaultMirrorConfig(),
|
|
366
390
|
enabled: true,
|
|
367
391
|
location: "internal",
|
|
368
|
-
|
|
392
|
+
sync_mode: "manual",
|
|
369
393
|
},
|
|
370
394
|
});
|
|
371
395
|
const mgr = new MirrorManager(deps);
|
|
@@ -391,7 +415,7 @@ describe("MirrorManager.start — lifecycle matrix", () => {
|
|
|
391
415
|
...defaultMirrorConfig(),
|
|
392
416
|
enabled: true,
|
|
393
417
|
location: "internal",
|
|
394
|
-
|
|
418
|
+
sync_mode: "manual",
|
|
395
419
|
auto_commit: false,
|
|
396
420
|
},
|
|
397
421
|
});
|
|
@@ -423,9 +447,9 @@ describe("MirrorManager.stop / reload", () => {
|
|
|
423
447
|
...defaultMirrorConfig(),
|
|
424
448
|
enabled: true,
|
|
425
449
|
location: "internal",
|
|
426
|
-
|
|
450
|
+
sync_mode: "events",
|
|
427
451
|
auto_commit: false,
|
|
428
|
-
|
|
452
|
+
safety_net_seconds: 60,
|
|
429
453
|
},
|
|
430
454
|
});
|
|
431
455
|
const mgr = new MirrorManager(deps);
|
|
@@ -477,7 +501,7 @@ describe("MirrorManager.stop / reload", () => {
|
|
|
477
501
|
...defaultMirrorConfig(),
|
|
478
502
|
enabled: true,
|
|
479
503
|
location: "internal",
|
|
480
|
-
|
|
504
|
+
sync_mode: "manual",
|
|
481
505
|
auto_commit: false,
|
|
482
506
|
},
|
|
483
507
|
});
|
|
@@ -520,7 +544,7 @@ describe("MirrorManager.runNow", () => {
|
|
|
520
544
|
...defaultMirrorConfig(),
|
|
521
545
|
enabled: true,
|
|
522
546
|
location: "internal",
|
|
523
|
-
|
|
547
|
+
sync_mode: "manual",
|
|
524
548
|
auto_commit: false,
|
|
525
549
|
},
|
|
526
550
|
});
|
|
@@ -548,3 +572,390 @@ describe("MirrorManager.runNow", () => {
|
|
|
548
572
|
await mgr.stop();
|
|
549
573
|
});
|
|
550
574
|
});
|
|
575
|
+
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
// Event-driven path (vault#382) — hook subscriptions, debounce, safety net
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
describe("MirrorManager — event-driven (sync_mode: events)", () => {
|
|
581
|
+
let home: string;
|
|
582
|
+
let hooks: HookRegistry;
|
|
583
|
+
afterEach(async () => {
|
|
584
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
/** Helper — build a manager pre-wired with hooks + events sync_mode. */
|
|
588
|
+
async function makeEventDrivenManager(homeArg: string): Promise<{
|
|
589
|
+
mgr: MirrorManager;
|
|
590
|
+
hooks: HookRegistry;
|
|
591
|
+
deps: ReturnType<typeof makeFakeDeps>;
|
|
592
|
+
}> {
|
|
593
|
+
fs.mkdirSync(path.join(homeArg, "vault", "data", "default"), { recursive: true });
|
|
594
|
+
const h = new HookRegistry({ concurrency: 4, logger: { error: () => {} } });
|
|
595
|
+
const deps = makeFakeDeps({
|
|
596
|
+
parachuteHome: homeArg,
|
|
597
|
+
initialConfig: {
|
|
598
|
+
...defaultMirrorConfig(),
|
|
599
|
+
enabled: true,
|
|
600
|
+
location: "internal",
|
|
601
|
+
sync_mode: "events",
|
|
602
|
+
auto_commit: false,
|
|
603
|
+
},
|
|
604
|
+
hooks: h,
|
|
605
|
+
});
|
|
606
|
+
const mgr = new MirrorManager(deps);
|
|
607
|
+
await mgr.start();
|
|
608
|
+
return { mgr, hooks: h, deps };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
test("start() subscribes to hooks (3 subscriptions: notes, tags, attachments)", async () => {
|
|
612
|
+
home = tmp("mgr-ev-sub-");
|
|
613
|
+
const { mgr } = await makeEventDrivenManager(home);
|
|
614
|
+
expect((mgr as unknown as { _subscriptionCount: () => number })._subscriptionCount()).toBe(3);
|
|
615
|
+
await mgr.stop();
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("start() does not subscribe when sync_mode: manual", async () => {
|
|
619
|
+
home = tmp("mgr-ev-manual-");
|
|
620
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
621
|
+
hooks = new HookRegistry({ concurrency: 4, logger: { error: () => {} } });
|
|
622
|
+
const deps = makeFakeDeps({
|
|
623
|
+
parachuteHome: home,
|
|
624
|
+
initialConfig: {
|
|
625
|
+
...defaultMirrorConfig(),
|
|
626
|
+
enabled: true,
|
|
627
|
+
location: "internal",
|
|
628
|
+
sync_mode: "manual",
|
|
629
|
+
auto_commit: false,
|
|
630
|
+
},
|
|
631
|
+
hooks,
|
|
632
|
+
});
|
|
633
|
+
const mgr = new MirrorManager(deps);
|
|
634
|
+
await mgr.start();
|
|
635
|
+
expect((mgr as unknown as { _subscriptionCount: () => number })._subscriptionCount()).toBe(0);
|
|
636
|
+
await mgr.stop();
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
test("one note-mutation event → debounced flush → one extra export", async () => {
|
|
640
|
+
home = tmp("mgr-ev-debounce-");
|
|
641
|
+
const { mgr, deps } = await makeEventDrivenManager(home);
|
|
642
|
+
expect(deps.exportCalls).toHaveLength(1); // initial export
|
|
643
|
+
|
|
644
|
+
// Fire a synthetic event via the hook registry. Use a fake store —
|
|
645
|
+
// the mirror's handler ignores the payload, just marks dirty.
|
|
646
|
+
const fakeStore = {} as never;
|
|
647
|
+
hooks = (mgr as unknown as { deps: MirrorDeps }).deps.hooks!;
|
|
648
|
+
hooks.dispatch("created", { id: "n1", content: "hi", createdAt: new Date().toISOString() } as never, fakeStore);
|
|
649
|
+
// Let the dispatch microtask + handler run.
|
|
650
|
+
await Promise.resolve();
|
|
651
|
+
await Promise.resolve();
|
|
652
|
+
await hooks.drain();
|
|
653
|
+
// The debounce timer is armed; force it for the test.
|
|
654
|
+
await (mgr as unknown as { _flushDebounceForTest: () => Promise<void> })._flushDebounceForTest();
|
|
655
|
+
|
|
656
|
+
expect(deps.exportCalls).toHaveLength(2);
|
|
657
|
+
// The non-initial export carries the cursor from the initial pass.
|
|
658
|
+
expect(deps.exportCalls[1]!.sinceCursor).toBeDefined();
|
|
659
|
+
// Prune was called (event-driven flush always prunes).
|
|
660
|
+
expect(deps.pruneCalls.length).toBeGreaterThanOrEqual(2);
|
|
661
|
+
await mgr.stop();
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
test("burst of events → debounce collapses into one export", async () => {
|
|
665
|
+
home = tmp("mgr-ev-burst-");
|
|
666
|
+
const { mgr, deps } = await makeEventDrivenManager(home);
|
|
667
|
+
expect(deps.exportCalls).toHaveLength(1);
|
|
668
|
+
const initialPruneCount = deps.pruneCalls.length;
|
|
669
|
+
|
|
670
|
+
const fakeStore = {} as never;
|
|
671
|
+
hooks = (mgr as unknown as { deps: MirrorDeps }).deps.hooks!;
|
|
672
|
+
for (let i = 0; i < 10; i++) {
|
|
673
|
+
hooks.dispatch("updated", { id: `n${i}`, content: "v", createdAt: new Date().toISOString() } as never, fakeStore);
|
|
674
|
+
}
|
|
675
|
+
await Promise.resolve();
|
|
676
|
+
await Promise.resolve();
|
|
677
|
+
await hooks.drain();
|
|
678
|
+
await (mgr as unknown as { _flushDebounceForTest: () => Promise<void> })._flushDebounceForTest();
|
|
679
|
+
|
|
680
|
+
// All 10 events collapsed into ONE additional export.
|
|
681
|
+
expect(deps.exportCalls).toHaveLength(2);
|
|
682
|
+
expect(deps.pruneCalls.length - initialPruneCount).toBe(1);
|
|
683
|
+
await mgr.stop();
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
test("event during in-flight export → second flush queued", async () => {
|
|
687
|
+
home = tmp("mgr-ev-during-flight-");
|
|
688
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
689
|
+
const h = new HookRegistry({ concurrency: 4, logger: { error: () => {} } });
|
|
690
|
+
|
|
691
|
+
// Build runExport that holds open for the first event-driven flush
|
|
692
|
+
// so we can dispatch a second event mid-flight.
|
|
693
|
+
let releaseFirstFlush: (() => void) | null = null;
|
|
694
|
+
let exportCallCount = 0;
|
|
695
|
+
const deps = makeFakeDeps({
|
|
696
|
+
parachuteHome: home,
|
|
697
|
+
initialConfig: {
|
|
698
|
+
...defaultMirrorConfig(),
|
|
699
|
+
enabled: true,
|
|
700
|
+
location: "internal",
|
|
701
|
+
sync_mode: "events",
|
|
702
|
+
auto_commit: false,
|
|
703
|
+
},
|
|
704
|
+
hooks: h,
|
|
705
|
+
runExport: async (_call) => {
|
|
706
|
+
exportCallCount++;
|
|
707
|
+
// Only the FIRST event-driven flush blocks (call #2; #1 is initial).
|
|
708
|
+
if (exportCallCount === 2) {
|
|
709
|
+
await new Promise<void>((resolve) => {
|
|
710
|
+
releaseFirstFlush = resolve;
|
|
711
|
+
});
|
|
712
|
+
}
|
|
713
|
+
return { notes: 1 };
|
|
714
|
+
},
|
|
715
|
+
});
|
|
716
|
+
const mgr = new MirrorManager(deps);
|
|
717
|
+
await mgr.start();
|
|
718
|
+
expect(exportCallCount).toBe(1);
|
|
719
|
+
|
|
720
|
+
const fakeStore = {} as never;
|
|
721
|
+
h.dispatch("created", { id: "first", content: "x", createdAt: new Date().toISOString() } as never, fakeStore);
|
|
722
|
+
await Promise.resolve();
|
|
723
|
+
await Promise.resolve();
|
|
724
|
+
await h.drain();
|
|
725
|
+
// Kick off the debounce flush — it'll block on the gate.
|
|
726
|
+
const flushPromise = (mgr as unknown as { _flushDebounceForTest: () => Promise<void> })._flushDebounceForTest();
|
|
727
|
+
// Give it a tick to enter runExport.
|
|
728
|
+
await new Promise((r) => setTimeout(r, 25));
|
|
729
|
+
expect(exportCallCount).toBe(2);
|
|
730
|
+
|
|
731
|
+
// While that one is in flight, dispatch a second event.
|
|
732
|
+
h.dispatch("updated", { id: "second", content: "y", createdAt: new Date().toISOString() } as never, fakeStore);
|
|
733
|
+
await Promise.resolve();
|
|
734
|
+
await Promise.resolve();
|
|
735
|
+
await h.drain();
|
|
736
|
+
// Release the first flush — dirtyDuringFlush should re-arm the debounce.
|
|
737
|
+
releaseFirstFlush!();
|
|
738
|
+
await flushPromise;
|
|
739
|
+
// Force the queued debounce too.
|
|
740
|
+
await (mgr as unknown as { _flushDebounceForTest: () => Promise<void> })._flushDebounceForTest();
|
|
741
|
+
|
|
742
|
+
expect(exportCallCount).toBe(3);
|
|
743
|
+
await mgr.stop();
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
test("stop() unsubscribes hooks + cancels debounce timer", async () => {
|
|
747
|
+
home = tmp("mgr-ev-stop-");
|
|
748
|
+
const { mgr, deps } = await makeEventDrivenManager(home);
|
|
749
|
+
const exportCountBeforeStop = deps.exportCalls.length;
|
|
750
|
+
|
|
751
|
+
await mgr.stop();
|
|
752
|
+
expect((mgr as unknown as { _subscriptionCount: () => number })._subscriptionCount()).toBe(0);
|
|
753
|
+
|
|
754
|
+
// Subsequent events should produce no further exports.
|
|
755
|
+
const h = (mgr as unknown as { deps: MirrorDeps }).deps.hooks!;
|
|
756
|
+
const fakeStore = {} as never;
|
|
757
|
+
h.dispatch("created", { id: "ghost", content: "z", createdAt: new Date().toISOString() } as never, fakeStore);
|
|
758
|
+
await Promise.resolve();
|
|
759
|
+
await h.drain();
|
|
760
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
761
|
+
expect(deps.exportCalls.length).toBe(exportCountBeforeStop);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("manual mode → no subscriptions, only runNow triggers export", async () => {
|
|
765
|
+
home = tmp("mgr-ev-manual-only-");
|
|
766
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
767
|
+
const h = new HookRegistry({ concurrency: 4, logger: { error: () => {} } });
|
|
768
|
+
const deps = makeFakeDeps({
|
|
769
|
+
parachuteHome: home,
|
|
770
|
+
initialConfig: {
|
|
771
|
+
...defaultMirrorConfig(),
|
|
772
|
+
enabled: true,
|
|
773
|
+
location: "internal",
|
|
774
|
+
sync_mode: "manual",
|
|
775
|
+
auto_commit: false,
|
|
776
|
+
},
|
|
777
|
+
hooks: h,
|
|
778
|
+
});
|
|
779
|
+
const mgr = new MirrorManager(deps);
|
|
780
|
+
await mgr.start();
|
|
781
|
+
expect(deps.exportCalls).toHaveLength(1); // initial
|
|
782
|
+
|
|
783
|
+
// Events fired now should NOT trigger flushes (no subscriptions).
|
|
784
|
+
h.dispatch("created", { id: "n1", content: "x", createdAt: new Date().toISOString() } as never, {} as never);
|
|
785
|
+
await Promise.resolve();
|
|
786
|
+
await h.drain();
|
|
787
|
+
expect(deps.exportCalls).toHaveLength(1);
|
|
788
|
+
|
|
789
|
+
// runNow still fires.
|
|
790
|
+
await mgr.runNow();
|
|
791
|
+
expect(deps.exportCalls).toHaveLength(2);
|
|
792
|
+
await mgr.stop();
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
test("reload() from events → manual tears down subscriptions", async () => {
|
|
796
|
+
home = tmp("mgr-ev-swap-mode-");
|
|
797
|
+
const { mgr, hooks: h } = await makeEventDrivenManager(home);
|
|
798
|
+
expect((mgr as unknown as { _subscriptionCount: () => number })._subscriptionCount()).toBe(3);
|
|
799
|
+
|
|
800
|
+
await mgr.reload({
|
|
801
|
+
...defaultMirrorConfig(),
|
|
802
|
+
enabled: true,
|
|
803
|
+
location: "internal",
|
|
804
|
+
sync_mode: "manual",
|
|
805
|
+
auto_commit: false,
|
|
806
|
+
});
|
|
807
|
+
expect((mgr as unknown as { _subscriptionCount: () => number })._subscriptionCount()).toBe(0);
|
|
808
|
+
|
|
809
|
+
// Confirm a dispatched event in the new mode is a no-op.
|
|
810
|
+
h.dispatch("created", { id: "n1", content: "x", createdAt: new Date().toISOString() } as never, {} as never);
|
|
811
|
+
await Promise.resolve();
|
|
812
|
+
await h.drain();
|
|
813
|
+
// No additional export beyond the reload's initial pass.
|
|
814
|
+
await mgr.stop();
|
|
815
|
+
});
|
|
816
|
+
|
|
817
|
+
test("missing hooks dep → falls back to safety-net only (logs warning, doesn't crash)", async () => {
|
|
818
|
+
home = tmp("mgr-ev-no-hooks-");
|
|
819
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
820
|
+
// No `hooks` field on deps → manager should still start cleanly.
|
|
821
|
+
const deps = makeFakeDeps({
|
|
822
|
+
parachuteHome: home,
|
|
823
|
+
initialConfig: {
|
|
824
|
+
...defaultMirrorConfig(),
|
|
825
|
+
enabled: true,
|
|
826
|
+
location: "internal",
|
|
827
|
+
sync_mode: "events",
|
|
828
|
+
auto_commit: false,
|
|
829
|
+
},
|
|
830
|
+
// Intentionally omit `hooks`.
|
|
831
|
+
});
|
|
832
|
+
const mgr = new MirrorManager(deps);
|
|
833
|
+
const status = await mgr.start();
|
|
834
|
+
expect(status.enabled).toBe(true);
|
|
835
|
+
expect(status.watch_running).toBe(true);
|
|
836
|
+
expect((mgr as unknown as { _subscriptionCount: () => number })._subscriptionCount()).toBe(0);
|
|
837
|
+
await mgr.stop();
|
|
838
|
+
});
|
|
839
|
+
|
|
840
|
+
test("prune is invoked on initial export AND on event-driven flushes", async () => {
|
|
841
|
+
home = tmp("mgr-ev-prune-");
|
|
842
|
+
const { mgr, deps } = await makeEventDrivenManager(home);
|
|
843
|
+
// Initial export should have called prune.
|
|
844
|
+
expect(deps.pruneCalls.length).toBeGreaterThanOrEqual(1);
|
|
845
|
+
|
|
846
|
+
const fakeStore = {} as never;
|
|
847
|
+
hooks = (mgr as unknown as { deps: MirrorDeps }).deps.hooks!;
|
|
848
|
+
hooks.dispatch("deleted", { id: "gone", path: "old/note" } as never, fakeStore);
|
|
849
|
+
await Promise.resolve();
|
|
850
|
+
await Promise.resolve();
|
|
851
|
+
await hooks.drain();
|
|
852
|
+
const pruneBefore = deps.pruneCalls.length;
|
|
853
|
+
await (mgr as unknown as { _flushDebounceForTest: () => Promise<void> })._flushDebounceForTest();
|
|
854
|
+
expect(deps.pruneCalls.length).toBe(pruneBefore + 1);
|
|
855
|
+
await mgr.stop();
|
|
856
|
+
});
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
// ---------------------------------------------------------------------------
|
|
860
|
+
// MirrorManager.pushNow — Cut 6 of vault#392 (credentials → push round-trip)
|
|
861
|
+
//
|
|
862
|
+
// Live `git push` against a local bare repo as the "remote." Tests the
|
|
863
|
+
// status mutations (last_push_at, last_push_sha, last_push_error,
|
|
864
|
+
// commits_unpushed) the SPA renders.
|
|
865
|
+
// ---------------------------------------------------------------------------
|
|
866
|
+
|
|
867
|
+
describe("MirrorManager.pushNow — push observability", () => {
|
|
868
|
+
let home: string;
|
|
869
|
+
let remote: string;
|
|
870
|
+
|
|
871
|
+
afterEach(async () => {
|
|
872
|
+
if (home) fs.rmSync(home, { recursive: true, force: true });
|
|
873
|
+
if (remote) fs.rmSync(remote, { recursive: true, force: true });
|
|
874
|
+
});
|
|
875
|
+
|
|
876
|
+
test("returns not_enabled when mirror is disabled", async () => {
|
|
877
|
+
home = tmp("mgr-pushnow-disabled-");
|
|
878
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
879
|
+
const deps = makeFakeDeps({
|
|
880
|
+
parachuteHome: home,
|
|
881
|
+
initialConfig: { ...defaultMirrorConfig(), enabled: false },
|
|
882
|
+
});
|
|
883
|
+
const mgr = new MirrorManager(deps);
|
|
884
|
+
await mgr.start();
|
|
885
|
+
const r = await mgr.pushNow();
|
|
886
|
+
expect(r.fired).toBe(false);
|
|
887
|
+
if (r.fired === false) expect(r.reason).toBe("not_enabled");
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test("first push wires upstream and sets last_push_at + last_push_sha", async () => {
|
|
891
|
+
// Spin up an enabled internal mirror; wire a local bare repo as
|
|
892
|
+
// origin; verify pushNow lands the seed commit and updates status.
|
|
893
|
+
home = tmp("mgr-pushnow-first-");
|
|
894
|
+
remote = tmp("mgr-pushnow-remote-");
|
|
895
|
+
Bun.spawnSync(["git", "init", "--bare", "-q", "-b", "main"], { cwd: remote });
|
|
896
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
897
|
+
const deps = makeFakeDeps({
|
|
898
|
+
parachuteHome: home,
|
|
899
|
+
initialConfig: {
|
|
900
|
+
...defaultMirrorConfig(),
|
|
901
|
+
enabled: true,
|
|
902
|
+
location: "internal",
|
|
903
|
+
sync_mode: "manual",
|
|
904
|
+
auto_commit: false,
|
|
905
|
+
},
|
|
906
|
+
});
|
|
907
|
+
const mgr = new MirrorManager(deps);
|
|
908
|
+
const status = await mgr.start();
|
|
909
|
+
expect(status.enabled).toBe(true);
|
|
910
|
+
// Wire origin to the bare repo. The internal-mirror's first commit
|
|
911
|
+
// (`initial mirror bootstrap`) is sitting on HEAD waiting to push.
|
|
912
|
+
Bun.spawnSync(["git", "remote", "add", "origin", remote], { cwd: status.mirror_path! });
|
|
913
|
+
|
|
914
|
+
const result = await mgr.pushNow();
|
|
915
|
+
expect(result.fired).toBe(true);
|
|
916
|
+
if (result.fired) {
|
|
917
|
+
expect(result.pushed).toBe(true);
|
|
918
|
+
expect(typeof result.sha).toBe("string");
|
|
919
|
+
}
|
|
920
|
+
const after = mgr.getStatus();
|
|
921
|
+
expect(after.last_push_at).not.toBeNull();
|
|
922
|
+
expect(after.last_push_sha).not.toBeNull();
|
|
923
|
+
expect(after.last_push_error).toBeNull();
|
|
924
|
+
expect(after.commits_unpushed).toBe(0);
|
|
925
|
+
await mgr.stop();
|
|
926
|
+
});
|
|
927
|
+
|
|
928
|
+
test("push failure surfaces redacted error in last_push_error", async () => {
|
|
929
|
+
// Wire origin to a non-existent host. Push fails; status reflects
|
|
930
|
+
// the failure without leaking any potentially-sensitive URL parts.
|
|
931
|
+
home = tmp("mgr-pushnow-fail-");
|
|
932
|
+
fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
|
|
933
|
+
const deps = makeFakeDeps({
|
|
934
|
+
parachuteHome: home,
|
|
935
|
+
initialConfig: {
|
|
936
|
+
...defaultMirrorConfig(),
|
|
937
|
+
enabled: true,
|
|
938
|
+
location: "internal",
|
|
939
|
+
sync_mode: "manual",
|
|
940
|
+
auto_commit: false,
|
|
941
|
+
},
|
|
942
|
+
});
|
|
943
|
+
const mgr = new MirrorManager(deps);
|
|
944
|
+
const status = await mgr.start();
|
|
945
|
+
Bun.spawnSync(
|
|
946
|
+
["git", "remote", "add", "origin", "https://x-access-token:ghp_fake1234567890abcdef@nonexistent.parachute.test/a/b.git"],
|
|
947
|
+
{ cwd: status.mirror_path! },
|
|
948
|
+
);
|
|
949
|
+
const result = await mgr.pushNow();
|
|
950
|
+
expect(result.fired).toBe(true);
|
|
951
|
+
if (result.fired) expect(result.pushed).toBe(false);
|
|
952
|
+
const after = mgr.getStatus();
|
|
953
|
+
expect(after.last_push_error).not.toBeNull();
|
|
954
|
+
// Redacted — no token leak.
|
|
955
|
+
expect(after.last_push_error).not.toContain("ghp_fake1234567890");
|
|
956
|
+
// Last successful push timestamp is unchanged (still null on a
|
|
957
|
+
// never-succeeded mirror).
|
|
958
|
+
expect(after.last_push_at).toBeNull();
|
|
959
|
+
await mgr.stop();
|
|
960
|
+
}, 30_000);
|
|
961
|
+
});
|