fieldlog 0.15.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/CHANGELOG.md +109 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/bin/fieldlog.js +18 -0
- package/bin/fieldlog.ts +145 -0
- package/package.json +35 -0
- package/src/auth.ts +297 -0
- package/src/cas.ts +357 -0
- package/src/deltasync.ts +306 -0
- package/src/hashchain.ts +106 -0
- package/src/index.ts +41 -0
- package/src/kernel.ts +333 -0
- package/src/log.ts +344 -0
- package/src/quota.ts +122 -0
- package/src/relay.ts +1027 -0
- package/src/retain.ts +267 -0
- package/src/revokelog.ts +291 -0
- package/src/store.ts +706 -0
- package/src/sync.ts +828 -0
- package/src/tombstone.ts +306 -0
package/src/sync.ts
ADDED
|
@@ -0,0 +1,828 @@
|
|
|
1
|
+
// sync.ts — delta push/pull by seq with server ack cursor.
|
|
2
|
+
// Idempotent by UUID, resumable in chunks, exponential backoff.
|
|
3
|
+
// The relay is simple: accept raw log, broadcast, store. No business logic.
|
|
4
|
+
import type { AppendLog, LogEvent } from './log.js';
|
|
5
|
+
import { checkAppend, type EventStore } from './store.js';
|
|
6
|
+
import { checkThreshold, verifyEvent, type Countersignature } from './auth.js';
|
|
7
|
+
|
|
8
|
+
export interface PushAck {
|
|
9
|
+
acked: string[]; // event UUIDs accepted
|
|
10
|
+
server_time: number; // authoritative time, stored on ack
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface Relay {
|
|
14
|
+
push(batch: LogEvent[]): Promise<PushAck>;
|
|
15
|
+
pull(sinceRelaySeq: number): Promise<{ events: LogEvent[]; cursor: number }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type BackoffJitter = boolean | number | (() => number);
|
|
19
|
+
|
|
20
|
+
export interface SyncOpts {
|
|
21
|
+
chunkSize?: number;
|
|
22
|
+
maxRetries?: number;
|
|
23
|
+
baseMs?: number;
|
|
24
|
+
maxMs?: number; // backoff cap; chaos tests pin this low
|
|
25
|
+
/** deviceId -> ed25519 publicKeyPem. When non-empty, pull verifies every
|
|
26
|
+
* remote event signature and dead-letters forgeries (cursor still advances). */
|
|
27
|
+
trustedDevices?: Map<string, string> | Record<string, string>;
|
|
28
|
+
/** High-value entry gate: value >= limit needs threshold countersignatures. */
|
|
29
|
+
highValue?: { limit: number; threshold: number };
|
|
30
|
+
/** Device-level revoke set (origin device ids). Mirrors the relay tombstone
|
|
31
|
+
* list or RevokeLog '*' rows via isDeviceRevoked below. Matching pull
|
|
32
|
+
* events quarantine instead of converging; already-stored matches purge
|
|
33
|
+
* from the read views on retroactive sweep. Cursor still advances. */
|
|
34
|
+
revokedDevices?: Set<string> | string[];
|
|
35
|
+
/** Per-event revoke predicate (tokenId/epoch mapping over RevokeLog lives
|
|
36
|
+
* in the caller's closure). Checked after revokedDevices; either match
|
|
37
|
+
* quarantines. Receives the remote event on pull, the local event on sweep. */
|
|
38
|
+
isRevoked?: (ev: LogEvent) => boolean;
|
|
39
|
+
/** Version stamp for the revoke state observed through `isRevoked` (e.g.
|
|
40
|
+
* RevokeLog.size). Lets purgeRevoked tell "same revoke state, only new log
|
|
41
|
+
* lines to scan" from "revokes landed, rescan everything". Omit it and a
|
|
42
|
+
* predicate sweep always rescans: the predicate is opaque, and its closure
|
|
43
|
+
* may close over mutated revoke state no fingerprint can see. */
|
|
44
|
+
revokeVersion?: string | number;
|
|
45
|
+
/** Backoff jitter policy. Undefined/false (default) = deterministic jitter
|
|
46
|
+
* seeded by the attempt number, so low-maxMs timing tests pin exactly.
|
|
47
|
+
* true = random jitter via Math.random(); a number fixes the jitter millis;
|
|
48
|
+
* a function supplies a custom [0,1) source. */
|
|
49
|
+
jitter?: BackoffJitter;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
const ACK_SEQ_KEY = 'sync.ack_seq'; // local seq fully acked by the relay
|
|
54
|
+
const PULL_CURSOR_KEY = 'sync.pull_cursor'; // relay seq consumed via pull
|
|
55
|
+
const SERVER_TIME_KEY = 'sync.server_time'; // last authoritative server_time
|
|
56
|
+
|
|
57
|
+
export function getAckSeq(store: EventStore): number {
|
|
58
|
+
return Number(store.getMeta(ACK_SEQ_KEY) ?? 0);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getServerTime(store: EventStore): number | null {
|
|
62
|
+
const v = store.getMeta(SERVER_TIME_KEY);
|
|
63
|
+
return v === null ? null : Number(v);
|
|
64
|
+
}
|
|
65
|
+
/** Deterministic jitter step in [0,100), seeded by the attempt number so a
|
|
66
|
+
* retry sleeps the same millis on every run. */
|
|
67
|
+
function deterministicJitterMs(attempt: number): number {
|
|
68
|
+
return ((attempt + 1) * 37) % 100;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Backoff: baseMs * 2^attempt, capped at maxMs, plus jitter. Deterministic
|
|
72
|
+
* by default (jitter seeded by the attempt); pass a number for fixed jitter
|
|
73
|
+
* millis or a [0,1) source for random/custom jitter (opt-in randomness). */
|
|
74
|
+
export function backoffMs(
|
|
75
|
+
attempt: number,
|
|
76
|
+
baseMs = 200,
|
|
77
|
+
maxMs = 30_000,
|
|
78
|
+
jitter?: number | (() => number),
|
|
79
|
+
): number {
|
|
80
|
+
const capped = Math.min(maxMs, baseMs * 2 ** attempt);
|
|
81
|
+
const extra =
|
|
82
|
+
typeof jitter === 'function'
|
|
83
|
+
? Math.floor(jitter() * 100)
|
|
84
|
+
: typeof jitter === 'number'
|
|
85
|
+
? jitter
|
|
86
|
+
: deterministicJitterMs(attempt);
|
|
87
|
+
return capped + extra;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolve a SyncOpts jitter policy to a backoffMs jitter arg. */
|
|
91
|
+
function resolveJitter(opt: BackoffJitter | undefined): number | (() => number) | undefined {
|
|
92
|
+
if (opt === true) return () => Math.random();
|
|
93
|
+
if (typeof opt === 'function' || typeof opt === 'number') return opt;
|
|
94
|
+
return undefined; // false/undefined: deterministic
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** True when retrying cannot heal: auth rejection (forbidden/capability/
|
|
98
|
+
* revoked/unknown device) or a bad cursor. Relay errors arrive as
|
|
99
|
+
* `relay rejected <op>: <message>` (code flattened into the message), so
|
|
100
|
+
* match the message; a structured `.code` is honored when present. */
|
|
101
|
+
const PERMANENT_SYNC_ERROR =
|
|
102
|
+
/forbidden|bad[_ ]cursor|capability|revok|unknown device|missing capability|unauthorized|not authorized/i;
|
|
103
|
+
|
|
104
|
+
export function isPermanentSyncError(err: unknown): boolean {
|
|
105
|
+
if (err === null || err === undefined) return false;
|
|
106
|
+
if (typeof err === 'object' && 'code' in err) {
|
|
107
|
+
const code = String(err.code ?? '');
|
|
108
|
+
if (/forbidden|bad[_-]?cursor|unauthorized/i.test(code)) return true;
|
|
109
|
+
}
|
|
110
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
111
|
+
return PERMANENT_SYNC_ERROR.test(msg);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function withBackoff<T>(fn: () => Promise<T>, opts: SyncOpts = {}): Promise<T> {
|
|
115
|
+
const maxRetries = opts.maxRetries ?? 5;
|
|
116
|
+
const baseMs = opts.baseMs ?? 200;
|
|
117
|
+
const maxMs = opts.maxMs ?? 30_000;
|
|
118
|
+
let attempt = 0;
|
|
119
|
+
for (;;) {
|
|
120
|
+
try {
|
|
121
|
+
return await fn();
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (isPermanentSyncError(err)) throw err; // retry never heals rejection: fail fast
|
|
124
|
+
if (attempt >= maxRetries) throw err;
|
|
125
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
126
|
+
setTimeout(resolve, backoffMs(attempt, baseMs, maxMs, resolveJitter(opts.jitter)));
|
|
127
|
+
await promise;
|
|
128
|
+
attempt += 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Per-chunk ack handling shared by pushPending and failover push. */
|
|
134
|
+
function applyPushAck(
|
|
135
|
+
store: EventStore,
|
|
136
|
+
batch: LogEvent[],
|
|
137
|
+
ack: PushAck,
|
|
138
|
+
cursor: number,
|
|
139
|
+
): { advanced: number; acked: number } {
|
|
140
|
+
// Relay is idempotent by UUID: only advance over events it actually acked,
|
|
141
|
+
// in log order, so a partial ack resumes exactly where it stopped. Ack must
|
|
142
|
+
// additionally imply durable store: a kill between log.append and
|
|
143
|
+
// store.apply leaves the event on disk but out of the read-model, and
|
|
144
|
+
// acking it would let truncate sweep it into permanent loss. Re-drive the
|
|
145
|
+
// logged event first; a transient miss still holds the cursor here, but a
|
|
146
|
+
// deterministically un-storable event dead-letters (evidence quarantined,
|
|
147
|
+
// cursor advances) so one poison event never pins the batch cursor and
|
|
148
|
+
// starves every event behind it. The truncate clamp refuses to sweep seqs
|
|
149
|
+
// missing from the read-model, so the log bytes stay until reconciled.
|
|
150
|
+
const ackedSet = new Set(ack.acked);
|
|
151
|
+
let advanced = cursor;
|
|
152
|
+
let acked = 0;
|
|
153
|
+
for (const ev of batch) {
|
|
154
|
+
if (!ackedSet.has(ev.id)) break;
|
|
155
|
+
if (!store.hasId(ev.id)) {
|
|
156
|
+
try {
|
|
157
|
+
store.apply(ev);
|
|
158
|
+
} catch (err) {
|
|
159
|
+
const reason = `apply failed for ${ev.id}: ${err instanceof Error ? err.message : String(err)}`;
|
|
160
|
+
try {
|
|
161
|
+
quarantineOne(store, ev, reason);
|
|
162
|
+
} catch {
|
|
163
|
+
break; // evidence itself unwritable: hold, retry next run
|
|
164
|
+
}
|
|
165
|
+
advanced = ev.seq;
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (!store.hasId(ev.id)) break;
|
|
169
|
+
}
|
|
170
|
+
advanced = ev.seq;
|
|
171
|
+
acked += 1;
|
|
172
|
+
}
|
|
173
|
+
store.setMeta(ACK_SEQ_KEY, String(advanced));
|
|
174
|
+
store.setMeta(SERVER_TIME_KEY, String(ack.server_time));
|
|
175
|
+
for (const ev of batch) {
|
|
176
|
+
if (!ackedSet.has(ev.id)) continue;
|
|
177
|
+
const local = store.getEventById(ev.id);
|
|
178
|
+
if (local && local.server_time === undefined) {
|
|
179
|
+
// Authoritative time only arrives via server ack — stamp it, never the clock.
|
|
180
|
+
store.query(`UPDATE _events SET server_time = $t WHERE id = $id`, { t: ack.server_time, id: ev.id });
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { advanced, acked };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Per-chunk pull handling shared by pullRemote and failover pull. */
|
|
187
|
+
function registryOf(opt: SyncOpts['trustedDevices']): Map<string, string> | null {
|
|
188
|
+
if (!opt) return null;
|
|
189
|
+
const m = opt instanceof Map ? opt : new Map(Object.entries(opt));
|
|
190
|
+
return m.size > 0 ? m : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Forgery gate: verify the origin device signature (+ countersign threshold
|
|
194
|
+
* for high-value entry). False = dead-letter, never re-hashed clean. */
|
|
195
|
+
function verifyPullAuth(remote: LogEvent, registry: Map<string, string> | null, highValue?: { limit: number; threshold: number }): boolean {
|
|
196
|
+
if (!registry) return true; // no registry: unsigned legacy path stays valid
|
|
197
|
+
const origin = remote.device_id;
|
|
198
|
+
const pub = typeof origin === 'string' ? registry.get(origin) : undefined;
|
|
199
|
+
if (!pub) return false; // unknown origin device: cannot authenticate
|
|
200
|
+
if (typeof remote.signature !== 'string' || remote.signature === '') return false;
|
|
201
|
+
if (!verifyEvent(pub, remote, remote.signature)) return false;
|
|
202
|
+
if (highValue && remote.type === 'entry') {
|
|
203
|
+
const value = Number((remote.payload as Record<string, unknown>)?.['value']);
|
|
204
|
+
if (Number.isFinite(value) && value >= highValue.limit) {
|
|
205
|
+
const sigs = (remote.countersignatures ?? []) as Countersignature[];
|
|
206
|
+
let met: boolean;
|
|
207
|
+
try {
|
|
208
|
+
met = checkThreshold(registry, remote, sigs, highValue.threshold).thresholdMet;
|
|
209
|
+
} catch (err) {
|
|
210
|
+
// Misconfiguration (threshold outside 1..registry.size) can never
|
|
211
|
+
// verify: fail loud instead of dead-lettering every high-value
|
|
212
|
+
// entry into silent loss. Malformed per-event countersignature data
|
|
213
|
+
// is unverified data, not misconfig: dead-letter as before.
|
|
214
|
+
if (err instanceof RangeError) {
|
|
215
|
+
throw new RangeError(
|
|
216
|
+
`sync highValue misconfigured: threshold ${highValue.threshold} unusable with ${registry.size} trusted device(s)`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
if (!met) return false;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
// Revoke quarantine + retroactive purge (closes leak 3).
|
|
227
|
+
//
|
|
228
|
+
// Philosophy: quarantine closes ACCESS and keeps EVIDENCE — it never rewrites
|
|
229
|
+
// or deletes the append-only log. A revoked pull event is recorded verbatim in
|
|
230
|
+
// the `_quarantine` table (sqlite, alongside the read-model) and skipped past
|
|
231
|
+
// the pull cursor like a dead-letter, so sync never converges blindly on
|
|
232
|
+
// tainted data. Data that converged BEFORE the revoke arrived is purged from
|
|
233
|
+
// the domain read views (entries/tally_moves/records) by purgeRevoked; the log
|
|
234
|
+
// line and the `_events` row stay so reopen replay (idempotent by UUID) cannot
|
|
235
|
+
// resurrect the rows and forensics keeps the bytes.
|
|
236
|
+
export interface QuarantineRow {
|
|
237
|
+
event_id: string;
|
|
238
|
+
reason: string;
|
|
239
|
+
ts: number;
|
|
240
|
+
event: string; // verbatim event JSON (evidence)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export interface PurgeResult {
|
|
244
|
+
scanned: number;
|
|
245
|
+
quarantined: number;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** True when a RevokeLog-style view carries a whole-device ('*') row for deviceId. */
|
|
249
|
+
export function isDeviceRevoked(
|
|
250
|
+
revokeLog: { revokedTokens(): Array<{ tokenId: string; deviceId: string }> },
|
|
251
|
+
deviceId: string,
|
|
252
|
+
): boolean {
|
|
253
|
+
try {
|
|
254
|
+
return revokeLog.revokedTokens().some((r) => r.tokenId === '*' && r.deviceId === deviceId);
|
|
255
|
+
} catch {
|
|
256
|
+
// Revocation state unreadable: fail closed. Treating an unreadable
|
|
257
|
+
// revoke view as "not revoked" would converge tainted events.
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Non-null when the event is revoked: device-set hit or predicate hit. */
|
|
263
|
+
function revokeReason(ev: LogEvent, opts: SyncOpts): string | null {
|
|
264
|
+
// Origin device: relay events carry it in device_id; local copies keep it in origin_device.
|
|
265
|
+
const origin = typeof ev.origin_device === 'string' && ev.origin_device !== '' ? ev.origin_device : ev.device_id;
|
|
266
|
+
const rd = opts.revokedDevices;
|
|
267
|
+
if (rd) {
|
|
268
|
+
const set = rd instanceof Set ? rd : new Set(rd);
|
|
269
|
+
if (set.size > 0 && typeof origin === 'string' && set.has(origin)) return `device revoked: ${origin}`;
|
|
270
|
+
}
|
|
271
|
+
if (opts.isRevoked) {
|
|
272
|
+
let hit = false;
|
|
273
|
+
try {
|
|
274
|
+
hit = opts.isRevoked(ev) === true;
|
|
275
|
+
} catch {
|
|
276
|
+
// A throwing revoke predicate is unreadable revocation state, not a
|
|
277
|
+
// clean verdict: quarantine the event (evidence kept) instead of
|
|
278
|
+
// converging data no predicate could vouch for.
|
|
279
|
+
return `revoke predicate failed: ${ev.id}`;
|
|
280
|
+
}
|
|
281
|
+
if (hit) return `revoke predicate matched: ${ev.id}`;
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function ensureQuarantine(store: EventStore): void {
|
|
287
|
+
store.exec(
|
|
288
|
+
`CREATE TABLE IF NOT EXISTS _quarantine(event_id TEXT PRIMARY KEY, reason TEXT NOT NULL, ts INTEGER NOT NULL, event TEXT NOT NULL)`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export function isQuarantined(store: EventStore, id: string): boolean {
|
|
293
|
+
ensureQuarantine(store);
|
|
294
|
+
return store.query(`SELECT 1 AS n FROM _quarantine WHERE event_id = ? LIMIT 1`, [id]).length > 0;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function listQuarantine(store: EventStore): QuarantineRow[] {
|
|
298
|
+
ensureQuarantine(store);
|
|
299
|
+
return store.query<QuarantineRow>(`SELECT event_id, reason, ts, event FROM _quarantine ORDER BY ts, event_id`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Record evidence + purge domain read views. Keeps the log line and the
|
|
303
|
+
* `_events` row (reopen replay stays a no-op by UUID). Returns true when newly quarantined. */
|
|
304
|
+
function quarantineOne(store: EventStore, ev: LogEvent, reason: string): boolean {
|
|
305
|
+
ensureQuarantine(store);
|
|
306
|
+
const known = store.query(`SELECT 1 AS n FROM _quarantine WHERE event_id = ? LIMIT 1`, [ev.id]).length > 0;
|
|
307
|
+
const moves = store.query<{ n: number }>(`SELECT COUNT(*) AS n FROM tally_moves WHERE event_id = ?`, [ev.id]);
|
|
308
|
+
// Atomic like store.apply: evidence row + view purges + tally rebuild commit
|
|
309
|
+
// together, so a kill mid-quarantine can neither lose evidence nor leave
|
|
310
|
+
// half-purged views. Same connection via store.exec, never nested inside
|
|
311
|
+
// another tx (callers only invoke this after apply rolled back).
|
|
312
|
+
store.exec('BEGIN IMMEDIATE');
|
|
313
|
+
try {
|
|
314
|
+
store.query(`INSERT OR IGNORE INTO _quarantine(event_id, reason, ts, event) VALUES(?,?,?,?)`, [
|
|
315
|
+
ev.id,
|
|
316
|
+
reason,
|
|
317
|
+
Date.now(),
|
|
318
|
+
JSON.stringify(ev),
|
|
319
|
+
]);
|
|
320
|
+
store.query(`DELETE FROM entries WHERE event_id = ?`, [ev.id]);
|
|
321
|
+
store.query(`DELETE FROM tally_moves WHERE event_id = ?`, [ev.id]);
|
|
322
|
+
store.query(`DELETE FROM records WHERE event_id = ?`, [ev.id]);
|
|
323
|
+
if ((moves[0]?.n ?? 0) > 0) {
|
|
324
|
+
// Balances derive from moves: rebuild so quarantined tally stops counting.
|
|
325
|
+
store.exec(`DELETE FROM tally`);
|
|
326
|
+
store.exec(`INSERT INTO tally(item, qty) SELECT item, SUM(qty) FROM tally_moves WHERE voided = 0 GROUP BY item`);
|
|
327
|
+
}
|
|
328
|
+
store.exec('COMMIT');
|
|
329
|
+
} catch (err) {
|
|
330
|
+
try {
|
|
331
|
+
store.exec('ROLLBACK');
|
|
332
|
+
} catch {
|
|
333
|
+
/* already rolled back */
|
|
334
|
+
}
|
|
335
|
+
throw err;
|
|
336
|
+
}
|
|
337
|
+
return !known;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const PURGE_CURSOR_KEY = 'sync.purge_seq'; // local log seq swept by purgeRevoked
|
|
341
|
+
const PURGE_FP_KEY = 'sync.purge_revoke_fp'; // revoke fingerprint of the last sweep
|
|
342
|
+
|
|
343
|
+
/** Last local log seq swept by purgeRevoked (0 when never swept). */
|
|
344
|
+
export function getPurgeSeq(store: EventStore): number {
|
|
345
|
+
return Number(store.getMeta(PURGE_CURSOR_KEY) ?? 0);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Fingerprint of the device-set half of the revoke signal, stable across
|
|
349
|
+
* Set/array shapes so steady-state pulls hit the incremental path. */
|
|
350
|
+
function revokeDevicesFingerprint(opts: SyncOpts): string {
|
|
351
|
+
const rd = opts.revokedDevices;
|
|
352
|
+
if (!rd) return '';
|
|
353
|
+
return [...(rd instanceof Set ? rd : rd)].sort().join(',');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Fingerprint of the whole revoke signal, or null when it is opaque: an
|
|
357
|
+
* isRevoked predicate without revokeVersion may close over mutated revoke
|
|
358
|
+
* state no string can see, so it always rescans. */
|
|
359
|
+
function purgeFingerprint(opts: SyncOpts): string | null {
|
|
360
|
+
if (typeof opts.isRevoked === 'function' && opts.revokeVersion === undefined) return null;
|
|
361
|
+
return `${revokeDevicesFingerprint(opts)}|${opts.revokeVersion ?? ''}`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Retroactive sweep: quarantine every logged event the revoke set/predicate
|
|
365
|
+
* matches and purge it from the domain read views. The log file is untouched.
|
|
366
|
+
* Run after merging a RevokeLog (or receiving a relay tombstone) so pre-revoke
|
|
367
|
+
* data stops serving. Idempotent: re-sweeps quarantine nothing new.
|
|
368
|
+
*
|
|
369
|
+
* Incremental: a (swept-seq cursor, revoke fingerprint) pair persists in store
|
|
370
|
+
* meta, so steady-state pulls under unchanged revoke state scan only the new
|
|
371
|
+
* log suffix instead of re-reading the whole log. Grown revoke state (or an
|
|
372
|
+
* unversioned predicate) falls back to a full rescan, so newly-tainted prefix
|
|
373
|
+
* lines still purge. */
|
|
374
|
+
export function purgeRevoked(log: AppendLog, store: EventStore, opts: SyncOpts = {}): PurgeResult {
|
|
375
|
+
ensureQuarantine(store);
|
|
376
|
+
const fp = purgeFingerprint(opts);
|
|
377
|
+
const base = fp !== null && store.getMeta(PURGE_FP_KEY) === fp ? Number(store.getMeta(PURGE_CURSOR_KEY) ?? 0) : 0;
|
|
378
|
+
let scanned = 0;
|
|
379
|
+
let quarantined = 0;
|
|
380
|
+
let frontier = base;
|
|
381
|
+
for (const ev of log.readAfter(base)) {
|
|
382
|
+
scanned += 1;
|
|
383
|
+
if (ev.seq > frontier) frontier = ev.seq;
|
|
384
|
+
const reason = revokeReason(ev, opts);
|
|
385
|
+
if (!reason) continue;
|
|
386
|
+
if (quarantineOne(store, ev, reason)) quarantined += 1;
|
|
387
|
+
}
|
|
388
|
+
store.setMeta(PURGE_CURSOR_KEY, String(frontier));
|
|
389
|
+
if (fp !== null) store.setMeta(PURGE_FP_KEY, fp);
|
|
390
|
+
return { scanned, quarantined };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** True when the caller carries revoke state worth a retroactive sweep. */
|
|
394
|
+
function hasRevokeSignal(opts: SyncOpts): boolean {
|
|
395
|
+
const rd = opts.revokedDevices;
|
|
396
|
+
if (rd && (rd instanceof Set ? rd.size : rd.length) > 0) return true;
|
|
397
|
+
return typeof opts.isRevoked === 'function';
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function applyPullEvents(
|
|
401
|
+
log: AppendLog,
|
|
402
|
+
store: EventStore,
|
|
403
|
+
deviceId: string,
|
|
404
|
+
events: LogEvent[],
|
|
405
|
+
cursor: number,
|
|
406
|
+
opts: SyncOpts = {},
|
|
407
|
+
cursorKey = PULL_CURSOR_KEY,
|
|
408
|
+
): { applied: number; quarantined: number } {
|
|
409
|
+
const registry = registryOf(opts.trustedDevices);
|
|
410
|
+
let applied = 0;
|
|
411
|
+
let quarantined = 0;
|
|
412
|
+
let storedAll = true;
|
|
413
|
+
for (const remote of events) {
|
|
414
|
+
// Fail-fast gate (mirrors kernel append): a poison event must never
|
|
415
|
+
// touch the local log nor pin the pull cursor. Shape + checkAppend run
|
|
416
|
+
// BEFORE log.append; dead-letters are skipped while the cursor below
|
|
417
|
+
// still advances past them, so one bad write can never brick sync.
|
|
418
|
+
if (!remote || typeof remote.id !== 'string' || remote.id === '') continue;
|
|
419
|
+
if (store.hasId(remote.id)) {
|
|
420
|
+
// Retroactive catch: the revoke landed after this event converged.
|
|
421
|
+
// Purge it from the read views now (evidence stays in _quarantine).
|
|
422
|
+
const reason = revokeReason(remote, opts);
|
|
423
|
+
if (reason) {
|
|
424
|
+
const stored = store.getEventById(remote.id);
|
|
425
|
+
if (quarantineOne(store, stored ?? remote, reason)) quarantined += 1;
|
|
426
|
+
}
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
if (log.hasId(remote.id)) {
|
|
430
|
+
// Logged on an earlier run but never durably stored (kill between
|
|
431
|
+
// log.append and store.apply). Re-drive the stored copy instead of
|
|
432
|
+
// minting a duplicate log line; a deterministically un-storable event
|
|
433
|
+
// dead-letters (evidence quarantined, cursor advances) so one poison
|
|
434
|
+
// event never pins the pull cursor and starves the batch behind it.
|
|
435
|
+
const pending = log.getById(remote.id);
|
|
436
|
+
if (pending === null) {
|
|
437
|
+
storedAll = false;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
// Revoked while parked: quarantine instead of re-driving into the views.
|
|
441
|
+
const parkedReason = revokeReason(pending, opts);
|
|
442
|
+
if (parkedReason) {
|
|
443
|
+
if (quarantineOne(store, pending, parkedReason)) quarantined += 1;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
try {
|
|
447
|
+
store.apply(pending);
|
|
448
|
+
} catch (err) {
|
|
449
|
+
const reason = `pull re-drive apply failed for ${pending.id}: ${err instanceof Error ? err.message : String(err)}`;
|
|
450
|
+
try {
|
|
451
|
+
if (quarantineOne(store, pending, reason)) quarantined += 1;
|
|
452
|
+
} catch {
|
|
453
|
+
storedAll = false; // evidence itself unwritable: hold, retry next run
|
|
454
|
+
}
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
applied += 1;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
try {
|
|
461
|
+
if (!remote.type || typeof remote.type !== 'string') throw new Error('pull: event without type');
|
|
462
|
+
const payload = (remote.payload ?? {}) as Record<string, unknown>;
|
|
463
|
+
if (typeof payload !== 'object' || payload === null) throw new Error('pull: payload must be an object');
|
|
464
|
+
checkAppend(remote.type, payload);
|
|
465
|
+
} catch {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
// Forgery laundering gate: the relay stores verbatim (simple by design),
|
|
469
|
+
// so anyone can stash an "entry 1000000 as budi". Verify the ORIGIN hash
|
|
470
|
+
// before the local re-hash below mints a clean copy. Forged events are
|
|
471
|
+
// dead-lettered (skipped, cursor still advances past them).
|
|
472
|
+
if (!verifyPullAuth(remote, registry, opts.highValue)) continue;
|
|
473
|
+
// Revoke gate: a tainted pull quarantines (evidence recorded, cursor
|
|
474
|
+
// advances) instead of converging blindly into the read views.
|
|
475
|
+
const reason = revokeReason(remote, opts);
|
|
476
|
+
if (reason) {
|
|
477
|
+
if (quarantineOne(store, remote, reason)) quarantined += 1;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const ev = log.append({
|
|
481
|
+
type: remote.type,
|
|
482
|
+
payload: (remote.payload ?? {}) as Record<string, unknown>,
|
|
483
|
+
actor: remote.actor,
|
|
484
|
+
device_id: deviceId,
|
|
485
|
+
id: remote.id,
|
|
486
|
+
ts_device: remote.ts_device, // origin stamp kept as display metadata; order stays local
|
|
487
|
+
origin_seq: remote.seq,
|
|
488
|
+
origin_device: remote.device_id,
|
|
489
|
+
});
|
|
490
|
+
try {
|
|
491
|
+
store.apply(ev);
|
|
492
|
+
} catch (err) {
|
|
493
|
+
// Fsynced in the log but deterministically un-storable: dead-letter
|
|
494
|
+
// (evidence quarantined, cursor advances) instead of holding the pull
|
|
495
|
+
// cursor forever. The truncate clamp still guards the log bytes.
|
|
496
|
+
const reason = `pull apply failed for ${ev.id}: ${err instanceof Error ? err.message : String(err)}`;
|
|
497
|
+
try {
|
|
498
|
+
if (quarantineOne(store, ev, reason)) quarantined += 1;
|
|
499
|
+
} catch {
|
|
500
|
+
storedAll = false; // evidence itself unwritable: hold, retry next run
|
|
501
|
+
}
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
applied += 1;
|
|
505
|
+
}
|
|
506
|
+
if (events.length && storedAll) store.setMeta(cursorKey, String(cursor));
|
|
507
|
+
return { applied, quarantined };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
export interface PushResult {
|
|
512
|
+
pushed: number;
|
|
513
|
+
acked: number;
|
|
514
|
+
serverTime: number | null;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Push pending events (seq > ack cursor) in chunks; cursor persists per chunk. */
|
|
518
|
+
export async function pushPending(
|
|
519
|
+
log: AppendLog,
|
|
520
|
+
store: EventStore,
|
|
521
|
+
relay: Relay,
|
|
522
|
+
opts: SyncOpts = {},
|
|
523
|
+
): Promise<PushResult> {
|
|
524
|
+
const chunkSize = opts.chunkSize ?? 10;
|
|
525
|
+
let cursor = getAckSeq(store);
|
|
526
|
+
let pushed = 0;
|
|
527
|
+
let acked = 0;
|
|
528
|
+
let serverTime: number | null = getServerTime(store);
|
|
529
|
+
for (;;) {
|
|
530
|
+
const batch = log.readAfter(cursor).slice(0, chunkSize);
|
|
531
|
+
if (batch.length === 0) break;
|
|
532
|
+
const ack = await withBackoff(() => relay.push(batch), opts);
|
|
533
|
+
const { advanced, acked: n } = applyPushAck(store, batch, ack, cursor);
|
|
534
|
+
acked += n;
|
|
535
|
+
pushed += batch.length;
|
|
536
|
+
serverTime = ack.server_time;
|
|
537
|
+
cursor = advanced;
|
|
538
|
+
if (advanced < batch[batch.length - 1].seq) break; // partial ack: stop, resume next run
|
|
539
|
+
}
|
|
540
|
+
return { pushed, acked, serverTime };
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export interface PullResult {
|
|
544
|
+
pulled: number;
|
|
545
|
+
applied: number;
|
|
546
|
+
/** Tainted pull events quarantined instead of applied (evidence in _quarantine). */
|
|
547
|
+
quarantined: number;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Pull remote events; apply idempotently by UUID under fresh local seq. */
|
|
551
|
+
export async function pullRemote(
|
|
552
|
+
log: AppendLog,
|
|
553
|
+
store: EventStore,
|
|
554
|
+
relay: Relay,
|
|
555
|
+
deviceId: string,
|
|
556
|
+
opts: SyncOpts = {},
|
|
557
|
+
): Promise<PullResult> {
|
|
558
|
+
const since = Number(store.getMeta(PULL_CURSOR_KEY) ?? 0);
|
|
559
|
+
const { events, cursor } = await withBackoff(() => relay.pull(since), opts);
|
|
560
|
+
const { applied, quarantined } = applyPullEvents(log, store, deviceId, events, cursor, opts);
|
|
561
|
+
// Retroactive leg: revokes that landed after convergence purge here, so the
|
|
562
|
+
// kernel surface needs no extra call. Runs only when revoke state is present.
|
|
563
|
+
const swept = hasRevokeSignal(opts) ? purgeRevoked(log, store, opts).quarantined : 0;
|
|
564
|
+
return { pulled: events.length, applied, quarantined: quarantined + swept };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
export async function syncKernel(
|
|
568
|
+
log: AppendLog,
|
|
569
|
+
store: EventStore,
|
|
570
|
+
relay: Relay,
|
|
571
|
+
deviceId: string,
|
|
572
|
+
opts: SyncOpts = {},
|
|
573
|
+
): Promise<PushResult & PullResult> {
|
|
574
|
+
const push = await pushPending(log, store, relay, opts);
|
|
575
|
+
const pull = await pullRemote(log, store, relay, deviceId, opts);
|
|
576
|
+
return { ...push, ...pull };
|
|
577
|
+
}
|
|
578
|
+
// Relay failover: try relays in list order per chunk, stick to the first
|
|
579
|
+
// healthy one, park failures on backoff and re-probe them later. A chunk is
|
|
580
|
+
// always served by exactly one relay; cursors stay per-chunk so resume is
|
|
581
|
+
// exact-once by UUID like the single-relay path.
|
|
582
|
+
export interface FailoverState {
|
|
583
|
+
fails: number[];
|
|
584
|
+
notBefore: number[];
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function createFailoverState(n: number): FailoverState {
|
|
588
|
+
return { fails: Array(n).fill(0), notBefore: Array(n).fill(0) };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
export interface FailoverResult extends PushResult, PullResult {
|
|
592
|
+
/** Index of the relay that served the last push chunk (-1 when nothing pushed). */
|
|
593
|
+
pushRelay: number;
|
|
594
|
+
/** Index of the relay that served the pull (-1 when pull never succeeded). */
|
|
595
|
+
pullRelay: number;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function failoverNoteFailure(state: FailoverState, i: number, opts: SyncOpts): void {
|
|
599
|
+
state.fails[i] += 1;
|
|
600
|
+
state.notBefore[i] = Date.now() + backoffMs(state.fails[i] - 1, opts.baseMs ?? 200, opts.maxMs ?? 30_000, resolveJitter(opts.jitter));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** List order first, relays still on backoff last (re-probed once cooled down). */
|
|
604
|
+
function failoverOrder(n: number, state: FailoverState, now: number): number[] {
|
|
605
|
+
const fresh: number[] = [];
|
|
606
|
+
const cooling: number[] = [];
|
|
607
|
+
for (let i = 0; i < n; i++) (now < state.notBefore[i] ? cooling : fresh).push(i);
|
|
608
|
+
return [...fresh, ...cooling];
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async function failoverPushOne(
|
|
612
|
+
log: AppendLog,
|
|
613
|
+
store: EventStore,
|
|
614
|
+
relays: Relay[],
|
|
615
|
+
batch: LogEvent[],
|
|
616
|
+
cursor: number,
|
|
617
|
+
opts: SyncOpts,
|
|
618
|
+
state: FailoverState,
|
|
619
|
+
): Promise<{ advanced: number; acked: number; serverTime: number; relay: number }> {
|
|
620
|
+
const maxPasses = (opts.maxRetries ?? 5) + 1;
|
|
621
|
+
let lastErr: unknown = null;
|
|
622
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
623
|
+
let skipped = 0;
|
|
624
|
+
let tried = 0;
|
|
625
|
+
let permanent = 0;
|
|
626
|
+
for (const i of failoverOrder(relays.length, state, Date.now())) {
|
|
627
|
+
if (Date.now() < state.notBefore[i]) {
|
|
628
|
+
skipped += 1;
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
try {
|
|
632
|
+
const ack = await relays[i].push(batch);
|
|
633
|
+
const { advanced, acked } = applyPushAck(store, batch, ack, cursor);
|
|
634
|
+
state.fails[i] = 0;
|
|
635
|
+
state.notBefore[i] = 0;
|
|
636
|
+
return { advanced, acked, serverTime: ack.server_time, relay: i };
|
|
637
|
+
} catch (err) {
|
|
638
|
+
lastErr = err;
|
|
639
|
+
tried += 1;
|
|
640
|
+
if (isPermanentSyncError(err)) permanent += 1;
|
|
641
|
+
failoverNoteFailure(state, i, opts);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
// Every relay rejects permanently (auth/cursor): re-probing after backoff
|
|
645
|
+
// cannot heal it. Fail fast instead of sleeping through every pass.
|
|
646
|
+
if (tried > 0 && skipped === 0 && permanent === tried) break;
|
|
647
|
+
if (pass + 1 >= maxPasses) break;
|
|
648
|
+
// All failed or still cooling: wait out the shortest backoff, then re-probe.
|
|
649
|
+
const wait = skipped > 0
|
|
650
|
+
? Math.max(0, Math.min(...state.notBefore) - Date.now())
|
|
651
|
+
: backoffMs(pass, opts.baseMs ?? 200, opts.maxMs ?? 30_000, resolveJitter(opts.jitter));
|
|
652
|
+
if (wait > 0) {
|
|
653
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
654
|
+
setTimeout(resolve, wait);
|
|
655
|
+
await promise;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
throw lastErr instanceof Error ? lastErr : new Error(`all ${relays.length} relays failed`);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
export async function syncWithFailover(
|
|
662
|
+
log: AppendLog,
|
|
663
|
+
store: EventStore,
|
|
664
|
+
relays: Relay[],
|
|
665
|
+
deviceId: string,
|
|
666
|
+
opts: SyncOpts = {},
|
|
667
|
+
state?: FailoverState,
|
|
668
|
+
): Promise<FailoverResult> {
|
|
669
|
+
if (relays.length === 0) throw new Error('sync needs at least one relay');
|
|
670
|
+
const st = state ?? createFailoverState(relays.length);
|
|
671
|
+
while (st.fails.length < relays.length) {
|
|
672
|
+
st.fails.push(0);
|
|
673
|
+
st.notBefore.push(0);
|
|
674
|
+
}
|
|
675
|
+
const chunkSize = opts.chunkSize ?? 10;
|
|
676
|
+
let cursor = getAckSeq(store);
|
|
677
|
+
const runStart = cursor;
|
|
678
|
+
let pushed = 0;
|
|
679
|
+
let acked = 0;
|
|
680
|
+
let serverTime: number | null = getServerTime(store);
|
|
681
|
+
let pushRelay = -1;
|
|
682
|
+
|
|
683
|
+
for (;;) {
|
|
684
|
+
const batch = log.readAfter(cursor).slice(0, chunkSize);
|
|
685
|
+
if (batch.length === 0) break;
|
|
686
|
+
const chunkStart = cursor;
|
|
687
|
+
const one = await failoverPushOne(log, store, relays, batch, cursor, opts, st);
|
|
688
|
+
if (pushRelay >= 0 && one.relay !== pushRelay) {
|
|
689
|
+
// Relay switch after acked chunks would stripe one log across relays
|
|
690
|
+
// (chunk 1 on A, rest on B) and a client reading a single relay then
|
|
691
|
+
// misses the other part with success status. Re-push this run's acked
|
|
692
|
+
// prefix to the new relay (idempotent by UUID) so the newest relay
|
|
693
|
+
// always ends complete. Loud on failure: the retry re-drives it.
|
|
694
|
+
// Seqs are positions, not counts: past a truncate gap `chunkStart -
|
|
695
|
+
// runStart` overshoots the acked prefix length and re-pushes the current
|
|
696
|
+
// chunk. Bound by seq instead: every event at/below the pre-chunk cursor.
|
|
697
|
+
const prefix = log.readAfter(runStart).filter((e) => e.seq <= chunkStart);
|
|
698
|
+
for (let off = 0; off < prefix.length; off += chunkSize) {
|
|
699
|
+
const b = prefix.slice(off, off + chunkSize);
|
|
700
|
+
const pre = off === 0 ? runStart : prefix[off - 1].seq;
|
|
701
|
+
const ack = await withBackoff(() => relays[one.relay].push(b), opts);
|
|
702
|
+
applyPushAck(store, b, ack, pre);
|
|
703
|
+
}
|
|
704
|
+
// Backfill writes the prefix cursor, which sits behind this run's
|
|
705
|
+
// frontier: re-assert it so the persisted ack never moves backwards.
|
|
706
|
+
store.setMeta(ACK_SEQ_KEY, String(one.advanced));
|
|
707
|
+
}
|
|
708
|
+
pushed += batch.length;
|
|
709
|
+
acked += one.acked;
|
|
710
|
+
serverTime = one.serverTime;
|
|
711
|
+
cursor = one.advanced;
|
|
712
|
+
pushRelay = one.relay;
|
|
713
|
+
if (one.advanced < batch[batch.length - 1].seq) break; // partial ack: stop, resume next run
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Pull from EVERY healthy relay with a per-relay cursor. A single shared
|
|
717
|
+
// cursor pins readers to the first relay's coordinates: when list order
|
|
718
|
+
// puts a stale replica first, the suffix living only on fresher replicas
|
|
719
|
+
// is missed with success status. Per-relay cursors (seeded once from the
|
|
720
|
+
// legacy shared cursor) plus UUID-idempotent apply close it; own echoes
|
|
721
|
+
// apply nothing twice.
|
|
722
|
+
let pullRelay = -1;
|
|
723
|
+
let pulled = 0;
|
|
724
|
+
let applied = 0;
|
|
725
|
+
let quarantined = 0;
|
|
726
|
+
const maxPasses = (opts.maxRetries ?? 5) + 1;
|
|
727
|
+
let lastErr: unknown = null;
|
|
728
|
+
for (let pass = 0; pass < maxPasses; pass++) {
|
|
729
|
+
let skipped = 0;
|
|
730
|
+
let tried = 0;
|
|
731
|
+
let permanent = 0;
|
|
732
|
+
let done = false;
|
|
733
|
+
for (const i of failoverOrder(relays.length, st, Date.now())) {
|
|
734
|
+
if (Date.now() < st.notBefore[i]) {
|
|
735
|
+
skipped += 1;
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
const key = `${PULL_CURSOR_KEY}.r${i}`;
|
|
740
|
+
const since = Number(store.getMeta(key) ?? store.getMeta(PULL_CURSOR_KEY) ?? 0);
|
|
741
|
+
const res = await relays[i].pull(since);
|
|
742
|
+
const out = applyPullEvents(log, store, deviceId, res.events, res.cursor, opts, key);
|
|
743
|
+
applied += out.applied;
|
|
744
|
+
quarantined += out.quarantined;
|
|
745
|
+
pulled += res.events.length;
|
|
746
|
+
if (pullRelay < 0) pullRelay = i;
|
|
747
|
+
st.fails[i] = 0;
|
|
748
|
+
st.notBefore[i] = 0;
|
|
749
|
+
done = true;
|
|
750
|
+
} catch (err) {
|
|
751
|
+
lastErr = err;
|
|
752
|
+
tried += 1;
|
|
753
|
+
if (isPermanentSyncError(err)) permanent += 1;
|
|
754
|
+
failoverNoteFailure(st, i, opts);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (done) break;
|
|
758
|
+
// Every relay rejects permanently: fail fast, the throw below reports it.
|
|
759
|
+
if (tried > 0 && skipped === 0 && permanent === tried) break;
|
|
760
|
+
if (pass + 1 >= maxPasses) break;
|
|
761
|
+
const wait = skipped > 0
|
|
762
|
+
? Math.max(0, Math.min(...st.notBefore) - Date.now())
|
|
763
|
+
: backoffMs(pass, opts.baseMs ?? 200, opts.maxMs ?? 30_000, resolveJitter(opts.jitter));
|
|
764
|
+
if (wait > 0) {
|
|
765
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
766
|
+
setTimeout(resolve, wait);
|
|
767
|
+
await promise;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
if (pullRelay < 0) throw lastErr instanceof Error ? lastErr : new Error(`all ${relays.length} relays failed`);
|
|
771
|
+
if (hasRevokeSignal(opts)) quarantined += purgeRevoked(log, store, opts).quarantined;
|
|
772
|
+
return { pushed, acked, serverTime, pulled, applied, quarantined, pushRelay, pullRelay };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// In-memory relay for tests and local dev. Replaceable in ~50 lines.
|
|
776
|
+
export class MemoryRelay implements Relay {
|
|
777
|
+
private byId = new Map<string, LogEvent>();
|
|
778
|
+
private order: LogEvent[] = [];
|
|
779
|
+
serverTime: number;
|
|
780
|
+
/** Fail the next N pushes (transient outage). */
|
|
781
|
+
failPushes = 0;
|
|
782
|
+
/** Fail a push after accepting the first N events of the batch (mid-batch cut). */
|
|
783
|
+
failAfterEvents: number | null = null;
|
|
784
|
+
pushesReceived = 0;
|
|
785
|
+
|
|
786
|
+
constructor(serverTime = 1_700_000_000_000) {
|
|
787
|
+
this.serverTime = serverTime;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
get size(): number {
|
|
791
|
+
return this.byId.size;
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
async push(batch: LogEvent[]): Promise<PushAck> {
|
|
795
|
+
this.pushesReceived += 1;
|
|
796
|
+
if (this.failPushes > 0) {
|
|
797
|
+
this.failPushes -= 1;
|
|
798
|
+
throw new Error('relay unavailable (injected failure)');
|
|
799
|
+
}
|
|
800
|
+
if (this.failAfterEvents !== null && this.failAfterEvents <= 0) {
|
|
801
|
+
throw new Error('relay cut mid-batch (injected failure)');
|
|
802
|
+
}
|
|
803
|
+
const acked: string[] = [];
|
|
804
|
+
for (const ev of batch) {
|
|
805
|
+
if (this.failAfterEvents !== null) {
|
|
806
|
+
if (this.failAfterEvents <= 0) break; // connection dropped: rest unacked
|
|
807
|
+
this.failAfterEvents -= 1;
|
|
808
|
+
}
|
|
809
|
+
if (!this.byId.has(ev.id)) {
|
|
810
|
+
this.byId.set(ev.id, ev);
|
|
811
|
+
this.order.push(ev);
|
|
812
|
+
}
|
|
813
|
+
acked.push(ev.id); // idempotent: re-push of a known UUID still acks
|
|
814
|
+
}
|
|
815
|
+
if (this.failAfterEvents !== null && acked.length < batch.length) {
|
|
816
|
+
// Persist what arrived, then report the cut so the client resumes.
|
|
817
|
+
throw new Error(`relay cut mid-batch after ${acked.length}/${batch.length}`);
|
|
818
|
+
}
|
|
819
|
+
this.serverTime += 1; // HLC-ish tick: server time moves on every ack
|
|
820
|
+
return { acked, server_time: this.serverTime };
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
async pull(sinceRelaySeq: number): Promise<{ events: LogEvent[]; cursor: number }> {
|
|
824
|
+
const events = this.order.slice(sinceRelaySeq);
|
|
825
|
+
// Positional cursor: the relay only promises an ordered, replayable log.
|
|
826
|
+
return { events: [...events], cursor: this.order.length };
|
|
827
|
+
}
|
|
828
|
+
}
|