@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.
- package/LICENSE +201 -0
- package/README.md +27 -0
- package/dist/apply.d.ts +299 -0
- package/dist/apply.js +741 -0
- package/dist/checkpoint.d.ts +31 -0
- package/dist/checkpoint.js +50 -0
- package/dist/connection.d.ts +87 -0
- package/dist/connection.js +210 -0
- package/dist/data-unit.d.ts +50 -0
- package/dist/data-unit.js +39 -0
- package/dist/engine-guard.d.ts +58 -0
- package/dist/engine-guard.js +148 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +15 -0
- package/dist/invite.d.ts +154 -0
- package/dist/invite.js +391 -0
- package/dist/outbound.d.ts +227 -0
- package/dist/outbound.js +508 -0
- package/dist/queue.d.ts +38 -0
- package/dist/queue.js +116 -0
- package/dist/resource-state.d.ts +34 -0
- package/dist/resource-state.js +24 -0
- package/dist/snapshot.d.ts +42 -0
- package/dist/snapshot.js +40 -0
- package/dist/storage.d.ts +96 -0
- package/dist/storage.js +289 -0
- package/dist/sync-client.d.ts +203 -0
- package/dist/sync-client.js +1032 -0
- package/package.json +50 -0
|
@@ -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
|