@openlfcp/client 0.1.0-rc.1

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.
@@ -0,0 +1,1032 @@
1
+ import { actorSequence, dataEpoch, LfcpError, toHex, } from "@openlfcp/core";
2
+ import { exportSecretKeyBytes, sha256 } from "@openlfcp/crypto";
3
+ import { dekSecretRef } from "@openlfcp/storage";
4
+ import { addRange, addSequence, batchDataRanges, canonicalFrontierToCbor, createMessage, ERROR_CODE, hasSequence, localControlOf, MESSAGE_TYPE, missingFrom, normalizeLiveHaves, parseControlRecord, parseDataUnit, planControlSync, receiveKeyPackage, receiveSnapshot, validateControlChain, } from "@openlfcp/wire";
5
+ import { encode } from "@openlfcp/wire/cbor";
6
+ import { LfcpConnection } from "./connection.js";
7
+ import { EngineGuard, isEngineTrap, snapshotItem } from "./engine-guard.js";
8
+ import { resourceSyncState, snapshotFrontier } from "./outbound.js";
9
+ import { createQueuedSnapshot } from "./queue.js";
10
+ import { resourcePhaseTransition, } from "./resource-state.js";
11
+ import { adoptStoredDeks, dekResolver, loadControlChain, saveControlChain, saveControlConflict, } from "./storage.js";
12
+ /** 1 s, doubling, at most 60 s, forever. */
13
+ export const defaultReconnect = (attempt) => Math.min(60_000, 1000 * 2 ** (attempt - 1));
14
+ const NACK_NAME = new Map(Object.entries(ERROR_CODE).map(([k, v]) => [v, k]));
15
+ const SUBSCRIBE_DATA_AND_CONTROL = 3n;
16
+ const iso = (ms) => new Date(ms).toISOString();
17
+ const haveToLive = (vector) => vector.map((h) => ({
18
+ principalId: h.principalId,
19
+ contiguous: h.contiguous,
20
+ ...(h.extras.length > 0 ? { ranges: h.extras } : {}),
21
+ }));
22
+ export class SyncClient {
23
+ #o;
24
+ #connection;
25
+ #resources = new Map();
26
+ #requests = new Map();
27
+ /** When each request was sent (caller's clock), for the request timeout. */
28
+ #sentAt = new Map();
29
+ #requestTimeoutMs;
30
+ #listeners = new Set();
31
+ #antiEntropyMs;
32
+ /** The crash-loop breaker for Snapshot loads (EngineGuard). */
33
+ #snapshots;
34
+ #stopped = true;
35
+ /** The profile engine trapped: nothing more is processed (ENGINE_TRAP). */
36
+ #trapped = false;
37
+ #attempt = 0;
38
+ #reconnectAt = null;
39
+ /** Serializes message handling: each message is handled after the previous one finished. */
40
+ #queue = Promise.resolve();
41
+ constructor(options) {
42
+ this.#o = options;
43
+ this.#snapshots = new EngineGuard(options.storage, "snapshots");
44
+ this.#antiEntropyMs = options.antiEntropyMs ?? 30_000;
45
+ this.#requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
46
+ this.#connection = new LfcpConnection({
47
+ url: options.url,
48
+ signer: options.signer,
49
+ now: options.now,
50
+ ...(options.webSocket === undefined ? {} : { webSocket: options.webSocket }),
51
+ ...(options.credential === undefined ? {} : { credential: options.credential }),
52
+ ...(options.dataProfiles === undefined ? {} : { dataProfiles: options.dataProfiles }),
53
+ }, {
54
+ state: (state) => this.#emit({ type: "connection", state }),
55
+ ready: (ready) => this.#serial(() => this.#onReady(ready)),
56
+ message: (m) => this.#serial(() => this.#onMessage(m)),
57
+ closed: (reason) => this.#serial(() => this.#onClosed(reason)),
58
+ });
59
+ }
60
+ /** Subscribes to session events; returns the unsubscribe function. */
61
+ on(listener) {
62
+ this.#listeners.add(listener);
63
+ return () => this.#listeners.delete(listener);
64
+ }
65
+ #emit(event) {
66
+ for (const l of this.#listeners)
67
+ l(event);
68
+ }
69
+ #serial(fn) {
70
+ this.#queue = this.#queue
71
+ .then(() => (this.#trapped ? undefined : fn()))
72
+ .catch((e) => {
73
+ if (!isEngineTrap(e)) {
74
+ this.#emit({
75
+ type: "error",
76
+ code: "INTERNAL",
77
+ message: e instanceof Error ? e.message : String(e),
78
+ });
79
+ return;
80
+ }
81
+ // The profile engine trapped: in this process it is gone for good.
82
+ // Stop once, loudly; the crash-loop breaker takes over at the next start.
83
+ if (this.#trapped)
84
+ return;
85
+ this.#trapped = true;
86
+ this.#stopped = true;
87
+ this.#reconnectAt = null;
88
+ this.#connection.close("the profile engine trapped");
89
+ this.#emit({
90
+ type: "error",
91
+ code: "ENGINE_TRAP",
92
+ message: `the profile engine trapped (${e instanceof Error ? e.message : String(e)}); this process must restart`,
93
+ });
94
+ });
95
+ }
96
+ /** Resolves once every message received so far has been handled (tests and shutdown). */
97
+ idle() {
98
+ return this.#queue;
99
+ }
100
+ get connectionState() {
101
+ return this.#connection.state;
102
+ }
103
+ resourceState(resource) {
104
+ return this.#resources.get(toHex(resource))?.state ?? "CLOSED";
105
+ }
106
+ /** Connects, and reconnects after losses (per the ReconnectPolicy) until stop(). */
107
+ start() {
108
+ if (this.#trapped)
109
+ return;
110
+ this.#stopped = false;
111
+ this.#reconnectAt = null;
112
+ this.#connection.connect();
113
+ }
114
+ /** Closes the connection and stops reconnecting. Local state stays usable. */
115
+ async stop() {
116
+ this.#stopped = true;
117
+ this.#reconnectAt = null;
118
+ this.#connection.close("stopped by the application");
119
+ await this.idle();
120
+ }
121
+ /** Registers a Resource and opens it now (or when the session is READY). */
122
+ open(binding) {
123
+ const key = toHex(binding.resourceId);
124
+ let ctx = this.#resources.get(key);
125
+ if (ctx === undefined) {
126
+ ctx = {
127
+ binding,
128
+ state: "CLOSED",
129
+ wanted: true,
130
+ view: null,
131
+ fetched: [],
132
+ controlTarget: null,
133
+ remoteHave: [],
134
+ expected: [],
135
+ received: [],
136
+ early: [],
137
+ lastHave: 0,
138
+ lastKeyRequest: 0,
139
+ lastHeads: [],
140
+ controlStale: false,
141
+ missingEpochs: [],
142
+ offered: null,
143
+ snapshotPending: false,
144
+ covered: [],
145
+ unitsSinceSnapshot: 0,
146
+ lastRound: "",
147
+ };
148
+ this.#resources.set(key, ctx);
149
+ }
150
+ ctx.wanted = true;
151
+ if (this.#connection.state === "READY")
152
+ this.#serial(() => this.#sendOpen(ctx));
153
+ }
154
+ /** RESOURCE_CLOSE (§43): no more pushes; local state stays usable. */
155
+ close(resource) {
156
+ const ctx = this.#resources.get(toHex(resource));
157
+ if (ctx === undefined)
158
+ return;
159
+ ctx.wanted = false;
160
+ if (ctx.state !== "CLOSED" && this.#connection.state === "READY") {
161
+ this.#request({ kind: "close", resource: toHex(resource) }, createMessage("RESOURCE_CLOSE", { resourceId: resource }));
162
+ }
163
+ this.#move(ctx, "CLOSE");
164
+ }
165
+ /** RESOURCE_HOST (§39): asks the server to host a Resource from its exact Genesis bytes; resolves with the durability applied. */
166
+ host(genesis, credential) {
167
+ return new Promise((resolve, reject) => {
168
+ try {
169
+ this.#request({ kind: "host", resolve, reject }, createMessage("RESOURCE_HOST", {
170
+ genesis,
171
+ ...(credential === undefined ? {} : { credential }),
172
+ }));
173
+ }
174
+ catch (e) {
175
+ reject(e);
176
+ }
177
+ });
178
+ }
179
+ /** Sends what the outbound queue has due for every Resource past KEY_SYNC (e.g. after a local write). */
180
+ flush() {
181
+ this.#serial(async () => {
182
+ for (const ctx of this.#resources.values())
183
+ await this.#flush(ctx);
184
+ });
185
+ }
186
+ /**
187
+ * The periodic work, on the caller's clock: heartbeat (§38), reconnect,
188
+ * anti-entropy DATA_HAVE for LIVE Resources (§69), Key Package retries,
189
+ * due outbound retries and debounced checkpoints.
190
+ */
191
+ tick(now) {
192
+ if (this.#connection.state === "DISCONNECTED") {
193
+ if (!this.#stopped && this.#reconnectAt !== null && now >= this.#reconnectAt) {
194
+ this.#reconnectAt = null;
195
+ this.#connection.connect();
196
+ }
197
+ return;
198
+ }
199
+ if (!this.#connection.tick(now))
200
+ return;
201
+ if (this.#connection.state === "READY")
202
+ this.#expireRequests(now);
203
+ this.#serial(async () => {
204
+ for (const ctx of this.#resources.values()) {
205
+ if (ctx.state === "LIVE" && now - ctx.lastHave >= this.#antiEntropyMs)
206
+ this.#sendHave(ctx, now);
207
+ if (ctx.state === "KEY_BLOCKED" && now - ctx.lastKeyRequest >= this.#antiEntropyMs)
208
+ this.#requestKeys(ctx, now);
209
+ await this.#flush(ctx);
210
+ await ctx.binding.checkpointer?.maybeFlush(now);
211
+ if (ctx.state === "LIVE" &&
212
+ ctx.binding.snapshot !== undefined &&
213
+ this.#o.snapshotPolicy?.(ctx.binding.resourceId, ctx.unitsSinceSnapshot) === true)
214
+ await this.#publish(ctx);
215
+ }
216
+ });
217
+ }
218
+ // -------------------------------------------------------------------------
219
+ // plumbing
220
+ #request(request, message) {
221
+ const key = toHex(message.messageId);
222
+ this.#requests.set(key, request);
223
+ this.#sentAt.set(key, this.#o.now());
224
+ this.#connection.send(message);
225
+ }
226
+ /**
227
+ * Requests unanswered for requestTimeoutMs on a live connection (§70: a
228
+ * lost request or reply): forgotten, and the step they belong to is
229
+ * issued again while its Resource is still in that phase. A late reply
230
+ * is harmless: units and records are idempotent.
231
+ */
232
+ #expireRequests(now) {
233
+ for (const key of this.#sentAt.keys())
234
+ if (!this.#requests.has(key))
235
+ this.#sentAt.delete(key);
236
+ const resend = new Map();
237
+ for (const [key, request] of this.#requests) {
238
+ const sent = this.#sentAt.get(key);
239
+ if (sent === undefined || now - sent < this.#requestTimeoutMs)
240
+ continue;
241
+ this.#requests.delete(key);
242
+ this.#sentAt.delete(key);
243
+ if (request.kind === "host") {
244
+ request.reject(new Error("RESOURCE_HOST got no answer within the request timeout"));
245
+ continue;
246
+ }
247
+ if ("resource" in request)
248
+ resend.set(`${request.kind}:${request.resource}`, request);
249
+ }
250
+ for (const request of resend.values()) {
251
+ if (!("resource" in request))
252
+ continue;
253
+ const ctx = this.#resources.get(request.resource);
254
+ if (ctx === undefined)
255
+ continue;
256
+ if (request.kind === "open" && ctx.state === "OPENING") {
257
+ this.#move(ctx, "CLOSE");
258
+ this.#serial(() => this.#sendOpen(ctx));
259
+ }
260
+ else if (request.kind === "control" && ctx.state === "CONTROL_SYNC") {
261
+ this.#serial(() => this.#controlRound(ctx, ctx.lastHeads));
262
+ }
263
+ else if (request.kind === "keys" && ctx.state === "KEY_SYNC") {
264
+ this.#requestKeys(ctx, now);
265
+ }
266
+ else if (request.kind === "snapshot" && ctx.snapshotPending) {
267
+ // A Snapshot is an optimization (§29.2): without it, replay the units.
268
+ this.#error("TIMEOUT", "the offered Snapshot did not arrive; replaying units instead", ctx.binding.resourceId);
269
+ this.#serial(() => this.#afterSnapshot(ctx));
270
+ }
271
+ else if (request.kind === "data-get" && ctx.state === "DATA_SYNC") {
272
+ ctx.lastRound = ""; // a lost reply is not a round without progress
273
+ ctx.expected = [];
274
+ this.#serial(() => this.#dataRound(ctx));
275
+ }
276
+ }
277
+ }
278
+ #move(ctx, event) {
279
+ const next = resourcePhaseTransition(ctx.state, event);
280
+ if (next === undefined)
281
+ return false;
282
+ ctx.state = next;
283
+ this.#emit({ type: "resource-state", resourceId: ctx.binding.resourceId, state: next });
284
+ if (next === "LIVE" && ctx.controlStale) {
285
+ ctx.controlStale = false;
286
+ this.#refreshControl(ctx);
287
+ }
288
+ else if (next === "CLOSED")
289
+ ctx.controlStale = false; // reopening syncs Control anyway
290
+ return true;
291
+ }
292
+ #ctx(resource) {
293
+ return this.#resources.get(toHex(resource));
294
+ }
295
+ #error(code, message, resourceId) {
296
+ this.#emit({
297
+ type: "error",
298
+ code,
299
+ message,
300
+ ...(resourceId === undefined ? {} : { resourceId }),
301
+ });
302
+ }
303
+ async #onReady(ready) {
304
+ this.#attempt = 0;
305
+ this.#o.outbound.session({
306
+ durability: ready.durability,
307
+ maxMessageBytes: ready.maxMessageBytes,
308
+ });
309
+ for (const ctx of this.#resources.values())
310
+ if (ctx.wanted)
311
+ await this.#sendOpen(ctx);
312
+ }
313
+ async #onClosed(reason) {
314
+ this.#emit({ type: "connection", state: "DISCONNECTED", reason });
315
+ for (const r of this.#requests.values())
316
+ if (r.kind === "host")
317
+ r.reject(new Error(`the connection closed before RESOURCE_HOSTED: ${reason}`));
318
+ this.#requests.clear();
319
+ this.#sentAt.clear();
320
+ for (const ctx of this.#resources.values()) {
321
+ this.#move(ctx, "CLOSE"); // §65, G-SM1: every Resource closes with the connection
322
+ ctx.view = null;
323
+ }
324
+ await this.#o.outbound.connectionLost(iso(this.#o.now()));
325
+ if (this.#stopped)
326
+ return;
327
+ this.#attempt += 1;
328
+ const delay = (this.#o.reconnect ?? defaultReconnect)(this.#attempt);
329
+ this.#reconnectAt = delay === null ? null : this.#o.now() + delay;
330
+ }
331
+ async #sendOpen(ctx) {
332
+ if (ctx.state !== "CLOSED")
333
+ return;
334
+ const R = ctx.binding.resourceId;
335
+ const chain = await loadControlChain(this.#o.storage, R);
336
+ const heads = chain?.kind === "linear" ? [{ seq: chain.state.seq, recordId: chain.state.head }] : [];
337
+ const have = (await resourceSyncState(this.#o.storage, R)).have;
338
+ ctx.fetched = [];
339
+ ctx.controlTarget = null;
340
+ ctx.early = [];
341
+ this.#request({ kind: "open", resource: toHex(R) }, createMessage("RESOURCE_OPEN", {
342
+ resourceId: R,
343
+ heads,
344
+ haves: haveToLive(have),
345
+ flags: SUBSCRIBE_DATA_AND_CONTROL,
346
+ }));
347
+ this.#move(ctx, "OPEN");
348
+ }
349
+ // -------------------------------------------------------------------------
350
+ // inbound
351
+ async #onMessage(m) {
352
+ const request = m.correlationId === undefined ? undefined : this.#requests.get(toHex(m.correlationId));
353
+ switch (m.type) {
354
+ case "RESOURCE_HOSTED":
355
+ if (request?.kind === "host") {
356
+ this.#requests.delete(toHex(m.correlationId));
357
+ request.resolve(m.body.durability);
358
+ }
359
+ return;
360
+ case "RESOURCE_OPENED": {
361
+ const ctx = this.#ctx(m.body.resourceId);
362
+ if (ctx === undefined || ctx.state !== "OPENING")
363
+ return;
364
+ this.#done(m);
365
+ ctx.remoteHave = normalizeLiveHaves(m.body.haves);
366
+ ctx.offered = m.body.snapshot ?? null;
367
+ this.#move(ctx, "OPENED");
368
+ await this.#controlRound(ctx, m.body.heads);
369
+ return;
370
+ }
371
+ case "CONTROL_HAVE": {
372
+ const ctx = this.#ctx(m.body.resourceId);
373
+ this.#done(m);
374
+ if (ctx !== undefined && ctx.state === "LIVE")
375
+ await this.#onControlHeads(ctx, m.body.heads);
376
+ return;
377
+ }
378
+ case "CONTROL_BATCH": {
379
+ const ctx = this.#ctx(m.body.resourceId);
380
+ if (ctx === undefined)
381
+ return;
382
+ const solicited = request?.kind === "control";
383
+ if (!solicited && ctx.state === "LIVE")
384
+ this.#move(ctx, "CONTROL_RECORD"); // a live push (§65)
385
+ if (ctx.state !== "CONTROL_SYNC") {
386
+ // A push during key or data sync: validated in place.
387
+ if (ctx.view !== null && !solicited)
388
+ await this.#onControlRecords(ctx, m.body.objects, true);
389
+ return;
390
+ }
391
+ await this.#onControlRecords(ctx, m.body.objects, solicited);
392
+ return;
393
+ }
394
+ case "KEY_PACKAGE_BATCH": {
395
+ const ctx = this.#ctx(m.body.resourceId);
396
+ this.#done(m);
397
+ if (ctx !== undefined)
398
+ await this.#onKeyPackages(ctx, m.body.objects);
399
+ return;
400
+ }
401
+ case "DATA_BATCH": {
402
+ const ctx = this.#ctx(m.body.resourceId);
403
+ if (ctx !== undefined)
404
+ await this.#onUnits(ctx, m.body.objects);
405
+ return;
406
+ }
407
+ case "SNAPSHOT": {
408
+ const ctx = this.#ctx(m.body.resourceId);
409
+ this.#done(m);
410
+ if (ctx?.snapshotPending)
411
+ await this.#onSnapshot(ctx, m.body.snapshot);
412
+ return;
413
+ }
414
+ case "DATA_HAVE": {
415
+ const ctx = this.#ctx(m.body.resourceId);
416
+ this.#done(m);
417
+ if (ctx === undefined)
418
+ return;
419
+ ctx.remoteHave = normalizeLiveHaves(m.body.haves);
420
+ if (ctx.state === "LIVE")
421
+ await this.#dataRound(ctx);
422
+ return;
423
+ }
424
+ case "ACK":
425
+ if (request !== undefined) {
426
+ this.#done(m);
427
+ return;
428
+ }
429
+ await this.#onAck(m);
430
+ return;
431
+ case "NACK":
432
+ if (request !== undefined) {
433
+ this.#done(m);
434
+ this.#onRequestNack(request, m);
435
+ return;
436
+ }
437
+ await this.#onNack(m);
438
+ return;
439
+ case "ERROR":
440
+ this.#error(`ERROR ${m.body.code}`, m.body.diagnostic ?? "the server reported an error");
441
+ return;
442
+ default:
443
+ return; // PONG and anything this client does not use
444
+ }
445
+ }
446
+ /** Forgets the request a reply answers (multi-batch replies keep it until the round ends). */
447
+ #done(m) {
448
+ if (m.correlationId !== undefined && m.type !== "CONTROL_BATCH" && m.type !== "DATA_BATCH")
449
+ this.#requests.delete(toHex(m.correlationId));
450
+ }
451
+ #onRequestNack(request, m) {
452
+ // The §62 name (e.g. AUTHORIZATION_FAILED), so applications can explain it; unknown codes keep their number.
453
+ const code = NACK_NAME.get(m.body.code) ?? `NACK ${m.body.code}`;
454
+ if (request.kind === "host") {
455
+ request.reject(new Error(`RESOURCE_HOST refused: NACK ${code}${m.body.diagnostic ? ` (${m.body.diagnostic})` : ""}`));
456
+ return;
457
+ }
458
+ const ctx = this.#resources.get(request.resource);
459
+ this.#error(code, `${request.kind} refused${m.body.diagnostic ? `: ${m.body.diagnostic}` : ""}`, ctx?.binding.resourceId);
460
+ if (ctx === undefined)
461
+ return;
462
+ if (request.kind === "open")
463
+ this.#move(ctx, "CLOSE");
464
+ if (request.kind === "keys")
465
+ this.#keyBlocked(ctx);
466
+ // A Snapshot is an optimization (§29.2): without it, replay the units.
467
+ if (request.kind === "snapshot" && ctx.snapshotPending)
468
+ this.#serial(() => this.#afterSnapshot(ctx));
469
+ }
470
+ // -------------------------------------------------------------------------
471
+ // Control (§44-§47, §67)
472
+ async #onControlHeads(ctx, heads) {
473
+ const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
474
+ const local = chain?.kind === "linear" ? localControlOf(chain) : null;
475
+ const plan = planControlSync(local, heads);
476
+ if (plan.kind === "in-sync" || plan.kind === "peer-behind" || plan.kind === "peer-empty")
477
+ return;
478
+ this.#move(ctx, "CONTROL_RECORD");
479
+ await this.#controlRound(ctx, heads);
480
+ }
481
+ async #controlRound(ctx, heads) {
482
+ const R = ctx.binding.resourceId;
483
+ const chain = await loadControlChain(this.#o.storage, R);
484
+ const local = chain?.kind === "linear" ? localControlOf(chain) : null;
485
+ const plan = planControlSync(local, heads);
486
+ ctx.fetched = [];
487
+ ctx.lastHeads = heads;
488
+ if (plan.kind === "fetch" || plan.kind === "fork") {
489
+ const range = plan.kind === "fetch" ? plan : plan.fetch;
490
+ ctx.controlTarget = range.end;
491
+ this.#request({ kind: "control", resource: toHex(R) }, createMessage("CONTROL_GET", { resourceId: R, start: range.start, end: range.end }));
492
+ return;
493
+ }
494
+ if (chain?.kind !== "linear") {
495
+ this.#error("INVALID_CONTROL_CHAIN", "no valid local Control Chain and nothing to fetch", R);
496
+ return;
497
+ }
498
+ await this.#controlComplete(ctx, chain);
499
+ }
500
+ async #onControlRecords(ctx, records, solicited) {
501
+ const R = ctx.binding.resourceId;
502
+ const stored = await loadControlChain(this.#o.storage, R);
503
+ const storedRecords = stored?.kind === "linear" ? stored.records.map((r) => r.signed.bytes) : [];
504
+ const localSeq = stored?.kind === "linear" ? stored.state.seq : -1n;
505
+ if (!solicited) {
506
+ // A pushed record that does not continue our chain: fetch the gap first.
507
+ const seqs = records.map((r) => parseControlRecord(r).payload.controlSeq);
508
+ const min = seqs.reduce((a, b) => (b < a ? b : a), seqs[0] ?? 0n);
509
+ const max = seqs.reduce((a, b) => (b > a ? b : a), seqs[0] ?? 0n);
510
+ ctx.controlTarget = max;
511
+ if (min > localSeq + 1n) {
512
+ ctx.fetched = [...records];
513
+ this.#request({ kind: "control", resource: toHex(R) }, createMessage("CONTROL_GET", { resourceId: R, start: localSeq + 1n, end: max }));
514
+ return;
515
+ }
516
+ }
517
+ ctx.fetched.push(...records);
518
+ const byId = new Map();
519
+ for (const b of [...storedRecords, ...ctx.fetched])
520
+ byId.set(toHex(parseControlRecord(b).signed.id), b);
521
+ const ordered = [...byId.values()]
522
+ .map((b) => ({ b, p: parseControlRecord(b) }))
523
+ .sort((x, y) => x.p.payload.controlSeq < y.p.payload.controlSeq
524
+ ? -1
525
+ : x.p.payload.controlSeq > y.p.payload.controlSeq
526
+ ? 1
527
+ : toHex(x.p.signed.id) < toHex(y.p.signed.id)
528
+ ? -1
529
+ : 1)
530
+ .map((x) => x.b);
531
+ const result = validateControlChain(ordered);
532
+ if (result.kind === "conflict") {
533
+ await saveControlConflict(this.#o.storage, R, result);
534
+ if (ctx.state === "CONTROL_SYNC")
535
+ this.#move(ctx, "FORK");
536
+ this.#emit({ type: "control-conflict", resourceId: R, heads: result.competing });
537
+ return;
538
+ }
539
+ if (result.kind === "invalid") {
540
+ // Several replies may still be on their way; an incomplete chain is not yet invalid.
541
+ if (result.problem === "MALFORMED" ||
542
+ result.problem === "UNSUPPORTED_TYPE" ||
543
+ ctx.controlTarget === null) {
544
+ this.#error(result.wireCode, `the server's Control Records do not validate (${result.problem})`, R);
545
+ }
546
+ return;
547
+ }
548
+ if (ctx.controlTarget !== null && result.state.seq < ctx.controlTarget)
549
+ return; // more batches to come
550
+ const saved = await saveControlChain(this.#o.storage, result, stored?.kind === "linear" ? stored.state.head : null);
551
+ if (!saved.ok) {
552
+ this.#error("CONTROL_HEAD_MISMATCH", "the stored Control Head moved during sync", R);
553
+ return;
554
+ }
555
+ for (const [k, r] of this.#requests)
556
+ if (r.kind === "control" && r.resource === toHex(R))
557
+ this.#requests.delete(k);
558
+ ctx.fetched = [];
559
+ ctx.controlTarget = null;
560
+ await this.#controlComplete(ctx, result, stored?.kind === "linear" ? stored : null);
561
+ }
562
+ /** A newly validated Control state: reconcile epochs (G-EP7, §88 step 7), release queued puts, then keys. */
563
+ async #controlComplete(ctx, chain, previous = null) {
564
+ const R = ctx.binding.resourceId;
565
+ const before = previous ?? ctx.view;
566
+ ctx.view = chain;
567
+ // DEKs we already hold for epochs the saved chain now has (our own
568
+ // rotation, or a package that came before the row): no request needed.
569
+ await adoptStoredDeks(this.#o.storage, this.#o.secrets, chain);
570
+ const epochsChanged = before === null ||
571
+ [...chain.state.epochs.values()].some((e) => {
572
+ const old = before.state.epochs.get(String(e.epoch));
573
+ return old === undefined || (old.closedBy === null) !== (e.closedBy === null);
574
+ });
575
+ if (epochsChanged) {
576
+ const applied = await ctx.binding.applier.reconcileEpochs(chain);
577
+ const outbound = await this.#o.outbound.reconcileEpochs(chain);
578
+ if (applied.snapshotDropped)
579
+ ctx.covered = []; // SNAP-EP: covered units are fetched again
580
+ if (applied.excluded.length > 0 || outbound.length > 0 || applied.snapshotDropped) {
581
+ ctx.binding.checkpointer?.noteChange();
582
+ this.#emit({ type: "epoch-reconciled", resourceId: R, applied, outbound });
583
+ }
584
+ }
585
+ await this.#o.outbound.controlSynced(R);
586
+ if (ctx.state === "CONTROL_SYNC") {
587
+ this.#move(ctx, "CONTROL_COMPLETE");
588
+ this.#requestKeys(ctx, this.#o.now());
589
+ }
590
+ else if (ctx.state === "KEY_BLOCKED" && epochsChanged) {
591
+ this.#requestKeys(ctx, this.#o.now());
592
+ }
593
+ }
594
+ // -------------------------------------------------------------------------
595
+ // Keys (§52, §53, §66 step 2)
596
+ async #epochsWithoutDek(ctx) {
597
+ if (ctx.view !== null)
598
+ await adoptStoredDeks(this.#o.storage, this.#o.secrets, ctx.view);
599
+ const rows = await this.#o.storage.control.epochs(ctx.binding.resourceId);
600
+ const out = [];
601
+ for (const e of ctx.view?.state.epochs.values() ?? []) {
602
+ const row = rows.find((r) => r.epoch === e.epoch);
603
+ if (row?.dekRef == null || (await this.#o.secrets.get(row.dekRef)) === undefined)
604
+ out.push(dataEpoch(e.epoch));
605
+ }
606
+ return out;
607
+ }
608
+ #requestKeys(ctx, now) {
609
+ this.#serial(async () => {
610
+ if (ctx.view === null || (ctx.state !== "KEY_SYNC" && ctx.state !== "KEY_BLOCKED"))
611
+ return;
612
+ const missing = await this.#epochsWithoutDek(ctx);
613
+ if (missing.length === 0) {
614
+ if (ctx.state === "KEY_BLOCKED")
615
+ this.#move(ctx, "PACKAGE_ARRIVED");
616
+ this.#move(ctx, "DEK_AVAILABLE");
617
+ await this.#startData(ctx);
618
+ return;
619
+ }
620
+ ctx.missingEpochs = missing;
621
+ ctx.lastKeyRequest = now;
622
+ const R = ctx.binding.resourceId;
623
+ this.#request({ kind: "keys", resource: toHex(R) }, createMessage("KEY_PACKAGE_GET", {
624
+ resourceId: R,
625
+ recipient: this.#o.signer.descriptor.principalId,
626
+ // §52: at most 256 epochs per request, the newest first: the
627
+ // current epoch's DEK unblocks the Resource; older ones only let
628
+ // their units apply, and a later round asks for them.
629
+ epochs: missing.slice(-256),
630
+ }));
631
+ });
632
+ }
633
+ async #onKeyPackages(ctx, packages) {
634
+ const view = ctx.view;
635
+ if (view === null)
636
+ return;
637
+ const R = ctx.binding.resourceId;
638
+ const recipient = { descriptor: this.#o.signer.descriptor, agreement: this.#o.agreement };
639
+ for (const bytes of packages) {
640
+ const r = await receiveKeyPackage(view, bytes, recipient);
641
+ if (r.kind !== "opened") {
642
+ this.#error(r.kind === "rejected" ? r.wireCode : r.code, `a Key Package was not used (${r.kind})`, R);
643
+ continue;
644
+ }
645
+ // Secret first, then the row that references it (LFCP-034).
646
+ const ref = dekSecretRef(R, r.epoch);
647
+ await this.#o.secrets.put(ref, exportSecretKeyBytes(r.dek));
648
+ const row = (await this.#o.storage.control.epochs(R)).find((e) => e.epoch === r.epoch);
649
+ if (row !== undefined) {
650
+ const next = { ...row, dekRef: ref };
651
+ await this.#o.storage.commit([{ op: "put-epoch", resourceId: R, epoch: next }]);
652
+ }
653
+ }
654
+ const missing = await this.#epochsWithoutDek(ctx);
655
+ const current = view.state.epoch.epoch;
656
+ if (missing.some((e) => e === current)) {
657
+ this.#keyBlocked(ctx);
658
+ return;
659
+ }
660
+ if (missing.length > 0)
661
+ this.#emit({ type: "key-blocked", resourceId: R, epochs: missing }); // older epochs: their units wait
662
+ if (ctx.state === "KEY_BLOCKED")
663
+ this.#move(ctx, "PACKAGE_ARRIVED");
664
+ if (ctx.state === "KEY_SYNC") {
665
+ this.#move(ctx, "DEK_AVAILABLE");
666
+ await this.#startData(ctx);
667
+ }
668
+ }
669
+ #keyBlocked(ctx) {
670
+ if (ctx.state === "KEY_SYNC")
671
+ this.#move(ctx, "KEY_UNAVAILABLE");
672
+ this.#emit({
673
+ type: "key-blocked",
674
+ resourceId: ctx.binding.resourceId,
675
+ epochs: ctx.missingEpochs,
676
+ });
677
+ }
678
+ // -------------------------------------------------------------------------
679
+ // Data (§48-§51, §68, §69)
680
+ async #startData(ctx) {
681
+ // Accepted units on disk that the profile state lacks (a crash before
682
+ // the checkpoint caught up) are applied again before any new unit.
683
+ if (ctx.view !== null) {
684
+ const R = ctx.binding.resourceId;
685
+ // Snapshots that crashed the engine twice are never loaded again.
686
+ for (const item of await this.#snapshots.recover(R))
687
+ this.#error("INVALID_AUTOMERGE_BYTES", `Snapshot ${item.slice("snapshot:".length)} crashed the profile engine twice; it is not loaded again on this device`, R);
688
+ const r = await ctx.binding.applier.replayStored(ctx.view);
689
+ if (r.replayed.length > 0 || r.skipped.length > 0 || r.crashed.length > 0) {
690
+ ctx.binding.checkpointer?.noteChange();
691
+ this.#emit({ type: "replayed", resourceId: R, ...r });
692
+ }
693
+ for (const unitId of r.crashed)
694
+ this.#error("INVALID_AUTOMERGE_BYTES", `Data Unit ${toHex(unitId)} crashed the profile engine twice; it is quarantined on this device`, R);
695
+ }
696
+ await this.#flush(ctx); // §88 step 6: upload locally queued valid units
697
+ if (await this.#snapshotUseful(ctx)) {
698
+ // §66 step 3: the preferred Snapshot first, then the units beyond it.
699
+ const R = ctx.binding.resourceId;
700
+ ctx.snapshotPending = true;
701
+ this.#request({ kind: "snapshot", resource: toHex(R) }, createMessage("SNAPSHOT_GET", {
702
+ resourceId: R,
703
+ snapshotId: ctx.offered.snapshotId,
704
+ }));
705
+ return;
706
+ }
707
+ await this.#afterSnapshot(ctx);
708
+ }
709
+ async #afterSnapshot(ctx) {
710
+ ctx.snapshotPending = false;
711
+ const early = ctx.early;
712
+ ctx.early = [];
713
+ await this.#dataRound(ctx);
714
+ if (early.length > 0)
715
+ await this.#onUnits(ctx, early);
716
+ }
717
+ /** The offered Snapshot holds units we lack, we can load it, and we did not load it before. */
718
+ async #snapshotUseful(ctx) {
719
+ const offered = ctx.offered;
720
+ if (offered === null || ctx.binding.snapshot === undefined)
721
+ return false;
722
+ if ((await this.#o.storage.snapshots.get(offered.snapshotId)) !== undefined)
723
+ return false;
724
+ const R = ctx.binding.resourceId;
725
+ await this.#snapshots.recover(R);
726
+ if (this.#snapshots.suspicion(R, snapshotItem(offered.snapshotId)) === 2)
727
+ return false;
728
+ const local = (await resourceSyncState(this.#o.storage, ctx.binding.resourceId)).have;
729
+ return missingFrom(local, normalizeLiveHaves(offered.frontier)).length > 0;
730
+ }
731
+ async #onSnapshot(ctx, bytes) {
732
+ const R = ctx.binding.resourceId;
733
+ const view = ctx.view;
734
+ const binding = ctx.binding.snapshot;
735
+ if (view !== null && binding !== undefined) {
736
+ // Decode and load under the crash-loop breaker; a trap in decode is
737
+ // rethrown, not reported as the Snapshot's local failure.
738
+ let trap;
739
+ const codec = binding.codec;
740
+ const guarded = {
741
+ dataProfile: codec.dataProfile,
742
+ encode: (v) => codec.encode(v),
743
+ decode: (plaintext) => {
744
+ try {
745
+ return codec.decode(plaintext);
746
+ }
747
+ catch (e) {
748
+ if (isEngineTrap(e))
749
+ trap = e;
750
+ throw e;
751
+ }
752
+ },
753
+ };
754
+ const r = await this.#snapshots.run(R, [snapshotItem(sha256(bytes))], async () => {
755
+ const received = await receiveSnapshot(view, bytes, {
756
+ dek: dekResolver(this.#o.storage, this.#o.secrets, R),
757
+ profile: guarded,
758
+ });
759
+ if (trap !== undefined)
760
+ throw trap;
761
+ if (received.kind === "accepted")
762
+ binding.load(received.value);
763
+ return received;
764
+ });
765
+ if (r.kind === "accepted") {
766
+ const frontier = encode(canonicalFrontierToCbor(r.frontier));
767
+ await this.#o.storage.commit([
768
+ {
769
+ op: "put-snapshot",
770
+ row: {
771
+ snapshotId: r.snapshotId,
772
+ resourceId: R,
773
+ dataEpoch: r.epoch,
774
+ publisher: r.publisher,
775
+ snapshotSeq: r.seq,
776
+ frontier,
777
+ bytes,
778
+ },
779
+ },
780
+ ]);
781
+ ctx.binding.checkpointer?.noteChange();
782
+ this.#emit({
783
+ type: "snapshot-loaded",
784
+ resourceId: R,
785
+ snapshotId: r.snapshotId,
786
+ frontier: r.frontier,
787
+ });
788
+ }
789
+ else {
790
+ const code = r.kind === "local-failure" ? r.reason : r.wireCode;
791
+ this.#error(code, `the offered Snapshot was not loaded (${r.kind}); replaying units instead`, R);
792
+ }
793
+ }
794
+ await this.#afterSnapshot(ctx);
795
+ }
796
+ /**
797
+ * Publishes a Snapshot of the Resource's current state (§29): its frontier
798
+ * is every merged unit and every stored Snapshot's frontier, so a client
799
+ * that loads it never skips content the state does not hold. Queued and
800
+ * sent like any object; returns its ID.
801
+ */
802
+ async publishSnapshot(resource) {
803
+ const ctx = this.#resources.get(toHex(resource));
804
+ if (ctx === undefined)
805
+ throw new LfcpError("UNSUPPORTED_VALUE", "the Resource is not open here");
806
+ let id;
807
+ await new Promise((resolve, reject) => this.#serial(async () => {
808
+ try {
809
+ id = await this.#publish(ctx);
810
+ resolve();
811
+ }
812
+ catch (e) {
813
+ reject(e);
814
+ }
815
+ }));
816
+ return id;
817
+ }
818
+ async #publish(ctx) {
819
+ const R = ctx.binding.resourceId;
820
+ const binding = ctx.binding.snapshot;
821
+ const view = ctx.view;
822
+ if (binding === undefined || view === null)
823
+ throw new LfcpError("UNSUPPORTED_VALUE", "no Snapshot binding, or the Control Chain is not synchronized");
824
+ const dek = await dekResolver(this.#o.storage, this.#o.secrets, R)(view.state.epoch.epoch);
825
+ if (dek === undefined)
826
+ throw new LfcpError("UNSUPPORTED_VALUE", "no DEK for the current epoch");
827
+ let frontier = await snapshotFrontier(this.#o.storage, R);
828
+ for (const u of await this.#o.storage.dataUnits.withStatus(R, "merged"))
829
+ if (u.accepted)
830
+ frontier = addSequence(frontier, u.actor, u.actorSeq);
831
+ const created = await createQueuedSnapshot(this.#o.storage, {
832
+ view,
833
+ controlHead: view.state.head,
834
+ publisher: this.#o.signer,
835
+ dek,
836
+ frontier: haveToLive(frontier),
837
+ profile: binding.codec,
838
+ value: binding.current(),
839
+ });
840
+ ctx.unitsSinceSnapshot = 0;
841
+ this.#emit({ type: "snapshot-published", resourceId: R, snapshotId: created.snapshotId });
842
+ await this.#flush(ctx);
843
+ return created.snapshotId;
844
+ }
845
+ async #dataRound(ctx) {
846
+ const R = ctx.binding.resourceId;
847
+ ctx.covered = await snapshotFrontier(this.#o.storage, R);
848
+ const local = (await resourceSyncState(this.#o.storage, R)).have;
849
+ // Beyond a loaded Snapshot's frontier (missingAfter: `local` includes it),
850
+ // plus each actor's last covered unit, so the next one links (§26.2).
851
+ const missing = [...(await this.#boundary(ctx)), ...missingFrom(local, ctx.remoteHave)];
852
+ if (missing.length === 0) {
853
+ ctx.lastRound = "";
854
+ if (ctx.state === "DATA_SYNC")
855
+ this.#move(ctx, "FRONTIER_REACHED");
856
+ ctx.expected = [];
857
+ ctx.received = [];
858
+ return;
859
+ }
860
+ // A round that asks again for exactly what the last one asked for made no
861
+ // progress (e.g. the server lacks units it announced): stop instead of
862
+ // looping; periodic anti-entropy tries again.
863
+ const key = missing.map((r) => `${toHex(r.actor)}:${r.start}-${r.end}`).join(",");
864
+ if (key === ctx.lastRound) {
865
+ ctx.lastRound = "";
866
+ this.#error("NO_PROGRESS", "a data round fetched nothing new; will retry on the next DATA_HAVE", R);
867
+ if (ctx.state === "DATA_SYNC")
868
+ this.#move(ctx, "FRONTIER_REACHED");
869
+ ctx.expected = [];
870
+ ctx.received = [];
871
+ return;
872
+ }
873
+ ctx.lastRound = key;
874
+ if (ctx.state === "LIVE")
875
+ this.#move(ctx, "MISSING_RANGES");
876
+ // Ranges stay intervals: the work is bounded by their number, never by
877
+ // their span (a Have may announce up to 2^64 - 1 sequences).
878
+ let expected = [];
879
+ for (const r of missing)
880
+ expected = addRange(expected, r.actor, r.start, r.end);
881
+ ctx.expected = expected;
882
+ ctx.received = [];
883
+ for (const batch of batchDataRanges(missing))
884
+ this.#request({ kind: "data-get", resource: toHex(R) }, createMessage("DATA_GET", {
885
+ resourceId: R,
886
+ ranges: batch.map((r) => ({ actor: r.actor, start: r.start, end: r.end })),
887
+ }));
888
+ }
889
+ /** The last unit of each actor run a stored Snapshot covers, when we do not hold it yet. */
890
+ async #boundary(ctx) {
891
+ const R = ctx.binding.resourceId;
892
+ const out = [];
893
+ for (const h of ctx.covered)
894
+ for (const seq of [
895
+ ...(h.contiguous > 0n ? [h.contiguous] : []),
896
+ ...h.extras.map(([, end]) => end),
897
+ ]) {
898
+ if (!hasSequence(ctx.remoteHave, h.principalId, seq))
899
+ continue;
900
+ if ((await this.#o.storage.dataUnits.acceptedAt(R, h.principalId, actorSequence(seq))) !==
901
+ undefined)
902
+ continue;
903
+ out.push({ actor: h.principalId, start: seq, end: seq });
904
+ }
905
+ return out;
906
+ }
907
+ async #onUnits(ctx, units) {
908
+ const view = ctx.view;
909
+ if (view === null ||
910
+ ctx.state === "KEY_SYNC" ||
911
+ ctx.state === "KEY_BLOCKED" ||
912
+ ctx.state === "CONTROL_SYNC" ||
913
+ ctx.snapshotPending) {
914
+ ctx.early.push(...units);
915
+ return;
916
+ }
917
+ const R = ctx.binding.resourceId;
918
+ let changed = false;
919
+ let needControl = false;
920
+ // Units a loaded Snapshot covers are accepted as covered, one by one;
921
+ // runs of the others go to the applier together (receiveBatch), so the
922
+ // profile merges a catch-up at once.
923
+ const outcomes = [];
924
+ let run = [];
925
+ const flushRun = async () => {
926
+ if (run.length > 0)
927
+ outcomes.push(...(await ctx.binding.applier.receiveBatch(view, run)));
928
+ run = [];
929
+ };
930
+ for (const bytes of units) {
931
+ let covered = false;
932
+ try {
933
+ const p = parseDataUnit(bytes).payload;
934
+ ctx.received = addSequence(ctx.received, p.actor, p.actorSeq);
935
+ covered = hasSequence(ctx.covered, p.actor, p.actorSeq);
936
+ }
937
+ catch {
938
+ // malformed: the applier reports it
939
+ }
940
+ if (!covered) {
941
+ run.push(bytes);
942
+ continue;
943
+ }
944
+ await flushRun();
945
+ outcomes.push(await ctx.binding.applier.acceptCovered(view, bytes));
946
+ }
947
+ await flushRun();
948
+ for (const outcome of outcomes) {
949
+ if (outcome.kind === "applied" || outcome.kind === "profile-pending") {
950
+ changed = true;
951
+ ctx.unitsSinceSnapshot += 1;
952
+ }
953
+ if (outcome.kind === "rejected" && outcome.wireCode === "MISSING_DEPENDENCY")
954
+ needControl = true;
955
+ this.#emit({ type: "unit", resourceId: R, outcome });
956
+ // Held units this one released (§26.2): their outcomes too.
957
+ const released = "released" in outcome ? outcome.released : [];
958
+ for (const r of released) {
959
+ if (r.kind === "applied" || r.kind === "profile-pending")
960
+ changed = true;
961
+ this.#emit({ type: "unit", resourceId: R, outcome: r });
962
+ }
963
+ }
964
+ if (changed)
965
+ ctx.binding.checkpointer?.noteChange();
966
+ if (needControl)
967
+ this.#refreshControl(ctx);
968
+ if (ctx.state === "DATA_SYNC" &&
969
+ ctx.expected.length > 0 &&
970
+ missingFrom(ctx.received, ctx.expected).length === 0) {
971
+ ctx.expected = [];
972
+ await this.#dataRound(ctx);
973
+ }
974
+ }
975
+ #sendHave(ctx, now) {
976
+ ctx.lastHave = now;
977
+ this.#serial(async () => {
978
+ const R = ctx.binding.resourceId;
979
+ const have = (await resourceSyncState(this.#o.storage, R)).have;
980
+ this.#request({ kind: "data-have", resource: toHex(R) }, createMessage("DATA_HAVE", { resourceId: R, haves: haveToLive(have) }));
981
+ });
982
+ }
983
+ #refreshControl(ctx) {
984
+ this.#serial(async () => {
985
+ const R = ctx.binding.resourceId;
986
+ const chain = await loadControlChain(this.#o.storage, R);
987
+ const heads = chain?.kind === "linear" ? [{ seq: chain.state.seq, recordId: chain.state.head }] : [];
988
+ this.#request({ kind: "control", resource: toHex(R) }, createMessage("CONTROL_HAVE", { resourceId: R, heads }));
989
+ });
990
+ }
991
+ // -------------------------------------------------------------------------
992
+ // Outbound (§51, §54, §57, §59, §60, §88)
993
+ async #flush(ctx) {
994
+ if (this.#connection.state !== "READY")
995
+ return;
996
+ if (ctx.state !== "DATA_SYNC" && ctx.state !== "LIVE")
997
+ return;
998
+ const messages = await this.#o.outbound.next(ctx.binding.resourceId, iso(this.#o.now()));
999
+ for (const m of messages)
1000
+ this.#connection.sendEncoded(m.bytes);
1001
+ }
1002
+ async #onAck(m) {
1003
+ const outcome = await this.#o.outbound.onAck(m, iso(this.#o.now()));
1004
+ this.#emit({ type: "ack", outcome });
1005
+ // Our own Control Record was committed: catch up, since the server does not push it back to us.
1006
+ if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT && outcome.acked.length > 0)
1007
+ for (const ctx of this.#resources.values())
1008
+ if (ctx.state === "LIVE")
1009
+ this.#refreshControl(ctx);
1010
+ else
1011
+ ctx.controlStale = true;
1012
+ }
1013
+ async #onNack(m) {
1014
+ const outcome = await this.#o.outbound.onNack(m, iso(this.#o.now()));
1015
+ this.#emit({ type: "nack", outcome });
1016
+ if (outcome.kind === "needs-control-sync")
1017
+ for (const ctx of this.#resources.values())
1018
+ if (ctx.state === "LIVE")
1019
+ this.#refreshControl(ctx);
1020
+ }
1021
+ }
1022
+ /**
1023
+ * A thin timer driver for SyncClient: calls tick(now) every `intervalMs`
1024
+ * with the given clock and timer functions (setInterval/clearInterval in
1025
+ * production). Returns the stop function. The core never sets timers
1026
+ * itself; this is the one explicit place that does.
1027
+ */
1028
+ export function startSyncDriver(client, timers, intervalMs = 1000) {
1029
+ const handle = timers.setInterval(() => client.tick(timers.now()), intervalMs);
1030
+ return () => timers.clearInterval(handle);
1031
+ }
1032
+ //# sourceMappingURL=sync-client.js.map