cursedbelt 2.6.1 → 2.7.0

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 (65) hide show
  1. package/dist/server/sync/alarm.d.ts +35 -0
  2. package/dist/server/sync/alarm.d.ts.map +1 -0
  3. package/dist/server/sync/alarm.js +92 -0
  4. package/dist/server/sync/alarm.js.map +1 -0
  5. package/dist/server/sync/commands.d.ts +88 -0
  6. package/dist/server/sync/commands.d.ts.map +1 -0
  7. package/dist/server/sync/commands.js +242 -0
  8. package/dist/server/sync/commands.js.map +1 -0
  9. package/dist/server/sync/engine.d.ts +63 -0
  10. package/dist/server/sync/engine.d.ts.map +1 -0
  11. package/dist/server/sync/engine.js +185 -0
  12. package/dist/server/sync/engine.js.map +1 -0
  13. package/dist/server/sync/http.d.ts +107 -0
  14. package/dist/server/sync/http.d.ts.map +1 -0
  15. package/dist/server/sync/http.js +244 -0
  16. package/dist/server/sync/http.js.map +1 -0
  17. package/dist/server/sync/index.d.ts +42 -0
  18. package/dist/server/sync/index.d.ts.map +1 -0
  19. package/dist/server/sync/index.js +42 -0
  20. package/dist/server/sync/index.js.map +1 -0
  21. package/dist/server/sync/opLog.d.ts +62 -0
  22. package/dist/server/sync/opLog.d.ts.map +1 -0
  23. package/dist/server/sync/opLog.js +97 -0
  24. package/dist/server/sync/opLog.js.map +1 -0
  25. package/dist/server/sync/planner.d.ts +32 -0
  26. package/dist/server/sync/planner.d.ts.map +1 -0
  27. package/dist/server/sync/planner.js +31 -0
  28. package/dist/server/sync/planner.js.map +1 -0
  29. package/dist/server/sync/status.d.ts +64 -0
  30. package/dist/server/sync/status.d.ts.map +1 -0
  31. package/dist/server/sync/status.js +50 -0
  32. package/dist/server/sync/status.js.map +1 -0
  33. package/dist/server/sync/timer.d.ts +53 -0
  34. package/dist/server/sync/timer.d.ts.map +1 -0
  35. package/dist/server/sync/timer.js +171 -0
  36. package/dist/server/sync/timer.js.map +1 -0
  37. package/dist/server/sync/tokens.d.ts +26 -0
  38. package/dist/server/sync/tokens.d.ts.map +1 -0
  39. package/dist/server/sync/tokens.js +52 -0
  40. package/dist/server/sync/tokens.js.map +1 -0
  41. package/dist/server/sync/types.d.ts +102 -0
  42. package/dist/server/sync/types.d.ts.map +1 -0
  43. package/dist/server/sync/types.js +30 -0
  44. package/dist/server/sync/types.js.map +1 -0
  45. package/package.json +19 -13
  46. package/src/server/sync/alarm.spec.ts +149 -0
  47. package/src/server/sync/alarm.ts +137 -0
  48. package/src/server/sync/commands.spec.ts +145 -0
  49. package/src/server/sync/commands.ts +361 -0
  50. package/src/server/sync/engine.spec.ts +496 -0
  51. package/src/server/sync/engine.ts +255 -0
  52. package/src/server/sync/http.ts +316 -0
  53. package/src/server/sync/httpRemote.spec.ts +158 -0
  54. package/src/server/sync/index.ts +90 -0
  55. package/src/server/sync/opLog.spec.ts +110 -0
  56. package/src/server/sync/opLog.ts +189 -0
  57. package/src/server/sync/planner.spec.ts +53 -0
  58. package/src/server/sync/planner.ts +62 -0
  59. package/src/server/sync/status.spec.ts +92 -0
  60. package/src/server/sync/status.ts +108 -0
  61. package/src/server/sync/timer.spec.ts +150 -0
  62. package/src/server/sync/timer.ts +207 -0
  63. package/src/server/sync/tokens.spec.ts +38 -0
  64. package/src/server/sync/tokens.ts +94 -0
  65. package/src/server/sync/types.ts +108 -0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `cursedbelt/sync` — the op-log replication engine, shared rather than forked.
