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.
- package/dist/server/sync/http.d.ts +20 -3
- package/dist/server/sync/http.js +20 -14
- package/dist/server/sync/index.d.ts +10 -2
- package/dist/server/sync/index.js +9 -1
- package/dist/server/sync/planner.d.ts +38 -8
- package/dist/server/sync/planner.js +32 -8
- package/dist/server/sync/signal.d.ts +161 -0
- package/dist/server/sync/signal.js +348 -0
- package/dist/server/sync/timer.d.ts +63 -19
- package/dist/server/sync/timer.js +104 -45
- package/dist/server/sync/types.d.ts +0 -2
- package/package.json +1 -1
- package/src/noTimerDialsAPeer.spec.ts +469 -0
- package/src/server/sync/http.ts +31 -16
- package/src/server/sync/index.ts +23 -1
- package/src/server/sync/planner.spec.ts +33 -16
- package/src/server/sync/planner.ts +48 -11
- package/src/server/sync/signal.spec.ts +306 -0
- package/src/server/sync/signal.ts +422 -0
- package/src/server/sync/timer.spec.ts +97 -16
- package/src/server/sync/timer.ts +124 -47
- package/src/server/sync/types.ts +0 -2
|
@@ -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": "
|
|
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
|
+
}
|
package/src/server/sync/http.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
139
|
-
|
|
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
|
}
|
package/src/server/sync/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
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({
|
|
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
|
|