@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/README.md +2 -2
- package/dist/access-recovery.d.ts +69 -0
- package/dist/access-recovery.js +45 -0
- package/dist/batch-status.d.ts +104 -0
- package/dist/batch-status.js +171 -0
- package/dist/claim-journal.d.ts +54 -0
- package/dist/claim-journal.js +52 -0
- package/dist/connection.d.ts +9 -0
- package/dist/connection.js +12 -1
- package/dist/data-unit.js +6 -4
- package/dist/index.d.ts +14 -6
- package/dist/index.js +13 -5
- package/dist/invite.d.ts +42 -3
- package/dist/invite.js +155 -10
- package/dist/outbound.d.ts +25 -3
- package/dist/outbound.js +38 -4
- package/dist/queue.d.ts +4 -0
- package/dist/queue.js +16 -10
- package/dist/receipts.d.ts +60 -0
- package/dist/receipts.js +118 -0
- package/dist/repropose.d.ts +81 -0
- package/dist/repropose.js +221 -0
- package/dist/revoke.d.ts +71 -0
- package/dist/revoke.js +108 -0
- package/dist/section-status.d.ts +94 -0
- package/dist/section-status.js +16 -0
- package/dist/storage.d.ts +46 -0
- package/dist/storage.js +105 -4
- package/dist/sync-client.d.ts +161 -3
- package/dist/sync-client.js +807 -14
- package/dist/typing.d.ts +60 -0
- package/dist/typing.js +110 -0
- package/dist/write-access.d.ts +115 -0
- package/dist/write-access.js +87 -0
- package/package.json +6 -6
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
|
-
/**
|
|
94
|
-
|
|
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
|
-
|
|
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
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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:
|
|
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" ||
|
|
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;
|
package/dist/outbound.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
43
|
-
export
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
115
|
+
return [
|
|
108
116
|
{
|
|
109
117
|
op: "enqueue",
|
|
110
118
|
item: outboundItem("control-record", id, parsed.payload.resourceId, parsed.signed.bytes),
|
|
111
119
|
},
|
|
112
|
-
|
|
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
|