3
+ *
4
+ * ## Why it is here and not in the app
5
+ *
6
+ * The retired tree had FOUR consumers of this engine: `apps/vault` (8 files),
7
+ * `apps/station` (6), `apps/notes` (4) and `packages/feedback-kit` (5). `vault`
8
+ * graduated first and carried all 3,245 lines in whole, as
9
+ * `apps/vault/src/kit/sync/`, because a Phase A that also publishes a library is a
10
+ * Phase A that does not land. This is the other half of that trade, and it had a
11
+ * DEADLINE rather than a backlog slot: the moment `apps/station` graduates with the
12
+ * copy still in `vault`, the generation owns two divergent forks of a replication
13
+ * engine — the exact shape the owner ruled against on 2026-09-13, *"if we don't
14
+ * adapt to this at the right time then it will be hard to remember to keep updating
15
+ * multiple copies as the apps move and grow."*
16
+ *
17
+ * ## 🔴 A LEAF export, and it must stay one
18
+ *
19
+ * `cursedbelt/sync` resolves straight to this directory. It is deliberately NOT
20
+ * reachable through the `cursedbelt/server` barrel, which drags `kysely` and
21
+ * `otplib` behind it — an app that wants an op-log should not install two
22
+ * databases to get one. The only runtime dependency beneath this subpath is
23
+ * `hono` (for the receiver routes in `http.ts` and `commands.ts`); everything else
24
+ * is `bun:sqlite`, `node:crypto` and `node:fs`.
25
+ *
26
+ * It lives under `src/server/` because that is where this package's two typecheck
27
+ * projects put Bun's ambient types: the root `tsconfig.json` runs `types: []` to
28
+ * keep the browser/React surface pristine and EXCLUDES `src/server`, so
29
+ * `bun:sqlite` and `node:crypto` only resolve on this side of the split. The
30
+ * directory decides the types; the `exports` key decides the spelling.
31
+ */
32
+ export { StopBatch, } from "./types";
33
+ export { createOpLog } from "./opLog";
34
+ export { createTokenStore } from "./tokens";
35
+ export { createSyncApplier, pushToRemote, pullFromRemote, runSync, } from "./engine";
36
+ export { capPageBytes, createHttpRemote, createSyncReceiver, } from "./http";
37
+ export { DEFAULT_LOOP, isUnreachable, planNextSync, } from "./planner";
38
+ export { OFFLINE_STATUS, PEER_CONTACT_WRITE_MS, createSyncStatusReporter, } from "./status";
39
+ export { REQUEST_POLL_MS, startSyncTimer } from "./timer";
40
+ export { createSyncAlarm } from "./alarm";
41
+ export { DEFAULT_HOLD_MS, createCommandQueue, createCommandReceiver, createCommandRemote, startCommandWorker, } from "./commands";
42
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/server/sync/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,OAAO,EACN,SAAS,GAQT,MAAM,SAAS,CAAC;AACjB,OAAO,EAAE,WAAW,EAAc,MAAM,SAAS,CAAC;AAClD,OAAO,EAAE,gBAAgB,EAAqC,MAAM,UAAU,CAAC;AAC/E,OAAO,EACN,iBAAiB,EACjB,YAAY,EACZ,cAAc,EACd,OAAO,GAIP,MAAM,UAAU,CAAC;AAClB,OAAO,EACN,YAAY,EACZ,gBAAgB,EAChB,kBAAkB,GAGlB,MAAM,QAAQ,CAAC;AAChB,OAAO,EACN,YAAY,EACZ,aAAa,EACb,YAAY,GAGZ,MAAM,WAAW,CAAC;AACnB,OAAO,EACN,cAAc,EACd,qBAAqB,EACrB,wBAAwB,GAIxB,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,eAAe,EAAE,cAAc,EAA4C,MAAM,SAAS,CAAC;AACpG,OAAO,EAAE,eAAe,EAA4D,MAAM,SAAS,CAAC;AACpG,OAAO,EACN,eAAe,EACf,kBAAkB,EAClB,qBAAqB,EACrB,mBAAmB,EACnB,kBAAkB,GAQlB,MAAM,YAAY,CAAC"}
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The durable op log + cursor bookkeeping, over the app's own `bun:sqlite` handle.
3
+ * Fixed table names (`sync_ops`, `sync_cursors`, `sync_meta`) — a consumer of this
4
+ * engine is expected to give sync its own tables rather than remap; the notes/vault
5
+ * retrofit maps their legacy tables behind this interface, not the other way round.
6
+ *
7
+ * Identity: `(origin, origin_seq)` UNIQUE — exactly-once application with no peer
8
+ * coordination (the notes ledger shape). `seq` is this instance's own monotonic
9
+ * stream every peer pages over.
10
+ */
11
+ import type { Database } from "bun:sqlite";
12
+ import type { SyncOp } from "./types";
13
+ export interface OpLog {
14
+ /** This instance's stable id (minted once, persisted in sync_meta). */
15
+ instanceId(): string;
16
+ /** Record a locally-born op; returns it with seq/originSeq assigned. */
17
+ recordLocal(kind: string, payload: unknown, ts?: number): SyncOp;
18
+ /** Record a remote op into the ledger (dedupe identity kept). Caller applies state
19
+ * FIRST in the same transaction scope; see `createSyncApplier`. */
20
+ recordRemote(op: SyncOp): void;
21
+ /** Is this (origin, originSeq) already in the ledger? */
22
+ has(origin: string, originSeq: number): boolean;
23
+ /** Page of ops with seq > after, excluding one origin (the peer's own — it already
24
+ * has them), oldest first. `cursor` = last row's seq served (or `after`). */
25
+ listAfter(after: number, excludeOrigin: string, limit: number): {
26
+ ops: SyncOp[];
27
+ cursor: number;
28
+ head: number;
29
+ };
30
+ /** Highest local seq (0 when empty). */
31
+ head(): number;
32
+ /** Durable per-peer cursors. `direction`: what WE track about the peer. */
33
+ cursor(peerId: string, direction: "push" | "pull" | "served"): number;
34
+ setCursor(peerId: string, direction: "push" | "pull" | "served", seq: number): void;
35
+ /** Compaction for STATE-shaped kinds (a snapshot is not history): delete this
36
+ * origin's OWN earlier ops of `kind` matching `payloadWhere` below `keepSeq`.
37
+ * Safe under the exactly-once ledger — peers dedupe by (origin, originSeq),
38
+ * pull pages tolerate seq gaps, and a deleted op is simply never sent. */
39
+ compactOwn(kind: string, keepSeq: number, payloadLike: string): number;
40
+ /** The same compaction, for a state kind that arrived from a PEER.
41
+ *
42
+ * `compactOwn` is scoped to `origin = instanceId`, so a pure RECEIVER of a
43
+ * state kind matches zero rows and its log grows without bound — prod
44
+ * station reached 5,537 revisions of one `overview` snapshot (963 MB, ~160
45
+ * MB/day) because every one of them originated on the Mac. The safety
46
+ * argument on `compactOwn` never depended on who minted the op, only on the
47
+ * kind being STATE rather than history, so it carries over verbatim.
48
+ *
49
+ * Keyed on `origin_seq`, not local `seq`: a receiver compacts while
50
+ * applying, which happens BEFORE the incoming row is inserted, so there is
51
+ * no local seq yet to compare against. `(origin, origin_seq)` is the
52
+ * ledger's own identity and is monotonic per origin, so it orders the
53
+ * origin's stream exactly as well. */
54
+ compactOrigin(origin: string, kind: string, keepOriginSeq: number, payloadLike: string): number;
55
+ /** Arbitrary sync-scoped metadata (instance id lives here too). */
56
+ getMeta(key: string): string | null;
57
+ setMeta(key: string, value: string): void;
58
+ /** Run `fn` inside one sqlite transaction (apply + ledger-insert atomicity). */
59
+ transaction<T>(fn: () => T): T;
60
+ }
61
+ export declare function createOpLog(db: Database): OpLog;
62
+ //# sourceMappingURL=opLog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opLog.d.ts","sourceRoot":"","sources":["../../../src/server/sync/opLog.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,YAAY,CAAC;AAE3C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAEtC,MAAM,WAAW,KAAK;IACrB,uEAAuE;IACvE,UAAU,IAAI,MAAM,CAAC;IACrB,wEAAwE;IACxE,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,EAAE,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACjE;wEACoE;IACpE,YAAY,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,yDAAyD;IACzD,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC;IAChD;kFAC8E;IAC9E,SAAS,CACR,KAAK,EAAE,MAAM,EACb,aAAa,EAAE,MAAM,EACrB,KAAK,EAAE,MAAM,GACX;QAAE,GAAG,EAAE,MAAM,EAAE,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAA;KAAE,CAAC;IACnD,wCAAwC;IACxC,IAAI,IAAI,MAAM,CAAC;IACf,2EAA2E;IAC3E,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,GAAG,MAAM,CAAC;IACtE,SAAS,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,GAAG,QAAQ,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IACpF;;;+EAG2E;IAC3E,UAAU,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC;IACvE;;;;;;;;;;;;;2CAauC;IACvC,aAAa,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC;IAChG,mEAAmE;IACnE,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IACpC,OAAO,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1C,gFAAgF;IAChF,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,CAAC,CAAC;CAC/B;AAID,wBAAgB,WAAW,CAAC,EAAE,EAAE,QAAQ,GAAG,KAAK,CA2H/C"}
@@ -0,0 +1,97 @@
1
+ import { randomUUID } from "node:crypto";
2
+ const INSTANCE_KEY = "sync.instance_id";
3
+ export function createOpLog(db) {
4
+ db.exec(`
5
+ CREATE TABLE IF NOT EXISTS sync_ops (
6
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
7
+ origin TEXT NOT NULL,
8
+ origin_seq INTEGER NOT NULL,
9
+ ts INTEGER NOT NULL,
10
+ kind TEXT NOT NULL,
11
+ payload TEXT NOT NULL,
12
+ UNIQUE(origin, origin_seq)
13
+ );
14
+ CREATE INDEX IF NOT EXISTS idx_sync_ops_origin ON sync_ops(origin, seq);
15
+ CREATE TABLE IF NOT EXISTS sync_cursors (
16
+ peer_id TEXT NOT NULL,
17
+ direction TEXT NOT NULL,
18
+ seq INTEGER NOT NULL DEFAULT 0,
19
+ PRIMARY KEY(peer_id, direction)
20
+ );
21
+ CREATE TABLE IF NOT EXISTS sync_meta (
22
+ key TEXT PRIMARY KEY,
23
+ value TEXT NOT NULL
24
+ );
25
+ `);
26
+ const getMetaStmt = db.prepare("SELECT value FROM sync_meta WHERE key = ?");
27
+ const setMetaStmt = db.prepare("INSERT INTO sync_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value");
28
+ const getMeta = (key) => getMetaStmt.get(key)?.value ?? null;
29
+ const setMeta = (key, value) => {
30
+ setMetaStmt.run(key, value);
31
+ };
32
+ let cachedId = getMeta(INSTANCE_KEY);
33
+ if (cachedId === null) {
34
+ // Short + url/label safe; uniqueness across a two-or-three instance fleet is
35
+ // what matters, not global collision resistance.
36
+ cachedId = `i-${randomUUID().slice(0, 12)}`;
37
+ setMeta(INSTANCE_KEY, cachedId);
38
+ }
39
+ const instanceId = cachedId;
40
+ const insertOp = db.prepare("INSERT INTO sync_ops (origin, origin_seq, ts, kind, payload) VALUES (?, ?, ?, ?, ?)");
41
+ const hasStmt = db.prepare("SELECT 1 FROM sync_ops WHERE origin = ? AND origin_seq = ? LIMIT 1");
42
+ const headStmt = db.prepare("SELECT COALESCE(MAX(seq), 0) AS head FROM sync_ops");
43
+ const maxOriginSeqStmt = db.prepare("SELECT COALESCE(MAX(origin_seq), 0) AS m FROM sync_ops WHERE origin = ?");
44
+ const listStmt = db.prepare("SELECT seq, origin, origin_seq, ts, kind, payload FROM sync_ops WHERE seq > ? AND origin != ? ORDER BY seq ASC LIMIT ?");
45
+ const compactStmt = db.prepare("DELETE FROM sync_ops WHERE origin = ? AND kind = ? AND seq < ? AND payload LIKE ?");
46
+ const compactOriginStmt = db.prepare("DELETE FROM sync_ops WHERE origin = ? AND kind = ? AND origin_seq < ? AND payload LIKE ?");
47
+ const cursorStmt = db.prepare("SELECT seq FROM sync_cursors WHERE peer_id = ? AND direction = ?");
48
+ const setCursorStmt = db.prepare("INSERT INTO sync_cursors (peer_id, direction, seq) VALUES (?, ?, ?) ON CONFLICT(peer_id, direction) DO UPDATE SET seq = excluded.seq");
49
+ const rowToOp = (row) => ({
50
+ seq: row.seq,
51
+ origin: row.origin,
52
+ originSeq: row.origin_seq,
53
+ ts: row.ts,
54
+ kind: row.kind,
55
+ payload: JSON.parse(row.payload),
56
+ });
57
+ return {
58
+ instanceId: () => instanceId,
59
+ recordLocal(kind, payload, ts) {
60
+ const at = ts ?? Date.now();
61
+ const originSeq = maxOriginSeqStmt.get(instanceId).m + 1;
62
+ insertOp.run(instanceId, originSeq, at, kind, JSON.stringify(payload ?? null));
63
+ const seq = headStmt.get().head;
64
+ return { seq, origin: instanceId, originSeq, ts: at, kind, payload };
65
+ },
66
+ recordRemote(op) {
67
+ insertOp.run(op.origin, op.originSeq, op.ts, op.kind, JSON.stringify(op.payload ?? null));
68
+ },
69
+ has: (origin, originSeq) => hasStmt.get(origin, originSeq) !== null,
70
+ listAfter(after, excludeOrigin, limit) {
71
+ const rows = listStmt.all(after, excludeOrigin, limit);
72
+ const ops = rows.map(rowToOp);
73
+ const head = headStmt.get().head;
74
+ // An all-excluded page must still advance the peer past what it skipped, or a
75
+ // log dominated by the peer's own reflected ops would page forever. Serve the
76
+ // true high-water mark of the WINDOW scanned: when the page is short, we
77
+ // reached head; otherwise the last row's seq.
78
+ const cursor = rows.length === 0 ? Math.max(after, head) : rows[rows.length - 1].seq;
79
+ return { ops, cursor, head };
80
+ },
81
+ head: () => headStmt.get().head,
82
+ compactOwn(kind, keepSeq, payloadLike) {
83
+ return compactStmt.run(instanceId, kind, keepSeq, payloadLike).changes;
84
+ },
85
+ compactOrigin(origin, kind, keepOriginSeq, payloadLike) {
86
+ return compactOriginStmt.run(origin, kind, keepOriginSeq, payloadLike).changes;
87
+ },
88
+ cursor: (peerId, direction) => cursorStmt.get(peerId, direction)?.seq ?? 0,
89
+ setCursor(peerId, direction, seq) {
90
+ setCursorStmt.run(peerId, direction, seq);
91
+ },
92
+ getMeta,
93
+ setMeta,
94
+ transaction: (fn) => db.transaction(fn)(),
95
+ };
96
+ }
97
+ //# sourceMappingURL=opLog.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"opLog.js","sourceRoot":"","sources":["../../../src/server/sync/opLog.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The pure loop planner — vault's `planNextSync`, unchanged in spirit: back off after
3
+ * a failure, else honor a debounced dirty flag, else the steady interval. Pure so
4
+ * the timer is a thin wrapper and this is fake-clock testable.
5
+ */
6
+ export interface SyncLoopConfig {
7
+ /** Steady poll cadence when reachable and idle. */
8
+ intervalMs: number;
9
+ /** How long after a local write to wait before syncing, so a burst coalesces. */
10
+ debounceMs: number;
11
+ /** First backoff step after a failure. */
12
+ backoffBaseMs: number;
13
+ /** Backoff ceiling — an unreachable peer (the nightly window) settles here. */
14
+ backoffMaxMs: number;
15
+ }
16
+ export declare const DEFAULT_LOOP: SyncLoopConfig;
17
+ export interface SyncLoopState {
18
+ /** When the last sync attempt STARTED (0 if never). */
19
+ lastAttempt: number;
20
+ /** Consecutive failed attempts — drives exponential backoff. */
21
+ consecutiveFailures: number;
22
+ /** When the oldest un-synced local write happened, or null if clean. */
23
+ dirtySince: number | null;
24
+ }
25
+ export declare function planNextSync(state: SyncLoopState, now: number, cfg?: SyncLoopConfig): {
26
+ runNow: boolean;
27
+ waitMs: number;
28
+ };
29
+ /** A peer that is asleep (the nightly EC2 window) or an internet-less Mac is NORMAL,
30
+ * not an error. It still feeds the backoff. */
31
+ export declare function isUnreachable(err: unknown): boolean;
32
+ //# sourceMappingURL=planner.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"planner.d.ts","sourceRoot":"","sources":["../../../src/server/sync/planner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,MAAM,WAAW,cAAc;IAC9B,mDAAmD;IACnD,UAAU,EAAE,MAAM,CAAC;IACnB,iFAAiF;IACjF,UAAU,EAAE,MAAM,CAAC;IACnB,0CAA0C;IAC1C,aAAa,EAAE,MAAM,CAAC;IACtB,+EAA+E;IAC/E,YAAY,EAAE,MAAM,CAAC;CACrB;AAED,eAAO,MAAM,YAAY,EAAE,cAK1B,CAAC;AAEF,MAAM,WAAW,aAAa;IAC7B,uDAAuD;IACvD,WAAW,EAAE,MAAM,CAAC;IACpB,gEAAgE;IAChE,mBAAmB,EAAE,MAAM,CAAC;IAC5B,wEAAwE;IACxE,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,wBAAgB,YAAY,CAC3B,KAAK,EAAE,aAAa,EACpB,GAAG,EAAE,MAAM,EACX,GAAG,GAAE,cAA6B,GAChC;IAAE,MAAM,EAAE,OAAO,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAE,CAerC;AAED;gDACgD;AAChD,wBAAgB,aAAa,CAAC,GAAG,EAAE,OAAO,GAAG,OAAO,CAKnD"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The pure loop planner — vault's `planNextSync`, unchanged in spirit: back off after
3
+ * a failure, else honor a debounced dirty flag, else the steady interval. Pure so
4
+ * the timer is a thin wrapper and this is fake-clock testable.
5
+ */
6
+ export const DEFAULT_LOOP = {
7
+ intervalMs: 5 * 60_000,
8
+ debounceMs: 3_000,
9
+ backoffBaseMs: 30_000,
10
+ backoffMaxMs: 30 * 60_000,
11
+ };
12
+ export function planNextSync(state, now, cfg = DEFAULT_LOOP) {
13
+ if (state.consecutiveFailures > 0) {
14
+ const step = Math.min(cfg.backoffMaxMs, cfg.backoffBaseMs * 2 ** (state.consecutiveFailures - 1));
15
+ const due = state.lastAttempt + step;
16
+ return now >= due ? { runNow: true, waitMs: 0 } : { runNow: false, waitMs: due - now };
17
+ }
18
+ if (state.dirtySince !== null) {
19
+ const due = state.dirtySince + cfg.debounceMs;
20
+ return now >= due ? { runNow: true, waitMs: 0 } : { runNow: false, waitMs: due - now };
21
+ }
22
+ const due = state.lastAttempt + cfg.intervalMs;
23
+ return now >= due ? { runNow: true, waitMs: 0 } : { runNow: false, waitMs: due - now };
24
+ }
25
+ /** A peer that is asleep (the nightly EC2 window) or an internet-less Mac is NORMAL,
26
+ * not an error. It still feeds the backoff. */
27
+ export function isUnreachable(err) {
28
+ const msg = err instanceof Error ? err.message : String(err);
29
+ return /ECONNREFUSED|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|fetch failed|timed out|The operation was aborted|network/i.test(msg);
30
+ }
31
+ //# sourceMappingURL=planner.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"planner.js","sourceRoot":"","sources":["../../../src/server/sync/planner.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAaH,MAAM,CAAC,MAAM,YAAY,GAAmB;IAC3C,UAAU,EAAE,CAAC,GAAG,MAAM;IACtB,UAAU,EAAE,KAAK;IACjB,aAAa,EAAE,MAAM;IACrB,YAAY,EAAE,EAAE,GAAG,MAAM;CACzB,CAAC;AAWF,MAAM,UAAU,YAAY,CAC3B,KAAoB,EACpB,GAAW,EACX,MAAsB,YAAY;IAElC,IAAI,KAAK,CAAC,mBAAmB,GAAG,CAAC,EAAE,CAAC;QACnC,MAAM,IAAI,GAAG,IAAI,CAAC,GAAG,CACpB,GAAG,CAAC,YAAY,EAChB,GAAG,CAAC,aAAa,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,mBAAmB,GAAG,CAAC,CAAC,CACxD,CAAC;QACF,MAAM,GAAG,GAAG,KAAK,CAAC,WAAW,GAAG,IAAI,CAAC;QACrC,OAAO,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,GAAG,GAAG,EAAE,CAAC;IACxF,CAAC;IACD,IAAI,KAAK,CAAC,UAAU,KAAK,IAAI,EAAE,CAAC;QAC/B,MAAM,GAAG,GAAG,KAAK,CAAC,UAAU,GAAG,GAAG,CAAC,UAAU,CAAC;QAC9C,OAAO,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,GAAG,GAAG,EAAE,CAAC;IACxF,CAAC;IACD,MAAM,GAAG,GAAG,KAAK,CAAC,WAAW,GAAG,GAAG,CAAC,UAAU,CAAC;IAC/C,OAAO,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,GAAG,GAAG,GAAG,EAAE,CAAC;AACxF,CAAC;AAED;gDACgD;AAChD,MAAM,UAAU,aAAa,CAAC,GAAY;IACzC,MAAM,GAAG,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IAC7D,OAAO,sGAAsG,CAAC,IAAI,CACjH,GAAG,CACH,CAAC;AACH,CAAC"}
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The status surface — vault's `SyncStatus` generalized verbatim, because its shape
3
+ * is the product of a real defect: `enabled: false` alone was read as "not syncing"
4
+ * on a receiving half that was in step the whole time. `role` is what tells the
5
+ * receiving half apart from a scratch instance, and anything reasoning about "is
6
+ * this instance syncing" must read `role`, not `enabled`.
7
+ */
8
+ export interface SyncStatus {
9
+ /** Does THIS instance dial out at all? */
10
+ enabled: boolean;
11
+ /** dialer — runs the loop · receiver — answers the sync surface for a dialer ·
12
+ * none — neither (a dev shell on its own scratch database). */
13
+ role: "dialer" | "receiver" | "none";
14
+ /** The peer's ORIGIN — never the bearer token. Null when unlinked. */
15
+ peer: string | null;
16
+ lastAttemptAt: number | null;
17
+ lastSuccessAt: number | null;
18
+ lastOutcome: "ok" | "unreachable" | "paused" | "error" | null;
19
+ /** Set while the push is version-skew paused (`blockedBy`) — names the op kind the
20
+ * peer cannot take yet, i.e. "the peer needs deploying", the true statement. */
21
+ pausedOn: string | null;
22
+ consecutiveFailures: number;
23
+ /** Local writes the peer has not acknowledged yet — 0 means "fully in step". */
24
+ pending: number;
25
+ /** RECEIVER: when a dialing peer last authenticated against the sync surface. */
26
+ lastPeerContactAt: number | null;
27
+ /** RECEIVER: when someone last pressed "Sync now" here (the dialer polls it). */
28
+ syncRequestedAt: number | null;
29
+ }
30
+ export declare const OFFLINE_STATUS: SyncStatus;
31
+ export interface SyncStatusReporter {
32
+ read(): SyncStatus;
33
+ /** The timer's / receiver hooks' writer half — nothing else should call it. */
34
+ update(patch: Partial<SyncStatus>): void;
35
+ }
36
+ /**
37
+ * Somewhere to keep the ONE field that is a fact about the DATA rather than about
38
+ * this process.
39
+ *
40
+ * 🔴 Why only `lastPeerContactAt`. Every other field describes the live process and
41
+ * SHOULD reset with it: a dialer that has not attempted anything since boot has
42
+ * genuinely not attempted anything, and a restored `consecutiveFailures: 3` would be
43
+ * a claim about failures that did not happen. But "a peer authenticated here" is
44
+ * something that happened to the database, and forgetting it produced the owner's
45
+ * 2026-08-16 report — prod saying *"Awaiting first sync"* over a vault he had used
46
+ * for weeks. The badge's own words are "your Mac has not dialled since this server
47
+ * last restarted", which is true and which nobody reads as anything but "this has
48
+ * never synced". A receiver restarts on every deploy, and the dialer runs every 5
49
+ * minutes only while the Mac is awake — so the window in which a perfectly converged
50
+ * pair reads as never-synced is wide. It was wider still when the receiver was the
51
+ * `prod-ec2` box (decommissioned since), which also slept 12am–10am CT: most of a
52
+ * morning, every morning. In-memory was the wrong home for it.
53
+ */
54
+ export interface SyncStatusStore {
55
+ /** The stored contact stamp, or null if a peer has genuinely never called. */
56
+ loadPeerContactAt(): number | null;
57
+ /** Persist a newer stamp. Called at most once per {@link PEER_CONTACT_WRITE_MS}. */
58
+ savePeerContactAt(at: number): void;
59
+ }
60
+ /** A dialer makes several requests per run; one durable write per this window is
61
+ * enough to answer "when did a peer last call" to the nearest half-minute. */
62
+ export declare const PEER_CONTACT_WRITE_MS = 30000;
63
+ export declare function createSyncStatusReporter(initial?: Partial<SyncStatus>, store?: SyncStatusStore): SyncStatusReporter;
64
+ //# sourceMappingURL=status.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.d.ts","sourceRoot":"","sources":["../../../src/server/sync/status.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,MAAM,WAAW,UAAU;IAC1B,0CAA0C;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB;oEACgE;IAChE,IAAI,EAAE,QAAQ,GAAG,UAAU,GAAG,MAAM,CAAC;IACrC,sEAAsE;IACtE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IACpB,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,WAAW,EAAE,IAAI,GAAG,aAAa,GAAG,QAAQ,GAAG,OAAO,GAAG,IAAI,CAAC;IAC9D;qFACiF;IACjF,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,mBAAmB,EAAE,MAAM,CAAC;IAC5B,gFAAgF;IAChF,OAAO,EAAE,MAAM,CAAC;IAChB,iFAAiF;IACjF,iBAAiB,EAAE,MAAM,GAAG,IAAI,CAAC;IACjC,iFAAiF;IACjF,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;CAC/B;AAED,eAAO,MAAM,cAAc,EAAE,UAY5B,CAAC;AAEF,MAAM,WAAW,kBAAkB;IAClC,IAAI,IAAI,UAAU,CAAC;IACnB,+EAA+E;IAC/E,MAAM,CAAC,KAAK,EAAE,OAAO,CAAC,UAAU,CAAC,GAAG,IAAI,CAAC;CACzC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,WAAW,eAAe;IAC/B,8EAA8E;IAC9E,iBAAiB,IAAI,MAAM,GAAG,IAAI,CAAC;IACnC,oFAAoF;IACpF,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED;+EAC+E;AAC/E,eAAO,MAAM,qBAAqB,QAAS,CAAC;AAE5C,wBAAgB,wBAAwB,CACvC,OAAO,CAAC,EAAE,OAAO,CAAC,UAAU,CAAC,EAC7B,KAAK,CAAC,EAAE,eAAe,GACrB,kBAAkB,CAwBpB"}
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The status surface — vault's `SyncStatus` generalized verbatim, because its shape
3
+ * is the product of a real defect: `enabled: false` alone was read as "not syncing"
4
+ * on a receiving half that was in step the whole time. `role` is what tells the
5
+ * receiving half apart from a scratch instance, and anything reasoning about "is
6
+ * this instance syncing" must read `role`, not `enabled`.
7
+ */
8
+ export const OFFLINE_STATUS = {
9
+ enabled: false,
10
+ role: "none",
11
+ peer: null,
12
+ lastAttemptAt: null,
13
+ lastSuccessAt: null,
14
+ lastOutcome: null,
15
+ pausedOn: null,
16
+ consecutiveFailures: 0,
17
+ pending: 0,
18
+ lastPeerContactAt: null,
19
+ syncRequestedAt: null,
20
+ };
21
+ /** A dialer makes several requests per run; one durable write per this window is
22
+ * enough to answer "when did a peer last call" to the nearest half-minute. */
23
+ export const PEER_CONTACT_WRITE_MS = 30_000;
24
+ export function createSyncStatusReporter(initial, store) {
25
+ const restored = store?.loadPeerContactAt() ?? null;
26
+ let current = {
27
+ ...OFFLINE_STATUS,
28
+ ...(restored === null ? {} : { lastPeerContactAt: restored }),
29
+ ...initial,
30
+ };
31
+ let lastWrittenAt = restored ?? 0;
32
+ return {
33
+ read: () => ({ ...current }),
34
+ update: (patch) => {
35
+ current = { ...current, ...patch };
36
+ const at = patch.lastPeerContactAt;
37
+ if (store && typeof at === "number" && at - lastWrittenAt >= PEER_CONTACT_WRITE_MS) {
38
+ lastWrittenAt = at;
39
+ // A status write must never be able to fail the sync request it rode in on.
40
+ try {
41
+ store.savePeerContactAt(at);
42
+ }
43
+ catch {
44
+ /* the in-memory value still answers; the next contact retries the write */
45
+ }
46
+ }
47
+ },
48
+ };
49
+ }
50
+ //# sourceMappingURL=status.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"status.js","sourceRoot":"","sources":["../../../src/server/sync/status.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAyBH,MAAM,CAAC,MAAM,cAAc,GAAe;IACzC,OAAO,EAAE,KAAK;IACd,IAAI,EAAE,MAAM;IACZ,IAAI,EAAE,IAAI;IACV,aAAa,EAAE,IAAI;IACnB,aAAa,EAAE,IAAI;IACnB,WAAW,EAAE,IAAI;IACjB,QAAQ,EAAE,IAAI;IACd,mBAAmB,EAAE,CAAC;IACtB,OAAO,EAAE,CAAC;IACV,iBAAiB,EAAE,IAAI;IACvB,eAAe,EAAE,IAAI;CACrB,CAAC;AAiCF;+EAC+E;AAC/E,MAAM,CAAC,MAAM,qBAAqB,GAAG,MAAM,CAAC;AAE5C,MAAM,UAAU,wBAAwB,CACvC,OAA6B,EAC7B,KAAuB;IAEvB,MAAM,QAAQ,GAAG,KAAK,EAAE,iBAAiB,EAAE,IAAI,IAAI,CAAC;IACpD,IAAI,OAAO,GAAe;QACzB,GAAG,cAAc;QACjB,GAAG,CAAC,QAAQ,KAAK,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,EAAE,iBAAiB,EAAE,QAAQ,EAAE,CAAC;QAC7D,GAAG,OAAO;KACV,CAAC;IACF,IAAI,aAAa,GAAG,QAAQ,IAAI,CAAC,CAAC;IAClC,OAAO;QACN,IAAI,EAAE,GAAG,EAAE,CAAC,CAAC,EAAE,GAAG,OAAO,EAAE,CAAC;QAC5B,MAAM,EAAE,CAAC,KAAK,EAAE,EAAE;YACjB,OAAO,GAAG,EAAE,GAAG,OAAO,EAAE,GAAG,KAAK,EAAE,CAAC;YACnC,MAAM,EAAE,GAAG,KAAK,CAAC,iBAAiB,CAAC;YACnC,IAAI,KAAK,IAAI,OAAO,EAAE,KAAK,QAAQ,IAAI,EAAE,GAAG,aAAa,IAAI,qBAAqB,EAAE,CAAC;gBACpF,aAAa,GAAG,EAAE,CAAC;gBACnB,4EAA4E;gBAC5E,IAAI,CAAC;oBACJ,KAAK,CAAC,iBAAiB,CAAC,EAAE,CAAC,CAAC;gBAC7B,CAAC;gBAAC,MAAM,CAAC;oBACR,2EAA2E;gBAC5E,CAAC;YACF,CAAC;QACF,CAAC;KACD,CAAC;AACH,CAAC"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The in-process dialer loop — vault's `syncTimer` generalized. Runs on the dialing
3
+ * half only; a failure NEVER takes the host daemon down (an unsynced instance still
4
+ * serves and still accepts writes — that is the point of two independently-writable
5
+ * instances). The timer is `unref`'d so it never holds the process open.
6
+ *
7
+ * The peer is re-resolved on EVERY tick, so linking (or revoking) a peer takes
8
+ * effect without a restart. The "Sync now" fast lane polls the receiver's request
9
+ * stamp every ~20s while healthy, so a button press on the far side means seconds,
10
+ * not "sometime in the next interval".
11
+ */
12
+ import { type SyncLoopConfig } from "./planner";
13
+ import type { SyncStatusReporter } from "./status";
14
+ import type { SyncSummary } from "./types";
15
+ export declare const REQUEST_POLL_MS = 20000;
16
+ export interface SyncTimerDeps {
17
+ /** Re-read every tick: null = not linked (quiet no-op, re-checked next tick). */
18
+ resolvePeer: () => {
19
+ basePath: string;
20
+ token: string;
21
+ } | null;
22
+ /** Run one full reconciliation against the resolved peer. */
23
+ sync: (peer: {
24
+ basePath: string;
25
+ token: string;
26
+ }) => Promise<SyncSummary>;
27
+ /** The local op-log head — a head beyond the last synced one marks dirty. */
28
+ head: () => number;
29
+ /** Ask the peer whether its "Sync now" was pressed; null on any failure. */
30
+ checkRequest?: (peer: {
31
+ basePath: string;
32
+ token: string;
33
+ }) => Promise<number | null>;
34
+ status?: SyncStatusReporter;
35
+ loop?: SyncLoopConfig;
36
+ requestPollMs?: number;
37
+ now?: () => number;
38
+ setTimer?: (fn: () => void, ms: number) => unknown;
39
+ clearTimer?: (handle: unknown) => void;
40
+ log?: (msg: string) => void;
41
+ warn?: (msg: string) => void;
42
+ /** Display origin for the status line; defaults to URL origin parsing. */
43
+ peerLabel?: (basePath: string) => string | null;
44
+ }
45
+ export interface SyncTimerHandle {
46
+ stop(): void;
47
+ /** Run the next tick immediately (the "Sync now" button on the dialing half). */
48
+ syncNow(): void;
49
+ /** Mark local writes pending (call after any local op) — debounces a burst. */
50
+ markDirty(): void;
51
+ }
52
+ export declare function startSyncTimer(deps: SyncTimerDeps): SyncTimerHandle;
53
+ //# sourceMappingURL=timer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"timer.d.ts","sourceRoot":"","sources":["../../../src/server/sync/timer.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AACH,OAAO,EAIN,KAAK,cAAc,EAEnB,MAAM,WAAW,CAAC;AACnB,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AACnD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAE3C,eAAO,MAAM,eAAe,QAAS,CAAC;AAGtC,MAAM,WAAW,aAAa;IAC7B,iFAAiF;IACjF,WAAW,EAAE,MAAM;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAC;IAC9D,6DAA6D;IAC7D,IAAI,EAAE,CAAC,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,WAAW,CAAC,CAAC;IAC1E,6EAA6E;IAC7E,IAAI,EAAE,MAAM,MAAM,CAAC;IACnB,4EAA4E;IAC5E,YAAY,CAAC,EAAE,CAAC,IAAI,EAAE;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,KAAK,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IACrF,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B,IAAI,CAAC,EAAE,cAAc,CAAC;IACtB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,IAAI,EAAE,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC;IACnD,UAAU,CAAC,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IACvC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC5B,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7B,0EAA0E;IAC1E,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,GAAG,IAAI,CAAC;CAChD;AAED,MAAM,WAAW,eAAe;IAC/B,IAAI,IAAI,IAAI,CAAC;IACb,iFAAiF;IACjF,OAAO,IAAI,IAAI,CAAC;IAChB,+EAA+E;IAC/E,SAAS,IAAI,IAAI,CAAC;CAClB;AAUD,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,GAAG,eAAe,CAiJnE"}
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The in-process dialer loop — vault's `syncTimer` generalized. Runs on the dialing
3
+ * half only; a failure NEVER takes the host daemon down (an unsynced instance still
4
+ * serves and still accepts writes — that is the point of two independently-writable
5
+ * instances). The timer is `unref`'d so it never holds the process open.
6
+ *
7
+ * The peer is re-resolved on EVERY tick, so linking (or revoking) a peer takes
8
+ * effect without a restart. The "Sync now" fast lane polls the receiver's request
9
+ * stamp every ~20s while healthy, so a button press on the far side means seconds,
10
+ * not "sometime in the next interval".
11
+ */
12
+ import { isUnreachable, DEFAULT_LOOP, planNextSync, } from "./planner";
13
+ export const REQUEST_POLL_MS = 20_000;
14
+ const KICKOFF_MS = 5_000;
15
+ function defaultPeerLabel(basePath) {
16
+ try {
17
+ return new URL(basePath).origin;
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export function startSyncTimer(deps) {
24
+ const now = deps.now ?? Date.now;
25
+ const setTimer = deps.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
26
+ const clearTimer = deps.clearTimer ?? ((h) => clearTimeout(h));
27
+ const loop = deps.loop ?? DEFAULT_LOOP;
28
+ const log = deps.log ?? ((m) => console.log(m));
29
+ const warn = deps.warn ?? ((m) => console.error(m));
30
+ const status = deps.status;
31
+ const peerLabel = deps.peerLabel ?? defaultPeerLabel;
32
+ const requestPollMs = deps.requestPollMs ?? REQUEST_POLL_MS;
33
+ status?.update({ enabled: true, role: "dialer" });
34
+ const state = { lastAttempt: 0, consecutiveFailures: 0, dirtySince: null };
35
+ let syncedHead = deps.head();
36
+ let stopped = false;
37
+ let handle = null;
38
+ let reportedUnreachable = false;
39
+ let forceRun = false;
40
+ let handledRequestAt = 0;
41
+ let probing = false;
42
+ let inFlight = false;
43
+ const schedule = (ms) => {
44
+ if (stopped)
45
+ return;
46
+ if (handle !== null)
47
+ clearTimer(handle);
48
+ handle = setTimer(tick, ms);
49
+ handle?.unref?.();
50
+ };
51
+ const sleepFor = (waitMs) => {
52
+ const healthy = state.consecutiveFailures === 0 && requestPollMs > 0;
53
+ return Math.max(1, healthy ? Math.min(waitMs, requestPollMs) : waitMs);
54
+ };
55
+ const probeRequest = (peer) => {
56
+ if (probing || requestPollMs === 0 || !deps.checkRequest)
57
+ return;
58
+ probing = true;
59
+ void deps
60
+ .checkRequest(peer)
61
+ .then((requestedAt) => {
62
+ if (stopped || requestedAt === null || requestedAt <= handledRequestAt)
63
+ return;
64
+ handledRequestAt = requestedAt;
65
+ log("[sync] sync requested from the peer — running now.");
66
+ forceRun = true;
67
+ schedule(1);
68
+ })
69
+ .finally(() => {
70
+ probing = false;
71
+ });
72
+ };
73
+ const tick = () => {
74
+ if (stopped)
75
+ return;
76
+ if (inFlight)
77
+ return; // two concurrent runs share cursors and would double-push
78
+ const head = deps.head();
79
+ if (head > syncedHead && state.dirtySince === null)
80
+ state.dirtySince = now();
81
+ const peer = deps.resolvePeer();
82
+ status?.update({
83
+ peer: peer === null ? null : peerLabel(peer.basePath),
84
+ pending: Math.max(0, head - syncedHead),
85
+ });
86
+ if (peer === null) {
87
+ schedule(loop.intervalMs);
88
+ return;
89
+ }
90
+ const plan = planNextSync(state, now(), loop);
91
+ if (!plan.runNow && !forceRun) {
92
+ const linkedAndHealthy = state.consecutiveFailures === 0 && requestPollMs > 0;
93
+ schedule(sleepFor(plan.waitMs));
94
+ if (linkedAndHealthy)
95
+ probeRequest(peer);
96
+ return;
97
+ }
98
+ forceRun = false;
99
+ state.lastAttempt = now();
100
+ status?.update({ lastAttemptAt: state.lastAttempt });
101
+ const headAtAttempt = head;
102
+ inFlight = true;
103
+ deps
104
+ .sync(peer)
105
+ .then((s) => {
106
+ state.consecutiveFailures = 0;
107
+ state.dirtySince = null;
108
+ syncedHead = headAtAttempt;
109
+ reportedUnreachable = false;
110
+ status?.update({
111
+ lastSuccessAt: now(),
112
+ lastOutcome: s.blockedBy ? "paused" : "ok",
113
+ pausedOn: s.blockedBy ?? null,
114
+ consecutiveFailures: 0,
115
+ pending: Math.max(0, deps.head() - syncedHead),
116
+ });
117
+ if (s.pushed > 0 || s.pulled > 0 || s.conflicts > 0) {
118
+ log(`[sync] ↑${s.pushed} ↓${s.pulled}` +
119
+ (s.conflicts > 0 ? ` (${s.conflicts} conflicts)` : ""));
120
+ }
121
+ if (s.blockedBy) {
122
+ log(`[sync] push paused before "${s.blockedBy}" — the peer cannot take it yet (deploy pending?).`);
123
+ }
124
+ })
125
+ .catch((err) => {
126
+ state.consecutiveFailures += 1;
127
+ status?.update({
128
+ lastOutcome: isUnreachable(err) ? "unreachable" : "error",
129
+ consecutiveFailures: state.consecutiveFailures,
130
+ });
131
+ if (isUnreachable(err)) {
132
+ if (!reportedUnreachable) {
133
+ log(`[sync] peer unreachable — will retry. This is normal (nightly window / offline).`);
134
+ reportedUnreachable = true;
135
+ }
136
+ }
137
+ else {
138
+ warn(`[sync] failed (ignored): ${err instanceof Error ? err.message : err}`);
139
+ }
140
+ })
141
+ .finally(() => {
142
+ inFlight = false;
143
+ if (forceRun)
144
+ schedule(1);
145
+ else
146
+ schedule(sleepFor(Math.max(1_000, planNextSync(state, now(), loop).waitMs)));
147
+ });
148
+ };
149
+ schedule(KICKOFF_MS);
150
+ return {
151
+ stop: () => {
152
+ stopped = true;
153
+ if (handle !== null)
154
+ clearTimer(handle);
155
+ },
156
+ syncNow: () => {
157
+ if (stopped)
158
+ return;
159
+ forceRun = true;
160
+ schedule(1);
161
+ },
162
+ markDirty: () => {
163
+ if (stopped)
164
+ return;
165
+ if (state.dirtySince === null)
166
+ state.dirtySince = now();
167
+ schedule(1);
168
+ },
169
+ };
170
+ }
171
+ //# sourceMappingURL=timer.js.map