@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
package/src/mirror-manager.ts
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Mirror lifecycle manager — boot-time bootstrap +
|
|
2
|
+
* Mirror lifecycle manager — boot-time bootstrap + event-driven exports.
|
|
3
3
|
*
|
|
4
4
|
* This is the persistent counterpart to vault#346's CLI watch+commit mode.
|
|
5
|
+
* Post-event-driven shift (vault#382, this PR), the manager replaces the
|
|
6
|
+
* original `setInterval` polling loop with in-process hook subscriptions.
|
|
7
|
+
* Every note / tag / attachment mutation fires an event; the manager
|
|
8
|
+
* debounces them (~500ms) into a single export pass. A background
|
|
9
|
+
* safety-net poll runs once per `safety_net_seconds` (default 1h) to
|
|
10
|
+
* catch anything the event path missed — direct SQL writes, dropped
|
|
11
|
+
* hook dispatches, restart gaps.
|
|
12
|
+
*
|
|
5
13
|
* Responsibilities:
|
|
6
14
|
*
|
|
7
15
|
* - On vault server boot: read mirror config, resolve mirror path,
|
|
8
16
|
* bootstrap (mkdir + git init + initial commit) when internal + new,
|
|
9
17
|
* trigger an initial export to bring the mirror to current state,
|
|
10
|
-
* and — if `
|
|
18
|
+
* and — if `sync_mode: events` — subscribe to hooks + arm the
|
|
19
|
+
* safety-net poll.
|
|
11
20
|
*
|
|
12
21
|
* - On config change (via `PUT /admin/mirror` or operator-triggered
|
|
13
|
-
* reload):
|
|
14
|
-
* with the new shape.
|
|
22
|
+
* reload): tear down all subscriptions + timers, re-resolve,
|
|
23
|
+
* restart with the new shape.
|
|
15
24
|
*
|
|
16
|
-
* - On vault server shutdown:
|
|
17
|
-
*
|
|
25
|
+
* - On vault server shutdown: unsubscribe, cancel any pending
|
|
26
|
+
* debounce timer, cancel safety-net poll, let an in-flight export
|
|
27
|
+
* finish via the soft settle window.
|
|
18
28
|
*
|
|
19
29
|
* Singleton per-process: one `MirrorManager` instance backs the vault
|
|
20
30
|
* server's lifecycle. Tests instantiate `MirrorManager` directly with
|
|
@@ -27,11 +37,28 @@
|
|
|
27
37
|
* `PARACHUTE_VAULT_NAME` / `default_vault`; the mirror config follows
|
|
28
38
|
* suit. Multi-vault mirror routing is a future ripple (open question 2
|
|
29
39
|
* in the design doc).
|
|
40
|
+
*
|
|
41
|
+
* ## Race-condition contract
|
|
42
|
+
*
|
|
43
|
+
* - **Burst collapse** — two events within DEBOUNCE_MS produce one
|
|
44
|
+
* export pass. The first event arms the timer; subsequent events
|
|
45
|
+
* re-arm it.
|
|
46
|
+
* - **Event during in-flight export** — flag `dirtyDuringFlush`; after
|
|
47
|
+
* the in-flight pass completes, run another. No second concurrent
|
|
48
|
+
* pass; no missed events.
|
|
49
|
+
* - **Stop() during in-flight** — like the legacy soft-settle window,
|
|
50
|
+
* give the export ~250ms to finish before returning. Subscriptions
|
|
51
|
+
* come down BEFORE the settle wait so no further events queue.
|
|
52
|
+
* - **Safety net during event-driven path** — the poll's tick first
|
|
53
|
+
* checks the dirty bit. If clear, it runs a full sweep anyway (catches
|
|
54
|
+
* missed events). If set, it skips — the debounce timer's already
|
|
55
|
+
* coming.
|
|
30
56
|
*/
|
|
31
57
|
|
|
32
58
|
import { existsSync, mkdirSync, readdirSync, statSync } from "fs";
|
|
33
59
|
|
|
34
60
|
import {
|
|
61
|
+
DEFAULT_SAFETY_NET_SECONDS,
|
|
35
62
|
defaultMirrorConfig,
|
|
36
63
|
resolveMirrorPath,
|
|
37
64
|
type MirrorConfig,
|
|
@@ -39,10 +66,25 @@ import {
|
|
|
39
66
|
import {
|
|
40
67
|
gitAddAll,
|
|
41
68
|
gitCommit,
|
|
69
|
+
gitPush,
|
|
42
70
|
isGitRepo,
|
|
71
|
+
redactToken,
|
|
43
72
|
runGitCommitCycle,
|
|
44
73
|
} from "./export-watch.ts";
|
|
45
74
|
import { vaultDir } from "./config.ts";
|
|
75
|
+
import {
|
|
76
|
+
applyToGitRemote,
|
|
77
|
+
readCredentials,
|
|
78
|
+
} from "./mirror-credentials.ts";
|
|
79
|
+
import type { HookRegistry } from "../core/src/hooks.ts";
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Debounce window between an event arrival and the export pass it
|
|
83
|
+
* triggers. 500ms is the sweet spot: long enough to collapse a burst
|
|
84
|
+
* (typing in a frontend, a batch import) into one export, short enough
|
|
85
|
+
* that the mirror feels live during normal editing.
|
|
86
|
+
*/
|
|
87
|
+
const DEBOUNCE_MS = 500;
|
|
46
88
|
|
|
47
89
|
// ---------------------------------------------------------------------------
|
|
48
90
|
// Types
|
|
@@ -56,11 +98,18 @@ import { vaultDir } from "./config.ts";
|
|
|
56
98
|
export interface MirrorStatus {
|
|
57
99
|
/** True iff `mirror.enabled` is true AND bootstrap succeeded. */
|
|
58
100
|
enabled: boolean;
|
|
59
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* True iff hook subscriptions are currently active.
|
|
103
|
+
*
|
|
104
|
+
* Pre-event-driven this was named after the polling loop; we retain
|
|
105
|
+
* the name for SPA-shape stability — the SPA's "Watch loop" label
|
|
106
|
+
* now reflects "events subscribed + safety-net armed". The semantics
|
|
107
|
+
* shifted, not the API.
|
|
108
|
+
*/
|
|
60
109
|
watch_running: boolean;
|
|
61
110
|
/** Resolved mirror path on disk, or null if disabled / unresolved. */
|
|
62
111
|
mirror_path: string | null;
|
|
63
|
-
/** ISO timestamp of the most recent export pass (initial or
|
|
112
|
+
/** ISO timestamp of the most recent export pass (initial or event-driven or safety-net). */
|
|
64
113
|
last_export_at: string | null;
|
|
65
114
|
/** Notes touched by the most recent export pass. */
|
|
66
115
|
last_export_notes_count: number | null;
|
|
@@ -68,6 +117,28 @@ export interface MirrorStatus {
|
|
|
68
117
|
last_commit_sha: string | null;
|
|
69
118
|
/** Last error message (if any). Cleared on the next successful pass. */
|
|
70
119
|
last_error: string | null;
|
|
120
|
+
/**
|
|
121
|
+
* Cut 5: push observability — surfaced via GET /admin/mirror so the
|
|
122
|
+
* operator (and the SPA's Status card) can see whether pushes are
|
|
123
|
+
* actually landing without grepping `[git-commit]` log lines.
|
|
124
|
+
*
|
|
125
|
+
* - `last_push_at` — ISO timestamp of the most recent SUCCESSFUL push.
|
|
126
|
+
* Null until the first successful push.
|
|
127
|
+
* - `last_push_sha` — sha that landed on the remote at `last_push_at`.
|
|
128
|
+
* Null when last_push_at is null.
|
|
129
|
+
* - `last_push_error` — message from the most recent FAILED push.
|
|
130
|
+
* Cleared (back to null) on the next successful push. Tokens are
|
|
131
|
+
* redacted before this field is set (push paths use the
|
|
132
|
+
* `gho_`/`ghp_`/userinfo regex to scrub).
|
|
133
|
+
* - `commits_unpushed` — count of local commits ahead of upstream
|
|
134
|
+
* (`git rev-list --count @{u}..HEAD`). Null when no upstream tracking
|
|
135
|
+
* exists yet (first push hasn't fired). 0 when the mirror is fully
|
|
136
|
+
* synced; positive when commits are queued.
|
|
137
|
+
*/
|
|
138
|
+
last_push_at: string | null;
|
|
139
|
+
last_push_sha: string | null;
|
|
140
|
+
last_push_error: string | null;
|
|
141
|
+
commits_unpushed: number | null;
|
|
71
142
|
}
|
|
72
143
|
|
|
73
144
|
/**
|
|
@@ -88,6 +159,18 @@ export interface MirrorDeps {
|
|
|
88
159
|
outDir: string;
|
|
89
160
|
sinceCursor?: string;
|
|
90
161
|
}) => Promise<{ notes: number }>;
|
|
162
|
+
/**
|
|
163
|
+
* Run an orphan-prune sweep against the mirror dir. Returns counts so
|
|
164
|
+
* the manager can log + surface them in status. Optional — when
|
|
165
|
+
* undefined, the manager skips the prune step (test seam; production
|
|
166
|
+
* always wires it through `mirror-deps.ts`).
|
|
167
|
+
*/
|
|
168
|
+
runPrune?: (opts: { outDir: string }) => Promise<{
|
|
169
|
+
notes_removed: number;
|
|
170
|
+
sidecars_removed: number;
|
|
171
|
+
schemas_removed: number;
|
|
172
|
+
attachment_dirs_removed: number;
|
|
173
|
+
}>;
|
|
91
174
|
/**
|
|
92
175
|
* Resolve the first-changed-note title since `cursor`, for the
|
|
93
176
|
* `{{first_note_title}}` commit-template variable. Best-effort — empty
|
|
@@ -101,6 +184,14 @@ export interface MirrorDeps {
|
|
|
101
184
|
* via the standard writer — used by `PUT /admin/mirror`.
|
|
102
185
|
*/
|
|
103
186
|
writeMirrorConfig: (config: MirrorConfig) => void;
|
|
187
|
+
/**
|
|
188
|
+
* Shared in-process hook registry. The manager calls `.onNote()` /
|
|
189
|
+
* `.onTag()` / `.onAttachment()` on this registry to drive
|
|
190
|
+
* event-driven exports. Optional — when undefined the manager runs
|
|
191
|
+
* with only the safety-net poll (back-compat shape for tests that
|
|
192
|
+
* predate the event-driven path).
|
|
193
|
+
*/
|
|
194
|
+
hooks?: HookRegistry;
|
|
104
195
|
}
|
|
105
196
|
|
|
106
197
|
// ---------------------------------------------------------------------------
|
|
@@ -221,19 +312,64 @@ export async function bootstrapInternalMirror(
|
|
|
221
312
|
// Manager
|
|
222
313
|
// ---------------------------------------------------------------------------
|
|
223
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Cut 5: count local commits ahead of upstream tracking. Implementation:
|
|
317
|
+
* `git rev-list --count @{u}..HEAD` returns N when N commits are ahead,
|
|
318
|
+
* 0 when synced, and exit-nonzero when no upstream tracking exists
|
|
319
|
+
* (the branch has never been pushed with `-u`). The non-zero case maps
|
|
320
|
+
* to `null` — distinct from "0 ahead" so the SPA can render "no remote
|
|
321
|
+
* tracking yet" vs "fully synced" differently if desired.
|
|
322
|
+
*/
|
|
323
|
+
async function readCommitsUnpushed(repoDir: string): Promise<number | null> {
|
|
324
|
+
const proc = Bun.spawn(
|
|
325
|
+
["git", "rev-list", "--count", "@{u}..HEAD"],
|
|
326
|
+
{
|
|
327
|
+
cwd: repoDir,
|
|
328
|
+
stdout: "pipe",
|
|
329
|
+
stderr: "pipe",
|
|
330
|
+
},
|
|
331
|
+
);
|
|
332
|
+
const exitCode = await proc.exited;
|
|
333
|
+
if (exitCode !== 0) return null; // no upstream configured yet
|
|
334
|
+
const out = new TextDecoder()
|
|
335
|
+
.decode(await new Response(proc.stdout).arrayBuffer())
|
|
336
|
+
.trim();
|
|
337
|
+
const n = Number(out);
|
|
338
|
+
return Number.isFinite(n) ? n : null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Read the current `origin` URL from a git repo's config, or null when no
|
|
343
|
+
* origin is set. Module-local helper for the credential-application path.
|
|
344
|
+
*/
|
|
345
|
+
async function readCurrentOrigin(repoDir: string): Promise<string | null> {
|
|
346
|
+
const proc = Bun.spawn(["git", "remote", "get-url", "origin"], {
|
|
347
|
+
cwd: repoDir,
|
|
348
|
+
stdout: "pipe",
|
|
349
|
+
stderr: "pipe",
|
|
350
|
+
});
|
|
351
|
+
const exitCode = await proc.exited;
|
|
352
|
+
if (exitCode !== 0) return null;
|
|
353
|
+
const url = new TextDecoder()
|
|
354
|
+
.decode(await new Response(proc.stdout).arrayBuffer())
|
|
355
|
+
.trim();
|
|
356
|
+
return url.length > 0 ? url : null;
|
|
357
|
+
}
|
|
358
|
+
|
|
224
359
|
/**
|
|
225
360
|
* Singleton lifecycle controller. Holds the active mirror config, the
|
|
226
|
-
* resolved path,
|
|
361
|
+
* resolved path, hook subscriptions (when sync_mode=events), the
|
|
362
|
+
* safety-net poll timer, the debounce timer, and the rolling status.
|
|
227
363
|
*
|
|
228
364
|
* State transitions:
|
|
229
365
|
*
|
|
230
|
-
* constructed → start() → [enabled? bootstrap+initial-export+
|
|
366
|
+
* constructed → start() → [enabled? bootstrap + initial-export +
|
|
367
|
+
* subscribe-to-hooks + arm-safety-net?]
|
|
231
368
|
* ↓ ↓
|
|
232
|
-
* stop() reload() —
|
|
369
|
+
* stop() reload() — tear down everything, re-evaluate
|
|
233
370
|
*
|
|
234
|
-
* Re-entrancy:
|
|
235
|
-
*
|
|
236
|
-
* don't pile up.
|
|
371
|
+
* Re-entrancy: in-flight + dirtyDuringFlush guards collapse concurrent
|
|
372
|
+
* triggers into one pass + one follow-up (never two parallel exports).
|
|
237
373
|
*/
|
|
238
374
|
export class MirrorManager {
|
|
239
375
|
private deps: MirrorDeps;
|
|
@@ -245,10 +381,22 @@ export class MirrorManager {
|
|
|
245
381
|
last_export_notes_count: null,
|
|
246
382
|
last_commit_sha: null,
|
|
247
383
|
last_error: null,
|
|
384
|
+
last_push_at: null,
|
|
385
|
+
last_push_sha: null,
|
|
386
|
+
last_push_error: null,
|
|
387
|
+
commits_unpushed: null,
|
|
248
388
|
};
|
|
249
|
-
|
|
389
|
+
/** Safety-net poll timer (interval). Null while not armed. */
|
|
390
|
+
private safetyNetTimer: ReturnType<typeof setInterval> | null = null;
|
|
391
|
+
/** Debounce timer (single-shot setTimeout). Null when no events pending. */
|
|
392
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
393
|
+
/** Unsubscribe handles returned by `hooks.on*()`. */
|
|
394
|
+
private unsubscribes: Array<() => void> = [];
|
|
250
395
|
private stopping = false;
|
|
396
|
+
/** A flush is currently running. Other triggers should not start a new one. */
|
|
251
397
|
private inFlight = false;
|
|
398
|
+
/** Set when an event/safety-net tick arrives during an in-flight flush. */
|
|
399
|
+
private dirtyDuringFlush = false;
|
|
252
400
|
/** Most recent export cursor — passed as `--since` to the next pass. */
|
|
253
401
|
private cursor: string | undefined = undefined;
|
|
254
402
|
private currentConfig: MirrorConfig = defaultMirrorConfig();
|
|
@@ -302,6 +450,10 @@ export class MirrorManager {
|
|
|
302
450
|
last_export_notes_count: null,
|
|
303
451
|
last_commit_sha: null,
|
|
304
452
|
last_error: null,
|
|
453
|
+
last_push_at: null,
|
|
454
|
+
last_push_sha: null,
|
|
455
|
+
last_push_error: null,
|
|
456
|
+
commits_unpushed: null,
|
|
305
457
|
};
|
|
306
458
|
return this.getStatus();
|
|
307
459
|
}
|
|
@@ -352,31 +504,45 @@ export class MirrorManager {
|
|
|
352
504
|
this.status.enabled = true;
|
|
353
505
|
this.status.last_error = null;
|
|
354
506
|
|
|
507
|
+
// Apply UI-configured credentials to the mirror's git remote, if any.
|
|
508
|
+
// The PAT path stores the full authed URL; we set it as `origin`.
|
|
509
|
+
// The GitHub OAuth path only sets `origin` when the operator has
|
|
510
|
+
// picked a repo (handleAuthGithubSelectRepo writes it then); on a
|
|
511
|
+
// restart with credentials present but no repo picked yet, this is
|
|
512
|
+
// a no-op. Failures are non-fatal — the operator's mirror still
|
|
513
|
+
// exports; they get the original "push needs credentials" feedback
|
|
514
|
+
// when auto_push fires.
|
|
515
|
+
await this.applyCredentialsToRemote(path);
|
|
516
|
+
|
|
355
517
|
// Initial export — full pass (no cursor) so the mirror starts
|
|
356
518
|
// byte-equivalent to current vault state, regardless of when the
|
|
357
|
-
// previous mirror was last refreshed.
|
|
519
|
+
// previous mirror was last refreshed. Also prunes orphans because
|
|
520
|
+
// the initial pass writes the entire vault; anything left over
|
|
521
|
+
// from a prior mirror run that doesn't match a current note is by
|
|
522
|
+
// definition orphaned.
|
|
358
523
|
try {
|
|
359
|
-
await this.runOneCycle({ isInitial: true });
|
|
524
|
+
await this.runOneCycle({ isInitial: true, prune: true });
|
|
360
525
|
} catch (err) {
|
|
361
526
|
const msg = (err as Error).message ?? String(err);
|
|
362
527
|
this.status.last_error = `initial export failed: ${msg}`;
|
|
363
528
|
console.warn(`[mirror] ${this.status.last_error}`);
|
|
364
529
|
// Don't disable the manager — operator may want to retry without
|
|
365
|
-
// restarting the server. Keep status.enabled true so the
|
|
366
|
-
// loop attempts again if armed; the next successful pass
|
|
367
|
-
// last_error.
|
|
530
|
+
// restarting the server. Keep status.enabled true so the safety-
|
|
531
|
+
// net loop attempts again if armed; the next successful pass
|
|
532
|
+
// clears last_error.
|
|
368
533
|
}
|
|
369
534
|
|
|
370
|
-
if (config.
|
|
371
|
-
this.
|
|
535
|
+
if (config.sync_mode === "events") {
|
|
536
|
+
this.subscribeToHooks();
|
|
537
|
+
this.armSafetyNetTimer();
|
|
372
538
|
this.status.watch_running = true;
|
|
373
539
|
console.log(
|
|
374
|
-
`[mirror] enabled (location: ${config.location},
|
|
540
|
+
`[mirror] enabled (location: ${config.location}, sync_mode: events) — initial export complete, hooks subscribed, safety-net every ${config.safety_net_seconds}s`,
|
|
375
541
|
);
|
|
376
542
|
} else {
|
|
377
543
|
this.status.watch_running = false;
|
|
378
544
|
console.log(
|
|
379
|
-
`[mirror] enabled (location: ${config.location},
|
|
545
|
+
`[mirror] enabled (location: ${config.location}, sync_mode: manual) — initial export complete, manual mode`,
|
|
380
546
|
);
|
|
381
547
|
}
|
|
382
548
|
|
|
@@ -384,21 +550,43 @@ export class MirrorManager {
|
|
|
384
550
|
}
|
|
385
551
|
|
|
386
552
|
/**
|
|
387
|
-
* Stop the
|
|
388
|
-
*
|
|
389
|
-
*
|
|
553
|
+
* Stop the mirror lifecycle cleanly:
|
|
554
|
+
* - Unsubscribe from hooks first so no new events queue.
|
|
555
|
+
* - Cancel debounce + safety-net timers.
|
|
556
|
+
* - Await the in-flight cycle (if any) up to a soft timeout.
|
|
390
557
|
*
|
|
391
558
|
* `preserveStatus: true` is the start()-internal path that keeps the
|
|
392
559
|
* status fields around for the about-to-restart pass; default false
|
|
393
|
-
* blanks
|
|
560
|
+
* blanks the `watch_running` indicator.
|
|
394
561
|
*/
|
|
395
562
|
async stop(opts: { preserveStatus?: boolean } = {}): Promise<void> {
|
|
396
563
|
this.stopping = true;
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
564
|
+
|
|
565
|
+
// Unsubscribe BEFORE waiting for in-flight — additional events
|
|
566
|
+
// arriving after we start the settle wait would re-arm the debounce
|
|
567
|
+
// timer and stretch the wait indefinitely.
|
|
568
|
+
for (const off of this.unsubscribes) {
|
|
569
|
+
try {
|
|
570
|
+
off();
|
|
571
|
+
} catch (err) {
|
|
572
|
+
// Unsubscribe should never throw; defensive log only.
|
|
573
|
+
console.warn(`[mirror] unsubscribe threw: ${(err as Error).message ?? err}`);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
this.unsubscribes = [];
|
|
577
|
+
|
|
578
|
+
if (this.debounceTimer) {
|
|
579
|
+
clearTimeout(this.debounceTimer);
|
|
580
|
+
this.debounceTimer = null;
|
|
400
581
|
}
|
|
401
|
-
|
|
582
|
+
if (this.safetyNetTimer) {
|
|
583
|
+
clearInterval(this.safetyNetTimer);
|
|
584
|
+
this.safetyNetTimer = null;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Brief settle window — give an in-flight export ~250ms to complete
|
|
588
|
+
// + write a coherent commit. Don't hang shutdown indefinitely; the
|
|
589
|
+
// export's status will surface a half-finished state on next start.
|
|
402
590
|
const settleMs = 250;
|
|
403
591
|
const start = Date.now();
|
|
404
592
|
while (this.inFlight && Date.now() - start < settleMs) {
|
|
@@ -408,6 +596,9 @@ export class MirrorManager {
|
|
|
408
596
|
this.status.watch_running = false;
|
|
409
597
|
}
|
|
410
598
|
this.stopping = false;
|
|
599
|
+
// Discard any dirty bit from events that arrived during shutdown —
|
|
600
|
+
// they'll be picked up on the next start()'s initial export anyway.
|
|
601
|
+
this.dirtyDuringFlush = false;
|
|
411
602
|
}
|
|
412
603
|
|
|
413
604
|
/**
|
|
@@ -425,26 +616,178 @@ export class MirrorManager {
|
|
|
425
616
|
|
|
426
617
|
/**
|
|
427
618
|
* Force a one-shot export cycle right now. Useful for "force re-export"
|
|
428
|
-
* buttons (
|
|
429
|
-
*
|
|
619
|
+
* buttons (admin SPA) + tests that want a deterministic cycle without
|
|
620
|
+
* waiting on the debounce or safety-net timer.
|
|
621
|
+
*
|
|
622
|
+
* Always runs with prune=true: a manual trigger is an explicit "make
|
|
623
|
+
* the mirror match vault state right now" request; orphan sweeps are
|
|
624
|
+
* cheap on typical vaults and the operator probably wanted that
|
|
625
|
+
* effect anyway.
|
|
430
626
|
*/
|
|
431
627
|
async runNow(): Promise<MirrorStatus> {
|
|
432
628
|
if (!this.status.enabled) {
|
|
433
629
|
return this.getStatus();
|
|
434
630
|
}
|
|
435
|
-
await this.runOneCycle({ isInitial: false });
|
|
631
|
+
await this.runOneCycle({ isInitial: false, prune: true });
|
|
436
632
|
return this.getStatus();
|
|
437
633
|
}
|
|
438
634
|
|
|
439
635
|
// Visible-for-test: number of `start()` calls so far.
|
|
440
636
|
_startCount(): number { return this.startCount; }
|
|
441
637
|
|
|
638
|
+
// ---------------------------------------------------------------------
|
|
639
|
+
// Event-driven path
|
|
640
|
+
// ---------------------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Subscribe to note / tag / attachment hooks. Each event arms the
|
|
644
|
+
* debounce timer (or marks the dirty bit if a flush is already
|
|
645
|
+
* running). Stop() will call the unsubscribe handles in `unsubscribes`.
|
|
646
|
+
*
|
|
647
|
+
* Subscribes to every mutation event the mirror cares about:
|
|
648
|
+
* - notes: created, updated, deleted
|
|
649
|
+
* - tags: upserted, deleted
|
|
650
|
+
* - attachments: created, deleted
|
|
651
|
+
*/
|
|
652
|
+
private subscribeToHooks(): void {
|
|
653
|
+
const hooks = this.deps.hooks;
|
|
654
|
+
if (!hooks) {
|
|
655
|
+
// No hook registry wired — fall back to safety-net only. Logged
|
|
656
|
+
// once at start; tests that exercise this path inspect status,
|
|
657
|
+
// not logs.
|
|
658
|
+
console.warn(
|
|
659
|
+
`[mirror] sync_mode: events requested but no HookRegistry wired in deps — running with safety-net poll only`,
|
|
660
|
+
);
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const onMutation = () => {
|
|
665
|
+
// Cheap pulse — flag dirty + arm the debounce.
|
|
666
|
+
if (this.inFlight) {
|
|
667
|
+
this.dirtyDuringFlush = true;
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
this.armDebounce();
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
this.unsubscribes.push(
|
|
674
|
+
hooks.onNote({
|
|
675
|
+
name: "mirror-note-mutation",
|
|
676
|
+
event: ["created", "updated", "deleted"],
|
|
677
|
+
handler: onMutation,
|
|
678
|
+
}),
|
|
679
|
+
);
|
|
680
|
+
this.unsubscribes.push(
|
|
681
|
+
hooks.onTag({
|
|
682
|
+
name: "mirror-tag-mutation",
|
|
683
|
+
event: ["upserted", "deleted"],
|
|
684
|
+
handler: onMutation,
|
|
685
|
+
}),
|
|
686
|
+
);
|
|
687
|
+
this.unsubscribes.push(
|
|
688
|
+
hooks.onAttachment({
|
|
689
|
+
name: "mirror-attachment-mutation",
|
|
690
|
+
event: ["created", "deleted"],
|
|
691
|
+
handler: onMutation,
|
|
692
|
+
}),
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Arm or re-arm the debounce timer. Subsequent calls within
|
|
698
|
+
* DEBOUNCE_MS reset the timer (classic debounce). When the timer
|
|
699
|
+
* fires, run an export pass; if events arrived during the export,
|
|
700
|
+
* run again.
|
|
701
|
+
*/
|
|
702
|
+
private armDebounce(): void {
|
|
703
|
+
if (this.stopping) return;
|
|
704
|
+
if (this.debounceTimer) {
|
|
705
|
+
clearTimeout(this.debounceTimer);
|
|
706
|
+
}
|
|
707
|
+
this.debounceTimer = setTimeout(() => {
|
|
708
|
+
this.debounceTimer = null;
|
|
709
|
+
void this.runFlush({ prune: false });
|
|
710
|
+
}, DEBOUNCE_MS);
|
|
711
|
+
this.debounceTimer.unref?.();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Run a single flush — guards against concurrent runs, follows up
|
|
716
|
+
* with another pass if events arrived mid-flight. The event-driven
|
|
717
|
+
* fast path uses prune=false (rely on the targeted "deleted" events
|
|
718
|
+
* for delete propagation; the safety-net + manual-run paths sweep).
|
|
719
|
+
*
|
|
720
|
+
* In practice deletions DO need pruning even on the fast path —
|
|
721
|
+
* `exportVaultToDir` only writes existing notes; the file the
|
|
722
|
+
* deletion event refers to has to be removed somewhere. The current
|
|
723
|
+
* implementation runs a full prune sweep on every flush so deletes
|
|
724
|
+
* propagate within the debounce window. (If profiling shows this is
|
|
725
|
+
* expensive for very large vaults, a targeted-deletion queue could
|
|
726
|
+
* replace the sweep on the fast path — recorded as a possible
|
|
727
|
+
* future optimization in the design doc.)
|
|
728
|
+
*/
|
|
729
|
+
private async runFlush(opts: { prune: boolean }): Promise<void> {
|
|
730
|
+
if (this.stopping) return;
|
|
731
|
+
if (this.inFlight) {
|
|
732
|
+
// Shouldn't happen given the dirty-bit guards in armDebounce +
|
|
733
|
+
// safetyNet; defensive only.
|
|
734
|
+
this.dirtyDuringFlush = true;
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
this.inFlight = true;
|
|
738
|
+
try {
|
|
739
|
+
// Event-driven flushes prune-on-flush so deletions actually
|
|
740
|
+
// propagate. Cost: O(mirror size) per flush — fine for typical
|
|
741
|
+
// vaults. See doc above on the targeted-queue optimization.
|
|
742
|
+
await this.runOneCycle({ isInitial: false, prune: true });
|
|
743
|
+
} catch (err) {
|
|
744
|
+
const msg = (err as Error).message ?? String(err);
|
|
745
|
+
this.status.last_error = `event-driven flush failed: ${msg}`;
|
|
746
|
+
console.warn(`[mirror] ${this.status.last_error}`);
|
|
747
|
+
} finally {
|
|
748
|
+
this.inFlight = false;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// If events arrived during the flush, run again (one follow-up,
|
|
752
|
+
// not a loop — armDebounce re-engages if events keep coming).
|
|
753
|
+
if (this.dirtyDuringFlush && !this.stopping) {
|
|
754
|
+
this.dirtyDuringFlush = false;
|
|
755
|
+
this.armDebounce();
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Arm the safety-net poll. Runs once per `safety_net_seconds`. When
|
|
761
|
+
* it fires:
|
|
762
|
+
* - If a flush is already pending (debounce armed) or in-flight,
|
|
763
|
+
* skip (the event path will catch it).
|
|
764
|
+
* - Otherwise, run a full prune+export sweep so anything missed by
|
|
765
|
+
* the events (direct SQL writes, dropped dispatches, restart
|
|
766
|
+
* gaps) lands in the mirror.
|
|
767
|
+
*/
|
|
768
|
+
private armSafetyNetTimer(): void {
|
|
769
|
+
if (this.safetyNetTimer) return;
|
|
770
|
+
const intervalMs = (
|
|
771
|
+
this.currentConfig.safety_net_seconds || DEFAULT_SAFETY_NET_SECONDS
|
|
772
|
+
) * 1000;
|
|
773
|
+
this.safetyNetTimer = setInterval(async () => {
|
|
774
|
+
if (this.stopping) return;
|
|
775
|
+
if (this.debounceTimer || this.inFlight) {
|
|
776
|
+
// Event path's already handling things; this poll is a no-op.
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
await this.runFlush({ prune: true });
|
|
780
|
+
}, intervalMs);
|
|
781
|
+
this.safetyNetTimer.unref?.();
|
|
782
|
+
}
|
|
783
|
+
|
|
442
784
|
/**
|
|
443
|
-
* Stage → export → commit pipeline for a single
|
|
444
|
-
* fields with the outcome. Errors logged +
|
|
445
|
-
* but never rethrown out of the
|
|
785
|
+
* Stage → export → prune (optional) → commit pipeline for a single
|
|
786
|
+
* cycle. Updates status fields with the outcome. Errors logged +
|
|
787
|
+
* reflected in `last_error` but never rethrown out of the event
|
|
788
|
+
* dispatcher (would kill the loop).
|
|
446
789
|
*/
|
|
447
|
-
private async runOneCycle(opts: { isInitial: boolean }): Promise<void> {
|
|
790
|
+
private async runOneCycle(opts: { isInitial: boolean; prune: boolean }): Promise<void> {
|
|
448
791
|
const nextCursor = new Date().toISOString();
|
|
449
792
|
const path = this.status.mirror_path!;
|
|
450
793
|
const sinceCursor = opts.isInitial ? undefined : this.cursor;
|
|
@@ -459,6 +802,34 @@ export class MirrorManager {
|
|
|
459
802
|
return;
|
|
460
803
|
}
|
|
461
804
|
|
|
805
|
+
// Orphan sweep — removes files in the mirror that don't correspond
|
|
806
|
+
// to a current vault note/tag/attachment. Counts surface in logs;
|
|
807
|
+
// the count of removed files is folded into the commit's
|
|
808
|
+
// `notes_changed` so the commit message reflects the delete too.
|
|
809
|
+
let prunedNotes = 0;
|
|
810
|
+
if (opts.prune && this.deps.runPrune) {
|
|
811
|
+
try {
|
|
812
|
+
const pruneStats = await this.deps.runPrune({ outDir: path });
|
|
813
|
+
prunedNotes = pruneStats.notes_removed + pruneStats.sidecars_removed;
|
|
814
|
+
const removedTotal =
|
|
815
|
+
pruneStats.notes_removed +
|
|
816
|
+
pruneStats.sidecars_removed +
|
|
817
|
+
pruneStats.schemas_removed +
|
|
818
|
+
pruneStats.attachment_dirs_removed;
|
|
819
|
+
if (removedTotal > 0) {
|
|
820
|
+
console.log(
|
|
821
|
+
`[mirror] prune: removed ${pruneStats.notes_removed} note(s), ${pruneStats.sidecars_removed} sidecar(s), ${pruneStats.schemas_removed} schema(s), ${pruneStats.attachment_dirs_removed} attachment dir(s)`,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
} catch (err) {
|
|
825
|
+
// Prune failure is non-fatal — the export already wrote the
|
|
826
|
+
// current state; the next sweep retries.
|
|
827
|
+
console.warn(
|
|
828
|
+
`[mirror] prune failed (non-fatal): ${(err as Error).message ?? err}`,
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
462
833
|
this.cursor = nextCursor;
|
|
463
834
|
this.status.last_export_at = nextCursor;
|
|
464
835
|
this.status.last_export_notes_count = stats.notes;
|
|
@@ -471,11 +842,23 @@ export class MirrorManager {
|
|
|
471
842
|
return;
|
|
472
843
|
}
|
|
473
844
|
|
|
845
|
+
// Commit when EITHER notes changed OR orphans were pruned. The
|
|
846
|
+
// commit message's `notes_changed` reflects the union — operators
|
|
847
|
+
// reading a "deleted 3 notes" diff in their mirror should see that
|
|
848
|
+
// count in the commit. (Pre-event-driven this was strictly
|
|
849
|
+
// notes-from-export; orphan deletes weren't tracked.)
|
|
850
|
+
const totalChanged = stats.notes + prunedNotes;
|
|
851
|
+
if (totalChanged === 0) {
|
|
852
|
+
// Nothing to commit; runGitCommitCycle would no-op anyway, but
|
|
853
|
+
// skipping early avoids the firstNoteTitle DB lookup.
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
|
|
474
857
|
const firstNoteTitle = await this.deps.firstChangedNoteTitle(sinceCursor);
|
|
475
858
|
const commitResult = await runGitCommitCycle({
|
|
476
859
|
repoDir: path,
|
|
477
860
|
template: this.currentConfig.commit_template,
|
|
478
|
-
notesChanged:
|
|
861
|
+
notesChanged: totalChanged,
|
|
479
862
|
vaultName: this.deps.vaultName,
|
|
480
863
|
firstNoteTitle,
|
|
481
864
|
push: this.currentConfig.auto_push,
|
|
@@ -490,32 +873,166 @@ export class MirrorManager {
|
|
|
490
873
|
const sha = new TextDecoder().decode(await new Response(shaProc.stdout).arrayBuffer()).trim();
|
|
491
874
|
if (sha.length > 0) this.status.last_commit_sha = sha;
|
|
492
875
|
}
|
|
876
|
+
|
|
877
|
+
// Cut 5: surface push outcome (success/failure + redacted error).
|
|
878
|
+
// runGitCommitCycle returns push info iff push was attempted; we
|
|
879
|
+
// mirror the same shape into status here.
|
|
880
|
+
if (commitResult.push) {
|
|
881
|
+
const now = new Date().toISOString();
|
|
882
|
+
if (commitResult.push.ok) {
|
|
883
|
+
this.status.last_push_at = now;
|
|
884
|
+
this.status.last_push_sha = this.status.last_commit_sha;
|
|
885
|
+
this.status.last_push_error = null;
|
|
886
|
+
} else if (commitResult.push.error) {
|
|
887
|
+
this.status.last_push_error = commitResult.push.error;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
// Cut 5: track local-commits-ahead-of-upstream so the UI can render
|
|
892
|
+
// "<N> commits ready to push" subdued helper text.
|
|
893
|
+
this.status.commits_unpushed = await readCommitsUnpushed(path);
|
|
493
894
|
}
|
|
494
895
|
|
|
495
896
|
/**
|
|
496
|
-
*
|
|
497
|
-
*
|
|
897
|
+
* Cut 6: fire a `git push` against the current mirror dir, capture
|
|
898
|
+
* the outcome into status, and return a struct the route handler can
|
|
899
|
+
* fold into its response.
|
|
900
|
+
*
|
|
901
|
+
* Distinguished from `runNow()` which exports + commits + pushes.
|
|
902
|
+
* `pushNow()` skips export + commit entirely and only pushes whatever
|
|
903
|
+
* has already been committed. Used by:
|
|
904
|
+
* - the credential save path (auto-fire after `auth/pat` /
|
|
905
|
+
* `auth/github/select-repo` lands) so the operator sees the
|
|
906
|
+
* push happen rather than waiting for the next write
|
|
907
|
+
* - the explicit POST /.parachute/mirror/push-now route the SPA's
|
|
908
|
+
* "Push now" button hits
|
|
909
|
+
*
|
|
910
|
+
* Idempotent against a fully-synced mirror (git push reports "nothing
|
|
911
|
+
* to push" with exit 0). Failure paths set `last_push_error` for the
|
|
912
|
+
* status surface.
|
|
498
913
|
*/
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
914
|
+
async pushNow(): Promise<
|
|
915
|
+
| { fired: false; reason: "not_enabled" | "no_mirror_path" }
|
|
916
|
+
| { fired: true; pushed: boolean; sha?: string; error?: string }
|
|
917
|
+
> {
|
|
918
|
+
if (!this.status.enabled) return { fired: false, reason: "not_enabled" };
|
|
919
|
+
if (!this.status.mirror_path) return { fired: false, reason: "no_mirror_path" };
|
|
920
|
+
const path = this.status.mirror_path;
|
|
921
|
+
const pushResult = await gitPush(path);
|
|
922
|
+
const now = new Date().toISOString();
|
|
923
|
+
// Refresh commits_unpushed either way — a no-op push still reflects
|
|
924
|
+
// the current state (0 ahead if synced, N if push failed mid-way).
|
|
925
|
+
this.status.commits_unpushed = await readCommitsUnpushed(path);
|
|
926
|
+
if (pushResult.ok) {
|
|
927
|
+
// Capture HEAD sha at this point so the UI can show the operator
|
|
928
|
+
// exactly what landed.
|
|
929
|
+
const shaProc = Bun.spawn(["git", "rev-parse", "HEAD"], {
|
|
930
|
+
cwd: path,
|
|
931
|
+
stdout: "pipe",
|
|
932
|
+
stderr: "pipe",
|
|
933
|
+
});
|
|
934
|
+
await shaProc.exited;
|
|
935
|
+
const sha = new TextDecoder()
|
|
936
|
+
.decode(await new Response(shaProc.stdout).arrayBuffer())
|
|
937
|
+
.trim();
|
|
938
|
+
this.status.last_push_at = now;
|
|
939
|
+
if (sha.length > 0) this.status.last_push_sha = sha;
|
|
940
|
+
this.status.last_push_error = null;
|
|
941
|
+
return { fired: true, pushed: true, sha: sha.length > 0 ? sha : undefined };
|
|
942
|
+
}
|
|
943
|
+
const redacted = redactToken(pushResult.stderr);
|
|
944
|
+
this.status.last_push_error = redacted;
|
|
945
|
+
console.warn(`[mirror] push-now failed (non-fatal): ${redacted}`);
|
|
946
|
+
return { fired: true, pushed: false, error: redacted };
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Apply UI-configured credentials to the mirror dir's `origin`. Called
|
|
951
|
+
* on every start()/reload() so credential rotations land without
|
|
952
|
+
* requiring a server restart. Idempotent:
|
|
953
|
+
* - active_method=pat → set origin to the stored authed URL
|
|
954
|
+
* - active_method=github_oauth + a picked repo → set origin (the
|
|
955
|
+
* pick path stores the authed URL in the same flow)
|
|
956
|
+
* - active_method=github_oauth + no repo picked → no-op (UI is mid-
|
|
957
|
+
* flow)
|
|
958
|
+
* - no credentials at all → leave whatever the operator wired by
|
|
959
|
+
* hand alone (don't clobber a manually-set remote)
|
|
960
|
+
*
|
|
961
|
+
* Never throws — push failures will surface via the existing
|
|
962
|
+
* non-fatal-warn path in runGitCommitCycle.
|
|
963
|
+
*/
|
|
964
|
+
private async applyCredentialsToRemote(repoDir: string): Promise<void> {
|
|
965
|
+
try {
|
|
966
|
+
const creds = readCredentials();
|
|
967
|
+
if (!creds || !creds.active_method) {
|
|
968
|
+
// No UI-configured credentials. Leave the remote alone — the
|
|
969
|
+
// operator may have set one up via `git remote add` manually.
|
|
970
|
+
return;
|
|
515
971
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
972
|
+
if (creds.active_method === "pat" && creds.pat) {
|
|
973
|
+
const result = await applyToGitRemote(repoDir, creds.pat.remote_url);
|
|
974
|
+
if (!result.ok) {
|
|
975
|
+
console.warn(
|
|
976
|
+
`[mirror] could not apply PAT remote URL (non-fatal): ${result.error}`,
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
// github_oauth: the active token is set, but a repo may not yet be
|
|
982
|
+
// picked. The select-repo route handler writes the URL; on a
|
|
983
|
+
// subsequent restart we can't reconstruct the URL without the
|
|
984
|
+
// owner/repo. Best-effort: if `origin` already points at a github.com
|
|
985
|
+
// URL, rewrite it with the stored token in case the token rotated.
|
|
986
|
+
if (creds.active_method === "github_oauth" && creds.github_oauth) {
|
|
987
|
+
const current = await readCurrentOrigin(repoDir);
|
|
988
|
+
if (current && current.includes("github.com")) {
|
|
989
|
+
// Parse owner/repo out of the current URL.
|
|
990
|
+
const match = current.match(
|
|
991
|
+
/github\.com[/:]([^/]+)\/([^/.]+?)(?:\.git)?$/,
|
|
992
|
+
);
|
|
993
|
+
if (match) {
|
|
994
|
+
const [, owner, repo] = match;
|
|
995
|
+
const { githubAuthedRemoteUrl } = await import("./mirror-credentials.ts");
|
|
996
|
+
const authed = githubAuthedRemoteUrl(
|
|
997
|
+
creds.github_oauth.access_token,
|
|
998
|
+
owner!,
|
|
999
|
+
repo!,
|
|
1000
|
+
);
|
|
1001
|
+
const result = await applyToGitRemote(repoDir, authed);
|
|
1002
|
+
if (!result.ok) {
|
|
1003
|
+
console.warn(
|
|
1004
|
+
`[mirror] could not refresh github_oauth remote URL (non-fatal): ${result.error}`,
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
} catch (err) {
|
|
1011
|
+
console.warn(
|
|
1012
|
+
`[mirror] credential application failed (non-fatal): ${(err as Error).message ?? err}`,
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// ---------------------------------------------------------------------
|
|
1018
|
+
// Test seams
|
|
1019
|
+
// ---------------------------------------------------------------------
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Force the debounce timer to fire immediately (cancel + run flush).
|
|
1023
|
+
* Used by mirror-manager.test.ts to avoid sleeping through 500ms in
|
|
1024
|
+
* every event-driven test case. Not exposed via the public API.
|
|
1025
|
+
*/
|
|
1026
|
+
async _flushDebounceForTest(): Promise<void> {
|
|
1027
|
+
if (this.debounceTimer) {
|
|
1028
|
+
clearTimeout(this.debounceTimer);
|
|
1029
|
+
this.debounceTimer = null;
|
|
1030
|
+
}
|
|
1031
|
+
await this.runFlush({ prune: false });
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Test seam: snapshot of the unsubscribe-handle count. */
|
|
1035
|
+
_subscriptionCount(): number {
|
|
1036
|
+
return this.unsubscribes.length;
|
|
520
1037
|
}
|
|
521
1038
|
}
|