@openlfcp/client 0.1.5 → 0.2.0-beta.10

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/dist/invite.d.ts CHANGED
@@ -49,7 +49,7 @@ export declare class InvitationLink {
49
49
  /** The usual invitation grant (§18): data/read, data/write, invite/claim. */
50
50
  export declare const DEFAULT_INVITATION_ABILITIES: readonly bigint[];
51
51
  export interface CreateInvitationOptions {
52
- readonly storage: Pick<LfcpStorage, "control" | "commit">;
52
+ readonly storage: Pick<LfcpStorage, "control" | "commit" | "outbound">;
53
53
  readonly resourceId: ResourceId;
54
54
  /** The issuer: needs the authority to grant the abilities (§17.2) and key/distribute (§25.2). */
55
55
  readonly inviter: Signer;
@@ -90,8 +90,13 @@ export interface AcceptInvitationOptions {
90
90
  readonly signer: Signer;
91
91
  readonly agreement: AgreementKeyPair;
92
92
  };
93
- /** The claimant's storage and secrets: on "claimed" they hold the chain and the DEK. */
94
- readonly storage: Pick<LfcpStorage, "control" | "commit">;
93
+ /**
94
+ * The claimant's storage and secrets: on "claimed" they hold the chain and
95
+ * the DEK. While a claim is in flight they also hold its journal
96
+ * (LFCP-02-110): a lost answer is then resolved by sending the same claim
97
+ * again, never by spending the invitation twice.
98
+ */
99
+ readonly storage: Pick<LfcpStorage, "control" | "commit" | "localMarks">;
95
100
  readonly secrets: SecretStore;
96
101
  /** Abilities to claim; default the grant's, without invite/claim unless it is delegable (§18.1 rule 4). */
97
102
  readonly abilities?: readonly bigint[];
@@ -132,6 +137,12 @@ export type AcceptedInvitation = {
132
137
  readonly epochs: readonly DataEpoch[];
133
138
  /** The CONTROL_PUT answers in order, e.g. ["ACK"] or ["CONTROL_HEAD_MISMATCH", "ACK"]. */
134
139
  readonly attempts: readonly string[];
140
+ /**
141
+ * True when an earlier attempt's claim, whose answer was lost, turned
142
+ * out to be committed: it was sent again from its journal (§47) and
143
+ * acknowledged as already committed (LFCP-02-110).
144
+ */
145
+ readonly resumed: boolean;
135
146
  } | {
136
147
  /** The coordinator refused the claim, e.g. AUTHORIZATION_FAILED: the invitation is used up. */
137
148
  readonly kind: "refused";
@@ -171,4 +182,32 @@ export type AcceptedInvitation = {
171
182
  * epoch's DEK; transport and server stops are "unavailable".
172
183
  */
173
184
  export declare function acceptInvitation(options: AcceptInvitationOptions): Promise<AcceptedInvitation>;
185
+ export interface ResumeInvitationClaimOptions {
186
+ /** The Resource of a journaled claim (pendingInvitationClaims). */
187
+ readonly resourceId: ResourceId;
188
+ /** The claimant: the session authenticates as it (§84: any session may upload a record). */
189
+ readonly claimant: {
190
+ readonly signer: Signer;
191
+ };
192
+ readonly storage: Pick<LfcpStorage, "control" | "commit" | "localMarks">;
193
+ readonly secrets: SecretStore;
194
+ /** The coordinator's endpoint. */
195
+ readonly url: string;
196
+ readonly now: () => number;
197
+ readonly webSocket?: WebSocketFactory;
198
+ readonly timeout?: Promise<unknown>;
199
+ }
200
+ /**
201
+ * Settles a journaled claim without its link, e.g. after a restart
202
+ * (LFCP-02-110): "claimed" when the claim had landed; "not-claimed" when it
203
+ * had not (the journal is removed, and the link, still unused, can be
204
+ * accepted again); "refused" for another refusal; "unavailable" keeps the
205
+ * journal for a later try; "none" when no claim is journaled.
206
+ */
207
+ export declare function resumeInvitationClaim(options: ResumeInvitationClaimOptions): Promise<AcceptedInvitation | {
208
+ readonly kind: "not-claimed" | "none";
209
+ readonly resourceId: ResourceId;
210
+ }>;
211
+ /** Gives up a journaled claim (the user declined to retry it): its journal is removed. */
212
+ export declare function abandonInvitationClaim(storage: Pick<LfcpStorage, "commit">, resource: ResourceId): Promise<void>;
174
213
  //# sourceMappingURL=invite.d.ts.map
package/dist/invite.js CHANGED
@@ -1,10 +1,11 @@
1
- import { bytesEqual, hash32, LfcpError, toHex, } from "@openlfcp/core";
1
+ import { bytesEqual, fromHex, hash32, LfcpError, toHex, } from "@openlfcp/core";
2
2
  import { dekCommitment, exportSecretKeyBytes, InvitationSecret, } from "@openlfcp/crypto";
3
3
  import { dekSecretRef } from "@openlfcp/storage";
4
- import { ABILITY, assembleInviteUri, createMessage, ERROR_CODE, invitationPrincipal, parseControlRecord, parseInviteUri, parseKeyPackage, receiveKeyPackage, sealKeyPackage, signControlRecord, validateControlChain, verifyInvitationSecret, verifyKeyPackage, } from "@openlfcp/wire";
4
+ import { ABILITY, assembleInviteUri, createMessage, ERROR_CODE, invitationPrincipal, isGrantActive, parseControlRecord, parseInviteUri, parseKeyPackage, receiveKeyPackage, sealKeyPackage, signControlRecord, validateControlChain, verifyInvitationSecret, verifyKeyPackage, } from "@openlfcp/wire";
5
+ import { clearClaimJournal, journaledDeks, readClaimJournal, writeClaimJournal, } from "./claim-journal.js";
5
6
  import { LfcpConnection } from "./connection.js";
6
7
  import { queueControlRecord, queueKeyPackage } from "./queue.js";
7
- import { loadControlChain, saveControlChain } from "./storage.js";
8
+ import { loadControlChain, localControlChain, saveControlChain } from "./storage.js";
8
9
  /**
9
10
  * Link invitations and the one-time capability claim (LFCP-WIRE-01 §18,
10
11
  * §18.1, §18.2, §25.2, §73; LFCP-053), built on the invitation codec of
@@ -68,6 +69,28 @@ export const DEFAULT_INVITATION_ABILITIES = Object.freeze([
68
69
  const fail = (code, why) => {
69
70
  throw new LfcpError(code, why);
70
71
  };
72
+ /**
73
+ * Resolves the invitation grant the claimant will reference (§18.1, §18.2).
74
+ * The link names the record the inviter first queued; a re-propose on a
75
+ * moved head gives the grant a new record ID while keeping the same
76
+ * Invitation Principal and claim parameters, so the grant is looked up by
77
+ * its ID first and, failing that, by its subject: the single active
78
+ * invitation grant (invite/claim) whose subject is the secret's Invitation
79
+ * Principal. Returns the on-chain grant ID, the grant and the claim signer.
80
+ * Throws MISSING_DEPENDENCY when no such grant is on the chain.
81
+ */
82
+ function resolveInvitationGrant(state, linkGrantId, secret) {
83
+ const principal = invitationPrincipal(secret);
84
+ const byId = state.grants.get(toHex(linkGrantId));
85
+ const grant = byId ??
86
+ [...state.grants.values()].find((g) => bytesEqual(g.subject, principal.principalId) &&
87
+ g.abilities.includes(ABILITY.INVITE_CLAIM) &&
88
+ isGrantActive(state, g.id)) ??
89
+ fail("MISSING_DEPENDENCY", "the invitation grant is not on the chain");
90
+ const grantId = grant.id;
91
+ const claimSigner = verifyInvitationSecret(state, grantId, secret);
92
+ return Object.freeze({ grantId, grant, claimSigner });
93
+ }
71
94
  /**
72
95
  * The inviter's side (§18, §73). The grant must validate on the stored
73
96
  * chain and the package must pass §25.2 at the grant's head, both checked
@@ -77,7 +100,11 @@ const fail = (code, why) => {
77
100
  */
78
101
  export async function createInvitation(options) {
79
102
  const R = options.resourceId;
80
- const stored = await loadControlChain(options.storage, R);
103
+ // §47: build the grant on the LOCAL head (the committed chain extended by
104
+ // this client's own still-queued records), so an invitation made before an
105
+ // earlier one is acknowledged chains on it instead of colliding on the
106
+ // committed head (CONTROL_HEAD_MISMATCH).
107
+ const stored = await localControlChain(options.storage, R);
81
108
  const view = stored?.kind === "linear"
82
109
  ? stored
83
110
  : fail("MISSING_DEPENDENCY", "no valid local Control Chain for the Resource");
@@ -274,15 +301,31 @@ export async function acceptInvitation(options) {
274
301
  // §73: the Invitation Principal authenticates and opens the Resource.
275
302
  progress("connecting");
276
303
  await s.connect();
304
+ // LFCP-02-110: a claim of this link whose answer was lost is settled first.
305
+ const journal = await readClaimJournal(options.storage, R);
306
+ if (journal !== undefined &&
307
+ journal.invitationGrantId === toHex(invitation.grantId) &&
308
+ journal.claimant === toHex(options.claimant.signer.descriptor.principalId)) {
309
+ progress("claiming-capability", 1);
310
+ const settled = await settleJournal(s, options, R, journal);
311
+ if (settled !== "not-committed")
312
+ return settled;
313
+ }
277
314
  progress("validating-invitation");
278
315
  const heads = await openAsInvitee(s, R, invitation.grantId);
279
316
  if (heads.length !== 1)
280
317
  throw new Unavailable("the Resource has no single Control Head: no claim is made (§42)");
281
318
  let chain = await fetchChain(s, R, [], { seq: heads[0].seq });
282
- // §18.2: the secret's Principal must be the grant's subject before the secret is used further.
283
- const claimSigner = verifyInvitationSecret(chain.state, invitation.grantId, secret);
284
- const grant = chain.state.grants.get(toHex(invitation.grantId)) ??
285
- fail("MISSING_DEPENDENCY", "the invitation grant is not on the chain");
319
+ // §18.2: the secret's Principal must be the grant's subject before the
320
+ // secret is used further. The link names the grant the inviter first
321
+ // queued; if that grant was re-proposed on a moved head (a new record ID,
322
+ // the same Invitation Principal and claim parameters), it is resolved by
323
+ // its subject, so a link copied before the re-propose still joins (§18.1).
324
+ const resolved = resolveInvitationGrant(chain.state, invitation.grantId, secret);
325
+ const claimSigner = resolved.claimSigner;
326
+ const grant = resolved.grant;
327
+ // The claim references the grant that is actually on the chain.
328
+ const grantId = resolved.grantId;
286
329
  // §27: a profile the caller cannot open is refused before the DEK and the claim.
287
330
  if (options.dataProfiles !== undefined &&
288
331
  !options.dataProfiles.includes(chain.state.dataProfile))
@@ -327,10 +370,22 @@ export async function acceptInvitation(options) {
327
370
  progress("claiming-capability", attempts.length + 1);
328
371
  const claim = signControlRecord({ resourceId: R, controlSeq: chain.state.seq + 1n, prevControlId: chain.state.head }, {
329
372
  type: "CAPABILITY_CLAIM",
330
- invitationGrantId: invitation.grantId,
373
+ invitationGrantId: grantId,
331
374
  claimant: options.claimant.signer.descriptor,
332
375
  abilities,
333
376
  }, claimSigner);
377
+ // The journal before the claim: a lost answer is resolved from it.
378
+ await writeClaimJournal(options.storage, options.secrets, R, {
379
+ v: 1,
380
+ resourceId: toHex(R),
381
+ invitationGrantId: toHex(invitation.grantId),
382
+ claimant: toHex(options.claimant.signer.descriptor.principalId),
383
+ claim: toHex(claim.bytes),
384
+ expectedHead: toHex(chain.state.head),
385
+ chain: chain.records.map((r) => toHex(r.signed.bytes)),
386
+ epochs: [...deks.keys()],
387
+ abilities: abilities.map(String),
388
+ }, deks, exportSecretKeyBytes);
334
389
  const answer = await s.request(createMessage("CONTROL_PUT", {
335
390
  resourceId: R,
336
391
  expectedHead: chain.state.head,
@@ -345,6 +400,7 @@ export async function acceptInvitation(options) {
345
400
  if (claimed.kind !== "linear")
346
401
  fail("INVALID_CONTROL_CHAIN", "the acknowledged claim does not validate locally");
347
402
  await persist(options, claimed, deks);
403
+ await clearClaimJournal(options.storage, R);
348
404
  return Object.freeze({
349
405
  kind: "claimed",
350
406
  resourceId: R,
@@ -352,6 +408,7 @@ export async function acceptInvitation(options) {
352
408
  abilities: Object.freeze([...abilities]),
353
409
  epochs: Object.freeze([...deks.keys()].map((e) => BigInt(e))),
354
410
  attempts: Object.freeze(attempts),
411
+ resumed: false,
355
412
  });
356
413
  }
357
414
  if (answer.type !== "NACK")
@@ -360,13 +417,18 @@ export async function acceptInvitation(options) {
360
417
  attempts.push(code);
361
418
  const head = answer.body.details;
362
419
  // One refresh after a lost race (§73); the coordinator's next answer is final.
363
- if (code !== "CONTROL_HEAD_MISMATCH" || attempts.length > 1 || !(head instanceof Uint8Array))
420
+ if (code !== "CONTROL_HEAD_MISMATCH" ||
421
+ attempts.length > 1 ||
422
+ !(head instanceof Uint8Array)) {
423
+ // A refused claim was not committed: nothing to resume.
424
+ await clearClaimJournal(options.storage, R);
364
425
  return Object.freeze({
365
426
  kind: "refused",
366
427
  resourceId: R,
367
428
  code,
368
429
  attempts: Object.freeze(attempts),
369
430
  });
431
+ }
370
432
  chain = await fetchChain(s, R, chain.records.map((r) => r.signed.bytes), { head });
371
433
  }
372
434
  }
@@ -379,6 +441,89 @@ export async function acceptInvitation(options) {
379
441
  s.close();
380
442
  }
381
443
  }
444
+ /**
445
+ * LFCP-02-110: sends a journaled claim again. A coordinator answers a
446
+ * record it has already committed with the same ACK (§47, §70): then the
447
+ * claim landed before and the claimant joins from the journal. Any other
448
+ * answer means it was not committed: a moved head lets the caller claim
449
+ * anew ("not-committed"); another refusal is final. A transport stop
450
+ * throws Unavailable and keeps the journal.
451
+ */
452
+ async function settleJournal(s, options, R, journal) {
453
+ const claim = fromHex(journal.claim);
454
+ const answer = await s.request(createMessage("CONTROL_PUT", {
455
+ resourceId: R,
456
+ expectedHead: fromHex(journal.expectedHead),
457
+ record: claim,
458
+ }), "the CONTROL_PUT answer")();
459
+ if (answer.type === "ACK") {
460
+ const claimed = validateControlChain([...journal.chain.map(fromHex), claim]);
461
+ if (claimed.kind !== "linear")
462
+ fail("INVALID_CONTROL_CHAIN", "the journaled claim does not validate locally");
463
+ const deks = await journaledDeks(options.secrets, R, journal);
464
+ await persist(options, claimed, deks);
465
+ await clearClaimJournal(options.storage, R);
466
+ return Object.freeze({
467
+ kind: "claimed",
468
+ resourceId: R,
469
+ grantId: claimed.state.head,
470
+ abilities: Object.freeze(journal.abilities.map((a) => BigInt(a))),
471
+ epochs: Object.freeze([...deks.keys()].map((e) => BigInt(e))),
472
+ attempts: Object.freeze(["ACK"]),
473
+ resumed: true,
474
+ });
475
+ }
476
+ if (answer.type !== "NACK")
477
+ throw new Unavailable(`CONTROL_PUT was answered with ${answer.type}`);
478
+ await clearClaimJournal(options.storage, R);
479
+ const code = codeName(answer.body.code);
480
+ if (code === "CONTROL_HEAD_MISMATCH")
481
+ return "not-committed";
482
+ return Object.freeze({
483
+ kind: "refused",
484
+ resourceId: R,
485
+ code,
486
+ attempts: Object.freeze([code]),
487
+ });
488
+ }
489
+ /**
490
+ * Settles a journaled claim without its link, e.g. after a restart
491
+ * (LFCP-02-110): "claimed" when the claim had landed; "not-claimed" when it
492
+ * had not (the journal is removed, and the link, still unused, can be
493
+ * accepted again); "refused" for another refusal; "unavailable" keeps the
494
+ * journal for a later try; "none" when no claim is journaled.
495
+ */
496
+ export async function resumeInvitationClaim(options) {
497
+ const R = options.resourceId;
498
+ const journal = await readClaimJournal(options.storage, R);
499
+ if (journal === undefined)
500
+ return Object.freeze({ kind: "none", resourceId: R });
501
+ const s = new ClaimSession({
502
+ url: options.url,
503
+ signer: options.claimant.signer,
504
+ now: options.now,
505
+ ...(options.webSocket === undefined ? {} : { webSocket: options.webSocket }),
506
+ }, options.timeout ?? NEVER);
507
+ try {
508
+ await s.connect();
509
+ const settled = await settleJournal(s, options, R, journal);
510
+ return settled === "not-committed"
511
+ ? Object.freeze({ kind: "not-claimed", resourceId: R })
512
+ : settled;
513
+ }
514
+ catch (e) {
515
+ if (e instanceof Unavailable)
516
+ return Object.freeze({ kind: "unavailable", resourceId: R, reason: e.message });
517
+ throw e;
518
+ }
519
+ finally {
520
+ s.close();
521
+ }
522
+ }
523
+ /** Gives up a journaled claim (the user declined to retry it): its journal is removed. */
524
+ export async function abandonInvitationClaim(storage, resource) {
525
+ await clearClaimJournal(storage, resource);
526
+ }
382
527
  /** The claimant's storage: the chain with its grant, then each DEK before the row that names it (LFCP-034). */
383
528
  async function persist(options, chain, deks) {
384
529
  const R = chain.state.resourceId;
@@ -1,5 +1,5 @@
1
1
  import { type ActorSequence, type ControlRecordId, type DataEpoch, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
- import type { LfcpStorage, OutboundBlock, OutboundItem, OutboundKind } from "@openlfcp/storage";
2
+ import type { LfcpStorage, OutboundBlock, OutboundItem, OutboundKind, StorageWrite } from "@openlfcp/storage";
3
3
  import { type ControlView, type HaveVector, type LfcpMessage, type WireErrorName } from "@openlfcp/wire";
4
4
  /**
5
5
  * The pending outbound queue and its sync state (LFCP-036): a
@@ -66,6 +66,20 @@ export interface AckOutcome {
66
66
  /** False when the ACK answers no message of this session (matched by object ID only). */
67
67
  readonly correlated: boolean;
68
68
  }
69
+ /** An item an ACK removed from the queue, for writes that go with its removal. */
70
+ export interface AckedItem {
71
+ readonly itemId: Hash32;
72
+ readonly resourceId: ResourceId;
73
+ readonly kind: OutboundKind;
74
+ }
75
+ /**
76
+ * Writes committed in the same transaction as an ACK's dequeue, from the
77
+ * items it removed and what the ACK established (e.g. batch acceptance).
78
+ */
79
+ export type AckWrites = (acked: readonly AckedItem[], ack: {
80
+ readonly durability: bigint;
81
+ readonly correlated: boolean;
82
+ }) => Promise<readonly StorageWrite[]>;
69
83
  /** A blocked item as surfaced to the application. */
70
84
  export interface BlockedItem {
71
85
  readonly itemId: Hash32;
@@ -186,9 +200,10 @@ export declare class OutboundQueue {
186
200
  * An ACK (§59): removes the queued items whose IDs it names (field 1),
187
201
  * and records them in the Resource's sync state. Items of the
188
202
  * acknowledged message it does not name stay queued. Idempotent: a
189
- * repeated ACK, or one for items already gone, changes nothing.
203
+ * repeated ACK, or one for items already gone, changes nothing. `also`
204
+ * adds writes to the dequeue's transaction.
190
205
  */
191
- onAck(message: LfcpMessage<"ACK">, now: string): Promise<AckOutcome>;
206
+ onAck(message: LfcpMessage<"ACK">, now: string, also?: AckWrites): Promise<AckOutcome>;
192
207
  /**
193
208
  * A NACK (§60) of one of this session's messages. Per-object codes on a
194
209
  * message of several objects resend each object alone, to learn which one
@@ -224,6 +239,13 @@ export declare class OutboundQueue {
224
239
  * are sent as the same bytes.
225
240
  */
226
241
  reconcileEpochs(view: ControlView): Promise<StaleOutboundUnit[]>;
242
+ /**
243
+ * Drops all in-memory tracking of these items, which a re-proposal has
244
+ * replaced with re-authored ones in storage (repropose.ts). The items are
245
+ * already dequeued in that same transaction; this clears any stale flight,
246
+ * solo or awaiting-control state so the queue never looks for them again.
247
+ */
248
+ release(ids: readonly Hash32[]): void;
227
249
  /** Removes a blocked item once the application has dealt with it (e.g. re-proposed or re-applied). */
228
250
  discard(itemId: Hash32): Promise<void>;
229
251
  }
package/dist/outbound.js CHANGED
@@ -36,6 +36,8 @@ const PER_OBJECT = new Set([
36
36
  const CODE_NAMES = new Map(Object.entries(ERROR_CODE).map(([name, code]) => [code, name]));
37
37
  /** Room left in a message for the envelope and body around the objects. */
38
38
  const OVERHEAD = 1024;
39
+ /** How many expired flights are remembered for a late answer. */
40
+ const EXPIRED_KEPT = 1024;
39
41
  const AT_ONCE = { nextAttempt: () => null };
40
42
  /** Data Units by actor, then actor sequence; unparsable bytes last (they are blocked when sent). */
41
43
  function unitOrder(a, b) {
@@ -65,6 +67,16 @@ export class OutboundQueue {
65
67
  #maxMessageBytes = DEFAULT_MAX_MESSAGE_BYTES;
66
68
  /** message ID hex → what it carried */
67
69
  #flights = new Map();
70
+ /**
71
+ * Flights that expired unanswered (#expire), by message ID, the most
72
+ * recent {@link EXPIRED_KEPT}: a late answer to one is still an answer to
73
+ * our message, correlated for §4.1's acceptance evidence. Without them, an
74
+ * ACK handled after the timeout sweep (a busy event loop: the sweep's
75
+ * timer runs before the waiting message) dequeued the items as
76
+ * uncorrelated, their batch was "evidence-unavailable" for good, and no
77
+ * later ACK could change it (the Shared Tasks soak of 0.4.0-beta.17).
78
+ */
79
+ #expired = new Map();
68
80
  /** item ID hex → message ID hex */
69
81
  #inFlight = new Map();
70
82
  /**
@@ -99,6 +111,9 @@ export class OutboundQueue {
99
111
  if (flight.deadline > at)
100
112
  continue;
101
113
  this.#flights.delete(key);
114
+ this.#expired.set(key, flight);
115
+ if (this.#expired.size > EXPIRED_KEPT)
116
+ this.#expired.delete(this.#expired.keys().next().value);
102
117
  for (const id of flight.itemIds)
103
118
  if (this.#inFlight.get(toHex(id)) === key) {
104
119
  this.#inFlight.delete(toHex(id));
@@ -261,8 +276,12 @@ export class OutboundQueue {
261
276
  return undefined;
262
277
  const key = toHex(messageId);
263
278
  const flight = this.#flights.get(key);
264
- if (flight === undefined)
265
- return undefined;
279
+ if (flight === undefined) {
280
+ // A late answer to a message whose flight expired: ours, correlated.
281
+ const late = this.#expired.get(key);
282
+ this.#expired.delete(key);
283
+ return late;
284
+ }
266
285
  this.#flights.delete(key);
267
286
  for (const id of flight.itemIds) {
268
287
  if (this.#inFlight.get(toHex(id)) === key)
@@ -294,14 +313,16 @@ export class OutboundQueue {
294
313
  * An ACK (§59): removes the queued items whose IDs it names (field 1),
295
314
  * and records them in the Resource's sync state. Items of the
296
315
  * acknowledged message it does not name stay queued. Idempotent: a
297
- * repeated ACK, or one for items already gone, changes nothing.
316
+ * repeated ACK, or one for items already gone, changes nothing. `also`
317
+ * adds writes to the dequeue's transaction.
298
318
  */
299
- async onAck(message, now) {
319
+ async onAck(message, now, also) {
300
320
  const flight = this.#land(message.correlationId);
301
321
  const named = message.body.objectIds ?? [];
302
322
  const durability = message.body.durable === true ? this.#durability : 0n;
303
323
  const requestType = message.body.requestType;
304
324
  const acked = [];
325
+ const ackedItems = [];
305
326
  const below = [];
306
327
  const byResource = new Map();
307
328
  for (const id of named) {
@@ -317,6 +338,7 @@ export class OutboundQueue {
317
338
  continue;
318
339
  }
319
340
  acked.push(item.itemId);
341
+ ackedItems.push({ itemId: item.itemId, resourceId: item.resourceId, kind: item.kind });
320
342
  const key = toHex(item.resourceId);
321
343
  byResource.set(key, [...(byResource.get(key) ?? []), item.itemId]);
322
344
  this.#forget(item.itemId);
@@ -345,6 +367,8 @@ export class OutboundQueue {
345
367
  },
346
368
  });
347
369
  }
370
+ if (also !== undefined && ackedItems.length > 0)
371
+ writes.push(...(await also(ackedItems, { durability, correlated: flight !== undefined })));
348
372
  await this.#commit(writes);
349
373
  const notCovered = flight?.itemIds.filter((id) => !acked.some((a) => bytesEqual(a, id)) && !below.some((b) => bytesEqual(b, id))) ?? [];
350
374
  const stillQueued = [];
@@ -558,6 +582,16 @@ export class OutboundQueue {
558
582
  await this.#commit(writes);
559
583
  return out;
560
584
  }
585
+ /**
586
+ * Drops all in-memory tracking of these items, which a re-proposal has
587
+ * replaced with re-authored ones in storage (repropose.ts). The items are
588
+ * already dequeued in that same transaction; this clears any stale flight,
589
+ * solo or awaiting-control state so the queue never looks for them again.
590
+ */
591
+ release(ids) {
592
+ for (const id of ids)
593
+ this.#forget(id);
594
+ }
561
595
  /** Removes a blocked item once the application has dealt with it (e.g. re-proposed or re-applied). */
562
596
  async discard(itemId) {
563
597
  const item = await this.#storage.outbound.get(itemId);
package/dist/queue.d.ts CHANGED
@@ -19,6 +19,8 @@ export declare const outboundItem: (kind: OutboundKind, itemId: Hash32, resource
19
19
  * (adoptStoredDeks) with no network round trip.
20
20
  */
21
21
  export declare function queueKeyEpoch(storage: Pick<LfcpStorage, "commit">, secrets: SecretStore, rotation: EpochRotation, also?: readonly StorageWrite[]): Promise<Hash32>;
22
+ /** The writes that store a sealed Key Package (§25) and queue it, for one commit with others. */
23
+ export declare function keyPackageWrites(bytes: Uint8Array): StorageWrite[];
22
24
  /** Stores a sealed Key Package (§25) and queues it for KEY_PACKAGE_PUT. */
23
25
  export declare function queueKeyPackage(storage: Pick<LfcpStorage, "commit">, bytes: Uint8Array, also?: readonly StorageWrite[]): Promise<Hash32>;
24
26
  /** Stores a sealed Snapshot (§29) with its selection metadata and queues it for SNAPSHOT_PUT. */
@@ -35,4 +37,6 @@ export declare function createQueuedSnapshot<T>(storage: Pick<LfcpStorage, "snap
35
37
  * validated chain contains it (saveControlChain).
36
38
  */
37
39
  export declare function queueControlRecord(storage: Pick<LfcpStorage, "commit">, bytes: Uint8Array, also?: readonly StorageWrite[]): Promise<Hash32>;
40
+ /** The write that queues a signed Control Record for CONTROL_PUT, for one commit with others. */
41
+ export declare function controlRecordWrites(bytes: Uint8Array): StorageWrite[];
38
42
  //# sourceMappingURL=queue.d.ts.map
package/dist/queue.js CHANGED
@@ -39,12 +39,12 @@ export async function queueKeyEpoch(storage, secrets, rotation, also = []) {
39
39
  await secrets.put(dekSecretRef(resource, rotation.epoch), exportSecretKeyBytes(rotation.dek));
40
40
  return queueControlRecord(storage, rotation.bytes, also);
41
41
  }
42
- /** Stores a sealed Key Package (§25) and queues it for KEY_PACKAGE_PUT. */
43
- export async function queueKeyPackage(storage, bytes, also = []) {
42
+ /** The writes that store a sealed Key Package (§25) and queue it, for one commit with others. */
43
+ export function keyPackageWrites(bytes) {
44
44
  const parsed = parseKeyPackage(bytes);
45
45
  const p = parsed.payload;
46
46
  const id = hash32(parsed.signed.id);
47
- await commit(storage, [
47
+ return [
48
48
  {
49
49
  op: "put-key-package",
50
50
  row: {
@@ -57,9 +57,12 @@ export async function queueKeyPackage(storage, bytes, also = []) {
57
57
  },
58
58
  },
59
59
  { op: "enqueue", item: outboundItem("key-package", id, p.resourceId, parsed.signed.bytes) },
60
- ...also,
61
- ]);
62
- return id;
60
+ ];
61
+ }
62
+ /** Stores a sealed Key Package (§25) and queues it for KEY_PACKAGE_PUT. */
63
+ export async function queueKeyPackage(storage, bytes, also = []) {
64
+ await commit(storage, [...keyPackageWrites(bytes), ...also]);
65
+ return hash32(parseKeyPackage(bytes).signed.id);
63
66
  }
64
67
  /** Stores a sealed Snapshot (§29) with its selection metadata and queues it for SNAPSHOT_PUT. */
65
68
  export async function queueSnapshot(storage, bytes, also = []) {
@@ -100,17 +103,20 @@ export async function createQueuedSnapshot(storage, options, also = []) {
100
103
  * validated chain contains it (saveControlChain).
101
104
  */
102
105
  export async function queueControlRecord(storage, bytes, also = []) {
106
+ await commit(storage, [...controlRecordWrites(bytes), ...also]);
107
+ return hash32(parseControlRecord(bytes).signed.id);
108
+ }
109
+ /** The write that queues a signed Control Record for CONTROL_PUT, for one commit with others. */
110
+ export function controlRecordWrites(bytes) {
103
111
  const parsed = parseControlRecord(bytes);
104
112
  if (parsed.payload.prevControlId === null)
105
113
  throw new Error("a Genesis record is hosted with RESOURCE_HOST, not CONTROL_PUT");
106
114
  const id = hash32(parsed.signed.id);
107
- await commit(storage, [
115
+ return [
108
116
  {
109
117
  op: "enqueue",
110
118
  item: outboundItem("control-record", id, parsed.payload.resourceId, parsed.signed.bytes),
111
119
  },
112
- ...also,
113
- ]);
114
- return id;
120
+ ];
115
121
  }
116
122
  //# sourceMappingURL=queue.js.map
@@ -0,0 +1,60 @@
1
+ import { type DataUnitId, type ResourceId } from "@openlfcp/core";
2
+ import type { LfcpStorage, StorageWrite } from "@openlfcp/storage";
3
+ /**
4
+ * Commit receipts (SDK-SECTIONS-INTEGRATION-01 §3, LFCP-02-025): the
5
+ * durable record that a batch of intents was committed locally, written in
6
+ * the same storage transaction as its Data Units, outbound entries and
7
+ * profile checkpoint (commitOperation). A receipt is kept as a local mark
8
+ * under its Resource and operation ID until the caller releases it.
9
+ *
10
+ * Released before its batch is final (accepted or rejected, §4.1), a
11
+ * receipt is no longer the caller's: receiptOf and receiptsOf no longer
12
+ * return it. Its batch is still reported (batchStatuses, the status
13
+ * snapshot) until it is final, and then forgotten; for that the receipt is
14
+ * kept under a `released-batch:` mark.
15
+ */
16
+ /** §3.2: what a committed batch produced. */
17
+ export interface Receipt {
18
+ readonly operationId: string;
19
+ /** The Data Unit IDs of the batch, in the order of their changes. */
20
+ readonly unitIds: readonly DataUnitId[];
21
+ /** The nodes the batch writes (and the section when it writes the title). */
22
+ readonly affectedNodeIds: readonly string[];
23
+ /** The local document's heads after the commit, sorted, as one opaque string. */
24
+ readonly modelRevision: string;
25
+ /** §3.3: SHA-256 (hex) of the batch's canonical form. */
26
+ readonly intentsHash: string;
27
+ /** Always true: a receipt exists only for a durable commit. */
28
+ readonly durable: true;
29
+ }
30
+ /** §3.3: an operation ID that already has a receipt for different intents. A local SDK error. */
31
+ export declare class OperationIdReusedError extends Error {
32
+ readonly operationId: string;
33
+ readonly code = "OPERATION_ID_REUSED";
34
+ constructor(operationId: string);
35
+ }
36
+ /**
37
+ * §3.3: SHA-256 (hex) of the deterministic CBOR (RFC 8949 §4.2.1) of the
38
+ * list of intents in order, each a map from its field names to their
39
+ * values, Text as given, nothing normalized.
40
+ */
41
+ export declare function intentsHash(intents: readonly unknown[]): string;
42
+ /** The local-mark write that stores `receipt` (for the commit's transaction). */
43
+ export declare function receiptWrite(resource: ResourceId, receipt: Receipt): StorageWrite;
44
+ /** §3.4: the receipt of an operation, definitive across restarts; undefined when nothing was committed. */
45
+ export declare function receiptOf(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId, operationId: string): Promise<Receipt | undefined>;
46
+ /** The receipt of a released batch that is not final yet; undefined otherwise. */
47
+ export declare function releasedReceiptOf(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId, operationId: string): Promise<Receipt | undefined>;
48
+ /** The receipts of a Resource's released batches that are not final yet. */
49
+ export declare function releasedReceiptsOf(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId): Promise<Receipt[]>;
50
+ /** Keeps a released receipt for its batch's status (or, with null, forgets it). */
51
+ export declare function releasedWrite(resource: ResourceId, operationId: string, receipt: Receipt | null): StorageWrite;
52
+ /** Every receipt of a Resource that is not released yet. */
53
+ export declare function receiptsOf(storage: Pick<LfcpStorage, "localMarks">, resource: ResourceId): Promise<Receipt[]>;
54
+ /**
55
+ * §3.5: forgets a receipt the caller has finished with. Its batch status
56
+ * goes with it when the batch is final (accepted or rejected); otherwise
57
+ * the batch is still reported until it is final.
58
+ */
59
+ export declare function releaseReceipt(storage: Pick<LfcpStorage, "commit" | "localMarks">, resource: ResourceId, operationId: string): Promise<void>;
60
+ //# sourceMappingURL=receipts.d.ts.map