cursedbelt-server 1.0.2 → 2.0.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 (36) hide show
  1. package/dist/server/index.d.ts +1 -0
  2. package/dist/server/index.js +1 -0
  3. package/dist/server/metrics/telemetrySink.d.ts +119 -0
  4. package/dist/server/metrics/telemetrySink.js +76 -0
  5. package/dist/server/middleware/index.d.ts +1 -0
  6. package/dist/server/middleware/index.js +3 -0
  7. package/dist/server/middleware/requestLogger.d.ts +30 -11
  8. package/dist/server/middleware/requestLogger.js +15 -6
  9. package/dist/server/sync/http.d.ts +20 -3
  10. package/dist/server/sync/http.js +20 -14
  11. package/dist/server/sync/index.d.ts +10 -2
  12. package/dist/server/sync/index.js +9 -1
  13. package/dist/server/sync/planner.d.ts +38 -8
  14. package/dist/server/sync/planner.js +32 -8
  15. package/dist/server/sync/signal.d.ts +161 -0
  16. package/dist/server/sync/signal.js +348 -0
  17. package/dist/server/sync/timer.d.ts +63 -19
  18. package/dist/server/sync/timer.js +104 -45
  19. package/dist/server/sync/types.d.ts +0 -2
  20. package/package.json +1 -1
  21. package/src/noTimerDialsAPeer.spec.ts +469 -0
  22. package/src/server/index.ts +9 -0
  23. package/src/server/metrics/telemetrySink.spec.ts +288 -0
  24. package/src/server/metrics/telemetrySink.ts +181 -0
  25. package/src/server/middleware/index.ts +9 -0
  26. package/src/server/middleware/requestLogger.ts +43 -17
  27. package/src/server/sync/http.ts +31 -16
  28. package/src/server/sync/index.ts +23 -1
  29. package/src/server/sync/planner.spec.ts +33 -16
  30. package/src/server/sync/planner.ts +48 -11
  31. package/src/server/sync/signal.spec.ts +306 -0
  32. package/src/server/sync/signal.ts +422 -0
  33. package/src/server/sync/timer.spec.ts +97 -16
  34. package/src/server/sync/timer.ts +124 -47
  35. package/src/server/sync/types.ts +0 -2
  36. package/src/shippedFilesAreTracked.spec.ts +69 -0
@@ -1,13 +1,45 @@
1
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.
2
+ * The in-process sync loop. Runs on the dialing half only; a failure NEVER takes
3
+ * the host daemon down (an unsynced instance still serves and still accepts writes
4
+ * — that is the point of two independently-writable instances). The timer is
5
+ * `unref`'d so it never holds the process open.
6
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".
7
+ * ── 🔴 This loop does not poll, and it may never be made to again ────────────────
8
+ *
9
+ * Until 2026-09-15 this file was a dialer twice over: a `GET /requested` probe at a
10
+ * peer every 20 s while healthy (`REQUEST_POLL_MS`), and a full reconciliation every
11
+ * 5 minutes (`DEFAULT_LOOP.intervalMs`) whether or not either side had news. The
12
+ * owner ruled both out — *"There should be no polling in cb unless you can make some
13
+ * good case for it that beats the api option"* — and the case against was asked for
14
+ * and not found. Measured in `vault`: the 20 s probe alone was **68 % of that app's
15
+ * entire traffic, one request every 22 seconds, for an app with one user.**
16
+ *
17
+ * Both are gone. Every wake-up this loop books now carries a {@link WakeReason}, and
18
+ * that union is deliberately closed with no periodic member — there is no value you
19
+ * can pass to {@link schedule} that means "again in a while". That makes the poll
20
+ * unrepresentable rather than merely discouraged, which matters because
21
+ * `REQUEST_POLL_MS` did not survive as a habit; it survived as **exported public
22
+ * API**, and the next consumer to import it would have made its removal a breaking
23
+ * change instead of an edit.
24
+ *
25
+ * What replaced each half:
26
+ *
27
+ * · **Mac → peer** is a PUSH. `markDirty()` after a local write books one
28
+ * debounced run: the side that has news says so, which is what "sync now"
29
+ * should always have been.
30
+ * · **peer → Mac** is `./signal` — the dialing half holds one long-lived stream
31
+ * open and the receiver writes a frame when its log grows. The Mac is still
32
+ * purely a client: it dials OUT, listens on nothing, and no timer fires.
33
+ *
34
+ * 🔴 **`markDirty()` is now REQUIRED wiring, not an optimization.** With the steady
35
+ * interval gone it is the only thing that pushes a local write. A consumer that
36
+ * takes the handle and drops `markDirty` (which `apps/vault` did while the interval
37
+ * still covered for it) will sit clean and silent forever.
38
+ *
39
+ * The peer is re-resolved on every run, so revoking a link takes effect without a
40
+ * restart. Linking a NEW peer is a user action and is noticed at the next
41
+ * `syncNow()` / `markDirty()` rather than by a timer that was watching for it —
42
+ * call `syncNow()` after a link and it is immediate.
11
43
  */
