@openlfcp/client 0.1.2 → 0.1.3
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/README.md +1 -1
- package/dist/apply.d.ts +319 -0
- package/dist/apply.js +807 -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 +174 -0
- package/dist/invite.js +402 -0
- package/dist/outbound.d.ts +245 -0
- package/dist/outbound.js +607 -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 +258 -0
- package/dist/sync-client.js +1401 -0
- package/package.json +5 -5
|
@@ -0,0 +1,1401 @@
|
|
|
1
|
+
import { actorSequence, dataEpoch, hash32, 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, haveDifference, localControlOf, MESSAGE_TYPE, missingFrom, normalizeLiveHaves, parseControlRecord, parseDataUnit, parseKeyPackage, 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
|
+
/**
|
|
15
|
+
* §62 codes that end syncing a Resource on this server when they refuse
|
|
16
|
+
* RESOURCE_OPEN or one of its reads (CONTROL_GET, KEY_PACKAGE_GET, DATA_GET,
|
|
17
|
+
* DATA_HAVE, SNAPSHOT_GET). Repeating the same request cannot change them:
|
|
18
|
+
* the server's hosting or the Resource's Control state must change first,
|
|
19
|
+
* and the application decides when to ask again. Every other code is
|
|
20
|
+
* transient for these requests (RATE_LIMITED, INTERNAL_ERROR, QUOTA_EXCEEDED,
|
|
21
|
+
* unknown codes) and is retried with the ReconnectPolicy's backoff, except
|
|
22
|
+
* where the request has its own fallback: KEY_PACKAGE_GET → KEY_BLOCKED,
|
|
23
|
+
* SNAPSHOT_GET → replaying the units (§29.2).
|
|
24
|
+
*/
|
|
25
|
+
export const TERMINAL_RESOURCE_CODES = new Set([
|
|
26
|
+
"RESOURCE_NOT_HOSTED",
|
|
27
|
+
"RESOURCE_NOT_FOUND",
|
|
28
|
+
"RESOURCE_TOMBSTONED",
|
|
29
|
+
"AUTHORIZATION_FAILED",
|
|
30
|
+
"PROTOCOL_UNSUPPORTED",
|
|
31
|
+
"MALFORMED_MESSAGE",
|
|
32
|
+
"PROFILE_UNSUPPORTED",
|
|
33
|
+
]);
|
|
34
|
+
const NACK_NAME = new Map(Object.entries(ERROR_CODE).map(([k, v]) => [v, k]));
|
|
35
|
+
/**
|
|
36
|
+
* Data Unit statuses whose units this client has accepted and may offer to
|
|
37
|
+
* a server that lacks them (§68.1): never a unit held for its `previous`
|
|
38
|
+
* link, a quarantined or equivocating one, or one that failed locally.
|
|
39
|
+
*/
|
|
40
|
+
const OFFERABLE = [
|
|
41
|
+
"merged",
|
|
42
|
+
"profile-pending",
|
|
43
|
+
"profile-held",
|
|
44
|
+
"profile-rejected",
|
|
45
|
+
"profile-unsupported",
|
|
46
|
+
"seen",
|
|
47
|
+
];
|
|
48
|
+
/** At most this many objects per offered DATA_PUT or KEY_PACKAGE_PUT. */
|
|
49
|
+
const OFFER_BATCH = 64;
|
|
50
|
+
/** The local mark that a route hosted (or opened) a Resource for this client (§41.1). */
|
|
51
|
+
const hostedMark = (resource, url) => `hosted-route:${toHex(resource)}:${url}`;
|
|
52
|
+
const SUBSCRIBE_DATA_AND_CONTROL = 3n;
|
|
53
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
54
|
+
const haveToLive = (vector) => vector.map((h) => ({
|
|
55
|
+
principalId: h.principalId,
|
|
56
|
+
contiguous: h.contiguous,
|
|
57
|
+
...(h.extras.length > 0 ? { ranges: h.extras } : {}),
|
|
58
|
+
}));
|
|
59
|
+
export class SyncClient {
|
|
60
|
+
#o;
|
|
61
|
+
#connection;
|
|
62
|
+
#resources = new Map();
|
|
63
|
+
#requests = new Map();
|
|
64
|
+
/** When each request was sent (caller's clock), for the request timeout. */
|
|
65
|
+
#sentAt = new Map();
|
|
66
|
+
#requestTimeoutMs;
|
|
67
|
+
#listeners = new Set();
|
|
68
|
+
#antiEntropyMs;
|
|
69
|
+
/** The crash-loop breaker for Snapshot loads (EngineGuard). */
|
|
70
|
+
#snapshots;
|
|
71
|
+
#stopped = true;
|
|
72
|
+
/** The profile engine trapped: nothing more is processed (ENGINE_TRAP). */
|
|
73
|
+
#trapped = false;
|
|
74
|
+
#attempt = 0;
|
|
75
|
+
#reconnectAt = null;
|
|
76
|
+
/** READY's maximum message size, for offered batches (§31). */
|
|
77
|
+
#maxMessageBytes = 8 * 1024 * 1024;
|
|
78
|
+
/** Serializes message handling: each message is handled after the previous one finished. */
|
|
79
|
+
#queue = Promise.resolve();
|
|
80
|
+
constructor(options) {
|
|
81
|
+
this.#o = options;
|
|
82
|
+
this.#snapshots = new EngineGuard(options.storage, "snapshots");
|
|
83
|
+
this.#antiEntropyMs = options.antiEntropyMs ?? 30_000;
|
|
84
|
+
this.#requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
|
85
|
+
this.#connection = new LfcpConnection({
|
|
86
|
+
url: options.url,
|
|
87
|
+
signer: options.signer,
|
|
88
|
+
now: options.now,
|
|
89
|
+
...(options.webSocket === undefined ? {} : { webSocket: options.webSocket }),
|
|
90
|
+
...(options.credential === undefined ? {} : { credential: options.credential }),
|
|
91
|
+
...(options.dataProfiles === undefined ? {} : { dataProfiles: options.dataProfiles }),
|
|
92
|
+
}, {
|
|
93
|
+
state: (state) => this.#emit({ type: "connection", state }),
|
|
94
|
+
ready: (ready) => this.#serial(() => this.#onReady(ready)),
|
|
95
|
+
message: (m) => this.#serial(() => this.#onMessage(m)),
|
|
96
|
+
closed: (reason) => this.#serial(() => this.#onClosed(reason)),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
/** Subscribes to session events; returns the unsubscribe function. */
|
|
100
|
+
on(listener) {
|
|
101
|
+
this.#listeners.add(listener);
|
|
102
|
+
return () => this.#listeners.delete(listener);
|
|
103
|
+
}
|
|
104
|
+
#emit(event) {
|
|
105
|
+
for (const l of this.#listeners)
|
|
106
|
+
l(event);
|
|
107
|
+
}
|
|
108
|
+
#serial(fn) {
|
|
109
|
+
this.#queue = this.#queue
|
|
110
|
+
.then(() => (this.#trapped ? undefined : fn()))
|
|
111
|
+
.catch((e) => {
|
|
112
|
+
if (!isEngineTrap(e)) {
|
|
113
|
+
this.#emit({
|
|
114
|
+
type: "error",
|
|
115
|
+
code: "INTERNAL",
|
|
116
|
+
message: e instanceof Error ? e.message : String(e),
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
// The profile engine trapped: in this process it is gone for good.
|
|
121
|
+
// Stop once, loudly; the crash-loop breaker takes over at the next start.
|
|
122
|
+
if (this.#trapped)
|
|
123
|
+
return;
|
|
124
|
+
this.#trapped = true;
|
|
125
|
+
this.#stopped = true;
|
|
126
|
+
this.#reconnectAt = null;
|
|
127
|
+
this.#connection.close("the profile engine trapped");
|
|
128
|
+
this.#emit({
|
|
129
|
+
type: "error",
|
|
130
|
+
code: "ENGINE_TRAP",
|
|
131
|
+
message: `the profile engine trapped (${e instanceof Error ? e.message : String(e)}); this process must restart`,
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
/** Resolves once every message received so far has been handled (tests and shutdown). */
|
|
136
|
+
idle() {
|
|
137
|
+
return this.#queue;
|
|
138
|
+
}
|
|
139
|
+
get connectionState() {
|
|
140
|
+
return this.#connection.state;
|
|
141
|
+
}
|
|
142
|
+
resourceState(resource) {
|
|
143
|
+
return this.#resources.get(toHex(resource))?.state ?? "CLOSED";
|
|
144
|
+
}
|
|
145
|
+
/** Why the server refused the Resource for good, or null (see ResourceRefusal). */
|
|
146
|
+
resourceRefusal(resource) {
|
|
147
|
+
return this.#resources.get(toHex(resource))?.refusal ?? null;
|
|
148
|
+
}
|
|
149
|
+
/** Connects, and reconnects after losses (per the ReconnectPolicy) until stop(). */
|
|
150
|
+
start() {
|
|
151
|
+
if (this.#trapped)
|
|
152
|
+
return;
|
|
153
|
+
this.#stopped = false;
|
|
154
|
+
this.#reconnectAt = null;
|
|
155
|
+
this.#connection.connect();
|
|
156
|
+
}
|
|
157
|
+
/** Closes the connection and stops reconnecting. Local state stays usable. */
|
|
158
|
+
async stop() {
|
|
159
|
+
this.#stopped = true;
|
|
160
|
+
this.#reconnectAt = null;
|
|
161
|
+
this.#connection.close("stopped by the application");
|
|
162
|
+
await this.idle();
|
|
163
|
+
}
|
|
164
|
+
/** Registers a Resource and opens it now (or when the session is READY). */
|
|
165
|
+
open(binding) {
|
|
166
|
+
const key = toHex(binding.resourceId);
|
|
167
|
+
let ctx = this.#resources.get(key);
|
|
168
|
+
if (ctx === undefined) {
|
|
169
|
+
ctx = {
|
|
170
|
+
binding,
|
|
171
|
+
state: "CLOSED",
|
|
172
|
+
wanted: true,
|
|
173
|
+
view: null,
|
|
174
|
+
fetched: [],
|
|
175
|
+
controlTarget: null,
|
|
176
|
+
remoteHave: [],
|
|
177
|
+
expected: [],
|
|
178
|
+
received: [],
|
|
179
|
+
early: [],
|
|
180
|
+
lastHave: 0,
|
|
181
|
+
lastKeyRequest: 0,
|
|
182
|
+
lastHeads: [],
|
|
183
|
+
controlStale: false,
|
|
184
|
+
missingEpochs: [],
|
|
185
|
+
offered: null,
|
|
186
|
+
snapshotPending: false,
|
|
187
|
+
covered: [],
|
|
188
|
+
unitsSinceSnapshot: 0,
|
|
189
|
+
lastRound: "",
|
|
190
|
+
refusal: null,
|
|
191
|
+
refusedAttempt: 0,
|
|
192
|
+
reopenAt: null,
|
|
193
|
+
offering: new Set(),
|
|
194
|
+
keysOffered: false,
|
|
195
|
+
rehosted: false,
|
|
196
|
+
};
|
|
197
|
+
this.#resources.set(key, ctx);
|
|
198
|
+
}
|
|
199
|
+
ctx.wanted = true;
|
|
200
|
+
// An explicit open asks the server again after a refusal.
|
|
201
|
+
ctx.refusal = null;
|
|
202
|
+
ctx.refusedAttempt = 0;
|
|
203
|
+
ctx.reopenAt = null;
|
|
204
|
+
if (this.#connection.state === "READY")
|
|
205
|
+
this.#serial(() => this.#sendOpen(ctx));
|
|
206
|
+
}
|
|
207
|
+
/** RESOURCE_CLOSE (§43): no more pushes; local state stays usable. */
|
|
208
|
+
close(resource) {
|
|
209
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
210
|
+
if (ctx === undefined)
|
|
211
|
+
return;
|
|
212
|
+
ctx.wanted = false;
|
|
213
|
+
if (ctx.state !== "CLOSED" && this.#connection.state === "READY") {
|
|
214
|
+
this.#request({ kind: "close", resource: toHex(resource) }, createMessage("RESOURCE_CLOSE", { resourceId: resource }));
|
|
215
|
+
}
|
|
216
|
+
this.#move(ctx, "CLOSE");
|
|
217
|
+
}
|
|
218
|
+
/** RESOURCE_HOST (§39): asks the server to host a Resource from its exact Genesis bytes; resolves with the durability applied. */
|
|
219
|
+
host(genesis, credential) {
|
|
220
|
+
return new Promise((resolve, reject) => {
|
|
221
|
+
try {
|
|
222
|
+
this.#request({ kind: "host", genesis, resolve, reject }, createMessage("RESOURCE_HOST", {
|
|
223
|
+
genesis,
|
|
224
|
+
...(credential === undefined ? {} : { credential }),
|
|
225
|
+
}));
|
|
226
|
+
}
|
|
227
|
+
catch (e) {
|
|
228
|
+
reject(e);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
/** Sends what the outbound queue has due for every Resource past KEY_SYNC (e.g. after a local write). */
|
|
233
|
+
flush() {
|
|
234
|
+
this.#serial(async () => {
|
|
235
|
+
for (const ctx of this.#resources.values())
|
|
236
|
+
await this.#flush(ctx);
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* The periodic work, on the caller's clock: heartbeat (§38), reconnect,
|
|
241
|
+
* anti-entropy DATA_HAVE for LIVE Resources (§69), Key Package retries,
|
|
242
|
+
* due outbound retries and debounced checkpoints.
|
|
243
|
+
*/
|
|
244
|
+
tick(now) {
|
|
245
|
+
if (this.#connection.state === "DISCONNECTED") {
|
|
246
|
+
if (!this.#stopped && this.#reconnectAt !== null && now >= this.#reconnectAt) {
|
|
247
|
+
this.#reconnectAt = null;
|
|
248
|
+
this.#connection.connect();
|
|
249
|
+
}
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (!this.#connection.tick(now))
|
|
253
|
+
return;
|
|
254
|
+
if (this.#connection.state === "READY")
|
|
255
|
+
this.#expireRequests(now);
|
|
256
|
+
this.#serial(async () => {
|
|
257
|
+
for (const ctx of this.#resources.values()) {
|
|
258
|
+
if (ctx.reopenAt !== null &&
|
|
259
|
+
now >= ctx.reopenAt &&
|
|
260
|
+
ctx.state === "CLOSED" &&
|
|
261
|
+
ctx.wanted &&
|
|
262
|
+
this.#connection.state === "READY")
|
|
263
|
+
await this.#sendOpen(ctx);
|
|
264
|
+
if (ctx.state === "LIVE" && now - ctx.lastHave >= this.#antiEntropyMs)
|
|
265
|
+
this.#sendHave(ctx, now);
|
|
266
|
+
if (ctx.state === "KEY_BLOCKED" && now - ctx.lastKeyRequest >= this.#antiEntropyMs)
|
|
267
|
+
this.#requestKeys(ctx, now);
|
|
268
|
+
await this.#flush(ctx);
|
|
269
|
+
await ctx.binding.checkpointer?.maybeFlush(now);
|
|
270
|
+
if (ctx.state === "LIVE" &&
|
|
271
|
+
ctx.binding.snapshot !== undefined &&
|
|
272
|
+
this.#o.snapshotPolicy?.(ctx.binding.resourceId, ctx.unitsSinceSnapshot) === true)
|
|
273
|
+
await this.#publish(ctx);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
// -------------------------------------------------------------------------
|
|
278
|
+
// plumbing
|
|
279
|
+
#request(request, message) {
|
|
280
|
+
const key = toHex(message.messageId);
|
|
281
|
+
this.#requests.set(key, request);
|
|
282
|
+
this.#sentAt.set(key, this.#o.now());
|
|
283
|
+
this.#connection.send(message);
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Requests unanswered for requestTimeoutMs on a live connection (§70: a
|
|
287
|
+
* lost request or reply): forgotten, and the step they belong to is
|
|
288
|
+
* issued again while its Resource is still in that phase. A late reply
|
|
289
|
+
* is harmless: units and records are idempotent.
|
|
290
|
+
*/
|
|
291
|
+
#expireRequests(now) {
|
|
292
|
+
for (const key of this.#sentAt.keys())
|
|
293
|
+
if (!this.#requests.has(key))
|
|
294
|
+
this.#sentAt.delete(key);
|
|
295
|
+
const resend = new Map();
|
|
296
|
+
for (const [key, request] of this.#requests) {
|
|
297
|
+
const sent = this.#sentAt.get(key);
|
|
298
|
+
if (sent === undefined || now - sent < this.#requestTimeoutMs)
|
|
299
|
+
continue;
|
|
300
|
+
this.#requests.delete(key);
|
|
301
|
+
this.#sentAt.delete(key);
|
|
302
|
+
if (request.kind === "host") {
|
|
303
|
+
request.reject(new Error("RESOURCE_HOST got no answer within the request timeout"));
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (request.kind === "offer-data") {
|
|
307
|
+
// Offered again on the next DATA_HAVE (§68.1).
|
|
308
|
+
const ctx = this.#resources.get(request.resource);
|
|
309
|
+
for (const u of request.units)
|
|
310
|
+
ctx?.offering.delete(u.id);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (request.kind === "offer-control" || request.kind === "offer-keys")
|
|
314
|
+
continue;
|
|
315
|
+
if ("resource" in request)
|
|
316
|
+
resend.set(`${request.kind}:${request.resource}`, request);
|
|
317
|
+
}
|
|
318
|
+
for (const request of resend.values()) {
|
|
319
|
+
if (!("resource" in request))
|
|
320
|
+
continue;
|
|
321
|
+
const ctx = this.#resources.get(request.resource);
|
|
322
|
+
if (ctx === undefined)
|
|
323
|
+
continue;
|
|
324
|
+
if (request.kind === "open" && ctx.state === "OPENING") {
|
|
325
|
+
this.#move(ctx, "CLOSE");
|
|
326
|
+
this.#serial(() => this.#sendOpen(ctx));
|
|
327
|
+
}
|
|
328
|
+
else if (request.kind === "rehost" && ctx.state === "CLOSED") {
|
|
329
|
+
ctx.rehosted = false; // a lost answer: open again, which re-hosts again if needed
|
|
330
|
+
this.#serial(() => this.#sendOpen(ctx));
|
|
331
|
+
}
|
|
332
|
+
else if (request.kind === "control" && ctx.state === "CONTROL_SYNC") {
|
|
333
|
+
this.#serial(() => this.#controlRound(ctx, ctx.lastHeads));
|
|
334
|
+
}
|
|
335
|
+
else if (request.kind === "keys" && ctx.state === "KEY_SYNC") {
|
|
336
|
+
this.#requestKeys(ctx, now);
|
|
337
|
+
}
|
|
338
|
+
else if (request.kind === "snapshot" && ctx.snapshotPending) {
|
|
339
|
+
// A Snapshot is an optimization (§29.2): without it, replay the units.
|
|
340
|
+
this.#error("TIMEOUT", "the offered Snapshot did not arrive; replaying units instead", ctx.binding.resourceId);
|
|
341
|
+
this.#serial(() => this.#afterSnapshot(ctx));
|
|
342
|
+
}
|
|
343
|
+
else if (request.kind === "data-get" && ctx.state === "DATA_SYNC") {
|
|
344
|
+
ctx.lastRound = ""; // a lost reply is not a round without progress
|
|
345
|
+
ctx.expected = [];
|
|
346
|
+
this.#serial(() => this.#dataRound(ctx));
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
#move(ctx, event) {
|
|
351
|
+
const next = resourcePhaseTransition(ctx.state, event);
|
|
352
|
+
if (next === undefined)
|
|
353
|
+
return false;
|
|
354
|
+
ctx.state = next;
|
|
355
|
+
if (next === "LIVE") {
|
|
356
|
+
ctx.refusedAttempt = 0;
|
|
357
|
+
ctx.rehosted = false;
|
|
358
|
+
}
|
|
359
|
+
this.#emit({ type: "resource-state", resourceId: ctx.binding.resourceId, state: next });
|
|
360
|
+
if (next === "LIVE" && ctx.controlStale) {
|
|
361
|
+
ctx.controlStale = false;
|
|
362
|
+
this.#refreshControl(ctx);
|
|
363
|
+
}
|
|
364
|
+
else if (next === "CLOSED")
|
|
365
|
+
ctx.controlStale = false; // reopening syncs Control anyway
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
#ctx(resource) {
|
|
369
|
+
return this.#resources.get(toHex(resource));
|
|
370
|
+
}
|
|
371
|
+
#error(code, message, resourceId) {
|
|
372
|
+
this.#emit({
|
|
373
|
+
type: "error",
|
|
374
|
+
code,
|
|
375
|
+
message,
|
|
376
|
+
...(resourceId === undefined ? {} : { resourceId }),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
async #onReady(ready) {
|
|
380
|
+
this.#attempt = 0;
|
|
381
|
+
this.#maxMessageBytes = Number(ready.maxMessageBytes);
|
|
382
|
+
this.#o.outbound.session({
|
|
383
|
+
durability: ready.durability,
|
|
384
|
+
maxMessageBytes: ready.maxMessageBytes,
|
|
385
|
+
});
|
|
386
|
+
for (const ctx of this.#resources.values())
|
|
387
|
+
if (ctx.wanted)
|
|
388
|
+
await this.#sendOpen(ctx);
|
|
389
|
+
}
|
|
390
|
+
async #onClosed(reason) {
|
|
391
|
+
this.#emit({ type: "connection", state: "DISCONNECTED", reason });
|
|
392
|
+
for (const r of this.#requests.values())
|
|
393
|
+
if (r.kind === "host")
|
|
394
|
+
r.reject(new Error(`the connection closed before RESOURCE_HOSTED: ${reason}`));
|
|
395
|
+
this.#requests.clear();
|
|
396
|
+
this.#sentAt.clear();
|
|
397
|
+
for (const ctx of this.#resources.values()) {
|
|
398
|
+
this.#move(ctx, "CLOSE"); // §65, G-SM1: every Resource closes with the connection
|
|
399
|
+
ctx.view = null;
|
|
400
|
+
ctx.offering.clear();
|
|
401
|
+
}
|
|
402
|
+
await this.#o.outbound.connectionLost(iso(this.#o.now()));
|
|
403
|
+
if (this.#stopped)
|
|
404
|
+
return;
|
|
405
|
+
this.#attempt += 1;
|
|
406
|
+
const delay = (this.#o.reconnect ?? defaultReconnect)(this.#attempt);
|
|
407
|
+
this.#reconnectAt = delay === null ? null : this.#o.now() + delay;
|
|
408
|
+
}
|
|
409
|
+
async #sendOpen(ctx) {
|
|
410
|
+
if (ctx.state !== "CLOSED")
|
|
411
|
+
return;
|
|
412
|
+
const R = ctx.binding.resourceId;
|
|
413
|
+
const chain = await loadControlChain(this.#o.storage, R);
|
|
414
|
+
const heads = chain?.kind === "linear" ? [{ seq: chain.state.seq, recordId: chain.state.head }] : [];
|
|
415
|
+
const have = (await resourceSyncState(this.#o.storage, R)).have;
|
|
416
|
+
ctx.fetched = [];
|
|
417
|
+
ctx.controlTarget = null;
|
|
418
|
+
ctx.early = [];
|
|
419
|
+
ctx.reopenAt = null;
|
|
420
|
+
ctx.keysOffered = false;
|
|
421
|
+
this.#request({ kind: "open", resource: toHex(R) }, createMessage("RESOURCE_OPEN", {
|
|
422
|
+
resourceId: R,
|
|
423
|
+
heads,
|
|
424
|
+
haves: haveToLive(have),
|
|
425
|
+
flags: SUBSCRIBE_DATA_AND_CONTROL,
|
|
426
|
+
}));
|
|
427
|
+
this.#move(ctx, "OPEN");
|
|
428
|
+
}
|
|
429
|
+
// -------------------------------------------------------------------------
|
|
430
|
+
// inbound
|
|
431
|
+
async #onMessage(m) {
|
|
432
|
+
const request = m.correlationId === undefined ? undefined : this.#requests.get(toHex(m.correlationId));
|
|
433
|
+
switch (m.type) {
|
|
434
|
+
case "RESOURCE_HOSTED":
|
|
435
|
+
if (request?.kind === "host") {
|
|
436
|
+
this.#requests.delete(toHex(m.correlationId));
|
|
437
|
+
await this.#markHosted(parseControlRecord(request.genesis).payload.resourceId);
|
|
438
|
+
request.resolve(m.body.durability);
|
|
439
|
+
}
|
|
440
|
+
else if (request?.kind === "rehost") {
|
|
441
|
+
this.#requests.delete(toHex(m.correlationId));
|
|
442
|
+
const ctx = this.#resources.get(request.resource);
|
|
443
|
+
if (ctx === undefined)
|
|
444
|
+
return;
|
|
445
|
+
this.#emit({
|
|
446
|
+
type: "rehost",
|
|
447
|
+
resourceId: ctx.binding.resourceId,
|
|
448
|
+
url: this.#o.url,
|
|
449
|
+
outcome: "hosted",
|
|
450
|
+
});
|
|
451
|
+
if (ctx.wanted)
|
|
452
|
+
await this.#sendOpen(ctx);
|
|
453
|
+
}
|
|
454
|
+
return;
|
|
455
|
+
case "RESOURCE_OPENED": {
|
|
456
|
+
const ctx = this.#ctx(m.body.resourceId);
|
|
457
|
+
if (ctx === undefined || ctx.state !== "OPENING")
|
|
458
|
+
return;
|
|
459
|
+
this.#done(m);
|
|
460
|
+
ctx.remoteHave = normalizeLiveHaves(m.body.haves);
|
|
461
|
+
ctx.offered = m.body.snapshot ?? null;
|
|
462
|
+
await this.#markHosted(ctx.binding.resourceId);
|
|
463
|
+
this.#move(ctx, "OPENED");
|
|
464
|
+
// §68.1, §88 step 6: what the server lacks goes first, Control Records before units.
|
|
465
|
+
await this.#offerControl(ctx, m.body.heads);
|
|
466
|
+
await this.#offerData(ctx);
|
|
467
|
+
await this.#controlRound(ctx, m.body.heads);
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
case "CONTROL_HAVE": {
|
|
471
|
+
const ctx = this.#ctx(m.body.resourceId);
|
|
472
|
+
this.#done(m);
|
|
473
|
+
if (ctx !== undefined && ctx.state === "LIVE")
|
|
474
|
+
await this.#onControlHeads(ctx, m.body.heads);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
case "CONTROL_BATCH": {
|
|
478
|
+
const ctx = this.#ctx(m.body.resourceId);
|
|
479
|
+
if (ctx === undefined)
|
|
480
|
+
return;
|
|
481
|
+
const solicited = request?.kind === "control";
|
|
482
|
+
if (!solicited && ctx.state === "LIVE")
|
|
483
|
+
this.#move(ctx, "CONTROL_RECORD"); // a live push (§65)
|
|
484
|
+
if (ctx.state !== "CONTROL_SYNC") {
|
|
485
|
+
// A push during key or data sync: validated in place.
|
|
486
|
+
if (ctx.view !== null && !solicited)
|
|
487
|
+
await this.#onControlRecords(ctx, m.body.objects, true);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
await this.#onControlRecords(ctx, m.body.objects, solicited);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
case "KEY_PACKAGE_BATCH": {
|
|
494
|
+
const ctx = this.#ctx(m.body.resourceId);
|
|
495
|
+
this.#done(m);
|
|
496
|
+
if (ctx !== undefined)
|
|
497
|
+
await this.#onKeyPackages(ctx, m.body.objects);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
case "DATA_BATCH": {
|
|
501
|
+
const ctx = this.#ctx(m.body.resourceId);
|
|
502
|
+
if (ctx !== undefined)
|
|
503
|
+
await this.#onUnits(ctx, m.body.objects);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
case "SNAPSHOT": {
|
|
507
|
+
const ctx = this.#ctx(m.body.resourceId);
|
|
508
|
+
this.#done(m);
|
|
509
|
+
if (ctx?.snapshotPending)
|
|
510
|
+
await this.#onSnapshot(ctx, m.body.snapshot);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
case "DATA_HAVE": {
|
|
514
|
+
const ctx = this.#ctx(m.body.resourceId);
|
|
515
|
+
this.#done(m);
|
|
516
|
+
if (ctx === undefined)
|
|
517
|
+
return;
|
|
518
|
+
ctx.remoteHave = normalizeLiveHaves(m.body.haves);
|
|
519
|
+
await this.#offerData(ctx); // §68.1: both directions
|
|
520
|
+
if (ctx.state === "LIVE")
|
|
521
|
+
await this.#dataRound(ctx);
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
case "ACK":
|
|
525
|
+
if (request !== undefined) {
|
|
526
|
+
this.#done(m);
|
|
527
|
+
if (request.kind === "offer-data")
|
|
528
|
+
for (const u of request.units)
|
|
529
|
+
this.#resources.get(request.resource)?.offering.delete(u.id);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
await this.#onAck(m);
|
|
533
|
+
return;
|
|
534
|
+
case "NACK":
|
|
535
|
+
if (request !== undefined) {
|
|
536
|
+
this.#done(m);
|
|
537
|
+
await this.#onRequestNack(request, m);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
await this.#onNack(m);
|
|
541
|
+
return;
|
|
542
|
+
case "ERROR":
|
|
543
|
+
this.#error(`ERROR ${m.body.code}`, m.body.diagnostic ?? "the server reported an error");
|
|
544
|
+
return;
|
|
545
|
+
default:
|
|
546
|
+
return; // PONG and anything this client does not use
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
/** Forgets the request a reply answers (multi-batch replies keep it until the round ends). */
|
|
550
|
+
#done(m) {
|
|
551
|
+
if (m.correlationId !== undefined && m.type !== "CONTROL_BATCH" && m.type !== "DATA_BATCH")
|
|
552
|
+
this.#requests.delete(toHex(m.correlationId));
|
|
553
|
+
}
|
|
554
|
+
async #onRequestNack(request, m) {
|
|
555
|
+
// The §62 name (e.g. AUTHORIZATION_FAILED), so applications can explain it; unknown codes keep their number.
|
|
556
|
+
const code = NACK_NAME.get(m.body.code) ?? `NACK ${m.body.code}`;
|
|
557
|
+
if (request.kind === "offer-control" ||
|
|
558
|
+
request.kind === "offer-data" ||
|
|
559
|
+
request.kind === "offer-keys") {
|
|
560
|
+
this.#onOfferNack(request, code, m.body.diagnostic);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (request.kind === "open" && code === "RESOURCE_NOT_HOSTED") {
|
|
564
|
+
const ctx = this.#resources.get(request.resource);
|
|
565
|
+
if (ctx !== undefined && (await this.#rehost(ctx)))
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (request.kind === "rehost") {
|
|
569
|
+
const ctx = this.#resources.get(request.resource);
|
|
570
|
+
if (ctx === undefined)
|
|
571
|
+
return;
|
|
572
|
+
this.#emit({
|
|
573
|
+
type: "rehost",
|
|
574
|
+
resourceId: ctx.binding.resourceId,
|
|
575
|
+
url: this.#o.url,
|
|
576
|
+
outcome: "refused",
|
|
577
|
+
code,
|
|
578
|
+
});
|
|
579
|
+
this.#error(code, `re-hosting refused${m.body.diagnostic ? `: ${m.body.diagnostic}` : ""}`, ctx.binding.resourceId);
|
|
580
|
+
// §41.1: stop re-hosting on this route and tell the user; never retried by itself.
|
|
581
|
+
this.#refuse(ctx, {
|
|
582
|
+
code,
|
|
583
|
+
url: this.#o.url,
|
|
584
|
+
request: "rehost",
|
|
585
|
+
...(m.body.diagnostic === undefined ? {} : { diagnostic: m.body.diagnostic }),
|
|
586
|
+
});
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
if (request.kind === "host") {
|
|
590
|
+
request.reject(new Error(`RESOURCE_HOST refused: NACK ${code}${m.body.diagnostic ? ` (${m.body.diagnostic})` : ""}`));
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
const ctx = this.#resources.get(request.resource);
|
|
594
|
+
this.#error(code, `${request.kind} refused${m.body.diagnostic ? `: ${m.body.diagnostic}` : ""}`, ctx?.binding.resourceId);
|
|
595
|
+
if (ctx === undefined || request.kind === "close")
|
|
596
|
+
return;
|
|
597
|
+
if (TERMINAL_RESOURCE_CODES.has(code)) {
|
|
598
|
+
this.#refuse(ctx, {
|
|
599
|
+
code,
|
|
600
|
+
url: this.#o.url,
|
|
601
|
+
request: request.kind,
|
|
602
|
+
...(m.body.diagnostic === undefined ? {} : { diagnostic: m.body.diagnostic }),
|
|
603
|
+
});
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (request.kind === "keys")
|
|
607
|
+
this.#keyBlocked(ctx);
|
|
608
|
+
// A Snapshot is an optimization (§29.2): without it, replay the units.
|
|
609
|
+
else if (request.kind === "snapshot") {
|
|
610
|
+
if (ctx.snapshotPending)
|
|
611
|
+
this.#serial(() => this.#afterSnapshot(ctx));
|
|
612
|
+
}
|
|
613
|
+
// A transient refusal of the open or of a sync round: the Resource
|
|
614
|
+
// would otherwise sit there until the next reconnect. Start over after
|
|
615
|
+
// a backoff. A refused DATA_HAVE is simply sent again (§69).
|
|
616
|
+
else if (request.kind !== "data-have")
|
|
617
|
+
this.#retryLater(ctx);
|
|
618
|
+
}
|
|
619
|
+
/** The server refused the Resource for good: CLOSED, not reopened until open() asks again. */
|
|
620
|
+
#refuse(ctx, refusal) {
|
|
621
|
+
const subscribed = ctx.state !== "CLOSED" && ctx.state !== "OPENING";
|
|
622
|
+
ctx.wanted = false;
|
|
623
|
+
ctx.reopenAt = null;
|
|
624
|
+
ctx.refusal = Object.freeze(refusal);
|
|
625
|
+
// Stop any pushes the server may still send for it (§43).
|
|
626
|
+
if (subscribed && this.#connection.state === "READY")
|
|
627
|
+
this.#request({ kind: "close", resource: toHex(ctx.binding.resourceId) }, createMessage("RESOURCE_CLOSE", { resourceId: ctx.binding.resourceId }));
|
|
628
|
+
this.#move(ctx, "CLOSE");
|
|
629
|
+
this.#emit({ type: "resource-refused", resourceId: ctx.binding.resourceId, refusal });
|
|
630
|
+
}
|
|
631
|
+
/** CLOSED now, opened again after the ReconnectPolicy's delay for this Resource's attempt. */
|
|
632
|
+
#retryLater(ctx) {
|
|
633
|
+
if (ctx.state !== "CLOSED" && ctx.state !== "OPENING" && this.#connection.state === "READY")
|
|
634
|
+
this.#request({ kind: "close", resource: toHex(ctx.binding.resourceId) }, createMessage("RESOURCE_CLOSE", { resourceId: ctx.binding.resourceId }));
|
|
635
|
+
this.#move(ctx, "CLOSE");
|
|
636
|
+
ctx.refusedAttempt += 1;
|
|
637
|
+
const delay = (this.#o.reconnect ?? defaultReconnect)(ctx.refusedAttempt);
|
|
638
|
+
ctx.reopenAt = delay === null ? null : this.#o.now() + delay;
|
|
639
|
+
}
|
|
640
|
+
// -------------------------------------------------------------------------
|
|
641
|
+
// Control (§44-§47, §67)
|
|
642
|
+
async #onControlHeads(ctx, heads) {
|
|
643
|
+
const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
|
|
644
|
+
const local = chain?.kind === "linear" ? localControlOf(chain) : null;
|
|
645
|
+
const plan = planControlSync(local, heads);
|
|
646
|
+
if (plan.kind === "peer-behind")
|
|
647
|
+
await this.#offerControl(ctx, heads); // §68.1
|
|
648
|
+
if (plan.kind === "in-sync" || plan.kind === "peer-behind" || plan.kind === "peer-empty")
|
|
649
|
+
return;
|
|
650
|
+
this.#move(ctx, "CONTROL_RECORD");
|
|
651
|
+
await this.#controlRound(ctx, heads);
|
|
652
|
+
}
|
|
653
|
+
async #controlRound(ctx, heads) {
|
|
654
|
+
const R = ctx.binding.resourceId;
|
|
655
|
+
const chain = await loadControlChain(this.#o.storage, R);
|
|
656
|
+
const local = chain?.kind === "linear" ? localControlOf(chain) : null;
|
|
657
|
+
const plan = planControlSync(local, heads);
|
|
658
|
+
ctx.fetched = [];
|
|
659
|
+
ctx.lastHeads = heads;
|
|
660
|
+
if (plan.kind === "fetch" || plan.kind === "fork") {
|
|
661
|
+
const range = plan.kind === "fetch" ? plan : plan.fetch;
|
|
662
|
+
ctx.controlTarget = range.end;
|
|
663
|
+
this.#request({ kind: "control", resource: toHex(R) }, createMessage("CONTROL_GET", { resourceId: R, start: range.start, end: range.end }));
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
if (chain?.kind !== "linear") {
|
|
667
|
+
this.#error("INVALID_CONTROL_CHAIN", "no valid local Control Chain and nothing to fetch", R);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
await this.#controlComplete(ctx, chain);
|
|
671
|
+
}
|
|
672
|
+
async #onControlRecords(ctx, records, solicited) {
|
|
673
|
+
const R = ctx.binding.resourceId;
|
|
674
|
+
const stored = await loadControlChain(this.#o.storage, R);
|
|
675
|
+
const storedRecords = stored?.kind === "linear" ? stored.records.map((r) => r.signed.bytes) : [];
|
|
676
|
+
const localSeq = stored?.kind === "linear" ? stored.state.seq : -1n;
|
|
677
|
+
if (!solicited) {
|
|
678
|
+
// A pushed record that does not continue our chain: fetch the gap first.
|
|
679
|
+
const seqs = records.map((r) => parseControlRecord(r).payload.controlSeq);
|
|
680
|
+
const min = seqs.reduce((a, b) => (b < a ? b : a), seqs[0] ?? 0n);
|
|
681
|
+
const max = seqs.reduce((a, b) => (b > a ? b : a), seqs[0] ?? 0n);
|
|
682
|
+
ctx.controlTarget = max;
|
|
683
|
+
if (min > localSeq + 1n) {
|
|
684
|
+
ctx.fetched = [...records];
|
|
685
|
+
this.#request({ kind: "control", resource: toHex(R) }, createMessage("CONTROL_GET", { resourceId: R, start: localSeq + 1n, end: max }));
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
ctx.fetched.push(...records);
|
|
690
|
+
const byId = new Map();
|
|
691
|
+
for (const b of [...storedRecords, ...ctx.fetched])
|
|
692
|
+
byId.set(toHex(parseControlRecord(b).signed.id), b);
|
|
693
|
+
const ordered = [...byId.values()]
|
|
694
|
+
.map((b) => ({ b, p: parseControlRecord(b) }))
|
|
695
|
+
.sort((x, y) => x.p.payload.controlSeq < y.p.payload.controlSeq
|
|
696
|
+
? -1
|
|
697
|
+
: x.p.payload.controlSeq > y.p.payload.controlSeq
|
|
698
|
+
? 1
|
|
699
|
+
: toHex(x.p.signed.id) < toHex(y.p.signed.id)
|
|
700
|
+
? -1
|
|
701
|
+
: 1)
|
|
702
|
+
.map((x) => x.b);
|
|
703
|
+
const result = validateControlChain(ordered);
|
|
704
|
+
if (result.kind === "conflict") {
|
|
705
|
+
await saveControlConflict(this.#o.storage, R, result);
|
|
706
|
+
if (ctx.state === "CONTROL_SYNC")
|
|
707
|
+
this.#move(ctx, "FORK");
|
|
708
|
+
this.#emit({ type: "control-conflict", resourceId: R, heads: result.competing });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (result.kind === "invalid") {
|
|
712
|
+
// Several replies may still be on their way; an incomplete chain is not yet invalid.
|
|
713
|
+
if (result.problem === "MALFORMED" ||
|
|
714
|
+
result.problem === "UNSUPPORTED_TYPE" ||
|
|
715
|
+
ctx.controlTarget === null) {
|
|
716
|
+
this.#error(result.wireCode, `the server's Control Records do not validate (${result.problem})`, R);
|
|
717
|
+
}
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (ctx.controlTarget !== null && result.state.seq < ctx.controlTarget)
|
|
721
|
+
return; // more batches to come
|
|
722
|
+
const saved = await saveControlChain(this.#o.storage, result, stored?.kind === "linear" ? stored.state.head : null);
|
|
723
|
+
if (!saved.ok) {
|
|
724
|
+
this.#error("CONTROL_HEAD_MISMATCH", "the stored Control Head moved during sync", R);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
for (const [k, r] of this.#requests)
|
|
728
|
+
if (r.kind === "control" && r.resource === toHex(R))
|
|
729
|
+
this.#requests.delete(k);
|
|
730
|
+
ctx.fetched = [];
|
|
731
|
+
ctx.controlTarget = null;
|
|
732
|
+
await this.#controlComplete(ctx, result, stored?.kind === "linear" ? stored : null);
|
|
733
|
+
}
|
|
734
|
+
/** A newly validated Control state: reconcile epochs (G-EP7, §88 step 7), release queued puts, then keys. */
|
|
735
|
+
async #controlComplete(ctx, chain, previous = null) {
|
|
736
|
+
const R = ctx.binding.resourceId;
|
|
737
|
+
const before = previous ?? ctx.view;
|
|
738
|
+
ctx.view = chain;
|
|
739
|
+
// DEKs we already hold for epochs the saved chain now has (our own
|
|
740
|
+
// rotation, or a package that came before the row): no request needed.
|
|
741
|
+
await adoptStoredDeks(this.#o.storage, this.#o.secrets, chain);
|
|
742
|
+
const epochsChanged = before === null ||
|
|
743
|
+
[...chain.state.epochs.values()].some((e) => {
|
|
744
|
+
const old = before.state.epochs.get(String(e.epoch));
|
|
745
|
+
return old === undefined || (old.closedBy === null) !== (e.closedBy === null);
|
|
746
|
+
});
|
|
747
|
+
if (epochsChanged) {
|
|
748
|
+
const applied = await ctx.binding.applier.reconcileEpochs(chain);
|
|
749
|
+
const outbound = await this.#o.outbound.reconcileEpochs(chain);
|
|
750
|
+
if (applied.snapshotDropped)
|
|
751
|
+
ctx.covered = []; // SNAP-EP: covered units are fetched again
|
|
752
|
+
if (applied.excluded.length > 0 || outbound.length > 0 || applied.snapshotDropped) {
|
|
753
|
+
ctx.binding.checkpointer?.noteChange();
|
|
754
|
+
this.#emit({ type: "epoch-reconciled", resourceId: R, applied, outbound });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
await this.#o.outbound.controlSynced(R);
|
|
758
|
+
if (ctx.state === "CONTROL_SYNC") {
|
|
759
|
+
this.#move(ctx, "CONTROL_COMPLETE");
|
|
760
|
+
this.#requestKeys(ctx, this.#o.now());
|
|
761
|
+
}
|
|
762
|
+
else if (ctx.state === "KEY_BLOCKED" && epochsChanged) {
|
|
763
|
+
this.#requestKeys(ctx, this.#o.now());
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
// -------------------------------------------------------------------------
|
|
767
|
+
// Keys (§52, §53, §66 step 2)
|
|
768
|
+
async #epochsWithoutDek(ctx) {
|
|
769
|
+
if (ctx.view !== null)
|
|
770
|
+
await adoptStoredDeks(this.#o.storage, this.#o.secrets, ctx.view);
|
|
771
|
+
const rows = await this.#o.storage.control.epochs(ctx.binding.resourceId);
|
|
772
|
+
const out = [];
|
|
773
|
+
for (const e of ctx.view?.state.epochs.values() ?? []) {
|
|
774
|
+
const row = rows.find((r) => r.epoch === e.epoch);
|
|
775
|
+
if (row?.dekRef == null || (await this.#o.secrets.get(row.dekRef)) === undefined)
|
|
776
|
+
out.push(dataEpoch(e.epoch));
|
|
777
|
+
}
|
|
778
|
+
return out;
|
|
779
|
+
}
|
|
780
|
+
#requestKeys(ctx, now) {
|
|
781
|
+
this.#serial(async () => {
|
|
782
|
+
if (ctx.view === null || (ctx.state !== "KEY_SYNC" && ctx.state !== "KEY_BLOCKED"))
|
|
783
|
+
return;
|
|
784
|
+
const missing = await this.#epochsWithoutDek(ctx);
|
|
785
|
+
if (missing.length === 0) {
|
|
786
|
+
if (ctx.state === "KEY_BLOCKED")
|
|
787
|
+
this.#move(ctx, "PACKAGE_ARRIVED");
|
|
788
|
+
this.#move(ctx, "DEK_AVAILABLE");
|
|
789
|
+
await this.#startData(ctx);
|
|
790
|
+
return;
|
|
791
|
+
}
|
|
792
|
+
ctx.missingEpochs = missing;
|
|
793
|
+
ctx.lastKeyRequest = now;
|
|
794
|
+
const R = ctx.binding.resourceId;
|
|
795
|
+
this.#request({ kind: "keys", resource: toHex(R) }, createMessage("KEY_PACKAGE_GET", {
|
|
796
|
+
resourceId: R,
|
|
797
|
+
recipient: this.#o.signer.descriptor.principalId,
|
|
798
|
+
// §52: at most 256 epochs per request, the newest first: the
|
|
799
|
+
// current epoch's DEK unblocks the Resource; older ones only let
|
|
800
|
+
// their units apply, and a later round asks for them.
|
|
801
|
+
epochs: missing.slice(-256),
|
|
802
|
+
}));
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
async #onKeyPackages(ctx, packages) {
|
|
806
|
+
const view = ctx.view;
|
|
807
|
+
if (view === null)
|
|
808
|
+
return;
|
|
809
|
+
const R = ctx.binding.resourceId;
|
|
810
|
+
const recipient = { descriptor: this.#o.signer.descriptor, agreement: this.#o.agreement };
|
|
811
|
+
for (const bytes of packages) {
|
|
812
|
+
const r = await receiveKeyPackage(view, bytes, recipient);
|
|
813
|
+
if (r.kind !== "opened") {
|
|
814
|
+
this.#error(r.kind === "rejected" ? r.wireCode : r.code, `a Key Package was not used (${r.kind})`, R);
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
// Secret first, then the row that references it (LFCP-034). The
|
|
818
|
+
// package itself is kept while we keep the Resource (§86), to
|
|
819
|
+
// re-supply a server that lost it (§68.1).
|
|
820
|
+
const ref = dekSecretRef(R, r.epoch);
|
|
821
|
+
await this.#o.secrets.put(ref, exportSecretKeyBytes(r.dek));
|
|
822
|
+
const parsed = parseKeyPackage(bytes);
|
|
823
|
+
const writes = [
|
|
824
|
+
{
|
|
825
|
+
op: "put-key-package",
|
|
826
|
+
row: {
|
|
827
|
+
packageId: hash32(parsed.signed.id),
|
|
828
|
+
resourceId: R,
|
|
829
|
+
dataEpoch: parsed.payload.dataEpoch,
|
|
830
|
+
recipient: parsed.payload.recipient,
|
|
831
|
+
sender: parsed.payload.sender,
|
|
832
|
+
bytes: parsed.signed.bytes,
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
];
|
|
836
|
+
const row = (await this.#o.storage.control.epochs(R)).find((e) => e.epoch === r.epoch);
|
|
837
|
+
if (row !== undefined) {
|
|
838
|
+
const next = { ...row, dekRef: ref };
|
|
839
|
+
writes.push({ op: "put-epoch", resourceId: R, epoch: next });
|
|
840
|
+
}
|
|
841
|
+
await this.#o.storage.commit(writes);
|
|
842
|
+
}
|
|
843
|
+
const missing = await this.#epochsWithoutDek(ctx);
|
|
844
|
+
const current = view.state.epoch.epoch;
|
|
845
|
+
if (missing.some((e) => e === current)) {
|
|
846
|
+
this.#keyBlocked(ctx);
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (missing.length > 0)
|
|
850
|
+
this.#emit({ type: "key-blocked", resourceId: R, epochs: missing }); // older epochs: their units wait
|
|
851
|
+
if (ctx.state === "KEY_BLOCKED")
|
|
852
|
+
this.#move(ctx, "PACKAGE_ARRIVED");
|
|
853
|
+
if (ctx.state === "KEY_SYNC") {
|
|
854
|
+
this.#move(ctx, "DEK_AVAILABLE");
|
|
855
|
+
await this.#startData(ctx);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
#keyBlocked(ctx) {
|
|
859
|
+
if (ctx.state === "KEY_SYNC")
|
|
860
|
+
this.#move(ctx, "KEY_UNAVAILABLE");
|
|
861
|
+
this.#emit({
|
|
862
|
+
type: "key-blocked",
|
|
863
|
+
resourceId: ctx.binding.resourceId,
|
|
864
|
+
epochs: ctx.missingEpochs,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
// -------------------------------------------------------------------------
|
|
868
|
+
// Data (§48-§51, §68, §69)
|
|
869
|
+
async #startData(ctx) {
|
|
870
|
+
// Accepted units on disk that the profile state lacks (a crash before
|
|
871
|
+
// the checkpoint caught up) are applied again before any new unit.
|
|
872
|
+
if (ctx.view !== null) {
|
|
873
|
+
const R = ctx.binding.resourceId;
|
|
874
|
+
// Snapshots that crashed the engine twice are never loaded again.
|
|
875
|
+
for (const item of await this.#snapshots.recover(R))
|
|
876
|
+
this.#error("INVALID_AUTOMERGE_BYTES", `Snapshot ${item.slice("snapshot:".length)} crashed the profile engine twice; it is not loaded again on this device`, R);
|
|
877
|
+
const r = await ctx.binding.applier.replayStored(ctx.view);
|
|
878
|
+
if (r.replayed.length > 0 || r.skipped.length > 0 || r.crashed.length > 0) {
|
|
879
|
+
ctx.binding.checkpointer?.noteChange();
|
|
880
|
+
this.#emit({ type: "replayed", resourceId: R, ...r });
|
|
881
|
+
}
|
|
882
|
+
for (const unitId of r.crashed)
|
|
883
|
+
this.#error("INVALID_AUTOMERGE_BYTES", `Data Unit ${toHex(unitId)} crashed the profile engine twice; it is quarantined on this device`, R);
|
|
884
|
+
}
|
|
885
|
+
await this.#flush(ctx); // §88 step 6: upload locally queued valid units
|
|
886
|
+
if (await this.#snapshotUseful(ctx)) {
|
|
887
|
+
// §66 step 3: the preferred Snapshot first, then the units beyond it.
|
|
888
|
+
const R = ctx.binding.resourceId;
|
|
889
|
+
ctx.snapshotPending = true;
|
|
890
|
+
this.#request({ kind: "snapshot", resource: toHex(R) }, createMessage("SNAPSHOT_GET", {
|
|
891
|
+
resourceId: R,
|
|
892
|
+
snapshotId: ctx.offered.snapshotId,
|
|
893
|
+
}));
|
|
894
|
+
return;
|
|
895
|
+
}
|
|
896
|
+
await this.#afterSnapshot(ctx);
|
|
897
|
+
}
|
|
898
|
+
async #afterSnapshot(ctx) {
|
|
899
|
+
ctx.snapshotPending = false;
|
|
900
|
+
const early = ctx.early;
|
|
901
|
+
ctx.early = [];
|
|
902
|
+
await this.#dataRound(ctx);
|
|
903
|
+
if (early.length > 0)
|
|
904
|
+
await this.#onUnits(ctx, early);
|
|
905
|
+
}
|
|
906
|
+
/** The offered Snapshot holds units we lack, we can load it, and we did not load it before. */
|
|
907
|
+
async #snapshotUseful(ctx) {
|
|
908
|
+
const offered = ctx.offered;
|
|
909
|
+
if (offered === null || ctx.binding.snapshot === undefined)
|
|
910
|
+
return false;
|
|
911
|
+
if ((await this.#o.storage.snapshots.get(offered.snapshotId)) !== undefined)
|
|
912
|
+
return false;
|
|
913
|
+
const R = ctx.binding.resourceId;
|
|
914
|
+
await this.#snapshots.recover(R);
|
|
915
|
+
if (this.#snapshots.suspicion(R, snapshotItem(offered.snapshotId)) === 2)
|
|
916
|
+
return false;
|
|
917
|
+
const local = (await resourceSyncState(this.#o.storage, ctx.binding.resourceId)).have;
|
|
918
|
+
return missingFrom(local, normalizeLiveHaves(offered.frontier)).length > 0;
|
|
919
|
+
}
|
|
920
|
+
async #onSnapshot(ctx, bytes) {
|
|
921
|
+
const R = ctx.binding.resourceId;
|
|
922
|
+
const view = ctx.view;
|
|
923
|
+
const binding = ctx.binding.snapshot;
|
|
924
|
+
if (view !== null && binding !== undefined) {
|
|
925
|
+
// Decode and load under the crash-loop breaker; a trap in decode is
|
|
926
|
+
// rethrown, not reported as the Snapshot's local failure.
|
|
927
|
+
let trap;
|
|
928
|
+
const codec = binding.codec;
|
|
929
|
+
const guarded = {
|
|
930
|
+
dataProfile: codec.dataProfile,
|
|
931
|
+
encode: (v) => codec.encode(v),
|
|
932
|
+
decode: (plaintext) => {
|
|
933
|
+
try {
|
|
934
|
+
return codec.decode(plaintext);
|
|
935
|
+
}
|
|
936
|
+
catch (e) {
|
|
937
|
+
if (isEngineTrap(e))
|
|
938
|
+
trap = e;
|
|
939
|
+
throw e;
|
|
940
|
+
}
|
|
941
|
+
},
|
|
942
|
+
};
|
|
943
|
+
const r = await this.#snapshots.run(R, [snapshotItem(sha256(bytes))], async () => {
|
|
944
|
+
const received = await receiveSnapshot(view, bytes, {
|
|
945
|
+
dek: dekResolver(this.#o.storage, this.#o.secrets, R),
|
|
946
|
+
profile: guarded,
|
|
947
|
+
});
|
|
948
|
+
if (trap !== undefined)
|
|
949
|
+
throw trap;
|
|
950
|
+
if (received.kind === "accepted")
|
|
951
|
+
binding.load(received.value);
|
|
952
|
+
return received;
|
|
953
|
+
});
|
|
954
|
+
if (r.kind === "accepted") {
|
|
955
|
+
const frontier = encode(canonicalFrontierToCbor(r.frontier));
|
|
956
|
+
await this.#o.storage.commit([
|
|
957
|
+
{
|
|
958
|
+
op: "put-snapshot",
|
|
959
|
+
row: {
|
|
960
|
+
snapshotId: r.snapshotId,
|
|
961
|
+
resourceId: R,
|
|
962
|
+
dataEpoch: r.epoch,
|
|
963
|
+
publisher: r.publisher,
|
|
964
|
+
snapshotSeq: r.seq,
|
|
965
|
+
frontier,
|
|
966
|
+
bytes,
|
|
967
|
+
},
|
|
968
|
+
},
|
|
969
|
+
]);
|
|
970
|
+
ctx.binding.checkpointer?.noteChange();
|
|
971
|
+
this.#emit({
|
|
972
|
+
type: "snapshot-loaded",
|
|
973
|
+
resourceId: R,
|
|
974
|
+
snapshotId: r.snapshotId,
|
|
975
|
+
frontier: r.frontier,
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
else {
|
|
979
|
+
const code = r.kind === "local-failure" ? r.reason : r.wireCode;
|
|
980
|
+
this.#error(code, `the offered Snapshot was not loaded (${r.kind}); replaying units instead`, R);
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
await this.#afterSnapshot(ctx);
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Publishes a Snapshot of the Resource's current state (§29): its frontier
|
|
987
|
+
* is every merged unit and every stored Snapshot's frontier, so a client
|
|
988
|
+
* that loads it never skips content the state does not hold. Queued and
|
|
989
|
+
* sent like any object; returns its ID.
|
|
990
|
+
*/
|
|
991
|
+
async publishSnapshot(resource) {
|
|
992
|
+
const ctx = this.#resources.get(toHex(resource));
|
|
993
|
+
if (ctx === undefined)
|
|
994
|
+
throw new LfcpError("UNSUPPORTED_VALUE", "the Resource is not open here");
|
|
995
|
+
let id;
|
|
996
|
+
await new Promise((resolve, reject) => this.#serial(async () => {
|
|
997
|
+
try {
|
|
998
|
+
id = await this.#publish(ctx);
|
|
999
|
+
resolve();
|
|
1000
|
+
}
|
|
1001
|
+
catch (e) {
|
|
1002
|
+
reject(e);
|
|
1003
|
+
}
|
|
1004
|
+
}));
|
|
1005
|
+
return id;
|
|
1006
|
+
}
|
|
1007
|
+
async #publish(ctx) {
|
|
1008
|
+
const R = ctx.binding.resourceId;
|
|
1009
|
+
const binding = ctx.binding.snapshot;
|
|
1010
|
+
const view = ctx.view;
|
|
1011
|
+
if (binding === undefined || view === null)
|
|
1012
|
+
throw new LfcpError("UNSUPPORTED_VALUE", "no Snapshot binding, or the Control Chain is not synchronized");
|
|
1013
|
+
const dek = await dekResolver(this.#o.storage, this.#o.secrets, R)(view.state.epoch.epoch);
|
|
1014
|
+
if (dek === undefined)
|
|
1015
|
+
throw new LfcpError("UNSUPPORTED_VALUE", "no DEK for the current epoch");
|
|
1016
|
+
let frontier = await snapshotFrontier(this.#o.storage, R);
|
|
1017
|
+
for (const u of await this.#o.storage.dataUnits.withStatus(R, "merged"))
|
|
1018
|
+
if (u.accepted)
|
|
1019
|
+
frontier = addSequence(frontier, u.actor, u.actorSeq);
|
|
1020
|
+
const created = await createQueuedSnapshot(this.#o.storage, {
|
|
1021
|
+
view,
|
|
1022
|
+
controlHead: view.state.head,
|
|
1023
|
+
publisher: this.#o.signer,
|
|
1024
|
+
dek,
|
|
1025
|
+
frontier: haveToLive(frontier),
|
|
1026
|
+
profile: binding.codec,
|
|
1027
|
+
value: binding.current(),
|
|
1028
|
+
});
|
|
1029
|
+
ctx.unitsSinceSnapshot = 0;
|
|
1030
|
+
this.#emit({ type: "snapshot-published", resourceId: R, snapshotId: created.snapshotId });
|
|
1031
|
+
await this.#flush(ctx);
|
|
1032
|
+
return created.snapshotId;
|
|
1033
|
+
}
|
|
1034
|
+
async #dataRound(ctx) {
|
|
1035
|
+
const R = ctx.binding.resourceId;
|
|
1036
|
+
ctx.covered = await snapshotFrontier(this.#o.storage, R);
|
|
1037
|
+
const local = (await resourceSyncState(this.#o.storage, R)).have;
|
|
1038
|
+
// Beyond a loaded Snapshot's frontier (missingAfter: `local` includes it),
|
|
1039
|
+
// plus each actor's last covered unit, so the next one links (§26.2).
|
|
1040
|
+
const missing = [...(await this.#boundary(ctx)), ...missingFrom(local, ctx.remoteHave)];
|
|
1041
|
+
if (missing.length === 0) {
|
|
1042
|
+
ctx.lastRound = "";
|
|
1043
|
+
if (ctx.state === "DATA_SYNC")
|
|
1044
|
+
this.#move(ctx, "FRONTIER_REACHED");
|
|
1045
|
+
ctx.expected = [];
|
|
1046
|
+
ctx.received = [];
|
|
1047
|
+
return;
|
|
1048
|
+
}
|
|
1049
|
+
// A round that asks again for exactly what the last one asked for made no
|
|
1050
|
+
// progress (e.g. the server lacks units it announced): stop instead of
|
|
1051
|
+
// looping; periodic anti-entropy tries again.
|
|
1052
|
+
const key = missing.map((r) => `${toHex(r.actor)}:${r.start}-${r.end}`).join(",");
|
|
1053
|
+
if (key === ctx.lastRound) {
|
|
1054
|
+
ctx.lastRound = "";
|
|
1055
|
+
this.#error("NO_PROGRESS", "a data round fetched nothing new; will retry on the next DATA_HAVE", R);
|
|
1056
|
+
if (ctx.state === "DATA_SYNC")
|
|
1057
|
+
this.#move(ctx, "FRONTIER_REACHED");
|
|
1058
|
+
ctx.expected = [];
|
|
1059
|
+
ctx.received = [];
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
ctx.lastRound = key;
|
|
1063
|
+
if (ctx.state === "LIVE")
|
|
1064
|
+
this.#move(ctx, "MISSING_RANGES");
|
|
1065
|
+
// Ranges stay intervals: the work is bounded by their number, never by
|
|
1066
|
+
// their span (a Have may announce up to 2^64 - 1 sequences).
|
|
1067
|
+
let expected = [];
|
|
1068
|
+
for (const r of missing)
|
|
1069
|
+
expected = addRange(expected, r.actor, r.start, r.end);
|
|
1070
|
+
ctx.expected = expected;
|
|
1071
|
+
ctx.received = [];
|
|
1072
|
+
for (const batch of batchDataRanges(missing))
|
|
1073
|
+
this.#request({ kind: "data-get", resource: toHex(R) }, createMessage("DATA_GET", {
|
|
1074
|
+
resourceId: R,
|
|
1075
|
+
ranges: batch.map((r) => ({ actor: r.actor, start: r.start, end: r.end })),
|
|
1076
|
+
}));
|
|
1077
|
+
}
|
|
1078
|
+
/** The last unit of each actor run a stored Snapshot covers, when we do not hold it yet. */
|
|
1079
|
+
async #boundary(ctx) {
|
|
1080
|
+
const R = ctx.binding.resourceId;
|
|
1081
|
+
const out = [];
|
|
1082
|
+
for (const h of ctx.covered)
|
|
1083
|
+
for (const seq of [
|
|
1084
|
+
...(h.contiguous > 0n ? [h.contiguous] : []),
|
|
1085
|
+
...h.extras.map(([, end]) => end),
|
|
1086
|
+
]) {
|
|
1087
|
+
if (!hasSequence(ctx.remoteHave, h.principalId, seq))
|
|
1088
|
+
continue;
|
|
1089
|
+
if ((await this.#o.storage.dataUnits.acceptedAt(R, h.principalId, actorSequence(seq))) !==
|
|
1090
|
+
undefined)
|
|
1091
|
+
continue;
|
|
1092
|
+
out.push({ actor: h.principalId, start: seq, end: seq });
|
|
1093
|
+
}
|
|
1094
|
+
return out;
|
|
1095
|
+
}
|
|
1096
|
+
async #onUnits(ctx, units) {
|
|
1097
|
+
const view = ctx.view;
|
|
1098
|
+
if (view === null ||
|
|
1099
|
+
ctx.state === "KEY_SYNC" ||
|
|
1100
|
+
ctx.state === "KEY_BLOCKED" ||
|
|
1101
|
+
ctx.state === "CONTROL_SYNC" ||
|
|
1102
|
+
ctx.snapshotPending) {
|
|
1103
|
+
ctx.early.push(...units);
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
const R = ctx.binding.resourceId;
|
|
1107
|
+
let changed = false;
|
|
1108
|
+
let needControl = false;
|
|
1109
|
+
// Units a loaded Snapshot covers are accepted as covered, one by one;
|
|
1110
|
+
// runs of the others go to the applier together (receiveBatch), so the
|
|
1111
|
+
// profile merges a catch-up at once.
|
|
1112
|
+
const outcomes = [];
|
|
1113
|
+
let run = [];
|
|
1114
|
+
const flushRun = async () => {
|
|
1115
|
+
if (run.length > 0)
|
|
1116
|
+
outcomes.push(...(await ctx.binding.applier.receiveBatch(view, run)));
|
|
1117
|
+
run = [];
|
|
1118
|
+
};
|
|
1119
|
+
for (const bytes of units) {
|
|
1120
|
+
let covered = false;
|
|
1121
|
+
try {
|
|
1122
|
+
const p = parseDataUnit(bytes).payload;
|
|
1123
|
+
ctx.received = addSequence(ctx.received, p.actor, p.actorSeq);
|
|
1124
|
+
covered = hasSequence(ctx.covered, p.actor, p.actorSeq);
|
|
1125
|
+
}
|
|
1126
|
+
catch {
|
|
1127
|
+
// malformed: the applier reports it
|
|
1128
|
+
}
|
|
1129
|
+
if (!covered) {
|
|
1130
|
+
run.push(bytes);
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
await flushRun();
|
|
1134
|
+
outcomes.push(await ctx.binding.applier.acceptCovered(view, bytes));
|
|
1135
|
+
}
|
|
1136
|
+
await flushRun();
|
|
1137
|
+
for (const outcome of outcomes) {
|
|
1138
|
+
if (outcome.kind === "applied" || outcome.kind === "profile-pending") {
|
|
1139
|
+
changed = true;
|
|
1140
|
+
ctx.unitsSinceSnapshot += 1;
|
|
1141
|
+
}
|
|
1142
|
+
if (outcome.kind === "rejected" && outcome.wireCode === "MISSING_DEPENDENCY")
|
|
1143
|
+
needControl = true;
|
|
1144
|
+
this.#emit({ type: "unit", resourceId: R, outcome });
|
|
1145
|
+
// Held units this one released (§26.2): their outcomes too.
|
|
1146
|
+
const released = "released" in outcome ? outcome.released : [];
|
|
1147
|
+
for (const r of released) {
|
|
1148
|
+
if (r.kind === "applied" || r.kind === "profile-pending")
|
|
1149
|
+
changed = true;
|
|
1150
|
+
this.#emit({ type: "unit", resourceId: R, outcome: r });
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
if (changed)
|
|
1154
|
+
ctx.binding.checkpointer?.noteChange();
|
|
1155
|
+
if (needControl)
|
|
1156
|
+
this.#refreshControl(ctx);
|
|
1157
|
+
if (ctx.state === "DATA_SYNC" &&
|
|
1158
|
+
ctx.expected.length > 0 &&
|
|
1159
|
+
missingFrom(ctx.received, ctx.expected).length === 0) {
|
|
1160
|
+
ctx.expected = [];
|
|
1161
|
+
await this.#dataRound(ctx);
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
#sendHave(ctx, now) {
|
|
1165
|
+
ctx.lastHave = now;
|
|
1166
|
+
this.#serial(async () => {
|
|
1167
|
+
const R = ctx.binding.resourceId;
|
|
1168
|
+
const have = (await resourceSyncState(this.#o.storage, R)).have;
|
|
1169
|
+
this.#request({ kind: "data-have", resource: toHex(R) }, createMessage("DATA_HAVE", { resourceId: R, haves: haveToLive(have) }));
|
|
1170
|
+
});
|
|
1171
|
+
}
|
|
1172
|
+
#refreshControl(ctx) {
|
|
1173
|
+
this.#serial(async () => {
|
|
1174
|
+
const R = ctx.binding.resourceId;
|
|
1175
|
+
const chain = await loadControlChain(this.#o.storage, R);
|
|
1176
|
+
const heads = chain?.kind === "linear" ? [{ seq: chain.state.seq, recordId: chain.state.head }] : [];
|
|
1177
|
+
this.#request({ kind: "control", resource: toHex(R) }, createMessage("CONTROL_HAVE", { resourceId: R, heads }));
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
// -------------------------------------------------------------------------
|
|
1181
|
+
// Outbound (§51, §54, §57, §59, §60, §88)
|
|
1182
|
+
async #flush(ctx) {
|
|
1183
|
+
if (this.#connection.state !== "READY")
|
|
1184
|
+
return;
|
|
1185
|
+
if (ctx.state !== "DATA_SYNC" && ctx.state !== "LIVE")
|
|
1186
|
+
return;
|
|
1187
|
+
const messages = await this.#o.outbound.next(ctx.binding.resourceId, iso(this.#o.now()));
|
|
1188
|
+
for (const m of messages)
|
|
1189
|
+
this.#connection.sendEncoded(m.bytes);
|
|
1190
|
+
}
|
|
1191
|
+
async #onAck(m) {
|
|
1192
|
+
const outcome = await this.#o.outbound.onAck(m, iso(this.#o.now()));
|
|
1193
|
+
this.#emit({ type: "ack", outcome });
|
|
1194
|
+
// Our own Control Record was committed: catch up, since the server does not push it back to us.
|
|
1195
|
+
if (m.body.requestType === MESSAGE_TYPE.CONTROL_PUT && outcome.acked.length > 0)
|
|
1196
|
+
for (const ctx of this.#resources.values())
|
|
1197
|
+
if (ctx.state === "LIVE")
|
|
1198
|
+
this.#refreshControl(ctx);
|
|
1199
|
+
else
|
|
1200
|
+
ctx.controlStale = true;
|
|
1201
|
+
}
|
|
1202
|
+
async #onNack(m) {
|
|
1203
|
+
const outcome = await this.#o.outbound.onNack(m, iso(this.#o.now()));
|
|
1204
|
+
this.#emit({ type: "nack", outcome });
|
|
1205
|
+
if (outcome.kind === "needs-control-sync")
|
|
1206
|
+
for (const ctx of this.#resources.values())
|
|
1207
|
+
if (ctx.state === "LIVE")
|
|
1208
|
+
this.#refreshControl(ctx);
|
|
1209
|
+
// §51.1: the server lost what our unit names. Its DATA_HAVE answer
|
|
1210
|
+
// starts the offer (§68.1), which releases the held items.
|
|
1211
|
+
if (outcome.kind === "needs-offer") {
|
|
1212
|
+
const ctx = this.#ctx(outcome.resourceId);
|
|
1213
|
+
if (ctx !== undefined && (ctx.state === "LIVE" || ctx.state === "DATA_SYNC"))
|
|
1214
|
+
this.#sendHave(ctx, this.#o.now());
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
// -------------------------------------------------------------------------
|
|
1218
|
+
// Offering what the server lacks (§68.1) and re-hosting (§41.1)
|
|
1219
|
+
async #markHosted(resource) {
|
|
1220
|
+
const key = hostedMark(resource, this.#o.url);
|
|
1221
|
+
if ((await this.#o.storage.localMarks.get(key)) !== undefined)
|
|
1222
|
+
return;
|
|
1223
|
+
await this.#o.storage.commit([{ op: "put-local-mark", key, value: "1" }]);
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* §41.1: RESOURCE_NOT_HOSTED from a route in the Resource's route set that
|
|
1227
|
+
* hosted or opened it for us before: host it again from the exact Genesis
|
|
1228
|
+
* bytes, then open it. False when re-hosting is not allowed here.
|
|
1229
|
+
*/
|
|
1230
|
+
async #rehost(ctx) {
|
|
1231
|
+
if (ctx.rehosted || this.#connection.state !== "READY")
|
|
1232
|
+
return false;
|
|
1233
|
+
const R = ctx.binding.resourceId;
|
|
1234
|
+
const chain = await loadControlChain(this.#o.storage, R);
|
|
1235
|
+
if (chain?.kind !== "linear")
|
|
1236
|
+
return false;
|
|
1237
|
+
const route = chain.state.route;
|
|
1238
|
+
const url = this.#o.url;
|
|
1239
|
+
if (!route.endpoints.some((e) => e.url === url) && route.coordinatorUrl !== url)
|
|
1240
|
+
return false;
|
|
1241
|
+
if ((await this.#o.storage.localMarks.get(hostedMark(R, url))) === undefined)
|
|
1242
|
+
return false;
|
|
1243
|
+
const genesis = chain.records[0];
|
|
1244
|
+
if (genesis === undefined || genesis.payload.controlSeq !== 0n)
|
|
1245
|
+
return false;
|
|
1246
|
+
ctx.rehosted = true;
|
|
1247
|
+
this.#move(ctx, "CLOSE");
|
|
1248
|
+
this.#request({ kind: "rehost", resource: toHex(R) }, createMessage("RESOURCE_HOST", { genesis: genesis.signed.bytes }));
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* §68.1: the server's Control Head is a record we hold below our own head:
|
|
1253
|
+
* upload the records above it, one CONTROL_PUT each, in order, each
|
|
1254
|
+
* expecting the record before it.
|
|
1255
|
+
*/
|
|
1256
|
+
async #offerControl(ctx, heads) {
|
|
1257
|
+
if (this.#connection.state !== "READY")
|
|
1258
|
+
return;
|
|
1259
|
+
const R = ctx.binding.resourceId;
|
|
1260
|
+
const chain = await loadControlChain(this.#o.storage, R);
|
|
1261
|
+
if (chain?.kind !== "linear")
|
|
1262
|
+
return;
|
|
1263
|
+
const plan = planControlSync(localControlOf(chain), heads);
|
|
1264
|
+
if (plan.kind !== "peer-behind")
|
|
1265
|
+
return;
|
|
1266
|
+
for (const record of chain.records) {
|
|
1267
|
+
if (record.payload.controlSeq <= plan.peerSeq)
|
|
1268
|
+
continue;
|
|
1269
|
+
const previous = record.payload.prevControlId;
|
|
1270
|
+
if (previous === null)
|
|
1271
|
+
continue;
|
|
1272
|
+
this.#request({ kind: "offer-control", resource: toHex(R) }, createMessage("CONTROL_PUT", {
|
|
1273
|
+
resourceId: R,
|
|
1274
|
+
expectedHead: previous,
|
|
1275
|
+
record: record.signed.bytes,
|
|
1276
|
+
}));
|
|
1277
|
+
}
|
|
1278
|
+
await this.#offerKeys(ctx);
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* §68.1: the accepted units the server's Have Vector lacks, ours and
|
|
1282
|
+
* other actors' (relay), per actor in ascending sequence. Units still in
|
|
1283
|
+
* our outbound queue go through it instead. Then the queued items held
|
|
1284
|
+
* for UNKNOWN_PREVIOUS are released behind them.
|
|
1285
|
+
*/
|
|
1286
|
+
async #offerData(ctx) {
|
|
1287
|
+
if (this.#connection.state !== "READY")
|
|
1288
|
+
return;
|
|
1289
|
+
const R = ctx.binding.resourceId;
|
|
1290
|
+
const queued = new Set((await this.#o.storage.outbound.list(R)).map((o) => toHex(o.itemId)));
|
|
1291
|
+
let local = [];
|
|
1292
|
+
for (const status of OFFERABLE)
|
|
1293
|
+
for (const u of await this.#o.storage.dataUnits.withStatus(R, status))
|
|
1294
|
+
if (u.accepted)
|
|
1295
|
+
local = addSequence(local, u.actor, u.actorSeq);
|
|
1296
|
+
const { offer } = haveDifference(local, ctx.remoteHave);
|
|
1297
|
+
const offerable = new Set(OFFERABLE);
|
|
1298
|
+
let sent = 0;
|
|
1299
|
+
for (const range of offer) {
|
|
1300
|
+
const units = (await this.#o.storage.dataUnits.range(R, range.actor, actorSequence(range.start), actorSequence(range.end))).filter((u) => u.accepted &&
|
|
1301
|
+
offerable.has(u.status) &&
|
|
1302
|
+
!queued.has(toHex(u.unitId)) &&
|
|
1303
|
+
!ctx.offering.has(toHex(u.unitId)));
|
|
1304
|
+
sent += units.length;
|
|
1305
|
+
this.#offerUnits(ctx, units.map((u) => ({ id: toHex(u.unitId), bytes: u.bytes })));
|
|
1306
|
+
}
|
|
1307
|
+
await this.#o.outbound.offered(R);
|
|
1308
|
+
if (sent > 0)
|
|
1309
|
+
await this.#offerKeys(ctx);
|
|
1310
|
+
await this.#flush(ctx);
|
|
1311
|
+
}
|
|
1312
|
+
/** DATA_PUTs of one actor's units in order, within the message size and object limits. */
|
|
1313
|
+
#offerUnits(ctx, units) {
|
|
1314
|
+
const R = ctx.binding.resourceId;
|
|
1315
|
+
const limit = this.#maxMessageBytes - 1024;
|
|
1316
|
+
let batch = [];
|
|
1317
|
+
let size = 0;
|
|
1318
|
+
const send = () => {
|
|
1319
|
+
if (batch.length === 0)
|
|
1320
|
+
return;
|
|
1321
|
+
for (const u of batch)
|
|
1322
|
+
ctx.offering.add(u.id);
|
|
1323
|
+
this.#request({ kind: "offer-data", resource: toHex(R), units: batch }, createMessage("DATA_PUT", { resourceId: R, objects: batch.map((u) => u.bytes) }));
|
|
1324
|
+
batch = [];
|
|
1325
|
+
size = 0;
|
|
1326
|
+
};
|
|
1327
|
+
for (const u of units) {
|
|
1328
|
+
if (batch.length >= OFFER_BATCH || size + u.bytes.length > limit)
|
|
1329
|
+
send();
|
|
1330
|
+
batch.push(u);
|
|
1331
|
+
size += u.bytes.length;
|
|
1332
|
+
}
|
|
1333
|
+
send();
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* §68.1, §86: a server that lacked Control Records or units may have lost
|
|
1337
|
+
* Key Packages too: the ones we sent or that are addressed to us are
|
|
1338
|
+
* uploaded again, once per open. A package the server stores is answered
|
|
1339
|
+
* as the first time (§70).
|
|
1340
|
+
*/
|
|
1341
|
+
async #offerKeys(ctx) {
|
|
1342
|
+
if (ctx.keysOffered || this.#connection.state !== "READY")
|
|
1343
|
+
return;
|
|
1344
|
+
ctx.keysOffered = true;
|
|
1345
|
+
const R = ctx.binding.resourceId;
|
|
1346
|
+
const me = toHex(this.#o.signer.descriptor.principalId);
|
|
1347
|
+
const packages = (await this.#o.storage.keyPackages.list(R)).filter((k) => toHex(k.sender) === me || toHex(k.recipient) === me);
|
|
1348
|
+
const limit = this.#maxMessageBytes - 1024;
|
|
1349
|
+
for (let i = 0; i < packages.length;) {
|
|
1350
|
+
const batch = [];
|
|
1351
|
+
let size = 0;
|
|
1352
|
+
while (i < packages.length && batch.length < OFFER_BATCH) {
|
|
1353
|
+
const bytes = packages[i].bytes;
|
|
1354
|
+
if (batch.length > 0 && size + bytes.length > limit)
|
|
1355
|
+
break;
|
|
1356
|
+
batch.push(bytes);
|
|
1357
|
+
size += bytes.length;
|
|
1358
|
+
i++;
|
|
1359
|
+
}
|
|
1360
|
+
this.#request({ kind: "offer-keys", resource: toHex(R) }, createMessage("KEY_PACKAGE_PUT", { resourceId: R, objects: batch }));
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* A refused offer. A DATA_PUT is all-or-nothing (§51): each unit of a
|
|
1365
|
+
* refused batch is offered again alone. ACTOR_EQUIVOCATION for a relayed
|
|
1366
|
+
* unit is expected when the server holds the other unit of a pair, and
|
|
1367
|
+
* UNKNOWN_PREVIOUS when it lacks an earlier unit we do not hold either:
|
|
1368
|
+
* neither is an alarm. A refused Control Record (the server's head moved)
|
|
1369
|
+
* is caught up by the next Control round.
|
|
1370
|
+
*/
|
|
1371
|
+
#onOfferNack(request, code, diagnostic) {
|
|
1372
|
+
const ctx = this.#resources.get(request.resource);
|
|
1373
|
+
if (ctx === undefined)
|
|
1374
|
+
return;
|
|
1375
|
+
if (request.kind === "offer-data") {
|
|
1376
|
+
for (const u of request.units)
|
|
1377
|
+
ctx.offering.delete(u.id);
|
|
1378
|
+
if (request.units.length > 1) {
|
|
1379
|
+
for (const u of request.units)
|
|
1380
|
+
this.#offerUnits(ctx, [u]);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if (code === "ACTOR_EQUIVOCATION" || code === "UNKNOWN_PREVIOUS")
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
if (request.kind === "offer-control" && code === "CONTROL_HEAD_MISMATCH")
|
|
1387
|
+
return;
|
|
1388
|
+
this.#error(code, `an object offered to the server was refused (§68.1)${diagnostic ? `: ${diagnostic}` : ""}`, ctx.binding.resourceId);
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
/**
|
|
1392
|
+
* A thin timer driver for SyncClient: calls tick(now) every `intervalMs`
|
|
1393
|
+
* with the given clock and timer functions (setInterval/clearInterval in
|
|
1394
|
+
* production). Returns the stop function. The core never sets timers
|
|
1395
|
+
* itself; this is the one explicit place that does.
|
|
1396
|
+
*/
|
|
1397
|
+
export function startSyncDriver(client, timers, intervalMs = 1000) {
|
|
1398
|
+
const handle = timers.setInterval(() => client.tick(timers.now()), intervalMs);
|
|
1399
|
+
return () => timers.clearInterval(handle);
|
|
1400
|
+
}
|
|
1401
|
+
//# sourceMappingURL=sync-client.js.map
|