cursedbelt-server 1.1.0 โ†’ 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.
@@ -67,8 +67,6 @@ export interface RemoteApi {
67
67
  ops: SyncOp[];
68
68
  peerHas: number;
69
69
  }): Promise<ApplyReport>;
70
- /** The receiver's "someone pressed Sync now here" stamp; null when unsupported. */
71
- requestedAt?(): Promise<number | null>;
72
70
  }
73
71
  /** Context the applier gets per batch โ€” vault's concurrency bound. */
74
72
  export interface ApplyContext {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cursedbelt-server",
3
- "version": "1.1.0",
3
+ "version": "2.0.0",
4
4
  "license": "ISC",
5
5
  "type": "module",
6
6
  "description": "The app-facing Bun/Hono server tier of the cursedbelt split \u2014 storage, sharing, activity, guard, sync. React-free; cursedbelt-core below it.",
@@ -0,0 +1,469 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
3
+ import { fileURLToPath } from 'node:url';
4
+
5
+ /**
6
+ * ๐Ÿ”ด **Nothing in this package may dial a peer on a timer.**
7
+ *
8
+ * The owner's ruling, 2026-09-15: *"station shouldn't need it either. This mac can
9
+ * use a station api to send updates to prod if needed, such as for openclaw. There
10
+ * should be no polling in cb unless you can make some good case for it that beats
11
+ * the api option."* He asked for the case against; there isn't one, and this file is
12
+ * the ruling expressed as a check rather than as a paragraph.
13
+ *
14
+ * ## Why a paragraph was not enough
15
+ *
16
+ * `REQUEST_POLL_MS = 20_000` did not survive in this repo as a habit. It survived as
17
+ * **exported public API** (`sync/index.ts:75`), four hours before the ruling, which
18
+ * meant one more consumer would have turned deleting it into a breaking change
19
+ * instead of an edit. It cost `vault` **68 % of that app's entire traffic โ€” one
20
+ * request every 22 seconds, for an app with one user.** Document the WHY; automate
21
+ * the WHETHER.
22
+ *
23
+ * ## The three things called "polling", and which one this forbids
24
+ *
25
+ * | | example | verdict |
26
+ * |---|---|---|
27
+ * | ๐Ÿ”ด a timer that dials a peer | the old `sync/timer.ts` `probeRequest` โ†’ `GET /requested` every 20s | **forbidden โ€” this check** |
28
+ * | prod โ†’ Mac delivery | `sync/signal.ts`: the Mac holds a stream open, the peer pushes down it | fine, and it is what makes the first one unnecessary |
29
+ * | ๐ŸŸข local housekeeping timers | `metrics/metricsBuffer.ts` flush ยท `auth/sessionStore.ts` prune ยท `retention/sweeper.ts` | **fine โ€” they issue no network request at all** |
30
+ *
31
+ * ๐Ÿ”ด The third row is why this is not "no `setInterval`". `metricsBuffer`'s flush
32
+ * timer is literally the *"turn many requests into 1"* the owner asked for in the
33
+ * same conversation, and a rule that deleted it would contradict him while claiming
34
+ * to enforce him. The forbidden act is **dialing a peer**, not scheduling work. The
35
+ * same carve-out covers `signal.ts`'s heartbeat (a comment frame on an already-open
36
+ * socket) and its reconnect backoff, which runs *only while disconnected*.
37
+ *
38
+ * ## What it measures, and what it cannot
39
+ *
40
+ * For every non-spec source file: strip comments and template literals, find each
41
+ * timer-scheduling site, resolve its callback (inline, or a same-file identifier),
42
+ * and red if that callback body issues a `fetch`.
43
+ *
44
+ * ๐Ÿ”ด **This is a FLOOR, not a ceiling, and the limit is worth naming because it is
45
+ * exactly where the original bug lived.** The scan cannot see through an injected
46
+ * dependency: `sync/timer.ts`'s callback calls `deps.sync(peer)`, which dials, and
47
+ * that is legitimate (it is the push). A reintroduced `deps.checkRequest(peer)`
48
+ * would be structurally identical and this check would miss it. Nothing static can
49
+ * separate those two โ€” so the strict measurement is **behavioural** and lives in
50
+ * `sync/timer.spec.ts` ("a healthy peer with no local news is dialed ONCE at boot,
51
+ * then never", 24 simulated hours) and `sync/signal.spec.ts` ("a connected client
52
+ * issues exactly ONE outbound request, forever"). Those two bound the sync engine
53
+ * whatever shape a future edit takes; this one catches the obvious reintroduction
54
+ * anywhere else in the package, where no such harness exists.
55
+ *
56
+ * ## Measured against the tree before its strictness was chosen
57
+ *
58
+ * A coarser rule โ€” "a file that has a timer AND has a `fetch`" โ€” was tried first and
59
+ * flagged **seven files, all false positives**: `metricsBuffer`/`sessionStore` match
60
+ * `.push(` on an array, and `master-lock/lockPage.ts` holds browser code inside a
61
+ * template literal whose `fetch(paths.status)` runs in the reader's tab, not on a
62
+ * server timer. Hence the template-literal and comment stripping below, and hence
63
+ * callback-scoped rather than file-scoped matching. With the dialer removed, the
64
+ * real count across `src/` is zero โ€” so this starts green on a clean tree rather
65
+ * than shipping with a baseline of excuses.
66
+ *
67
+ * ## Verified failing before it was trusted
68
+ *
69
+ * Every assertion below has a fixture proving it reds on the thing it forbids and
70
+ * passes on the thing it must not touch โ€” see `FIXTURES`. A check that has only ever
71
+ * been seen green is not a check.
72
+ */
73
+
74
+ const REPO = fileURLToPath(new URL('.', import.meta.url));
75
+
76
+ /** Schedulers, including this package's injected seams (`setTimer`, `setIntervalFn`). */
77
+ const TIMER_CALL =
78
+ /\b(setInterval|setTimeout|setTimer|setIntervalFn|setTimeoutFn|setImmediate)\s*\(/g;
79
+
80
+ /** What "dials a peer" looks like once the callback body is isolated. */
81
+ const OUTBOUND_CALL = /\b(fetch|doFetch|fetchImpl)\s*\(/;
82
+
83
+ /**
84
+ * Blank out comments, string literals and template literals, preserving LENGTH so
85
+ * every offset below still refers to the original source.
86
+ *
87
+ * ๐Ÿ”ด Both halves are load-bearing in THIS repo. Comments are code to a scanner and
88
+ * the files here are unusually comment-dense โ€” the paragraph above literally
89
+ * contains `probeRequest โ†’ GET /requested`, and `sync/signal.ts` documents the very
90
+ * `setTimeout`-that-calls-`fetch` shape it refuses to use. Template literals hold
91
+ * browser payloads (`master-lock/lockPage.ts`) whose `fetch` runs in a tab.
92
+ *
93
+ * Indexing is plain UTF-16 offsets โ€” never `[...source]`, which splits CODE POINTS
94
+ * and desynchronizes every later index the moment one emoji appears in a comment.
95
+ */
96
+ export function stripNonCode(source: string): string {
97
+ const out: string[] = [];
98
+ let i = 0;
99
+ const n = source.length;
100
+ // Template-literal nesting: each `${` inside a template pushes back to code.
101
+ const stack: Array<'template' | 'expr'> = [];
102
+ while (i < n) {
103
+ const c = source[i];
104
+ const next = source[i + 1];
105
+ if (c === '/' && next === '/') {
106
+ while (i < n && source[i] !== '\n') {
107
+ out.push(' ');
108
+ i++;
109
+ }
110
+ continue;
111
+ }
112
+ if (c === '/' && next === '*') {
113
+ while (i < n && !(source[i] === '*' && source[i + 1] === '/')) {
114
+ out.push(source[i] === '\n' ? '\n' : ' ');
115
+ i++;
116
+ }
117
+ out.push(' ');
118
+ i += 2;
119
+ continue;
120
+ }
121
+ if (c === '"' || c === "'") {
122
+ const quote = c;
123
+ out.push(' ');
124
+ i++;
125
+ while (i < n && source[i] !== quote) {
126
+ if (source[i] === '\\') {
127
+ out.push(' ');
128
+ i++;
129
+ }
130
+ if (i < n) {
131
+ out.push(source[i] === '\n' ? '\n' : ' ');
132
+ i++;
133
+ }
134
+ }
135
+ out.push(' ');
136
+ i++;
137
+ continue;
138
+ }
139
+ if (c === '`') {
140
+ out.push(' ');
141
+ i++;
142
+ while (i < n) {
143
+ if (source[i] === '\\') {
144
+ out.push(' ');
145
+ i += 2;
146
+ continue;
147
+ }
148
+ if (source[i] === '`') break;
149
+ if (source[i] === '$' && source[i + 1] === '{') {
150
+ // An interpolation is real code again โ€” hand it back to the main loop.
151
+ stack.push('template');
152
+ out.push(' ');
153
+ i += 2;
154
+ break;
155
+ }
156
+ out.push(source[i] === '\n' ? '\n' : ' ');
157
+ i++;
158
+ }
159
+ if (stack.at(-1) === 'template') continue;
160
+ out.push(' ');
161
+ i++;
162
+ continue;
163
+ }
164
+ if (c === '}' && stack.at(-1) === 'template') {
165
+ // Back into the template's literal half.
166
+ stack.pop();
167
+ out.push(' ');
168
+ i++;
169
+ while (i < n && source[i] !== '`') {
170
+ out.push(source[i] === '\n' ? '\n' : ' ');
171
+ i++;
172
+ }
173
+ out.push(' ');
174
+ i++;
175
+ continue;
176
+ }
177
+ out.push(c ?? '');
178
+ i++;
179
+ }
180
+ return out.join('');
181
+ }
182
+
183
+ /** Slice from `start` (at an opening bracket) to its match, or '' if unbalanced. */
184
+ function balanced(source: string, start: number, open: string, close: string): string {
185
+ let depth = 0;
186
+ for (let i = start; i < source.length; i++) {
187
+ if (source[i] === open) depth++;
188
+ else if (source[i] === close) {
189
+ depth--;
190
+ if (depth === 0) return source.slice(start, i + 1);
191
+ }
192
+ }
193
+ return '';
194
+ }
195
+
196
+ /** `const name = โ€ฆ` / `function name(โ€ฆ)` / `name = โ€ฆ` โ€” the body, or null. */
197
+ function resolveIdentifier(code: string, name: string): string | null {
198
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
199
+ const declaration = new RegExp(
200
+ `(?:const|let|var)\\s+${escaped}\\s*(?::[^=]+)?=|function\\s+${escaped}\\s*\\(`,
201
+ );
202
+ const at = declaration.exec(code);
203
+ if (!at) return null;
204
+ const brace = code.indexOf('{', at.index);
205
+ if (brace === -1) return null;
206
+ const body = balanced(code, brace, '{', '}');
207
+ return body === '' ? null : body;
208
+ }
209
+
210
+ export interface TimerSite {
211
+ scheduler: string;
212
+ /** 1-indexed line of the scheduling call. */
213
+ line: number;
214
+ /** The callback body, or '' when it could not be resolved (a parameter, say). */
215
+ body: string;
216
+ }
217
+
218
+ /** Every timer-scheduling site in `source`, with its callback body resolved. */
219
+ export function timerSites(source: string): TimerSite[] {
220
+ const code = stripNonCode(source);
221
+ const sites: TimerSite[] = [];
222
+ for (const match of code.matchAll(TIMER_CALL)) {
223
+ const scheduler = match[1] as string;
224
+ const open = match.index + match[0].length - 1;
225
+ const args = balanced(code, open, '(', ')');
226
+ // `async () => {โ€ฆ}` is the shape the deleted `probeRequest` used, and it is the
227
+ // one the first draft of this scan missed โ€” the fixture caught it.
228
+ const inner = args.slice(1).trimStart().replace(/^async\s+/, '');
229
+ let body = '';
230
+ if (inner.startsWith('(') || inner.startsWith('function') || /^[A-Za-z_$]/.test(inner)) {
231
+ const brace = inner.indexOf('{');
232
+ const arrow = inner.indexOf('=>');
233
+ if (brace !== -1 && (arrow === -1 || brace > arrow) && /^[({]|^function/.test(inner)) {
234
+ // An inline callback with a block body.
235
+ body = balanced(inner, brace, '{', '}');
236
+ }
237
+ if (body === '' && arrow !== -1 && /^\(/.test(inner)) {
238
+ // A concise arrow body: `() => somethingUp(to, the, comma)`.
239
+ body = inner.slice(arrow + 2);
240
+ }
241
+ if (body === '') {
242
+ // A bare identifier: `setTimer(tick, ms)`. Resolve it in this file.
243
+ const identifier = /^([A-Za-z_$][\w$]*)\s*[,)]/.exec(inner)?.[1];
244
+ if (identifier) body = resolveIdentifier(code, identifier) ?? '';
245
+ }
246
+ }
247
+ sites.push({
248
+ scheduler,
249
+ line: code.slice(0, match.index).split('\n').length,
250
+ body,
251
+ });
252
+ }
253
+ return sites;
254
+ }
255
+
256
+ /** The rule: a timer callback that issues an outbound request. */
257
+ export function dialingTimerSites(source: string): TimerSite[] {
258
+ return timerSites(source).filter((site) => OUTBOUND_CALL.test(site.body));
259
+ }
260
+
261
+ /** Exported constants whose NAME is a polling cadence. See the describe below. */
262
+ export function pollCadenceExports(source: string): string[] {
263
+ const found: string[] = [];
264
+ for (const match of stripNonCode(source).matchAll(
265
+ /export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/g,
266
+ )) {
267
+ const name = match[1] as string;
268
+ if (/POLL_?MS$|_POLL_|POLL_INTERVAL/i.test(name)) found.push(name);
269
+ }
270
+ return found;
271
+ }
272
+
273
+ /* ------------------------------------------------------------------ fixtures */
274
+
275
+ /**
276
+ * ๐Ÿ”ด Proven both ways. Without these the check could be silently inert โ€” the failure
277
+ * mode `leafSubpathsImportNothing.spec.ts` calls a vacuous pass, and the one that let
278
+ * `REQUEST_POLL_MS` reach public API in the first place.
279
+ */
280
+ const FIXTURES = {
281
+ reintroducedDialer: {
282
+ reds: true,
283
+ why: 'the exact shape that was deleted โ€” a cadence constant and a timer that dials',
284
+ source: `
285
+ export const REQUEST_POLL_MS = 20_000;
286
+ export function startProbe(peer: Peer) {
287
+ setInterval(async () => {
288
+ const r = await fetch(\`\${peer.basePath}/requested\`);
289
+ if (r.ok) syncNow();
290
+ }, REQUEST_POLL_MS);
291
+ }`,
292
+ },
293
+ dialerBehindAnIdentifier: {
294
+ reds: true,
295
+ why: 'the same thing written as a named callback โ€” `setTimer(tick, ms)` is how timer.ts spells it',
296
+ source: `
297
+ const tick = (): void => {
298
+ void fetch(peer.basePath + '/requested');
299
+ };
300
+ setTimeout(tick, 20_000);`,
301
+ },
302
+ conciseArrowDialer: {
303
+ reds: true,
304
+ why: 'a concise arrow body has no braces to match',
305
+ source: `setInterval(() => fetch(peer.basePath + '/requested'), 20_000);`,
306
+ },
307
+ housekeepingFlush: {
308
+ reds: false,
309
+ why: "metricsBuffer's flush โ€” the 'turn many requests into 1' timer the owner ASKED for",
310
+ source: `
311
+ const flush = (): void => {
312
+ if (buffer.length === 0) return;
313
+ insertMany(db, buffer.splice(0, buffer.length));
314
+ };
315
+ setInterval(flush, FLUSH_MS).unref();`,
316
+ },
317
+ sessionPrune: {
318
+ reds: false,
319
+ why: 'a local sweep โ€” no network at all',
320
+ source: `setInterval(() => { db.query('DELETE FROM sessions WHERE expires_at < ?').run(now()); }, PRUNE_MS);`,
321
+ },
322
+ streamHeartbeat: {
323
+ reds: false,
324
+ why: "signal.ts's keep-alive: a comment frame on an ALREADY-OPEN socket, not a request",
325
+ source: `setIntervalFn(() => { write(': keep-alive\\n\\n'); }, heartbeatMs);`,
326
+ },
327
+ reconnectSleep: {
328
+ reds: false,
329
+ why: 'the reconnect backoff โ€” a sleep that resolves a promise, dialing nothing itself',
330
+ source: `const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));`,
331
+ },
332
+ fetchInAComment: {
333
+ reds: false,
334
+ why: 'this repo documents the shapes it refuses; prose must not red the gate',
335
+ source: `
336
+ // Never do this: setInterval(() => fetch(peer), 20_000) is the poll we deleted.
337
+ /* setTimeout(async () => { await fetch(peer.basePath); }, 1000); */
338
+ setInterval(() => { compactTheLog(); }, HOUR);`,
339
+ },
340
+ fetchInBrowserPayload: {
341
+ reds: false,
342
+ why: "master-lock/lockPage.ts โ€” the fetch runs in the reader's tab, not on a server timer",
343
+ source: `
344
+ const page = \`<script>
345
+ setInterval(async () => { const r = await fetch(paths.status); render(await r.json()); }, 2000);
346
+ </script>\`;
347
+ setInterval(() => { sweepExpiredEnrollments(); }, SWEEP_MS);`,
348
+ },
349
+ } as const;
350
+
351
+ /* -------------------------------------------------------------------- the tree */
352
+
353
+ function sourceFiles(dir: string, found: string[] = []): string[] {
354
+ for (const entry of readdirSync(dir)) {
355
+ if (entry === 'node_modules' || entry === 'dist') continue;
356
+ const path = `${dir}/${entry}`;
357
+ if (statSync(path).isDirectory()) {
358
+ sourceFiles(path, found);
359
+ continue;
360
+ }
361
+ if (!entry.endsWith('.ts') && !entry.endsWith('.tsx')) continue;
362
+ // Specs may construct a dialer on purpose โ€” the fixtures above are the proof
363
+ // that this check works, and they live in a spec.
364
+ if (/\.(spec|test)\.tsx?$/.test(entry)) continue;
365
+ found.push(path);
366
+ }
367
+ return found;
368
+ }
369
+
370
+ describe('the check itself reds on a dialer and not on housekeeping', () => {
371
+ for (const [name, fixture] of Object.entries(FIXTURES)) {
372
+ it(`${fixture.reds ? 'reds' : 'passes'}: ${name} โ€” ${fixture.why}`, () => {
373
+ const hits = dialingTimerSites(fixture.source);
374
+ expect(
375
+ hits.length > 0,
376
+ fixture.reds
377
+ ? `${name} must be caught: it is a timer callback that dials a peer, and this check is the only thing standing between that shape and the tree`
378
+ : `${name} is a FALSE POSITIVE โ€” it issues no outbound request. Narrow the rule to the act being forbidden; do not excuse the file. Matched: ${JSON.stringify(hits)}`,
379
+ ).toBe(fixture.reds);
380
+ });
381
+ }
382
+
383
+ it('finds the timer sites it is supposed to be looking at (non-vacuity)', () => {
384
+ // Without this, a stripNonCode that blanked EVERYTHING would pass every
385
+ // "passes" fixture above and the whole suite would be inert.
386
+ expect(timerSites(FIXTURES.housekeepingFlush.source)).toHaveLength(1);
387
+ expect(timerSites(FIXTURES.fetchInBrowserPayload.source)).toHaveLength(1);
388
+ expect(timerSites(FIXTURES.reintroducedDialer.source)).toHaveLength(1);
389
+ });
390
+ });
391
+
392
+ describe('no timer in this package dials a peer', () => {
393
+ const files = sourceFiles(REPO);
394
+
395
+ it('is actually reading the tree', () => {
396
+ expect(files.length, 'the scan found no source files โ€” it is measuring nothing').toBeGreaterThan(
397
+ 50,
398
+ );
399
+ const withTimers = files.filter((f) => timerSites(readText(f)).length > 0);
400
+ expect(
401
+ withTimers.length,
402
+ 'no file in this package schedules a timer at all โ€” the scan is not resolving callbacks',
403
+ ).toBeGreaterThan(5);
404
+ });
405
+
406
+ it('has no timer callback that issues an outbound request', () => {
407
+ const offenders: string[] = [];
408
+ for (const file of files) {
409
+ for (const site of dialingTimerSites(readText(file))) {
410
+ offenders.push(`${file.slice(REPO.length)}:${site.line} (${site.scheduler})`);
411
+ }
412
+ }
413
+ expect(
414
+ offenders,
415
+ 'a timer callback here dials a peer. That is the poll the owner ruled out on 2026-09-15 ' +
416
+ '("There should be no polling in cb unless you can make some good case for it that beats ' +
417
+ 'the api option"), and it cost vault 68% of its traffic the last time it shipped. If the ' +
418
+ 'far side needs to tell this one something, it says so: see src/server/sync/signal.ts. ' +
419
+ 'Do not add the file to an exception list.',
420
+ ).toEqual([]);
421
+ });
422
+ });
423
+
424
+ /**
425
+ * ๐Ÿ”ด The regression that actually happened: not a stray `setInterval`, but a cadence
426
+ * published as **public API**, which is what made it expensive to remove. A constant
427
+ * naming a POLL has no legitimate use in this package.
428
+ *
429
+ * ๐Ÿ”ด **`_INTERVAL_MS` is deliberately NOT matched, and that was a measurement, not a
430
+ * guess.** The first draft included it and immediately flagged two innocents โ€”
431
+ * `retention/sweeper.ts`'s `DEFAULT_SWEEP_INTERVAL_MS` and `jobs/types.ts`'s
432
+ * `DEFAULT_FIRE_INTERVAL_MS`, both row (c) housekeeping that touches no network.
433
+ * Shipping that would have meant two `:ignore` comments on good code on day one,
434
+ * which is how `check-app-tenancy.ts`'s header records a checker dying: *a checker
435
+ * that reddens on the record of a bug gets excused line by line until it checks
436
+ * nothing.* An interval is not a poll; a poll is.
437
+ */
438
+ describe('no exported symbol names a polling cadence', () => {
439
+ it('catches the constant that was actually shipped, and spares the innocents', () => {
440
+ // Verified both ways, from the real names in this tree.
441
+ expect(pollCadenceExports('export const REQUEST_POLL_MS = 20_000;')).toEqual([
442
+ 'REQUEST_POLL_MS',
443
+ ]);
444
+ expect(pollCadenceExports('export const PEER_POLL_INTERVAL = 5;')).toHaveLength(1);
445
+ expect(pollCadenceExports('export const DEFAULT_SWEEP_INTERVAL_MS = 60_000;')).toEqual([]);
446
+ expect(pollCadenceExports('export const DEFAULT_FIRE_INTERVAL_MS = 1_000;')).toEqual([]);
447
+ expect(pollCadenceExports('export const PEER_CONTACT_WRITE_MS = 30_000;')).toEqual([]);
448
+ expect(pollCadenceExports('// export const REQUEST_POLL_MS = 20_000;')).toEqual([]);
449
+ });
450
+
451
+ it('exports no *_POLL_MS constant', () => {
452
+ const offenders: string[] = [];
453
+ for (const file of sourceFiles(REPO)) {
454
+ for (const name of pollCadenceExports(readText(file))) {
455
+ offenders.push(`${file.slice(REPO.length)} โ†’ ${name}`);
456
+ }
457
+ }
458
+ expect(
459
+ offenders,
460
+ 'this is exactly how REQUEST_POLL_MS survived: a poll cadence exported from ' +
461
+ 'src/server/sync/index.ts, four hours before the ruling against it. Public API is ' +
462
+ 'the difference between deleting a poll and negotiating its removal with every consumer.',
463
+ ).toEqual([]);
464
+ });
465
+ });
466
+
467
+ function readText(path: string): string {
468
+ return readFileSync(path, 'utf8');
469
+ }
@@ -12,6 +12,7 @@ import { Hono } from "hono";
12
12
  import type { OpLog } from "./opLog";
13
13
  import type { ApplyContext, ApplyOne, ApplyReport, PeerInfo, RemoteApi, SyncOp } from "./types";
14
14
  import { createSyncApplier } from "./engine";
15
+ import { type SyncSignalHub, signalResponse } from "./signal";
15
16
 
16
17
  const noStore = { "cache-control": "no-store" } as const;
17
18
  const SYNC_PAGE = 500;
@@ -65,8 +66,6 @@ export interface ReceiverHooks {
65
66
  /** Every authenticated hit โ€” the only evidence a non-dialing half has that it is
66
67
  * in step (vault's `lastPeerContactAt` lesson). */
67
68
  onPeerContact?: (peerId: string) => void;
68
- /** The "Sync now was pressed HERE" stamp the dialer polls. */
69
- readRequest?: () => number | null;
70
69
  }
71
70
 
72
71
  export interface ReceiverOptions {
@@ -98,9 +97,27 @@ export interface ReceiverOptions {
98
97
  */
99
98
  afterBatch?: (report: ApplyReport) => void | Promise<void>;
100
99
  hooks?: ReceiverHooks;
100
+ /**
101
+ * Mount `GET /signal` โ€” the long-lived stream that replaced the dialer's
102
+ * 20-second `GET /requested` probe. Omitted โ‡’ the route does not exist, the
103
+ * orch-companion idiom: nothing to probe, nothing to authenticate against.
104
+ *
105
+ * Call {@link SyncSignalHub.announce} after a local write on THIS half and the
106
+ * dialing half reconciles within the round trip. See `./signal.ts`.
107
+ */
108
+ signal?: SyncSignalHub;
101
109
  }
102
110
 
103
- /** Build the receiver: GET /info, GET /pull, POST /push, GET /requested. */
111
+ /**
112
+ * Build the receiver: GET /info, GET /pull, POST /push.
113
+ *
114
+ * ๐Ÿ”ด There was a fourth route, `GET /requested`, and it is gone (2026-09-15). It
115
+ * served a single integer โ€” "was Sync now pressed here?" โ€” and existed only to be
116
+ * asked, every 20 seconds, by a dialer that otherwise had nothing to say. That made
117
+ * it **68 % of `vault`'s entire traffic**. A receiver with news now says so over
118
+ * {@link import("./signal")} instead of waiting to be asked; deleting the route is
119
+ * what stops a stale consumer from keeping the poll alive against a new build.
120
+ */
104
121
  export function createSyncReceiver(options: ReceiverOptions): Hono {
105
122
  const { log, verifyToken } = options;
106
123
  if ((options.applyOne === undefined) === (options.beginBatch === undefined)) {
@@ -135,9 +152,17 @@ export function createSyncReceiver(options: ReceiverOptions): Hono {
135
152
  return c.json(body, 200, noStore);
136
153
  });
137
154
 
138
- api.get("/requested", (c) =>
139
- c.json({ requestedAt: options.hooks?.readRequest?.() ?? null }, 200, noStore),
140
- );
155
+ const signal = options.signal;
156
+ if (signal !== undefined) {
157
+ api.get("/signal", (c) =>
158
+ signalResponse(signal, {
159
+ // The catch-up frame: a client reconnecting after a sleep learns the
160
+ // current head without a round trip of its own.
161
+ initial: { head: log.head(), instanceId: log.instanceId() },
162
+ signal: c.req.raw.signal,
163
+ }),
164
+ );
165
+ }
141
166
 
142
167
  api.get("/pull", (c) => {
143
168
  const afterRaw = c.req.query("after");
@@ -302,15 +327,5 @@ export function createHttpRemote(options: {
302
327
  if (!r.ok && r.status !== 409) throw new Error(`sync/push: ${r.status}`);
303
328
  return await readJson<ApplyReport>(r, "sync/push");
304
329
  },
305
- async requestedAt() {
306
- try {
307
- const r = await request("/requested");
308
- if (!r.ok) return null;
309
- const body = await readJson<{ requestedAt?: unknown }>(r, "sync/requested");
310
- return typeof body.requestedAt === "number" ? body.requestedAt : null;
311
- } catch {
312
- return null;
313
- }
314
- },
315
330
  };
316
331
  }
@@ -63,7 +63,22 @@ export {
63
63
  planNextSync,
64
64
  type SyncLoopConfig,
65
65
  type SyncLoopState,
66
+ type SyncPlan,
66
67
  } from "./planner";
68
+ export {
69
+ SIGNAL_SSE_HEADERS,
70
+ connectSyncSignal,
71
+ createSyncSignalHub,
72
+ encodeNewsFrame,
73
+ parseNewsFrame,
74
+ signalResponse,
75
+ signalStream,
76
+ type SignalClient,
77
+ type SignalClientOptions,
78
+ type SignalStreamOptions,
79
+ type SyncNews,
80
+ type SyncSignalHub,
81
+ } from "./signal";
67
82
  export {
68
83
  OFFLINE_STATUS,
69
84
  PEER_CONTACT_WRITE_MS,
@@ -72,7 +87,14 @@ export {
72
87
  type SyncStatusReporter,
73
88
  type SyncStatusStore,
74
89
  } from "./status";
75
- export { REQUEST_POLL_MS, startSyncTimer, type SyncTimerDeps, type SyncTimerHandle } from "./timer";
90
+ /**
91
+ * ๐Ÿ”ด `REQUEST_POLL_MS` was exported from here until 2026-09-15 and is GONE โ€” that
92
+ * removal is why this package went to 2.0.0. It was a 20-second dial at a configured
93
+ * peer, and being *public API* is what made it dangerous: one more consumer and
94
+ * deleting it would have been a breaking change rather than an edit. `./timer`'s
95
+ * header has the ruling and the measurement; `./signal` is what replaced it.
96
+ */
97
+ export { startSyncTimer, type SyncTimerDeps, type SyncTimerHandle, type WakeReason } from "./timer";
76
98
  export { createSyncAlarm, type AlarmOutcome, type SyncAlarm, type SyncAlarmOptions } from "./alarm";
77
99
  export {
78
100
  DEFAULT_HOLD_MS,
@@ -1,31 +1,37 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { isUnreachable, planNextSync, type SyncLoopConfig } from "./planner";
2
+ import { DEFAULT_LOOP, isUnreachable, planNextSync, type SyncLoopConfig } from "./planner";
3
3
 
4
4
  const cfg: SyncLoopConfig = {
5
- intervalMs: 300_000,
6
5
  debounceMs: 3_000,
7
6
  backoffBaseMs: 30_000,
8
7
  backoffMaxMs: 1_800_000,
9
8
  };
10
9
 
11
10
  describe("planNextSync", () => {
12
- test("steady interval when clean", () => {
13
- expect(
14
- planNextSync({ lastAttempt: 0, consecutiveFailures: 0, dirtySince: null }, 300_000, cfg)
15
- .runNow,
16
- ).toBe(true);
17
- const wait = planNextSync(
18
- { lastAttempt: 100_000, consecutiveFailures: 0, dirtySince: null },
19
- 200_000,
20
- cfg,
21
- );
22
- expect(wait.runNow).toBe(false);
23
- expect(wait.waitMs).toBe(200_000);
11
+ /**
12
+ * ๐Ÿ”ด The load-bearing one. This test used to be called "steady interval when
13
+ * clean" and asserted the opposite: that a clean, healthy loop books another
14
+ * dial. That interval was 5 minutes at a configured peer with nothing to say,
15
+ * and the owner ruled it out on 2026-09-15. `waitMs: null` is the ruling
16
+ * expressed as a return value โ€” "there is no reason to wake up".
17
+ */
18
+ test("a clean, healthy state books NOTHING โ€” there is no steady interval", () => {
19
+ for (const lastAttempt of [0, 100_000, 1_000_000]) {
20
+ for (const now of [100_000, 500_000, 86_400_000]) {
21
+ expect(
22
+ planNextSync({ lastAttempt, consecutiveFailures: 0, dirtySince: null }, now, cfg),
23
+ ).toEqual({ runNow: false, waitMs: null, reason: null });
24
+ }
25
+ }
24
26
  });
25
27
 
26
- test("a dirty flag debounces then wins over the interval", () => {
28
+ test("a dirty flag debounces, then runs โ€” the push side", () => {
27
29
  const state = { lastAttempt: 0, consecutiveFailures: 0, dirtySince: 10_000 };
28
- expect(planNextSync(state, 11_000, cfg)).toEqual({ runNow: false, waitMs: 2_000 });
30
+ expect(planNextSync(state, 11_000, cfg)).toEqual({
31
+ runNow: false,
32
+ waitMs: 2_000,
33
+ reason: "dirty",
34
+ });
29
35
  expect(planNextSync(state, 13_000, cfg).runNow).toBe(true);
30
36
  });
31
37
 
@@ -39,6 +45,17 @@ describe("planNextSync", () => {
39
45
  // 2^10 * 30s would be ~8.5h โ€” capped at the ceiling.
40
46
  expect(at(11, 1_800_000).runNow).toBe(true);
41
47
  expect(at(11, 1_799_999).waitMs).toBe(1);
48
+ // The backoff is the ONE timer allowed to dial, and only while disconnected.
49
+ expect(at(1, 0).reason).toBe("retry");
50
+ });
51
+
52
+ test("the config has no interval field left to set", () => {
53
+ // A cadence key is a cadence somebody configures. Its absence is the check.
54
+ expect(Object.keys(DEFAULT_LOOP).sort()).toEqual([
55
+ "backoffBaseMs",
56
+ "backoffMaxMs",
57
+ "debounceMs",
58
+ ]);
42
59
  });
43
60
  });
44
61