12
44
  import {
13
45
  isUnreachable,
@@ -19,21 +51,35 @@ import {
19
51
  import type { SyncStatusReporter } from "./status";
20
52
  import type { SyncSummary } from "./types";
21
53
 
22
- export const REQUEST_POLL_MS = 20_000;
54
+ /**
55
+ * One reconciliation shortly after start: catch up on whatever happened while this
56
+ * process was down. A single shot at boot, not a cadence — the same catch-up a push
57
+ * model needs anyway.
58
+ */
23
59
  const KICKOFF_MS = 5_000;
24
60
 
61
+ /**
62
+ * 🔴 Every reason this loop is allowed to wake up. There is deliberately no
63
+ * `"interval"` / `"poll"` member, and adding one is the change this whole file
64
+ * exists to refuse.
65
+ *
66
+ * · `boot` — one catch-up run at start.
67
+ * · `dirty` — local ops are waiting to be pushed (debounced).
68
+ * · `forced` — `syncNow()`, or a frame off `./signal` saying the peer has news.
69
+ * · `retry` — the last attempt failed. Runs only while disconnected, never
70
+ * against a healthy peer; that is a reconnect backoff, not a poll.
71
+ */
72
+ export type WakeReason = "boot" | "dirty" | "forced" | "retry";
73
+
25
74
  export interface SyncTimerDeps {
26
- /** Re-read every tick: null = not linked (quiet no-op, re-checked next tick). */
75
+ /** Re-read on every run: null = not linked (quiet no-op). */
27
76
  resolvePeer: () => { basePath: string; token: string } | null;
28
77
  /** Run one full reconciliation against the resolved peer. */
29
78
  sync: (peer: { basePath: string; token: string }) => Promise<SyncSummary>;
30
79
  /** The local op-log head — a head beyond the last synced one marks dirty. */
31
80
  head: () => number;
32
- /** Ask the peer whether its "Sync now" was pressed; null on any failure. */
33
- checkRequest?: (peer: { basePath: string; token: string }) => Promise<number | null>;
34
81
  status?: SyncStatusReporter;
35
82
  loop?: SyncLoopConfig;
36
- requestPollMs?: number;
37
83
  now?: () => number;
38
84
  setTimer?: (fn: () => void, ms: number) => unknown;
39
85
  clearTimer?: (handle: unknown) => void;
@@ -45,9 +91,16 @@ export interface SyncTimerDeps {
45
91
 
46
92
  export interface SyncTimerHandle {
47
93
  stop(): void;
48
- /** Run the next tick immediately (the "Sync now" button on the dialing half). */
94
+ /**
95
+ * Run now. The "Sync now" button on the dialing half, and the handler for a
96
+ * `./signal` frame — both are "something has news", which is a push.
97
+ */
49
98
  syncNow(): void;
50
- /** Mark local writes pending (call after any local op) — debounces a burst. */
99
+ /**
100
+ * Mark local writes pending — call after any local op. 🔴 Required wiring: with
101
+ * no steady interval this is what pushes a local write. A burst coalesces into
102
+ * one run via the planner's debounce.
103
+ */
51
104
  markDirty(): void;
52
105
  }
53
106
 
@@ -68,7 +121,6 @@ export function startSyncTimer(deps: SyncTimerDeps): SyncTimerHandle {
68
121
  const warn = deps.warn ?? ((m: string) => console.error(m));
69
122
  const status = deps.status;
70
123
  const peerLabel = deps.peerLabel ?? defaultPeerLabel;
71
- const requestPollMs = deps.requestPollMs ?? REQUEST_POLL_MS;
72
124
  status?.update({ enabled: true, role: "dialer" });
73
125
 
74
126
  const state: SyncLoopState = { lastAttempt: 0, consecutiveFailures: 0, dirtySince: null };
@@ -76,38 +128,56 @@ export function startSyncTimer(deps: SyncTimerDeps): SyncTimerHandle {
76
128
  let stopped = false;
77
129
  let handle: unknown = null;
78
130
  let reportedUnreachable = false;
79
- let forceRun = false;
80
- let handledRequestAt = 0;
81
- let probing = false;
131
+ /**
132
+ * 🔴 Starts TRUE: the boot catch-up is a forced run, not a scheduled one.
133
+ *
134
+ * It used to fall out of the planner for free — `lastAttempt: 0` plus a steady
135
+ * `intervalMs` made the first tick always due. With no interval left, a clean
136
+ * healthy state plans nothing, so the one run this loop genuinely owes at
137
+ * startup has to be asked for explicitly. It has a reason: this process may have
138
+ * missed ops while it was down, and catching up is exactly what a push model
139
+ * does on reconnect.
140
+ */
141
+ let forceRun = true;
82
142
  let inFlight = false;
143
+ /** Why the currently-booked wake exists, or null when the loop is quiet. */
144
+ let bookedFor: WakeReason | null = null;
83
145
 
84
- const schedule = (ms: number): void => {
146
+ /**
147
+ * Book the next wake-up. 🔴 The `reason` is not decoration — it is the type-level
148
+ * guard: {@link WakeReason} has no periodic member, so there is no way to spell
149
+ * "wake again in `intervalMs`" through this function. It is also read back by
150
+ * {@link SyncTimerHandle.markDirty}, which must not push an imminent forced run
151
+ * out to a debounce.
152
+ */
153
+ const schedule = (reason: WakeReason, ms: number): void => {
85
154
  if (stopped) return;
86
155
  if (handle !== null) clearTimer(handle);
87
156
  handle = setTimer(tick, ms);
88
157
  (handle as { unref?: () => void })?.unref?.();
158
+ bookedFor = reason;
89
159
  };
90
160
 
91
- const sleepFor = (waitMs: number): number => {
92
- const healthy = state.consecutiveFailures === 0 && requestPollMs > 0;
93
- return Math.max(1, healthy ? Math.min(waitMs, requestPollMs) : waitMs);
161
+ /** Nothing to push, nothing to retry: cancel any pending wake and go quiet. */
162
+ const idle = (): void => {
163
+ if (handle !== null) clearTimer(handle);
164
+ handle = null;
165
+ bookedFor = null;
94
166
  };
95
167
 
96
- const probeRequest = (peer: { basePath: string; token: string }): void => {
97
- if (probing || requestPollMs === 0 || !deps.checkRequest) return;
98
- probing = true;
99
- void deps
100
- .checkRequest(peer)
101
- .then((requestedAt) => {
102
- if (stopped || requestedAt === null || requestedAt <= handledRequestAt) return;
103
- handledRequestAt = requestedAt;
104
- log("[sync] sync requested from the peer — running now.");
105
- forceRun = true;
106
- schedule(1);
107
- })
108
- .finally(() => {
109
- probing = false;
110
- });
168
+ /** Apply the planner's verdict — the ONE place a wake-up is booked or declined. */
169
+ const bookNext = (): void => {
170
+ if (stopped) return;
171
+ if (forceRun) {
172
+ schedule("forced", 1);
173
+ return;
174
+ }
175
+ const plan = planNextSync(state, now(), loop);
176
+ if (plan.waitMs === null || plan.reason === null) {
177
+ idle();
178
+ return;
179
+ }
180
+ schedule(plan.reason, Math.max(1, plan.waitMs));
111
181
  };
112
182
 
113
183
  const tick = (): void => {
@@ -122,15 +192,17 @@ export function startSyncTimer(deps: SyncTimerDeps): SyncTimerHandle {
122
192
  pending: Math.max(0, head - syncedHead),
123
193
  });
124
194
  if (peer === null) {
125
- schedule(loop.intervalMs);
195
+ // Not linked. Nothing to dial and nothing to wait for — `syncNow()` or the
196
+ // next local write re-checks. A re-check timer here would be a poll at a
197
+ // peer that does not exist yet.
198
+ forceRun = false;
199
+ idle();
126
200
  return;
127
201
  }
128
202
 
129
203
  const plan = planNextSync(state, now(), loop);
130
204
  if (!plan.runNow && !forceRun) {
131
- const linkedAndHealthy = state.consecutiveFailures === 0 && requestPollMs > 0;
132
- schedule(sleepFor(plan.waitMs));
133
- if (linkedAndHealthy) probeRequest(peer);
205
+ bookNext();
134
206
  return;
135
207
  }
136
208
 
@@ -182,26 +254,31 @@ export function startSyncTimer(deps: SyncTimerDeps): SyncTimerHandle {
182
254
  })
183
255
  .finally(() => {
184
256
  inFlight = false;
185
- if (forceRun) schedule(1);
186
- else schedule(sleepFor(Math.max(1_000, planNextSync(state, now(), loop).waitMs)));
257
+ // A local write that landed mid-run is news the planner must see.
258
+ if (deps.head() > syncedHead && state.dirtySince === null) state.dirtySince = now();
259
+ bookNext();
187
260
  });
188
261
  };
189
262
 
190
- schedule(KICKOFF_MS);
263
+ schedule("boot", KICKOFF_MS);
191
264
  return {
192
265
  stop: () => {
193
266
  stopped = true;
194
267
  if (handle !== null) clearTimer(handle);
268
+ handle = null;
195
269
  },
196
270
  syncNow: () => {
197
271
  if (stopped) return;
198
272
  forceRun = true;
199
- schedule(1);
273
+ schedule("forced", 1);
200
274
  },
201
275
  markDirty: () => {
202
276
  if (stopped) return;
203
277
  if (state.dirtySince === null) state.dirtySince = now();
204
- schedule(1);
278
+ // A forced run is already due in ~1ms and will push these ops; re-booking
279
+ // would replace it with a 3s debounce and make "Sync now" slower.
280
+ if (bookedFor === "forced") return;
281
+ bookNext();
205
282
  },
206
283
  };
207
284
  }
@@ -67,8 +67,6 @@ export interface RemoteApi {
67
67
  excludeOrigin: string,
68
68
  ): Promise<{ ops: SyncOp[]; cursor: number; head: number }>;
69
69
  push(payload: { ops: SyncOp[]; peerHas: number }): Promise<ApplyReport>;
70
- /** The receiver's "someone pressed Sync now here" stamp; null when unsupported. */
71
- requestedAt?(): Promise<number | null>;
72
70
  }
73
71
 
74
72
  /** Context the applier gets per batch — vault's concurrency bound. */
@@ -0,0 +1,69 @@
1
+ /**
2
+ * 🔴 `bun publish` packs the WORKING DIRECTORY, not the git index — so a package can
3
+ * ship a file the repo does not have.
4
+ *
5
+ * ── What this cost, 2026-09-15 ─────────────────────────────────────────────────────
6
+ * The cursedbelt split (task 148) moved two integration specs into this package from
7
+ * `cursedbelt`. `git add -u .` stages only files git already knows about, so both were
8
+ * committed nowhere — and `bun run verify` was green *because they were present on disk*,
9
+ * which is the same reason nobody noticed. `1.0.2` went to the registry carrying two
10
+ * `src/` files that existed in no commit, and `git status` said `tracked-dirty=0`.
11
+ *
12
+ * "The tree is clean" and "everything that ships is committed" are different claims, and
13
+ * only the first one is what people actually check. npm versions are immutable, so the
14
+ * window to notice closes at publish time.
15
+ *
16
+ * ── Why this shape ─────────────────────────────────────────────────────────────────
17
+ * It asks git, for exactly the directories `package.json#files` promises to ship, which
18
+ * of those paths git does not track. That is narrower than "any untracked file" on
19
+ * purpose: scratch files, `.agent.noindex/`, a half-written script and an uncommitted
20
+ * lockfile are all legitimate states for a working tree and reddening on them would teach
21
+ * people to skip this. A file INSIDE the published surface is the only case where
22
+ * untracked means "about to ship something no one can review or revert".
23
+ *
24
+ * 🔴 It must run from a real checkout to mean anything, so it refuses rather than passes
25
+ * when `git` cannot answer — a check that silently degrades to green is worse than none.
26
+ */
27
+ import { describe, expect, test } from 'bun:test';
28
+ import { spawnSync } from 'node:child_process';
29
+ import { existsSync } from 'node:fs';
30
+ import { fileURLToPath } from 'node:url';
31
+ import pkg from '../package.json';
32
+
33
+ const repoRoot = fileURLToPath(new URL('..', import.meta.url));
34
+
35
+ /** The directories `files` promises to ship, minus build output nobody commits. */
36
+ const shippedDirs = (): string[] =>
37
+ ((pkg as { files?: string[] }).files ?? []).filter((f) => f !== 'dist' && existsSync(`${repoRoot}/${f}`));
38
+
39
+ const git = (...args: string[]): { ok: boolean; out: string } => {
40
+ const r = spawnSync('git', ['-C', repoRoot, ...args], { encoding: 'utf8' });
41
+ return { ok: r.status === 0, out: (r.stdout ?? '').trim() };
42
+ };
43
+
44
+ describe('every file inside the published surface is tracked', () => {
45
+ test('git can answer at all — a degraded check must refuse, not pass', () => {
46
+ expect(git('rev-parse', '--is-inside-work-tree').ok, 'not a git checkout, so this check proves nothing').toBe(true);
47
+ });
48
+
49
+ test('package.json names a shippable directory to scan', () => {
50
+ // Vacuity guard: an empty `files`, or one naming only `dist`, makes the assertion
51
+ // below pass while scanning nothing at all.
52
+ expect(shippedDirs().length, '`files` names no committed directory — nothing would be scanned').toBeGreaterThan(0);
53
+ });
54
+
55
+ test('no untracked file sits inside a directory `files` ships', () => {
56
+ // --others = untracked; --exclude-standard honours .gitignore, so deliberately
57
+ // ignored paths (.agent.noindex, node_modules) are not offences.
58
+ const { ok, out } = git('ls-files', '--others', '--exclude-standard', '--', ...shippedDirs());
59
+ expect(ok, 'git ls-files failed').toBe(true);
60
+ const untracked = out === '' ? [] : out.split('\n');
61
+ expect(
62
+ untracked,
63
+ 'these would be PUBLISHED but exist in no commit — `bun publish` packs the working directory, not the index,\n' +
64
+ 'so the registry would carry code nobody can review, diff or revert, and npm versions are immutable.\n' +
65
+ 'Commit them (or add them to .gitignore if they genuinely must not ship):\n ' +
66
+ untracked.join('\n '),
67
+ ).toEqual([]);
68
+ });
69
+ });