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.
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/metrics/telemetrySink.d.ts +119 -0
- package/dist/server/metrics/telemetrySink.js +76 -0
- package/dist/server/middleware/index.d.ts +1 -0
- package/dist/server/middleware/index.js +3 -0
- package/dist/server/middleware/requestLogger.d.ts +30 -11
- package/dist/server/middleware/requestLogger.js +15 -6
- 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/index.ts +9 -0
- package/src/server/metrics/telemetrySink.spec.ts +288 -0
- package/src/server/metrics/telemetrySink.ts +181 -0
- package/src/server/middleware/index.ts +9 -0
- package/src/server/middleware/requestLogger.ts +43 -17
- 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
- package/src/shippedFilesAreTracked.spec.ts +69 -0
|
@@ -1,16 +1,52 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The in-process
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 { isUnreachable, DEFAULT_LOOP, planNextSync, } from "./planner";
|
|
13
|
-
|
|
45
|
+
/**
|
|
46
|
+
* One reconciliation shortly after start: catch up on whatever happened while this
|
|
47
|
+
* process was down. A single shot at boot, not a cadence — the same catch-up a push
|
|
48
|
+
* model needs anyway.
|
|
49
|
+
*/
|
|
14
50
|
const KICKOFF_MS = 5_000;
|
|
15
51
|
function defaultPeerLabel(basePath) {
|
|
16
52
|
try {
|
|
@@ -29,46 +65,63 @@ export function startSyncTimer(deps) {
|
|
|
29
65
|
const warn = deps.warn ?? ((m) => console.error(m));
|
|
30
66
|
const status = deps.status;
|
|
31
67
|
const peerLabel = deps.peerLabel ?? defaultPeerLabel;
|
|
32
|
-
const requestPollMs = deps.requestPollMs ?? REQUEST_POLL_MS;
|
|
33
68
|
status?.update({ enabled: true, role: "dialer" });
|
|
34
69
|
const state = { lastAttempt: 0, consecutiveFailures: 0, dirtySince: null };
|
|
35
70
|
let syncedHead = deps.head();
|
|
36
71
|
let stopped = false;
|
|
37
72
|
let handle = null;
|
|
38
73
|
let reportedUnreachable = false;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
74
|
+
/**
|
|
75
|
+
* 🔴 Starts TRUE: the boot catch-up is a forced run, not a scheduled one.
|
|
76
|
+
*
|
|
77
|
+
* It used to fall out of the planner for free — `lastAttempt: 0` plus a steady
|
|
78
|
+
* `intervalMs` made the first tick always due. With no interval left, a clean
|
|
79
|
+
* healthy state plans nothing, so the one run this loop genuinely owes at
|
|
80
|
+
* startup has to be asked for explicitly. It has a reason: this process may have
|
|
81
|
+
* missed ops while it was down, and catching up is exactly what a push model
|
|
82
|
+
* does on reconnect.
|
|
83
|
+
*/
|
|
84
|
+
let forceRun = true;
|
|
42
85
|
let inFlight = false;
|
|
43
|
-
|
|
86
|
+
/** Why the currently-booked wake exists, or null when the loop is quiet. */
|
|
87
|
+
let bookedFor = null;
|
|
88
|
+
/**
|
|
89
|
+
* Book the next wake-up. 🔴 The `reason` is not decoration — it is the type-level
|
|
90
|
+
* guard: {@link WakeReason} has no periodic member, so there is no way to spell
|
|
91
|
+
* "wake again in `intervalMs`" through this function. It is also read back by
|
|
92
|
+
* {@link SyncTimerHandle.markDirty}, which must not push an imminent forced run
|
|
93
|
+
* out to a debounce.
|
|
94
|
+
*/
|
|
95
|
+
const schedule = (reason, ms) => {
|
|
44
96
|
if (stopped)
|
|
45
97
|
return;
|
|
46
98
|
if (handle !== null)
|
|
47
99
|
clearTimer(handle);
|
|
48
100
|
handle = setTimer(tick, ms);
|
|
49
101
|
handle?.unref?.();
|
|
102
|
+
bookedFor = reason;
|
|
50
103
|
};
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
104
|
+
/** Nothing to push, nothing to retry: cancel any pending wake and go quiet. */
|
|
105
|
+
const idle = () => {
|
|
106
|
+
if (handle !== null)
|
|
107
|
+
clearTimer(handle);
|
|
108
|
+
handle = null;
|
|
109
|
+
bookedFor = null;
|
|
54
110
|
};
|
|
55
|
-
|
|
56
|
-
|
|
111
|
+
/** Apply the planner's verdict — the ONE place a wake-up is booked or declined. */
|
|
112
|
+
const bookNext = () => {
|
|
113
|
+
if (stopped)
|
|
57
114
|
return;
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
})
|
|
69
|
-
.finally(() => {
|
|
70
|
-
probing = false;
|
|
71
|
-
});
|
|
115
|
+
if (forceRun) {
|
|
116
|
+
schedule("forced", 1);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const plan = planNextSync(state, now(), loop);
|
|
120
|
+
if (plan.waitMs === null || plan.reason === null) {
|
|
121
|
+
idle();
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
schedule(plan.reason, Math.max(1, plan.waitMs));
|
|
72
125
|
};
|
|
73
126
|
const tick = () => {
|
|
74
127
|
if (stopped)
|
|
@@ -84,15 +137,16 @@ export function startSyncTimer(deps) {
|
|
|
84
137
|
pending: Math.max(0, head - syncedHead),
|
|
85
138
|
});
|
|
86
139
|
if (peer === null) {
|
|
87
|
-
|
|
140
|
+
// Not linked. Nothing to dial and nothing to wait for — `syncNow()` or the
|
|
141
|
+
// next local write re-checks. A re-check timer here would be a poll at a
|
|
142
|
+
// peer that does not exist yet.
|
|
143
|
+
forceRun = false;
|
|
144
|
+
idle();
|
|
88
145
|
return;
|
|
89
146
|
}
|
|
90
147
|
const plan = planNextSync(state, now(), loop);
|
|
91
148
|
if (!plan.runNow && !forceRun) {
|
|
92
|
-
|
|
93
|
-
schedule(sleepFor(plan.waitMs));
|
|
94
|
-
if (linkedAndHealthy)
|
|
95
|
-
probeRequest(peer);
|
|
149
|
+
bookNext();
|
|
96
150
|
return;
|
|
97
151
|
}
|
|
98
152
|
forceRun = false;
|
|
@@ -140,31 +194,36 @@ export function startSyncTimer(deps) {
|
|
|
140
194
|
})
|
|
141
195
|
.finally(() => {
|
|
142
196
|
inFlight = false;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
197
|
+
// A local write that landed mid-run is news the planner must see.
|
|
198
|
+
if (deps.head() > syncedHead && state.dirtySince === null)
|
|
199
|
+
state.dirtySince = now();
|
|
200
|
+
bookNext();
|
|
147
201
|
});
|
|
148
202
|
};
|
|
149
|
-
schedule(KICKOFF_MS);
|
|
203
|
+
schedule("boot", KICKOFF_MS);
|
|
150
204
|
return {
|
|
151
205
|
stop: () => {
|
|
152
206
|
stopped = true;
|
|
153
207
|
if (handle !== null)
|
|
154
208
|
clearTimer(handle);
|
|
209
|
+
handle = null;
|
|
155
210
|
},
|
|
156
211
|
syncNow: () => {
|
|
157
212
|
if (stopped)
|
|
158
213
|
return;
|
|
159
214
|
forceRun = true;
|
|
160
|
-
schedule(1);
|
|
215
|
+
schedule("forced", 1);
|
|
161
216
|
},
|
|
162
217
|
markDirty: () => {
|
|
163
218
|
if (stopped)
|
|
164
219
|
return;
|
|
165
220
|
if (state.dirtySince === null)
|
|
166
221
|
state.dirtySince = now();
|
|
167
|
-
|
|
222
|
+
// A forced run is already due in ~1ms and will push these ops; re-booking
|
|
223
|
+
// would replace it with a 3s debounce and make "Sync now" slower.
|
|
224
|
+
if (bookedFor === "forced")
|
|
225
|
+
return;
|
|
226
|
+
bookNext();
|
|
168
227
|
},
|
|
169
228
|
};
|
|
170
229
|
}
|
|
@@ -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.",
|