@openlfcp/client 0.1.0-rc.1

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