@openlfcp/client 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,607 @@
1
+ import { bytesEqual, dataUnitId, fromHex, hash32, toHex, } from "@openlfcp/core";
2
+ import { sha256 } from "@openlfcp/crypto";
3
+ import { addSequence, canonicalFrontierFromCbor, classifyDataUnit, createMessage, DEFAULT_MAX_MESSAGE_BYTES, ERROR_CODE, encodeMessage, MESSAGE_TYPE, parseControlRecord, parseDataUnit, unionHaves, } from "@openlfcp/wire";
4
+ import { decodeDeterministic } from "@openlfcp/wire/cbor";
5
+ /** Exponential backoff from `baseMs`, doubling per attempt, capped at `maxMs`. */
6
+ export function exponentialBackoff(baseMs, maxMs) {
7
+ return {
8
+ nextAttempt: ({ item, now }) => {
9
+ const delay = Math.min(maxMs, baseMs * 2 ** Math.max(0, item.attempts - 1));
10
+ return new Date(Date.parse(now) + delay).toISOString();
11
+ },
12
+ };
13
+ }
14
+ const KIND_TYPE = {
15
+ "control-record": "CONTROL_PUT",
16
+ "key-package": "KEY_PACKAGE_PUT",
17
+ "data-unit": "DATA_PUT",
18
+ snapshot: "SNAPSHOT_PUT",
19
+ };
20
+ /** §88: Control Plane first, then Key Packages, Data Units, Snapshots. */
21
+ const KIND_ORDER = [
22
+ "control-record",
23
+ "key-package",
24
+ "data-unit",
25
+ "snapshot",
26
+ ];
27
+ /** NACK codes that concern one object: a multi-object message is split to find it. */
28
+ const PER_OBJECT = new Set([
29
+ "STALE_DATA_EPOCH",
30
+ "ACTOR_EQUIVOCATION",
31
+ "AUTHORIZATION_FAILED",
32
+ "MESSAGE_TOO_LARGE",
33
+ "INVALID_SIGNATURE",
34
+ "MALFORMED_MESSAGE",
35
+ ]);
36
+ const CODE_NAMES = new Map(Object.entries(ERROR_CODE).map(([name, code]) => [code, name]));
37
+ /** Room left in a message for the envelope and body around the objects. */
38
+ const OVERHEAD = 1024;
39
+ const AT_ONCE = { nextAttempt: () => null };
40
+ /** Data Units by actor, then actor sequence; unparsable bytes last (they are blocked when sent). */
41
+ function unitOrder(a, b) {
42
+ const key = (o) => {
43
+ try {
44
+ const p = parseDataUnit(o.bytes).payload;
45
+ return [toHex(p.actor), p.actorSeq];
46
+ }
47
+ catch {
48
+ return ["~", 0n];
49
+ }
50
+ };
51
+ const [x, y] = [key(a), key(b)];
52
+ if (x[0] !== y[0])
53
+ return x[0] < y[0] ? -1 : 1;
54
+ return x[1] < y[1] ? -1 : x[1] > y[1] ? 1 : 0;
55
+ }
56
+ export class OutboundQueue {
57
+ #storage;
58
+ #retry;
59
+ #maxObjects;
60
+ #minimumDurability;
61
+ #recentAckLimit;
62
+ #timeout;
63
+ /** Session parameters from READY. */
64
+ #durability = 0n;
65
+ #maxMessageBytes = DEFAULT_MAX_MESSAGE_BYTES;
66
+ /** message ID hex → what it carried */
67
+ #flights = new Map();
68
+ /** item ID hex → message ID hex */
69
+ #inFlight = new Map();
70
+ /**
71
+ * Unanswered sends in a row per item (request timeouts), for the
72
+ * timeout backoff: an answered send (ACK or NACK) starts it again, so
73
+ * refusals the client recovers from never lengthen the wait.
74
+ */
75
+ #unanswered = new Map();
76
+ /** Items to send one per message (to find which one a per-object NACK meant). */
77
+ #solo = new Set();
78
+ /** Items waiting for a Control sync (MISSING_DEPENDENCY). */
79
+ #awaitingControl = new Set();
80
+ /** Items waiting until what the server lacks was offered (UNKNOWN_PREVIOUS, §68.1). */
81
+ #awaitingOffer = new Map();
82
+ constructor(options) {
83
+ this.#storage = options.storage;
84
+ this.#retry = options.retry ?? AT_ONCE;
85
+ this.#maxObjects = Math.max(1, options.maxObjectsPerMessage ?? 64);
86
+ this.#minimumDurability = options.minimumDurability ?? 0n;
87
+ this.#recentAckLimit = options.recentAckLimit ?? 256;
88
+ this.#timeout = options.requestTimeout ?? { baseMs: 10_000, maxMs: 60_000 };
89
+ }
90
+ /**
91
+ * Messages whose answer is overdue: their items leave flight and are due
92
+ * again, after the RetryPolicy's delay for a "timeout". A late answer
93
+ * still removes the items it names (onAck matches by object ID).
94
+ */
95
+ async #expire(now) {
96
+ const at = Date.parse(now);
97
+ const overdue = [];
98
+ for (const [key, flight] of this.#flights) {
99
+ if (flight.deadline > at)
100
+ continue;
101
+ this.#flights.delete(key);
102
+ for (const id of flight.itemIds)
103
+ if (this.#inFlight.get(toHex(id)) === key) {
104
+ this.#inFlight.delete(toHex(id));
105
+ this.#unanswered.set(toHex(id), (this.#unanswered.get(toHex(id)) ?? 0) + 1);
106
+ overdue.push(id);
107
+ }
108
+ }
109
+ if (overdue.length > 0)
110
+ await this.#retryLater(overdue, "timeout", now);
111
+ }
112
+ /** READY: the server's durability level and maximum message size for this session (§37). */
113
+ session(ready) {
114
+ this.#durability = ready.durability;
115
+ this.#maxMessageBytes = Number(ready.maxMessageBytes);
116
+ }
117
+ async #commit(writes) {
118
+ if (writes.length === 0)
119
+ return;
120
+ const r = await this.#storage.commit(writes);
121
+ if (!r.ok)
122
+ throw new Error(`unexpected storage precondition failure: ${r.reason}`);
123
+ }
124
+ /**
125
+ * The messages to send now for `resource`: every unblocked item that is
126
+ * not in flight, not waiting for a Control sync and due by `now`, in §88
127
+ * order, batched where the message type allows. Each item's attempt is
128
+ * recorded durably before the messages are returned. An item too large
129
+ * for any message is blocked ("too-large") instead.
130
+ */
131
+ async next(resource, now) {
132
+ await this.#expire(now);
133
+ const due = (await this.#storage.outbound.list(resource)).filter((o) => o.blocked === null &&
134
+ !this.#inFlight.has(toHex(o.itemId)) &&
135
+ !this.#awaitingControl.has(toHex(o.itemId)) &&
136
+ !this.#awaitingOffer.has(toHex(o.itemId)) &&
137
+ (o.nextAttempt === null || o.nextAttempt <= now));
138
+ const out = [];
139
+ const writes = [];
140
+ const limit = this.#maxMessageBytes - OVERHEAD;
141
+ for (const kind of KIND_ORDER) {
142
+ // §51, §51.1: a server checks a DATA_PUT's units in message order, so
143
+ // each actor's units go oldest first (the `previous` link of a unit
144
+ // can then be an earlier unit of the same message).
145
+ const items = due.filter((o) => o.kind === kind);
146
+ if (kind === "data-unit")
147
+ items.sort(unitOrder);
148
+ let batch = [];
149
+ let size = 0;
150
+ const flush = () => {
151
+ if (batch.length === 0)
152
+ return;
153
+ out.push(this.#message(resource, kind, batch));
154
+ batch = [];
155
+ size = 0;
156
+ };
157
+ for (const item of items) {
158
+ // Fail closed on local corruption: an object ID is the SHA-256 of its
159
+ // exact bytes, so other bytes are never sent (nor "repaired").
160
+ if (!bytesEqual(sha256(item.bytes), item.itemId)) {
161
+ writes.push({
162
+ op: "update-outbound",
163
+ itemId: item.itemId,
164
+ blocked: {
165
+ reason: "rejected",
166
+ detail: "local corruption: the stored bytes do not hash to the object ID",
167
+ },
168
+ });
169
+ continue;
170
+ }
171
+ if (item.bytes.length > limit) {
172
+ writes.push({
173
+ op: "update-outbound",
174
+ itemId: item.itemId,
175
+ blocked: { reason: "too-large", detail: `${item.bytes.length} bytes > ${limit}` },
176
+ });
177
+ continue;
178
+ }
179
+ const batchable = (kind === "data-unit" || kind === "key-package") && !this.#solo.has(toHex(item.itemId));
180
+ if (!batchable) {
181
+ flush();
182
+ batch = [item];
183
+ flush();
184
+ continue;
185
+ }
186
+ if (batch.length >= this.#maxObjects || size + item.bytes.length > limit)
187
+ flush();
188
+ batch.push(item);
189
+ size += item.bytes.length;
190
+ }
191
+ flush();
192
+ }
193
+ for (const m of out)
194
+ for (const id of m.itemIds) {
195
+ const item = due.find((o) => bytesEqual(o.itemId, id));
196
+ writes.push({
197
+ op: "update-outbound",
198
+ itemId: id,
199
+ attempts: item.attempts + 1,
200
+ lastAttempt: now,
201
+ });
202
+ }
203
+ await this.#commit(writes);
204
+ for (const m of out) {
205
+ const key = toHex(m.message.messageId);
206
+ const unanswered = Math.max(...m.itemIds.map((id) => this.#unanswered.get(toHex(id)) ?? 0));
207
+ const wait = Math.min(this.#timeout.maxMs, this.#timeout.baseMs * 2 ** unanswered);
208
+ this.#flights.set(key, {
209
+ resourceId: resource,
210
+ type: m.message.type,
211
+ itemIds: m.itemIds,
212
+ deadline: Date.parse(now) + wait,
213
+ });
214
+ for (const id of m.itemIds)
215
+ this.#inFlight.set(toHex(id), key);
216
+ }
217
+ return out;
218
+ }
219
+ /** A new message, with a new Message ID, around the exact stored bytes. */
220
+ #message(resource, kind, items) {
221
+ let message;
222
+ const first = items[0];
223
+ switch (kind) {
224
+ case "data-unit":
225
+ message = createMessage("DATA_PUT", {
226
+ resourceId: resource,
227
+ objects: items.map((i) => i.bytes),
228
+ });
229
+ break;
230
+ case "key-package":
231
+ message = createMessage("KEY_PACKAGE_PUT", {
232
+ resourceId: resource,
233
+ objects: items.map((i) => i.bytes),
234
+ });
235
+ break;
236
+ case "snapshot":
237
+ message = createMessage("SNAPSHOT_PUT", { resourceId: resource, snapshot: first.bytes });
238
+ break;
239
+ case "control-record": {
240
+ // §47: the expected head is the record's own previous record.
241
+ const prev = parseControlRecord(first.bytes).payload.prevControlId;
242
+ if (prev === null)
243
+ throw new Error("a Genesis record is hosted with RESOURCE_HOST, not CONTROL_PUT");
244
+ message = createMessage("CONTROL_PUT", {
245
+ resourceId: resource,
246
+ expectedHead: prev,
247
+ record: first.bytes,
248
+ });
249
+ break;
250
+ }
251
+ }
252
+ return Object.freeze({
253
+ resourceId: resource,
254
+ message,
255
+ bytes: encodeMessage(message),
256
+ itemIds: Object.freeze(items.map((i) => i.itemId)),
257
+ });
258
+ }
259
+ #land(messageId) {
260
+ if (messageId === undefined)
261
+ return undefined;
262
+ const key = toHex(messageId);
263
+ const flight = this.#flights.get(key);
264
+ if (flight === undefined)
265
+ return undefined;
266
+ this.#flights.delete(key);
267
+ for (const id of flight.itemIds) {
268
+ if (this.#inFlight.get(toHex(id)) === key)
269
+ this.#inFlight.delete(toHex(id));
270
+ this.#unanswered.delete(toHex(id));
271
+ }
272
+ return flight;
273
+ }
274
+ async #retryLater(ids, reason, now, code) {
275
+ const writes = [];
276
+ for (const id of ids) {
277
+ const item = await this.#storage.outbound.get(id);
278
+ if (item === undefined)
279
+ continue;
280
+ writes.push({
281
+ op: "update-outbound",
282
+ itemId: id,
283
+ nextAttempt: this.#retry.nextAttempt({
284
+ item,
285
+ reason,
286
+ now,
287
+ ...(code === undefined ? {} : { code }),
288
+ }),
289
+ });
290
+ }
291
+ await this.#commit(writes);
292
+ }
293
+ /**
294
+ * An ACK (§59): removes the queued items whose IDs it names (field 1),
295
+ * and records them in the Resource's sync state. Items of the
296
+ * acknowledged message it does not name stay queued. Idempotent: a
297
+ * repeated ACK, or one for items already gone, changes nothing.
298
+ */
299
+ async onAck(message, now) {
300
+ const flight = this.#land(message.correlationId);
301
+ const named = message.body.objectIds ?? [];
302
+ const durability = message.body.durable === true ? this.#durability : 0n;
303
+ const requestType = message.body.requestType;
304
+ const acked = [];
305
+ const below = [];
306
+ const byResource = new Map();
307
+ for (const id of named) {
308
+ const item = await this.#storage.outbound.get(id);
309
+ if (item === undefined)
310
+ continue;
311
+ if (MESSAGE_TYPE[KIND_TYPE[item.kind]] !== requestType)
312
+ continue;
313
+ if (flight !== undefined && !bytesEqual(item.resourceId, flight.resourceId))
314
+ continue;
315
+ if (durability < this.#minimumDurability) {
316
+ below.push(item.itemId);
317
+ continue;
318
+ }
319
+ acked.push(item.itemId);
320
+ const key = toHex(item.resourceId);
321
+ byResource.set(key, [...(byResource.get(key) ?? []), item.itemId]);
322
+ this.#forget(item.itemId);
323
+ }
324
+ const writes = acked.map((itemId) => ({ op: "dequeue", itemId }));
325
+ // Units held for UNKNOWN_PREVIOUS behind one of ours that is now on the server.
326
+ const landed = new Set(acked.map((id) => toHex(id)));
327
+ for (const [key, wait] of [...this.#awaitingOffer])
328
+ if (wait.previous !== null && landed.has(wait.previous))
329
+ this.#awaitingOffer.delete(key);
330
+ for (const ids of byResource.values()) {
331
+ const resourceId = (await this.#storage.outbound.get(ids[0]))?.resourceId;
332
+ if (resourceId === undefined)
333
+ continue;
334
+ const old = await this.#storage.syncState.get(resourceId);
335
+ const recent = [
336
+ ...(old?.recentlyAcked ?? []).filter((x) => !ids.some((i) => bytesEqual(i, x))),
337
+ ...ids,
338
+ ];
339
+ writes.push({
340
+ op: "put-sync-state",
341
+ row: {
342
+ resourceId,
343
+ recentlyAcked: recent.slice(-this.#recentAckLimit),
344
+ ackedDurability: durability,
345
+ },
346
+ });
347
+ }
348
+ await this.#commit(writes);
349
+ const notCovered = flight?.itemIds.filter((id) => !acked.some((a) => bytesEqual(a, id)) && !below.some((b) => bytesEqual(b, id))) ?? [];
350
+ const stillQueued = [];
351
+ for (const id of [...notCovered, ...below])
352
+ if ((await this.#storage.outbound.get(id)) !== undefined)
353
+ stillQueued.push(id);
354
+ await this.#retryLater(stillQueued, "not-acked", now);
355
+ return Object.freeze({
356
+ acked: Object.freeze(acked),
357
+ notCovered: Object.freeze(notCovered.filter((id) => stillQueued.some((s) => bytesEqual(s, id)))),
358
+ durability,
359
+ belowDurability: Object.freeze(below),
360
+ correlated: flight !== undefined,
361
+ });
362
+ }
363
+ #forget(itemId) {
364
+ const key = toHex(itemId);
365
+ this.#inFlight.delete(key);
366
+ this.#solo.delete(key);
367
+ this.#awaitingControl.delete(key);
368
+ this.#awaitingOffer.delete(key);
369
+ this.#unanswered.delete(key);
370
+ }
371
+ async #block(ids, reason, detail) {
372
+ const out = [];
373
+ const writes = [];
374
+ for (const id of ids) {
375
+ const item = await this.#storage.outbound.get(id);
376
+ if (item === undefined)
377
+ continue;
378
+ writes.push({ op: "update-outbound", itemId: id, blocked: { reason, detail } });
379
+ out.push(Object.freeze({ itemId: item.itemId, kind: item.kind, reason, detail }));
380
+ this.#forget(id);
381
+ }
382
+ await this.#commit(writes);
383
+ return out;
384
+ }
385
+ /**
386
+ * A NACK (§60) of one of this session's messages. Per-object codes on a
387
+ * message of several objects resend each object alone, to learn which one
388
+ * the server meant. Then:
389
+ * STALE_DATA_EPOCH → blocked "stale-epoch" (G-EP5: never resent);
390
+ * ACTOR_EQUIVOCATION → blocked "equivocation", surfaced as an alarm;
391
+ * AUTHORIZATION_FAILED (and other per-object refusals) → blocked "rejected";
392
+ * MESSAGE_TOO_LARGE → blocked "too-large";
393
+ * CONTROL_HEAD_MISMATCH → blocked "repropose" with the current head;
394
+ * MISSING_DEPENDENCY → retried after the next controlSynced();
395
+ * UNKNOWN_PREVIOUS → retried after the next offered() (§51.1, §68.1);
396
+ * anything else → retried after the RetryPolicy delay.
397
+ */
398
+ async onNack(message, now) {
399
+ const code = CODE_NAMES.get(message.body.code) ?? message.body.code;
400
+ const flight = this.#land(message.correlationId);
401
+ if (flight === undefined)
402
+ return Object.freeze({ kind: "uncorrelated", code });
403
+ const items = [];
404
+ for (const id of flight.itemIds)
405
+ if ((await this.#storage.outbound.get(id)) !== undefined)
406
+ items.push(id);
407
+ const detail = message.body.diagnostic ?? null;
408
+ if (typeof code === "string" && PER_OBJECT.has(code) && items.length > 1) {
409
+ for (const id of items)
410
+ this.#solo.add(toHex(id));
411
+ return Object.freeze({ kind: "isolating", code, items: Object.freeze(items) });
412
+ }
413
+ switch (code) {
414
+ case "STALE_DATA_EPOCH":
415
+ return Object.freeze({
416
+ kind: "stale",
417
+ items: await this.#block(items, "stale-epoch", detail),
418
+ });
419
+ case "ACTOR_EQUIVOCATION":
420
+ return Object.freeze({
421
+ kind: "equivocation-alarm",
422
+ items: await this.#block(items, "equivocation", detail),
423
+ });
424
+ case "MESSAGE_TOO_LARGE":
425
+ return Object.freeze({
426
+ kind: "rejected",
427
+ code,
428
+ items: await this.#block(items, "too-large", detail),
429
+ });
430
+ case "AUTHORIZATION_FAILED":
431
+ case "INVALID_SIGNATURE":
432
+ case "MALFORMED_MESSAGE":
433
+ return Object.freeze({
434
+ kind: "rejected",
435
+ code,
436
+ items: await this.#block(items, "rejected", detail),
437
+ });
438
+ case "CONTROL_HEAD_MISMATCH": {
439
+ const d = message.body.details;
440
+ const currentHead = d instanceof Uint8Array && d.length === 32
441
+ ? hash32(d)
442
+ : null;
443
+ const blocked = await this.#block(items, "repropose", currentHead === null ? detail : `current head ${toHex(currentHead)}`);
444
+ return Object.freeze({ kind: "repropose", items: blocked, currentHead });
445
+ }
446
+ case "MISSING_DEPENDENCY":
447
+ for (const id of items)
448
+ this.#awaitingControl.add(toHex(id));
449
+ return Object.freeze({ kind: "needs-control-sync", items: Object.freeze(items) });
450
+ case "UNKNOWN_PREVIOUS": {
451
+ const d = message.body.details;
452
+ const previous = d instanceof Uint8Array && d.length === 32 ? hash32(d) : null;
453
+ // Each unit waits on its OWN previous: the details name only the
454
+ // first refused unit's, and a later unit of the message may name
455
+ // one that is itself waiting here.
456
+ for (const id of items) {
457
+ const item = await this.#storage.outbound.get(id);
458
+ let own = null;
459
+ try {
460
+ const p = item === undefined ? null : parseDataUnit(item.bytes).payload.prevDataUnitId;
461
+ own = p === null || p === undefined ? null : toHex(p);
462
+ }
463
+ catch {
464
+ own = null;
465
+ }
466
+ this.#awaitingOffer.set(toHex(id), { resource: toHex(flight.resourceId), previous: own });
467
+ }
468
+ // The server lacks a unit of ours we sent before: on one connection
469
+ // it would have handled that message first, so it was lost. It is
470
+ // due again now rather than after its request timeout (a late ACK
471
+ // still removes it).
472
+ for (const wait of this.#awaitingOffer.values())
473
+ if (wait.previous !== null && this.#inFlight.has(wait.previous))
474
+ this.#inFlight.delete(wait.previous);
475
+ return Object.freeze({
476
+ kind: "needs-offer",
477
+ resourceId: flight.resourceId,
478
+ previous,
479
+ items: Object.freeze(items),
480
+ });
481
+ }
482
+ default:
483
+ await this.#retryLater(items, "transient", now, code);
484
+ return Object.freeze({ kind: "retry", code, items: Object.freeze(items) });
485
+ }
486
+ }
487
+ /**
488
+ * The connection is gone: every message in flight is unanswered, so its
489
+ * items are due again (same bytes, a new message) after the RetryPolicy
490
+ * delay. Returns the affected item IDs.
491
+ */
492
+ async connectionLost(now) {
493
+ const ids = [...this.#flights.values()].flatMap((f) => f.itemIds);
494
+ this.#flights.clear();
495
+ this.#inFlight.clear();
496
+ await this.#retryLater(ids, "connection-lost", now);
497
+ return ids;
498
+ }
499
+ /** The Control Plane of `resource` was synchronized: items held for MISSING_DEPENDENCY are due again. */
500
+ async controlSynced(resource) {
501
+ for (const item of await this.#storage.outbound.list(resource))
502
+ this.#awaitingControl.delete(toHex(item.itemId));
503
+ }
504
+ /**
505
+ * What the server of `resource` lacked was offered (§68.1): items held
506
+ * for UNKNOWN_PREVIOUS are due again, behind the offered objects.
507
+ */
508
+ async offered(resource) {
509
+ for (const [key, wait] of [...this.#awaitingOffer]) {
510
+ if (wait.resource !== toHex(resource))
511
+ continue;
512
+ // The unit the server lacks is our own, still queued (e.g. its
513
+ // message was lost): its successors wait for its ACK (onAck), or the
514
+ // server would refuse them again.
515
+ if (wait.previous !== null) {
516
+ const own = await this.#storage.outbound.get(hash32(fromHex(wait.previous)));
517
+ if (own !== undefined && own.blocked === null)
518
+ continue;
519
+ }
520
+ this.#awaitingOffer.delete(key);
521
+ }
522
+ }
523
+ /**
524
+ * §88 step 7, G-EP5: with a newly validated Control view, our queued
525
+ * Data Units of a closed epoch beyond its final frontier are never sent;
526
+ * they are blocked "stale-epoch" and surfaced (also when in flight: a
527
+ * later ACK still removes them). Units within the cutoff stay queued and
528
+ * are sent as the same bytes.
529
+ */
530
+ async reconcileEpochs(view) {
531
+ const out = [];
532
+ const writes = [];
533
+ for (const item of await this.#storage.outbound.list(view.state.resourceId)) {
534
+ if (item.kind !== "data-unit" || item.blocked !== null)
535
+ continue;
536
+ const unit = await this.#storage.dataUnits.get(dataUnitId(item.itemId));
537
+ if (unit === undefined)
538
+ continue;
539
+ const c = classifyDataUnit(view, unit);
540
+ if (c.kind !== "quarantine")
541
+ continue;
542
+ writes.push({
543
+ op: "update-outbound",
544
+ itemId: item.itemId,
545
+ blocked: { reason: "stale-epoch", detail: c.reason },
546
+ });
547
+ this.#solo.delete(toHex(item.itemId));
548
+ out.push(Object.freeze({
549
+ itemId: item.itemId,
550
+ kind: item.kind,
551
+ reason: "stale-epoch",
552
+ detail: c.reason,
553
+ actor: unit.actor,
554
+ seq: unit.actorSeq,
555
+ epoch: unit.dataEpoch,
556
+ }));
557
+ }
558
+ await this.#commit(writes);
559
+ return out;
560
+ }
561
+ /** Removes a blocked item once the application has dealt with it (e.g. re-proposed or re-applied). */
562
+ async discard(itemId) {
563
+ const item = await this.#storage.outbound.get(itemId);
564
+ if (item === undefined)
565
+ return;
566
+ if (item.blocked === null)
567
+ throw new Error("only a blocked item can be discarded; an unsent object would be lost");
568
+ this.#forget(itemId);
569
+ await this.#commit([{ op: "dequeue", itemId }]);
570
+ }
571
+ }
572
+ /** The sync state of `resource` from storage alone (valid after a restart). */
573
+ export async function resourceSyncState(storage, resource) {
574
+ // Units held through a stored (loaded or published) Snapshot count too (§29).
575
+ let have = await snapshotFrontier(storage, resource);
576
+ // Every LFCP-accepted unit is held here, whatever its profile status: a
577
+ // crash between accepting and recording the merge leaves it "seen" or
578
+ // "held" until replayStored applies it.
579
+ for (const status of [
580
+ "merged",
581
+ "profile-pending",
582
+ "profile-held",
583
+ "profile-rejected",
584
+ "seen",
585
+ "held",
586
+ ])
587
+ for (const u of await storage.dataUnits.withStatus(resource, status))
588
+ if (u.accepted)
589
+ have = addSequence(have, u.actor, u.actorSeq);
590
+ const items = await storage.outbound.list(resource);
591
+ const sync = await storage.syncState.get(resource);
592
+ return Object.freeze({
593
+ have,
594
+ outstanding: Object.freeze(items.filter((i) => i.blocked === null)),
595
+ blocked: Object.freeze(items.filter((i) => i.blocked !== null)),
596
+ recentlyAcked: sync?.recentlyAcked ?? [],
597
+ ackedDurability: sync?.ackedDurability ?? null,
598
+ });
599
+ }
600
+ /** The union of the frontiers of the Snapshots stored for `resource` (those loaded or published here). */
601
+ export async function snapshotFrontier(storage, resource) {
602
+ let have = [];
603
+ for (const s of await storage.snapshots.list(resource))
604
+ have = unionHaves(have, canonicalFrontierFromCbor(decodeDeterministic(s.frontier)));
605
+ return have;
606
+ }
607
+ //# sourceMappingURL=outbound.js.map
@@ -0,0 +1,38 @@
1
+ import { type Hash32, type ResourceId } from "@openlfcp/core";
2
+ import { type LfcpStorage, type OutboundItem, type OutboundKind, type SecretStore, type StorageWrite } from "@openlfcp/storage";
3
+ import { type EpochRotation } from "@openlfcp/wire";
4
+ import { type CreatedSnapshot, type CreateSnapshotOptions } from "./snapshot.js";
5
+ /**
6
+ * Putting this client's immutable objects into the outbound queue
7
+ * (LFCP-036): each is stored with its exact bytes, and queued, in ONE
8
+ * atomic commit before it can be sent. The queue (OutboundQueue) sends
9
+ * these same bytes until an ACK names the object.
10
+ */
11
+ /** A new outbound item: never attempted, not blocked. */
12
+ export declare const outboundItem: (kind: OutboundKind, itemId: Hash32, resourceId: ResourceId, bytes: Uint8Array) => OutboundItem;
13
+ /**
14
+ * Queues a Key Epoch this client created (rotateEpoch) and keeps its new
15
+ * DEK: the secret first, under the epoch's standard reference
16
+ * (dekSecretRef), then the record for CONTROL_PUT. The creator never needs
17
+ * a Key Package for a key it made: once the coordinator accepts the record
18
+ * and the chain is saved, the epoch's row takes the stored DEK
19
+ * (adoptStoredDeks) with no network round trip.
20
+ */
21
+ export declare function queueKeyEpoch(storage: Pick<LfcpStorage, "commit">, secrets: SecretStore, rotation: EpochRotation, also?: readonly StorageWrite[]): Promise<Hash32>;
22
+ /** Stores a sealed Key Package (§25) and queues it for KEY_PACKAGE_PUT. */
23
+ export declare function queueKeyPackage(storage: Pick<LfcpStorage, "commit">, bytes: Uint8Array, also?: readonly StorageWrite[]): Promise<Hash32>;
24
+ /** Stores a sealed Snapshot (§29) with its selection metadata and queues it for SNAPSHOT_PUT. */
25
+ export declare function queueSnapshot(storage: Pick<LfcpStorage, "commit">, bytes: Uint8Array, also?: readonly StorageWrite[]): Promise<Hash32>;
26
+ /**
27
+ * Creates a Snapshot with a sequence from the storage's reservation and
28
+ * queues it (see createSnapshot and queueSnapshot): a crash before the
29
+ * commit leaves only an abandoned sequence.
30
+ */
31
+ export declare function createQueuedSnapshot<T>(storage: Pick<LfcpStorage, "snapshotSequences" | "commit">, options: Omit<CreateSnapshotOptions<T>, "sequences">, also?: readonly StorageWrite[]): Promise<CreatedSnapshot>;
32
+ /**
33
+ * Queues a signed Control Record for CONTROL_PUT (§47); its expected head
34
+ * is its own previous record. It joins the stored chain only once a
35
+ * validated chain contains it (saveControlChain).
36
+ */
37
+ export declare function queueControlRecord(storage: Pick<LfcpStorage, "commit">, bytes: Uint8Array, also?: readonly StorageWrite[]): Promise<Hash32>;
38
+ //# sourceMappingURL=queue.d.ts.map