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/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
+ }