@openlfcp/client 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/apply.d.ts +20 -0
- package/dist/apply.js +79 -13
- package/dist/invite.d.ts +21 -1
- package/dist/invite.js +12 -1
- package/dist/outbound.d.ts +19 -1
- package/dist/outbound.js +105 -6
- package/dist/sync-client.d.ts +14 -0
- package/dist/sync-client.js +303 -9
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -12,9 +12,9 @@ ESM only; Node.js 24 or later (browsers and Electron for every package except `@
|
|
|
12
12
|
|
|
13
13
|
Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
|
|
14
14
|
subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
|
|
15
|
-
at `mvp-0.1-baseline.
|
|
15
|
+
at `mvp-0.1-baseline.9`, not every deferred WIRE-01 feature. It does not
|
|
16
16
|
claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
|
|
17
|
-
(`0.1.
|
|
17
|
+
(`0.1.3`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
|
|
18
18
|
|
|
19
19
|
## Links
|
|
20
20
|
|
package/dist/apply.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ export interface ProfileApplyResult {
|
|
|
60
60
|
readonly diagnostics: readonly ProfileDiagnostic[];
|
|
61
61
|
/** Why the unit is buffered by the profile when it did not merge now. */
|
|
62
62
|
readonly pending?: string;
|
|
63
|
+
/**
|
|
64
|
+
* Why the profile holds the unit (SHARED-OBJECTS-PROFILE-01 §14.1): a
|
|
65
|
+
* different change has its actor and sequence number. Never merged until
|
|
66
|
+
* a rebuild frees them.
|
|
67
|
+
*/
|
|
68
|
+
readonly held?: string;
|
|
63
69
|
}
|
|
64
70
|
/** What a handler's applyBatch reports: per unit merged, pending or rejected, and the objects once. */
|
|
65
71
|
export interface ProfileBatchResult {
|
|
@@ -67,6 +73,8 @@ export interface ProfileBatchResult {
|
|
|
67
73
|
readonly merged: readonly DataUnitId[];
|
|
68
74
|
/** Units buffered by the profile (the batch's, and earlier ones still waiting). */
|
|
69
75
|
readonly pending: readonly DataUnitId[];
|
|
76
|
+
/** Units the profile holds (§14.1): a different change has their actor and sequence number. */
|
|
77
|
+
readonly held?: readonly DataUnitId[];
|
|
70
78
|
/** Units the profile refused, with the code and message apply would have thrown. */
|
|
71
79
|
readonly rejected: readonly {
|
|
72
80
|
readonly unitId: DataUnitId;
|
|
@@ -81,6 +89,8 @@ export interface ProfileExcludeResult {
|
|
|
81
89
|
readonly objects: readonly string[];
|
|
82
90
|
/** Merged units that now wait for an excluded unit's content (profile-pending). */
|
|
83
91
|
readonly pending: readonly DataUnitId[];
|
|
92
|
+
/** Held units (§14.1) that merged after the rebuild freed their actor sequence. */
|
|
93
|
+
readonly released?: readonly DataUnitId[];
|
|
84
94
|
}
|
|
85
95
|
/** The application side of one Data Profile. Implemented by profile packages. */
|
|
86
96
|
export interface DataProfileHandler<T> {
|
|
@@ -169,6 +179,16 @@ export type ApplyOutcome =
|
|
|
169
179
|
readonly kind: "profile-pending";
|
|
170
180
|
readonly detail: string;
|
|
171
181
|
})
|
|
182
|
+
/**
|
|
183
|
+
* Accepted by LFCP and kept (a holding: advertised and relayed); the
|
|
184
|
+
* profile holds it because a different change has its actor and sequence
|
|
185
|
+
* number (SHARED-OBJECTS-PROFILE-01 §14.1, POST-001). It is retried after
|
|
186
|
+
* every rebuild that removes changes, and reported as applied then.
|
|
187
|
+
*/
|
|
188
|
+
| (Applied & {
|
|
189
|
+
readonly kind: "profile-held";
|
|
190
|
+
readonly detail: string;
|
|
191
|
+
})
|
|
172
192
|
/** Accepted by LFCP, refused by the profile when merging: never merged. */
|
|
173
193
|
| {
|
|
174
194
|
readonly kind: "profile-rejected";
|
package/dist/apply.js
CHANGED
|
@@ -14,7 +14,8 @@ const unreachableCodec = (dataProfile) => ({
|
|
|
14
14
|
throw new Error("no codec for a malformed unit");
|
|
15
15
|
},
|
|
16
16
|
});
|
|
17
|
-
const LFCP_ACCEPTED = ["merged", "profile-pending"];
|
|
17
|
+
const LFCP_ACCEPTED = ["merged", "profile-pending", "profile-held"];
|
|
18
|
+
const HELD = "held: a different change has its actor and sequence number (SHARED-OBJECTS-PROFILE-01 §14.1)";
|
|
18
19
|
const CRASHED = "this unit crashed the profile engine twice, applied alone; it is not applied again on this device (local only)";
|
|
19
20
|
const profileUnit = (view, r) => ({
|
|
20
21
|
unitId: r.unitId,
|
|
@@ -247,6 +248,7 @@ export class DataUnitApplier {
|
|
|
247
248
|
return out;
|
|
248
249
|
}
|
|
249
250
|
const merged = new Set(batch.merged.map((id) => toHex(id)));
|
|
251
|
+
const heldNow = new Set((batch.held ?? []).map((id) => toHex(id)));
|
|
250
252
|
const rejected = new Map(batch.rejected.map((x) => [toHex(x.unitId), x]));
|
|
251
253
|
const inBatch = new Set(staged.map(({ r }) => toHex(r.unitId)));
|
|
252
254
|
const unblocked = batch.merged.filter((id) => !inBatch.has(toHex(id)));
|
|
@@ -273,7 +275,11 @@ export class DataUnitApplier {
|
|
|
273
275
|
merged: [...(now ? [r.unitId] : []), ...(k === 0 ? unblocked : [])],
|
|
274
276
|
objects: k === last ? batch.objects : [],
|
|
275
277
|
diagnostics: k === last ? batch.diagnostics : [],
|
|
276
|
-
...(now
|
|
278
|
+
...(now
|
|
279
|
+
? {}
|
|
280
|
+
: heldNow.has(key)
|
|
281
|
+
? { held: HELD }
|
|
282
|
+
: { pending: "buffered by the profile" }),
|
|
277
283
|
}, false),
|
|
278
284
|
]);
|
|
279
285
|
}
|
|
@@ -323,15 +329,23 @@ export class DataUnitApplier {
|
|
|
323
329
|
const dataProfile = handler.dataProfile;
|
|
324
330
|
const mergedNow = result.merged.some((id) => bytesEqual(id, r.unitId));
|
|
325
331
|
const alsoMerged = result.merged.filter((id) => !bytesEqual(id, r.unitId));
|
|
332
|
+
const held = !mergedNow && result.held !== undefined;
|
|
326
333
|
await this.#write([
|
|
327
334
|
mergedNow
|
|
328
335
|
? { op: "set-data-unit-status", unitId: r.unitId, status: "merged" }
|
|
329
|
-
:
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
336
|
+
: held
|
|
337
|
+
? {
|
|
338
|
+
op: "set-data-unit-status",
|
|
339
|
+
unitId: r.unitId,
|
|
340
|
+
status: "profile-held",
|
|
341
|
+
detail: result.held ?? null,
|
|
342
|
+
}
|
|
343
|
+
: {
|
|
344
|
+
op: "set-data-unit-status",
|
|
345
|
+
unitId: r.unitId,
|
|
346
|
+
status: "profile-pending",
|
|
347
|
+
detail: result.pending ?? "buffered by the profile",
|
|
348
|
+
},
|
|
335
349
|
...alsoMerged.map((unitId) => ({ op: "set-data-unit-status", unitId, status: "merged" })),
|
|
336
350
|
]);
|
|
337
351
|
// §26.2 (G-DP1, G-DP1-GAP): a held unit of this actor above it may link now, across a gap.
|
|
@@ -360,6 +374,8 @@ export class DataUnitApplier {
|
|
|
360
374
|
alsoMerged: Object.freeze(alsoMerged),
|
|
361
375
|
released: Object.freeze(released),
|
|
362
376
|
};
|
|
377
|
+
if (held)
|
|
378
|
+
return Object.freeze({ ...base, kind: "profile-held", detail: result.held });
|
|
363
379
|
return Object.freeze(mergedNow
|
|
364
380
|
? {
|
|
365
381
|
...base,
|
|
@@ -394,9 +410,13 @@ export class DataUnitApplier {
|
|
|
394
410
|
status: "profile-pending",
|
|
395
411
|
detail: "builds on an equivocating unit",
|
|
396
412
|
})),
|
|
413
|
+
...this.#freedWrites(result),
|
|
397
414
|
]);
|
|
398
415
|
// The actor's latest accepted unit may now be lower: held units can link (G-DP1-GAP).
|
|
399
|
-
const released =
|
|
416
|
+
const released = [
|
|
417
|
+
...(merged.length > 0 ? await this.#retryHeld(view, [r.actor]) : []),
|
|
418
|
+
...(await this.#freed(handler, result)),
|
|
419
|
+
];
|
|
400
420
|
return Object.freeze({
|
|
401
421
|
...r,
|
|
402
422
|
excluded: Object.freeze(merged),
|
|
@@ -435,6 +455,7 @@ export class DataUnitApplier {
|
|
|
435
455
|
const stored = [
|
|
436
456
|
...(await this.#storage.dataUnits.withStatus(resource, "merged")),
|
|
437
457
|
...(await this.#storage.dataUnits.withStatus(resource, "profile-pending")),
|
|
458
|
+
...(await this.#storage.dataUnits.withStatus(resource, "profile-held")),
|
|
438
459
|
...(await this.#storage.dataUnits.withStatus(resource, "seen")),
|
|
439
460
|
...(await this.#storage.dataUnits.withStatus(resource, "held")),
|
|
440
461
|
]
|
|
@@ -442,18 +463,28 @@ export class DataUnitApplier {
|
|
|
442
463
|
.sort((a, b) => (a.actorSeq < b.actorSeq ? -1 : a.actorSeq > b.actorSeq ? 1 : 0));
|
|
443
464
|
const merged = [];
|
|
444
465
|
const pendingNow = [];
|
|
466
|
+
const heldNow = [];
|
|
445
467
|
// Suspects of an earlier crash replay alone, each under its own record.
|
|
446
468
|
const suspect = (u) => this.#guard.suspicion(resource, unitItem(u.unitId)) > 0;
|
|
447
469
|
const groups = [...stored.filter(suspect).map((u) => [u]), stored.filter((u) => !suspect(u))];
|
|
448
470
|
for (const group of groups)
|
|
449
471
|
if (group.length > 0)
|
|
450
|
-
await this.#guard.run(resource, group.map((u) => unitItem(u.unitId)), () => this.#replayGroup(handler, group, { merged, pendingNow, replayed, skipped }));
|
|
472
|
+
await this.#guard.run(resource, group.map((u) => unitItem(u.unitId)), () => this.#replayGroup(handler, group, { merged, pendingNow, heldNow, replayed, skipped }));
|
|
473
|
+
const isHeld = (id) => heldNow.some((h) => bytesEqual(h, id));
|
|
451
474
|
await this.#write([
|
|
452
|
-
...
|
|
475
|
+
...heldNow
|
|
453
476
|
.filter((id) => !merged.some((m) => bytesEqual(m, id)))
|
|
454
477
|
.map((unitId) => ({
|
|
455
478
|
op: "set-data-unit-status",
|
|
456
479
|
unitId,
|
|
480
|
+
status: "profile-held",
|
|
481
|
+
detail: HELD,
|
|
482
|
+
})),
|
|
483
|
+
...pendingNow
|
|
484
|
+
.filter((id) => !merged.some((m) => bytesEqual(m, id)) && !isHeld(id))
|
|
485
|
+
.map((unitId) => ({
|
|
486
|
+
op: "set-data-unit-status",
|
|
487
|
+
unitId,
|
|
457
488
|
status: "profile-pending",
|
|
458
489
|
detail: "replayed; waiting for content it builds on",
|
|
459
490
|
})),
|
|
@@ -467,7 +498,7 @@ export class DataUnitApplier {
|
|
|
467
498
|
}
|
|
468
499
|
/** Decrypts, decodes and applies replayed units (replayStored), adding to `acc`. */
|
|
469
500
|
async #replayGroup(handler, group, acc) {
|
|
470
|
-
const { merged, pendingNow, replayed, skipped } = acc;
|
|
501
|
+
const { merged, pendingNow, heldNow, replayed, skipped } = acc;
|
|
471
502
|
const decoded = [];
|
|
472
503
|
for (const u of group) {
|
|
473
504
|
const dek = await this.#options.dek(u.dataEpoch);
|
|
@@ -504,6 +535,7 @@ export class DataUnitApplier {
|
|
|
504
535
|
const batch = handler.applyBatch(decoded);
|
|
505
536
|
const refused = new Map(batch.rejected.map((x) => [toHex(x.unitId), x]));
|
|
506
537
|
merged.push(...batch.merged);
|
|
538
|
+
heldNow.push(...(batch.held ?? []));
|
|
507
539
|
for (const { unit } of decoded) {
|
|
508
540
|
const no = refused.get(toHex(unit.unitId));
|
|
509
541
|
if (no !== undefined) {
|
|
@@ -520,6 +552,8 @@ export class DataUnitApplier {
|
|
|
520
552
|
try {
|
|
521
553
|
const r = handler.apply(unit, value);
|
|
522
554
|
merged.push(...r.merged);
|
|
555
|
+
if (r.held !== undefined)
|
|
556
|
+
heldNow.push(unit.unitId);
|
|
523
557
|
if (!r.merged.some((id) => bytesEqual(id, unit.unitId)))
|
|
524
558
|
pendingNow.push(unit.unitId);
|
|
525
559
|
replayed.push(unit.unitId);
|
|
@@ -658,6 +692,33 @@ export class DataUnitApplier {
|
|
|
658
692
|
}
|
|
659
693
|
return [...byHex.values()];
|
|
660
694
|
}
|
|
695
|
+
/** Status writes for the held units (§14.1) a rebuild merged. */
|
|
696
|
+
#freedWrites(result) {
|
|
697
|
+
return (result.released ?? []).map((unitId) => ({ op: "set-data-unit-status", unitId, status: "merged" }));
|
|
698
|
+
}
|
|
699
|
+
/** The held units (§14.1) a rebuild merged, reported as applied. */
|
|
700
|
+
async #freed(handler, result) {
|
|
701
|
+
const out = [];
|
|
702
|
+
for (const unitId of result.released ?? []) {
|
|
703
|
+
const u = await this.#storage.dataUnits.get(unitId);
|
|
704
|
+
if (u === undefined)
|
|
705
|
+
continue;
|
|
706
|
+
out.push(Object.freeze({
|
|
707
|
+
kind: "applied",
|
|
708
|
+
unitId,
|
|
709
|
+
dataProfile: handler.dataProfile,
|
|
710
|
+
actor: u.actor,
|
|
711
|
+
seq: u.actorSeq,
|
|
712
|
+
epoch: u.dataEpoch,
|
|
713
|
+
haveEligible: false,
|
|
714
|
+
alsoMerged: Object.freeze([]),
|
|
715
|
+
released: Object.freeze([]),
|
|
716
|
+
objects: Object.freeze([]),
|
|
717
|
+
diagnostics: Object.freeze([]),
|
|
718
|
+
}));
|
|
719
|
+
}
|
|
720
|
+
return out;
|
|
721
|
+
}
|
|
661
722
|
/**
|
|
662
723
|
* §26.2 (G-DP1-GAP): after units of `actors` stopped being accepted, a
|
|
663
724
|
* held unit may now name the actor's latest accepted unit. Every held
|
|
@@ -697,6 +758,7 @@ export class DataUnitApplier {
|
|
|
697
758
|
const candidates = [
|
|
698
759
|
...(await this.#storage.dataUnits.withStatus(resource, "merged")),
|
|
699
760
|
...(await this.#storage.dataUnits.withStatus(resource, "profile-pending")),
|
|
761
|
+
...(await this.#storage.dataUnits.withStatus(resource, "profile-held")),
|
|
700
762
|
].filter((u) => u.accepted);
|
|
701
763
|
const excluded = [];
|
|
702
764
|
for (const u of candidates) {
|
|
@@ -726,9 +788,13 @@ export class DataUnitApplier {
|
|
|
726
788
|
status: "profile-pending",
|
|
727
789
|
detail: "builds on an excluded unit",
|
|
728
790
|
})),
|
|
791
|
+
...this.#freedWrites(result),
|
|
729
792
|
]);
|
|
730
793
|
const actors = await this.#actorsOf(excluded.map((e) => e.unitId));
|
|
731
|
-
const released =
|
|
794
|
+
const released = [
|
|
795
|
+
...(await this.#retryHeld(view, actors)),
|
|
796
|
+
...(await this.#freed(handler, result)),
|
|
797
|
+
];
|
|
732
798
|
return Object.freeze({
|
|
733
799
|
snapshotDropped,
|
|
734
800
|
excluded: Object.freeze(excluded),
|
package/dist/invite.d.ts
CHANGED
|
@@ -95,6 +95,13 @@ export interface AcceptInvitationOptions {
|
|
|
95
95
|
readonly secrets: SecretStore;
|
|
96
96
|
/** Abilities to claim; default the grant's, without invite/claim unless it is delegable (§18.1 rule 4). */
|
|
97
97
|
readonly abilities?: readonly bigint[];
|
|
98
|
+
/**
|
|
99
|
+
* The Data Profiles the caller can open (§27). When given, the Resource's
|
|
100
|
+
* Genesis profile must be one of them: otherwise the join stops with
|
|
101
|
+
* "profile-unsupported" before the Key Package is fetched and before the
|
|
102
|
+
* claim, so the invitation stays unused. Default: no check.
|
|
103
|
+
*/
|
|
104
|
+
readonly dataProfiles?: readonly string[];
|
|
98
105
|
/** The endpoint to use; default the link's first. */
|
|
99
106
|
readonly url?: string;
|
|
100
107
|
readonly now: () => number;
|
|
@@ -131,6 +138,17 @@ export type AcceptedInvitation = {
|
|
|
131
138
|
readonly resourceId: ResourceId;
|
|
132
139
|
readonly code: string;
|
|
133
140
|
readonly attempts: readonly string[];
|
|
141
|
+
} | {
|
|
142
|
+
/**
|
|
143
|
+
* The Resource's Data Profile is not in the caller's dataProfiles: no
|
|
144
|
+
* Key Package was fetched, no claim was made and nothing was stored,
|
|
145
|
+
* so the invitation can still be used by a client that implements it.
|
|
146
|
+
*/
|
|
147
|
+
readonly kind: "profile-unsupported";
|
|
148
|
+
readonly resourceId: ResourceId;
|
|
149
|
+
readonly code: "PROFILE_UNSUPPORTED";
|
|
150
|
+
/** The Resource's Data Profile, from its Genesis. */
|
|
151
|
+
readonly dataProfile: string;
|
|
134
152
|
} | {
|
|
135
153
|
/**
|
|
136
154
|
* The claim could not be attempted or answered: the connection failed
|
|
@@ -144,7 +162,9 @@ export type AcceptedInvitation = {
|
|
|
144
162
|
/**
|
|
145
163
|
* The joiner's side (§18.1, §18.2, §73). On "claimed" the claimant's
|
|
146
164
|
* storage holds the chain with the claimant's grant and its secrets the
|
|
147
|
-
* DEK: open the Resource with a SyncClient for the claimant.
|
|
165
|
+
* DEK: open the Resource with a SyncClient for the claimant. With
|
|
166
|
+
* `dataProfiles`, a Resource of another Data Profile is
|
|
167
|
+
* "profile-unsupported" before the Key Package and the claim. Throws
|
|
148
168
|
* INVALID_INVITATION for a link without a secret or a secret that is not
|
|
149
169
|
* the grant's subject, MISSING_DEPENDENCY when the grant is not on the
|
|
150
170
|
* chain, and KEY_PACKAGE_OPEN_FAILED when no package delivers the current
|
package/dist/invite.js
CHANGED
|
@@ -246,7 +246,9 @@ async function fetchChain(s, R, have, until) {
|
|
|
246
246
|
/**
|
|
247
247
|
* The joiner's side (§18.1, §18.2, §73). On "claimed" the claimant's
|
|
248
248
|
* storage holds the chain with the claimant's grant and its secrets the
|
|
249
|
-
* DEK: open the Resource with a SyncClient for the claimant.
|
|
249
|
+
* DEK: open the Resource with a SyncClient for the claimant. With
|
|
250
|
+
* `dataProfiles`, a Resource of another Data Profile is
|
|
251
|
+
* "profile-unsupported" before the Key Package and the claim. Throws
|
|
250
252
|
* INVALID_INVITATION for a link without a secret or a secret that is not
|
|
251
253
|
* the grant's subject, MISSING_DEPENDENCY when the grant is not on the
|
|
252
254
|
* chain, and KEY_PACKAGE_OPEN_FAILED when no package delivers the current
|
|
@@ -281,6 +283,15 @@ export async function acceptInvitation(options) {
|
|
|
281
283
|
const claimSigner = verifyInvitationSecret(chain.state, invitation.grantId, secret);
|
|
282
284
|
const grant = chain.state.grants.get(toHex(invitation.grantId)) ??
|
|
283
285
|
fail("MISSING_DEPENDENCY", "the invitation grant is not on the chain");
|
|
286
|
+
// §27: a profile the caller cannot open is refused before the DEK and the claim.
|
|
287
|
+
if (options.dataProfiles !== undefined &&
|
|
288
|
+
!options.dataProfiles.includes(chain.state.dataProfile))
|
|
289
|
+
return Object.freeze({
|
|
290
|
+
kind: "profile-unsupported",
|
|
291
|
+
resourceId: R,
|
|
292
|
+
code: "PROFILE_UNSUPPORTED",
|
|
293
|
+
dataProfile: chain.state.dataProfile,
|
|
294
|
+
});
|
|
284
295
|
// §73, §25.2: the invitation's Key Package delivers the DEK.
|
|
285
296
|
progress("retrieving-key");
|
|
286
297
|
const batch = await s.request(createMessage("KEY_PACKAGE_GET", {
|
package/dist/outbound.d.ts
CHANGED
|
@@ -111,6 +111,18 @@ export type NackOutcome =
|
|
|
111
111
|
readonly kind: "needs-control-sync";
|
|
112
112
|
readonly items: readonly Hash32[];
|
|
113
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* The server lacks the unit a Data Unit names as its `previous`
|
|
116
|
+
* (UNKNOWN_PREVIOUS, §51.1): it lost what it acknowledged. Retried once
|
|
117
|
+
* the client offered what the server lacks (§68.1, `offered`), or, when
|
|
118
|
+
* that unit is our own and still queued, once it is acknowledged.
|
|
119
|
+
*/
|
|
120
|
+
| {
|
|
121
|
+
readonly kind: "needs-offer";
|
|
122
|
+
readonly resourceId: ResourceId;
|
|
123
|
+
readonly previous: Hash32 | null;
|
|
124
|
+
readonly items: readonly Hash32[];
|
|
125
|
+
}
|
|
114
126
|
/** Transient or unknown: retried after the RetryPolicy delay. */
|
|
115
127
|
| {
|
|
116
128
|
readonly kind: "retry";
|
|
@@ -145,7 +157,7 @@ export interface OutboundQueueOptions {
|
|
|
145
157
|
/**
|
|
146
158
|
* How long a sent message waits for its answer on a live connection
|
|
147
159
|
* before its items are due again (a lost request or reply, §70
|
|
148
|
-
* at-least-once): `baseMs`, doubling per
|
|
160
|
+
* at-least-once): `baseMs`, doubling per unanswered send in a row, at most
|
|
149
161
|
* `maxMs`. Default 10 s to 60 s. A re-put is harmless: the server answers
|
|
150
162
|
* a repeated object with the same ACK (§47, §70).
|
|
151
163
|
*/
|
|
@@ -187,6 +199,7 @@ export declare class OutboundQueue {
|
|
|
187
199
|
* MESSAGE_TOO_LARGE → blocked "too-large";
|
|
188
200
|
* CONTROL_HEAD_MISMATCH → blocked "repropose" with the current head;
|
|
189
201
|
* MISSING_DEPENDENCY → retried after the next controlSynced();
|
|
202
|
+
* UNKNOWN_PREVIOUS → retried after the next offered() (§51.1, §68.1);
|
|
190
203
|
* anything else → retried after the RetryPolicy delay.
|
|
191
204
|
*/
|
|
192
205
|
onNack(message: LfcpMessage<"NACK">, now: string): Promise<NackOutcome>;
|
|
@@ -198,6 +211,11 @@ export declare class OutboundQueue {
|
|
|
198
211
|
connectionLost(now: string): Promise<Hash32[]>;
|
|
199
212
|
/** The Control Plane of `resource` was synchronized: items held for MISSING_DEPENDENCY are due again. */
|
|
200
213
|
controlSynced(resource: ResourceId): Promise<void>;
|
|
214
|
+
/**
|
|
215
|
+
* What the server of `resource` lacked was offered (§68.1): items held
|
|
216
|
+
* for UNKNOWN_PREVIOUS are due again, behind the offered objects.
|
|
217
|
+
*/
|
|
218
|
+
offered(resource: ResourceId): Promise<void>;
|
|
201
219
|
/**
|
|
202
220
|
* §88 step 7, G-EP5: with a newly validated Control view, our queued
|
|
203
221
|
* Data Units of a closed epoch beyond its final frontier are never sent;
|
package/dist/outbound.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { bytesEqual, dataUnitId, hash32, toHex, } from "@openlfcp/core";
|
|
1
|
+
import { bytesEqual, dataUnitId, fromHex, hash32, toHex, } from "@openlfcp/core";
|
|
2
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";
|
|
3
|
+
import { addSequence, canonicalFrontierFromCbor, classifyDataUnit, createMessage, DEFAULT_MAX_MESSAGE_BYTES, ERROR_CODE, encodeMessage, MESSAGE_TYPE, parseControlRecord, parseDataUnit, unionHaves, } from "@openlfcp/wire";
|
|
4
4
|
import { decodeDeterministic } from "@openlfcp/wire/cbor";
|
|
5
5
|
/** Exponential backoff from `baseMs`, doubling per attempt, capped at `maxMs`. */
|
|
6
6
|
export function exponentialBackoff(baseMs, maxMs) {
|
|
@@ -37,6 +37,22 @@ const CODE_NAMES = new Map(Object.entries(ERROR_CODE).map(([name, code]) => [cod
|
|
|
37
37
|
/** Room left in a message for the envelope and body around the objects. */
|
|
38
38
|
const OVERHEAD = 1024;
|
|
39
39
|
const AT_ONCE = { nextAttempt: () => null };
|
|
40
|
+
/** Data Units by actor, then actor sequence; unparsable bytes last (they are blocked when sent). */
|
|
41
|
+
function unitOrder(a, b) {
|
|
42
|
+
const key = (o) => {
|
|
43
|
+
try {
|
|
44
|
+
const p = parseDataUnit(o.bytes).payload;
|
|
45
|
+
return [toHex(p.actor), p.actorSeq];
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return ["~", 0n];
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const [x, y] = [key(a), key(b)];
|
|
52
|
+
if (x[0] !== y[0])
|
|
53
|
+
return x[0] < y[0] ? -1 : 1;
|
|
54
|
+
return x[1] < y[1] ? -1 : x[1] > y[1] ? 1 : 0;
|
|
55
|
+
}
|
|
40
56
|
export class OutboundQueue {
|
|
41
57
|
#storage;
|
|
42
58
|
#retry;
|
|
@@ -51,10 +67,18 @@ export class OutboundQueue {
|
|
|
51
67
|
#flights = new Map();
|
|
52
68
|
/** item ID hex → message ID hex */
|
|
53
69
|
#inFlight = new Map();
|
|
70
|
+
/**
|
|
71
|
+
* Unanswered sends in a row per item (request timeouts), for the
|
|
72
|
+
* timeout backoff: an answered send (ACK or NACK) starts it again, so
|
|
73
|
+
* refusals the client recovers from never lengthen the wait.
|
|
74
|
+
*/
|
|
75
|
+
#unanswered = new Map();
|
|
54
76
|
/** Items to send one per message (to find which one a per-object NACK meant). */
|
|
55
77
|
#solo = new Set();
|
|
56
78
|
/** Items waiting for a Control sync (MISSING_DEPENDENCY). */
|
|
57
79
|
#awaitingControl = new Set();
|
|
80
|
+
/** Items waiting until what the server lacks was offered (UNKNOWN_PREVIOUS, §68.1). */
|
|
81
|
+
#awaitingOffer = new Map();
|
|
58
82
|
constructor(options) {
|
|
59
83
|
this.#storage = options.storage;
|
|
60
84
|
this.#retry = options.retry ?? AT_ONCE;
|
|
@@ -78,6 +102,7 @@ export class OutboundQueue {
|
|
|
78
102
|
for (const id of flight.itemIds)
|
|
79
103
|
if (this.#inFlight.get(toHex(id)) === key) {
|
|
80
104
|
this.#inFlight.delete(toHex(id));
|
|
105
|
+
this.#unanswered.set(toHex(id), (this.#unanswered.get(toHex(id)) ?? 0) + 1);
|
|
81
106
|
overdue.push(id);
|
|
82
107
|
}
|
|
83
108
|
}
|
|
@@ -108,12 +133,18 @@ export class OutboundQueue {
|
|
|
108
133
|
const due = (await this.#storage.outbound.list(resource)).filter((o) => o.blocked === null &&
|
|
109
134
|
!this.#inFlight.has(toHex(o.itemId)) &&
|
|
110
135
|
!this.#awaitingControl.has(toHex(o.itemId)) &&
|
|
136
|
+
!this.#awaitingOffer.has(toHex(o.itemId)) &&
|
|
111
137
|
(o.nextAttempt === null || o.nextAttempt <= now));
|
|
112
138
|
const out = [];
|
|
113
139
|
const writes = [];
|
|
114
140
|
const limit = this.#maxMessageBytes - OVERHEAD;
|
|
115
141
|
for (const kind of KIND_ORDER) {
|
|
142
|
+
// §51, §51.1: a server checks a DATA_PUT's units in message order, so
|
|
143
|
+
// each actor's units go oldest first (the `previous` link of a unit
|
|
144
|
+
// can then be an earlier unit of the same message).
|
|
116
145
|
const items = due.filter((o) => o.kind === kind);
|
|
146
|
+
if (kind === "data-unit")
|
|
147
|
+
items.sort(unitOrder);
|
|
117
148
|
let batch = [];
|
|
118
149
|
let size = 0;
|
|
119
150
|
const flush = () => {
|
|
@@ -172,8 +203,8 @@ export class OutboundQueue {
|
|
|
172
203
|
await this.#commit(writes);
|
|
173
204
|
for (const m of out) {
|
|
174
205
|
const key = toHex(m.message.messageId);
|
|
175
|
-
const
|
|
176
|
-
const wait = Math.min(this.#timeout.maxMs, this.#timeout.baseMs * 2 **
|
|
206
|
+
const unanswered = Math.max(...m.itemIds.map((id) => this.#unanswered.get(toHex(id)) ?? 0));
|
|
207
|
+
const wait = Math.min(this.#timeout.maxMs, this.#timeout.baseMs * 2 ** unanswered);
|
|
177
208
|
this.#flights.set(key, {
|
|
178
209
|
resourceId: resource,
|
|
179
210
|
type: m.message.type,
|
|
@@ -233,9 +264,11 @@ export class OutboundQueue {
|
|
|
233
264
|
if (flight === undefined)
|
|
234
265
|
return undefined;
|
|
235
266
|
this.#flights.delete(key);
|
|
236
|
-
for (const id of flight.itemIds)
|
|
267
|
+
for (const id of flight.itemIds) {
|
|
237
268
|
if (this.#inFlight.get(toHex(id)) === key)
|
|
238
269
|
this.#inFlight.delete(toHex(id));
|
|
270
|
+
this.#unanswered.delete(toHex(id));
|
|
271
|
+
}
|
|
239
272
|
return flight;
|
|
240
273
|
}
|
|
241
274
|
async #retryLater(ids, reason, now, code) {
|
|
@@ -289,6 +322,11 @@ export class OutboundQueue {
|
|
|
289
322
|
this.#forget(item.itemId);
|
|
290
323
|
}
|
|
291
324
|
const writes = acked.map((itemId) => ({ op: "dequeue", itemId }));
|
|
325
|
+
// Units held for UNKNOWN_PREVIOUS behind one of ours that is now on the server.
|
|
326
|
+
const landed = new Set(acked.map((id) => toHex(id)));
|
|
327
|
+
for (const [key, wait] of [...this.#awaitingOffer])
|
|
328
|
+
if (wait.previous !== null && landed.has(wait.previous))
|
|
329
|
+
this.#awaitingOffer.delete(key);
|
|
292
330
|
for (const ids of byResource.values()) {
|
|
293
331
|
const resourceId = (await this.#storage.outbound.get(ids[0]))?.resourceId;
|
|
294
332
|
if (resourceId === undefined)
|
|
@@ -327,6 +365,8 @@ export class OutboundQueue {
|
|
|
327
365
|
this.#inFlight.delete(key);
|
|
328
366
|
this.#solo.delete(key);
|
|
329
367
|
this.#awaitingControl.delete(key);
|
|
368
|
+
this.#awaitingOffer.delete(key);
|
|
369
|
+
this.#unanswered.delete(key);
|
|
330
370
|
}
|
|
331
371
|
async #block(ids, reason, detail) {
|
|
332
372
|
const out = [];
|
|
@@ -352,6 +392,7 @@ export class OutboundQueue {
|
|
|
352
392
|
* MESSAGE_TOO_LARGE → blocked "too-large";
|
|
353
393
|
* CONTROL_HEAD_MISMATCH → blocked "repropose" with the current head;
|
|
354
394
|
* MISSING_DEPENDENCY → retried after the next controlSynced();
|
|
395
|
+
* UNKNOWN_PREVIOUS → retried after the next offered() (§51.1, §68.1);
|
|
355
396
|
* anything else → retried after the RetryPolicy delay.
|
|
356
397
|
*/
|
|
357
398
|
async onNack(message, now) {
|
|
@@ -406,6 +447,38 @@ export class OutboundQueue {
|
|
|
406
447
|
for (const id of items)
|
|
407
448
|
this.#awaitingControl.add(toHex(id));
|
|
408
449
|
return Object.freeze({ kind: "needs-control-sync", items: Object.freeze(items) });
|
|
450
|
+
case "UNKNOWN_PREVIOUS": {
|
|
451
|
+
const d = message.body.details;
|
|
452
|
+
const previous = d instanceof Uint8Array && d.length === 32 ? hash32(d) : null;
|
|
453
|
+
// Each unit waits on its OWN previous: the details name only the
|
|
454
|
+
// first refused unit's, and a later unit of the message may name
|
|
455
|
+
// one that is itself waiting here.
|
|
456
|
+
for (const id of items) {
|
|
457
|
+
const item = await this.#storage.outbound.get(id);
|
|
458
|
+
let own = null;
|
|
459
|
+
try {
|
|
460
|
+
const p = item === undefined ? null : parseDataUnit(item.bytes).payload.prevDataUnitId;
|
|
461
|
+
own = p === null || p === undefined ? null : toHex(p);
|
|
462
|
+
}
|
|
463
|
+
catch {
|
|
464
|
+
own = null;
|
|
465
|
+
}
|
|
466
|
+
this.#awaitingOffer.set(toHex(id), { resource: toHex(flight.resourceId), previous: own });
|
|
467
|
+
}
|
|
468
|
+
// The server lacks a unit of ours we sent before: on one connection
|
|
469
|
+
// it would have handled that message first, so it was lost. It is
|
|
470
|
+
// due again now rather than after its request timeout (a late ACK
|
|
471
|
+
// still removes it).
|
|
472
|
+
for (const wait of this.#awaitingOffer.values())
|
|
473
|
+
if (wait.previous !== null && this.#inFlight.has(wait.previous))
|
|
474
|
+
this.#inFlight.delete(wait.previous);
|
|
475
|
+
return Object.freeze({
|
|
476
|
+
kind: "needs-offer",
|
|
477
|
+
resourceId: flight.resourceId,
|
|
478
|
+
previous,
|
|
479
|
+
items: Object.freeze(items),
|
|
480
|
+
});
|
|
481
|
+
}
|
|
409
482
|
default:
|
|
410
483
|
await this.#retryLater(items, "transient", now, code);
|
|
411
484
|
return Object.freeze({ kind: "retry", code, items: Object.freeze(items) });
|
|
@@ -428,6 +501,25 @@ export class OutboundQueue {
|
|
|
428
501
|
for (const item of await this.#storage.outbound.list(resource))
|
|
429
502
|
this.#awaitingControl.delete(toHex(item.itemId));
|
|
430
503
|
}
|
|
504
|
+
/**
|
|
505
|
+
* What the server of `resource` lacked was offered (§68.1): items held
|
|
506
|
+
* for UNKNOWN_PREVIOUS are due again, behind the offered objects.
|
|
507
|
+
*/
|
|
508
|
+
async offered(resource) {
|
|
509
|
+
for (const [key, wait] of [...this.#awaitingOffer]) {
|
|
510
|
+
if (wait.resource !== toHex(resource))
|
|
511
|
+
continue;
|
|
512
|
+
// The unit the server lacks is our own, still queued (e.g. its
|
|
513
|
+
// message was lost): its successors wait for its ACK (onAck), or the
|
|
514
|
+
// server would refuse them again.
|
|
515
|
+
if (wait.previous !== null) {
|
|
516
|
+
const own = await this.#storage.outbound.get(hash32(fromHex(wait.previous)));
|
|
517
|
+
if (own !== undefined && own.blocked === null)
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
this.#awaitingOffer.delete(key);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
431
523
|
/**
|
|
432
524
|
* §88 step 7, G-EP5: with a newly validated Control view, our queued
|
|
433
525
|
* Data Units of a closed epoch beyond its final frontier are never sent;
|
|
@@ -484,7 +576,14 @@ export async function resourceSyncState(storage, resource) {
|
|
|
484
576
|
// Every LFCP-accepted unit is held here, whatever its profile status: a
|
|
485
577
|
// crash between accepting and recording the merge leaves it "seen" or
|
|
486
578
|
// "held" until replayStored applies it.
|
|
487
|
-
for (const status of [
|
|
579
|
+
for (const status of [
|
|
580
|
+
"merged",
|
|
581
|
+
"profile-pending",
|
|
582
|
+
"profile-held",
|
|
583
|
+
"profile-rejected",
|
|
584
|
+
"seen",
|
|
585
|
+
"held",
|
|
586
|
+
])
|
|
488
587
|
for (const u of await storage.dataUnits.withStatus(resource, status))
|
|
489
588
|
if (u.accepted)
|
|
490
589
|
have = addSequence(have, u.actor, u.actorSeq);
|
package/dist/sync-client.d.ts
CHANGED
|
@@ -154,6 +154,20 @@ export type SyncEvent = {
|
|
|
154
154
|
readonly code: string;
|
|
155
155
|
readonly message: string;
|
|
156
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* The server answered RESOURCE_OPEN with RESOURCE_NOT_HOSTED although it
|
|
159
|
+
* hosted the Resource before (it lost it, e.g. restored from an older
|
|
160
|
+
* store): the client hosted it again from its Genesis (§41.1), or the
|
|
161
|
+
* server refused that ("refused", with the §62 code; the Resource is then
|
|
162
|
+
* refused for good, see resource-refused).
|
|
163
|
+
*/
|
|
164
|
+
| {
|
|
165
|
+
readonly type: "rehost";
|
|
166
|
+
readonly resourceId: ResourceId;
|
|
167
|
+
readonly url: string;
|
|
168
|
+
readonly outcome: "hosted" | "refused";
|
|
169
|
+
readonly code?: string;
|
|
170
|
+
}
|
|
157
171
|
/**
|
|
158
172
|
* The server refused the Resource for good (see ResourceRefusal): it is
|
|
159
173
|
* CLOSED and is not opened again until open() is called for it.
|
package/dist/sync-client.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { actorSequence, dataEpoch, LfcpError, toHex, } from "@openlfcp/core";
|
|
1
|
+
import { actorSequence, dataEpoch, hash32, LfcpError, toHex, } from "@openlfcp/core";
|
|
2
2
|
import { exportSecretKeyBytes, sha256 } from "@openlfcp/crypto";
|
|
3
|
-
import { dekSecretRef } from "@openlfcp/storage";
|
|
4
|
-
import { addRange, addSequence, batchDataRanges, canonicalFrontierToCbor, createMessage, ERROR_CODE, hasSequence, localControlOf, MESSAGE_TYPE, missingFrom, normalizeLiveHaves, parseControlRecord, parseDataUnit, planControlSync, receiveKeyPackage, receiveSnapshot, validateControlChain, } from "@openlfcp/wire";
|
|
3
|
+
import { dekSecretRef, } from "@openlfcp/storage";
|
|
4
|
+
import { addRange, addSequence, batchDataRanges, canonicalFrontierToCbor, createMessage, ERROR_CODE, hasSequence, haveDifference, localControlOf, MESSAGE_TYPE, missingFrom, normalizeLiveHaves, parseControlRecord, parseDataUnit, parseKeyPackage, planControlSync, receiveKeyPackage, receiveSnapshot, validateControlChain, } from "@openlfcp/wire";
|
|
5
5
|
import { encode } from "@openlfcp/wire/cbor";
|
|
6
6
|
import { LfcpConnection } from "./connection.js";
|
|
7
7
|
import { EngineGuard, isEngineTrap, snapshotItem } from "./engine-guard.js";
|
|
@@ -32,6 +32,23 @@ export const TERMINAL_RESOURCE_CODES = new Set([
|
|
|
32
32
|
"PROFILE_UNSUPPORTED",
|
|
33
33
|
]);
|
|
34
34
|
const NACK_NAME = new Map(Object.entries(ERROR_CODE).map(([k, v]) => [v, k]));
|
|
35
|
+
/**
|
|
36
|
+
* Data Unit statuses whose units this client has accepted and may offer to
|
|
37
|
+
* a server that lacks them (§68.1): never a unit held for its `previous`
|
|
38
|
+
* link, a quarantined or equivocating one, or one that failed locally.
|
|
39
|
+
*/
|
|
40
|
+
const OFFERABLE = [
|
|
41
|
+
"merged",
|
|
42
|
+
"profile-pending",
|
|
43
|
+
"profile-held",
|
|
44
|
+
"profile-rejected",
|
|
45
|
+
"profile-unsupported",
|
|
46
|
+
"seen",
|
|
47
|
+
];
|
|
48
|
+
/** At most this many objects per offered DATA_PUT or KEY_PACKAGE_PUT. */
|
|
49
|
+
const OFFER_BATCH = 64;
|
|
50
|
+
/** The local mark that a route hosted (or opened) a Resource for this client (§41.1). */
|
|
51
|
+
const hostedMark = (resource, url) => `hosted-route:${toHex(resource)}:${url}`;
|
|
35
52
|
const SUBSCRIBE_DATA_AND_CONTROL = 3n;
|
|
36
53
|
const iso = (ms) => new Date(ms).toISOString();
|
|
37
54
|
const haveToLive = (vector) => vector.map((h) => ({
|
|
@@ -56,6 +73,8 @@ export class SyncClient {
|
|
|
56
73
|
#trapped = false;
|
|
57
74
|
#attempt = 0;
|
|
58
75
|
#reconnectAt = null;
|
|
76
|
+
/** READY's maximum message size, for offered batches (§31). */
|
|
77
|
+
#maxMessageBytes = 8 * 1024 * 1024;
|
|
59
78
|
/** Serializes message handling: each message is handled after the previous one finished. */
|
|
60
79
|
#queue = Promise.resolve();
|
|
61
80
|
constructor(options) {
|
|
@@ -171,6 +190,9 @@ export class SyncClient {
|
|
|
171
190
|
refusal: null,
|
|
172
191
|
refusedAttempt: 0,
|
|
173
192
|
reopenAt: null,
|
|
193
|
+
offering: new Set(),
|
|
194
|
+
keysOffered: false,
|
|
195
|
+
rehosted: false,
|
|
174
196
|
};
|
|
175
197
|
this.#resources.set(key, ctx);
|
|
176
198
|
}
|
|
@@ -197,7 +219,7 @@ export class SyncClient {
|
|
|
197
219
|
host(genesis, credential) {
|
|
198
220
|
return new Promise((resolve, reject) => {
|
|
199
221
|
try {
|
|
200
|
-
this.#request({ kind: "host", resolve, reject }, createMessage("RESOURCE_HOST", {
|
|
222
|
+
this.#request({ kind: "host", genesis, resolve, reject }, createMessage("RESOURCE_HOST", {
|
|
201
223
|
genesis,
|
|
202
224
|
...(credential === undefined ? {} : { credential }),
|
|
203
225
|
}));
|
|
@@ -281,6 +303,15 @@ export class SyncClient {
|
|
|
281
303
|
request.reject(new Error("RESOURCE_HOST got no answer within the request timeout"));
|
|
282
304
|
continue;
|
|
283
305
|
}
|
|
306
|
+
if (request.kind === "offer-data") {
|
|
307
|
+
// Offered again on the next DATA_HAVE (§68.1).
|
|
308
|
+
const ctx = this.#resources.get(request.resource);
|
|
309
|
+
for (const u of request.units)
|
|
310
|
+
ctx?.offering.delete(u.id);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (request.kind === "offer-control" || request.kind === "offer-keys")
|
|
314
|
+
continue;
|
|
284
315
|
if ("resource" in request)
|
|
285
316
|
resend.set(`${request.kind}:${request.resource}`, request);
|
|
286
317
|
}
|
|
@@ -294,6 +325,10 @@ export class SyncClient {
|
|
|
294
325
|
this.#move(ctx, "CLOSE");
|
|
295
326
|
this.#serial(() => this.#sendOpen(ctx));
|
|
296
327
|
}
|
|
328
|
+
else if (request.kind === "rehost" && ctx.state === "CLOSED") {
|
|
329
|
+
ctx.rehosted = false; // a lost answer: open again, which re-hosts again if needed
|
|
330
|
+
this.#serial(() => this.#sendOpen(ctx));
|
|
331
|
+
}
|
|
297
332
|
else if (request.kind === "control" && ctx.state === "CONTROL_SYNC") {
|
|
298
333
|
this.#serial(() => this.#controlRound(ctx, ctx.lastHeads));
|
|
299
334
|
}
|
|
@@ -317,8 +352,10 @@ export class SyncClient {
|
|
|
317
352
|
if (next === undefined)
|
|
318
353
|
return false;
|
|
319
354
|
ctx.state = next;
|
|
320
|
-
if (next === "LIVE")
|
|
355
|
+
if (next === "LIVE") {
|
|
321
356
|
ctx.refusedAttempt = 0;
|
|
357
|
+
ctx.rehosted = false;
|
|
358
|
+
}
|
|
322
359
|
this.#emit({ type: "resource-state", resourceId: ctx.binding.resourceId, state: next });
|
|
323
360
|
if (next === "LIVE" && ctx.controlStale) {
|
|
324
361
|
ctx.controlStale = false;
|
|
@@ -341,6 +378,7 @@ export class SyncClient {
|
|
|
341
378
|
}
|
|
342
379
|
async #onReady(ready) {
|
|
343
380
|
this.#attempt = 0;
|
|
381
|
+
this.#maxMessageBytes = Number(ready.maxMessageBytes);
|
|
344
382
|
this.#o.outbound.session({
|
|
345
383
|
durability: ready.durability,
|
|
346
384
|
maxMessageBytes: ready.maxMessageBytes,
|
|
@@ -359,6 +397,7 @@ export class SyncClient {
|
|
|
359
397
|
for (const ctx of this.#resources.values()) {
|
|
360
398
|
this.#move(ctx, "CLOSE"); // §65, G-SM1: every Resource closes with the connection
|
|
361
399
|
ctx.view = null;
|
|
400
|
+
ctx.offering.clear();
|
|
362
401
|
}
|
|
363
402
|
await this.#o.outbound.connectionLost(iso(this.#o.now()));
|
|
364
403
|
if (this.#stopped)
|
|
@@ -378,6 +417,7 @@ export class SyncClient {
|
|
|
378
417
|
ctx.controlTarget = null;
|
|
379
418
|
ctx.early = [];
|
|
380
419
|
ctx.reopenAt = null;
|
|
420
|
+
ctx.keysOffered = false;
|
|
381
421
|
this.#request({ kind: "open", resource: toHex(R) }, createMessage("RESOURCE_OPEN", {
|
|
382
422
|
resourceId: R,
|
|
383
423
|
heads,
|
|
@@ -394,8 +434,23 @@ export class SyncClient {
|
|
|
394
434
|
case "RESOURCE_HOSTED":
|
|
395
435
|
if (request?.kind === "host") {
|
|
396
436
|
this.#requests.delete(toHex(m.correlationId));
|
|
437
|
+
await this.#markHosted(parseControlRecord(request.genesis).payload.resourceId);
|
|
397
438
|
request.resolve(m.body.durability);
|
|
398
439
|
}
|
|
440
|
+
else if (request?.kind === "rehost") {
|
|
441
|
+
this.#requests.delete(toHex(m.correlationId));
|
|
442
|
+
const ctx = this.#resources.get(request.resource);
|
|
443
|
+
if (ctx === undefined)
|
|
444
|
+
return;
|
|
445
|
+
this.#emit({
|
|
446
|
+
type: "rehost",
|
|
447
|
+
resourceId: ctx.binding.resourceId,
|
|
448
|
+
url: this.#o.url,
|
|
449
|
+
outcome: "hosted",
|
|
450
|
+
});
|
|
451
|
+
if (ctx.wanted)
|
|
452
|
+
await this.#sendOpen(ctx);
|
|
453
|
+
}
|
|
399
454
|
return;
|
|
400
455
|
case "RESOURCE_OPENED": {
|
|
401
456
|
const ctx = this.#ctx(m.body.resourceId);
|
|
@@ -404,7 +459,11 @@ export class SyncClient {
|
|
|
404
459
|
this.#done(m);
|
|
405
460
|
ctx.remoteHave = normalizeLiveHaves(m.body.haves);
|
|
406
461
|
ctx.offered = m.body.snapshot ?? null;
|
|
462
|
+
await this.#markHosted(ctx.binding.resourceId);
|
|
407
463
|
this.#move(ctx, "OPENED");
|
|
464
|
+
// §68.1, §88 step 6: what the server lacks goes first, Control Records before units.
|
|
465
|
+
await this.#offerControl(ctx, m.body.heads);
|
|
466
|
+
await this.#offerData(ctx);
|
|
408
467
|
await this.#controlRound(ctx, m.body.heads);
|
|
409
468
|
return;
|
|
410
469
|
}
|
|
@@ -457,6 +516,7 @@ export class SyncClient {
|
|
|
457
516
|
if (ctx === undefined)
|
|
458
517
|
return;
|
|
459
518
|
ctx.remoteHave = normalizeLiveHaves(m.body.haves);
|
|
519
|
+
await this.#offerData(ctx); // §68.1: both directions
|
|
460
520
|
if (ctx.state === "LIVE")
|
|
461
521
|
await this.#dataRound(ctx);
|
|
462
522
|
return;
|
|
@@ -464,6 +524,9 @@ export class SyncClient {
|
|
|
464
524
|
case "ACK":
|
|
465
525
|
if (request !== undefined) {
|
|
466
526
|
this.#done(m);
|
|
527
|
+
if (request.kind === "offer-data")
|
|
528
|
+
for (const u of request.units)
|
|
529
|
+
this.#resources.get(request.resource)?.offering.delete(u.id);
|
|
467
530
|
return;
|
|
468
531
|
}
|
|
469
532
|
await this.#onAck(m);
|
|
@@ -471,7 +534,7 @@ export class SyncClient {
|
|
|
471
534
|
case "NACK":
|
|
472
535
|
if (request !== undefined) {
|
|
473
536
|
this.#done(m);
|
|
474
|
-
this.#onRequestNack(request, m);
|
|
537
|
+
await this.#onRequestNack(request, m);
|
|
475
538
|
return;
|
|
476
539
|
}
|
|
477
540
|
await this.#onNack(m);
|
|
@@ -488,9 +551,41 @@ export class SyncClient {
|
|
|
488
551
|
if (m.correlationId !== undefined && m.type !== "CONTROL_BATCH" && m.type !== "DATA_BATCH")
|
|
489
552
|
this.#requests.delete(toHex(m.correlationId));
|
|
490
553
|
}
|
|
491
|
-
#onRequestNack(request, m) {
|
|
554
|
+
async #onRequestNack(request, m) {
|
|
492
555
|
// The §62 name (e.g. AUTHORIZATION_FAILED), so applications can explain it; unknown codes keep their number.
|
|
493
556
|
const code = NACK_NAME.get(m.body.code) ?? `NACK ${m.body.code}`;
|
|
557
|
+
if (request.kind === "offer-control" ||
|
|
558
|
+
request.kind === "offer-data" ||
|
|
559
|
+
request.kind === "offer-keys") {
|
|
560
|
+
this.#onOfferNack(request, code, m.body.diagnostic);
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
if (request.kind === "open" && code === "RESOURCE_NOT_HOSTED") {
|
|
564
|
+
const ctx = this.#resources.get(request.resource);
|
|
565
|
+
if (ctx !== undefined && (await this.#rehost(ctx)))
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
if (request.kind === "rehost") {
|
|
569
|
+
const ctx = this.#resources.get(request.resource);
|
|
570
|
+
if (ctx === undefined)
|
|
571
|
+
return;
|
|
572
|
+
this.#emit({
|
|
573
|
+
type: "rehost",
|
|
574
|
+
resourceId: ctx.binding.resourceId,
|
|
575
|
+
url: this.#o.url,
|
|
576
|
+
outcome: "refused",
|
|
577
|
+
code,
|
|
578
|
+
});
|
|
579
|
+
this.#error(code, `re-hosting refused${m.body.diagnostic ? `: ${m.body.diagnostic}` : ""}`, ctx.binding.resourceId);
|
|
580
|
+
// §41.1: stop re-hosting on this route and tell the user; never retried by itself.
|
|
581
|
+
this.#refuse(ctx, {
|
|
582
|
+
code,
|
|
583
|
+
url: this.#o.url,
|
|
584
|
+
request: "rehost",
|
|
585
|
+
...(m.body.diagnostic === undefined ? {} : { diagnostic: m.body.diagnostic }),
|
|
586
|
+
});
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
494
589
|
if (request.kind === "host") {
|
|
495
590
|
request.reject(new Error(`RESOURCE_HOST refused: NACK ${code}${m.body.diagnostic ? ` (${m.body.diagnostic})` : ""}`));
|
|
496
591
|
return;
|
|
@@ -548,6 +643,8 @@ export class SyncClient {
|
|
|
548
643
|
const chain = await loadControlChain(this.#o.storage, ctx.binding.resourceId);
|
|
549
644
|
const local = chain?.kind === "linear" ? localControlOf(chain) : null;
|
|
550
645
|
const plan = planControlSync(local, heads);
|
|
646
|
+
if (plan.kind === "peer-behind")
|
|
647
|
+
await this.#offerControl(ctx, heads); // §68.1
|
|
551
648
|
if (plan.kind === "in-sync" || plan.kind === "peer-behind" || plan.kind === "peer-empty")
|
|
552
649
|
return;
|
|
553
650
|
this.#move(ctx, "CONTROL_RECORD");
|
|
@@ -717,14 +814,31 @@ export class SyncClient {
|
|
|
717
814
|
this.#error(r.kind === "rejected" ? r.wireCode : r.code, `a Key Package was not used (${r.kind})`, R);
|
|
718
815
|
continue;
|
|
719
816
|
}
|
|
720
|
-
// Secret first, then the row that references it (LFCP-034).
|
|
817
|
+
// Secret first, then the row that references it (LFCP-034). The
|
|
818
|
+
// package itself is kept while we keep the Resource (§86), to
|
|
819
|
+
// re-supply a server that lost it (§68.1).
|
|
721
820
|
const ref = dekSecretRef(R, r.epoch);
|
|
722
821
|
await this.#o.secrets.put(ref, exportSecretKeyBytes(r.dek));
|
|
822
|
+
const parsed = parseKeyPackage(bytes);
|
|
823
|
+
const writes = [
|
|
824
|
+
{
|
|
825
|
+
op: "put-key-package",
|
|
826
|
+
row: {
|
|
827
|
+
packageId: hash32(parsed.signed.id),
|
|
828
|
+
resourceId: R,
|
|
829
|
+
dataEpoch: parsed.payload.dataEpoch,
|
|
830
|
+
recipient: parsed.payload.recipient,
|
|
831
|
+
sender: parsed.payload.sender,
|
|
832
|
+
bytes: parsed.signed.bytes,
|
|
833
|
+
},
|
|
834
|
+
},
|
|
835
|
+
];
|
|
723
836
|
const row = (await this.#o.storage.control.epochs(R)).find((e) => e.epoch === r.epoch);
|
|
724
837
|
if (row !== undefined) {
|
|
725
838
|
const next = { ...row, dekRef: ref };
|
|
726
|
-
|
|
839
|
+
writes.push({ op: "put-epoch", resourceId: R, epoch: next });
|
|
727
840
|
}
|
|
841
|
+
await this.#o.storage.commit(writes);
|
|
728
842
|
}
|
|
729
843
|
const missing = await this.#epochsWithoutDek(ctx);
|
|
730
844
|
const current = view.state.epoch.epoch;
|
|
@@ -1092,6 +1206,186 @@ export class SyncClient {
|
|
|
1092
1206
|
for (const ctx of this.#resources.values())
|
|
1093
1207
|
if (ctx.state === "LIVE")
|
|
1094
1208
|
this.#refreshControl(ctx);
|
|
1209
|
+
// §51.1: the server lost what our unit names. Its DATA_HAVE answer
|
|
1210
|
+
// starts the offer (§68.1), which releases the held items.
|
|
1211
|
+
if (outcome.kind === "needs-offer") {
|
|
1212
|
+
const ctx = this.#ctx(outcome.resourceId);
|
|
1213
|
+
if (ctx !== undefined && (ctx.state === "LIVE" || ctx.state === "DATA_SYNC"))
|
|
1214
|
+
this.#sendHave(ctx, this.#o.now());
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
// -------------------------------------------------------------------------
|
|
1218
|
+
// Offering what the server lacks (§68.1) and re-hosting (§41.1)
|
|
1219
|
+
async #markHosted(resource) {
|
|
1220
|
+
const key = hostedMark(resource, this.#o.url);
|
|
1221
|
+
if ((await this.#o.storage.localMarks.get(key)) !== undefined)
|
|
1222
|
+
return;
|
|
1223
|
+
await this.#o.storage.commit([{ op: "put-local-mark", key, value: "1" }]);
|
|
1224
|
+
}
|
|
1225
|
+
/**
|
|
1226
|
+
* §41.1: RESOURCE_NOT_HOSTED from a route in the Resource's route set that
|
|
1227
|
+
* hosted or opened it for us before: host it again from the exact Genesis
|
|
1228
|
+
* bytes, then open it. False when re-hosting is not allowed here.
|
|
1229
|
+
*/
|
|
1230
|
+
async #rehost(ctx) {
|
|
1231
|
+
if (ctx.rehosted || this.#connection.state !== "READY")
|
|
1232
|
+
return false;
|
|
1233
|
+
const R = ctx.binding.resourceId;
|
|
1234
|
+
const chain = await loadControlChain(this.#o.storage, R);
|
|
1235
|
+
if (chain?.kind !== "linear")
|
|
1236
|
+
return false;
|
|
1237
|
+
const route = chain.state.route;
|
|
1238
|
+
const url = this.#o.url;
|
|
1239
|
+
if (!route.endpoints.some((e) => e.url === url) && route.coordinatorUrl !== url)
|
|
1240
|
+
return false;
|
|
1241
|
+
if ((await this.#o.storage.localMarks.get(hostedMark(R, url))) === undefined)
|
|
1242
|
+
return false;
|
|
1243
|
+
const genesis = chain.records[0];
|
|
1244
|
+
if (genesis === undefined || genesis.payload.controlSeq !== 0n)
|
|
1245
|
+
return false;
|
|
1246
|
+
ctx.rehosted = true;
|
|
1247
|
+
this.#move(ctx, "CLOSE");
|
|
1248
|
+
this.#request({ kind: "rehost", resource: toHex(R) }, createMessage("RESOURCE_HOST", { genesis: genesis.signed.bytes }));
|
|
1249
|
+
return true;
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* §68.1: the server's Control Head is a record we hold below our own head:
|
|
1253
|
+
* upload the records above it, one CONTROL_PUT each, in order, each
|
|
1254
|
+
* expecting the record before it.
|
|
1255
|
+
*/
|
|
1256
|
+
async #offerControl(ctx, heads) {
|
|
1257
|
+
if (this.#connection.state !== "READY")
|
|
1258
|
+
return;
|
|
1259
|
+
const R = ctx.binding.resourceId;
|
|
1260
|
+
const chain = await loadControlChain(this.#o.storage, R);
|
|
1261
|
+
if (chain?.kind !== "linear")
|
|
1262
|
+
return;
|
|
1263
|
+
const plan = planControlSync(localControlOf(chain), heads);
|
|
1264
|
+
if (plan.kind !== "peer-behind")
|
|
1265
|
+
return;
|
|
1266
|
+
for (const record of chain.records) {
|
|
1267
|
+
if (record.payload.controlSeq <= plan.peerSeq)
|
|
1268
|
+
continue;
|
|
1269
|
+
const previous = record.payload.prevControlId;
|
|
1270
|
+
if (previous === null)
|
|
1271
|
+
continue;
|
|
1272
|
+
this.#request({ kind: "offer-control", resource: toHex(R) }, createMessage("CONTROL_PUT", {
|
|
1273
|
+
resourceId: R,
|
|
1274
|
+
expectedHead: previous,
|
|
1275
|
+
record: record.signed.bytes,
|
|
1276
|
+
}));
|
|
1277
|
+
}
|
|
1278
|
+
await this.#offerKeys(ctx);
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* §68.1: the accepted units the server's Have Vector lacks, ours and
|
|
1282
|
+
* other actors' (relay), per actor in ascending sequence. Units still in
|
|
1283
|
+
* our outbound queue go through it instead. Then the queued items held
|
|
1284
|
+
* for UNKNOWN_PREVIOUS are released behind them.
|
|
1285
|
+
*/
|
|
1286
|
+
async #offerData(ctx) {
|
|
1287
|
+
if (this.#connection.state !== "READY")
|
|
1288
|
+
return;
|
|
1289
|
+
const R = ctx.binding.resourceId;
|
|
1290
|
+
const queued = new Set((await this.#o.storage.outbound.list(R)).map((o) => toHex(o.itemId)));
|
|
1291
|
+
let local = [];
|
|
1292
|
+
for (const status of OFFERABLE)
|
|
1293
|
+
for (const u of await this.#o.storage.dataUnits.withStatus(R, status))
|
|
1294
|
+
if (u.accepted)
|
|
1295
|
+
local = addSequence(local, u.actor, u.actorSeq);
|
|
1296
|
+
const { offer } = haveDifference(local, ctx.remoteHave);
|
|
1297
|
+
const offerable = new Set(OFFERABLE);
|
|
1298
|
+
let sent = 0;
|
|
1299
|
+
for (const range of offer) {
|
|
1300
|
+
const units = (await this.#o.storage.dataUnits.range(R, range.actor, actorSequence(range.start), actorSequence(range.end))).filter((u) => u.accepted &&
|
|
1301
|
+
offerable.has(u.status) &&
|
|
1302
|
+
!queued.has(toHex(u.unitId)) &&
|
|
1303
|
+
!ctx.offering.has(toHex(u.unitId)));
|
|
1304
|
+
sent += units.length;
|
|
1305
|
+
this.#offerUnits(ctx, units.map((u) => ({ id: toHex(u.unitId), bytes: u.bytes })));
|
|
1306
|
+
}
|
|
1307
|
+
await this.#o.outbound.offered(R);
|
|
1308
|
+
if (sent > 0)
|
|
1309
|
+
await this.#offerKeys(ctx);
|
|
1310
|
+
await this.#flush(ctx);
|
|
1311
|
+
}
|
|
1312
|
+
/** DATA_PUTs of one actor's units in order, within the message size and object limits. */
|
|
1313
|
+
#offerUnits(ctx, units) {
|
|
1314
|
+
const R = ctx.binding.resourceId;
|
|
1315
|
+
const limit = this.#maxMessageBytes - 1024;
|
|
1316
|
+
let batch = [];
|
|
1317
|
+
let size = 0;
|
|
1318
|
+
const send = () => {
|
|
1319
|
+
if (batch.length === 0)
|
|
1320
|
+
return;
|
|
1321
|
+
for (const u of batch)
|
|
1322
|
+
ctx.offering.add(u.id);
|
|
1323
|
+
this.#request({ kind: "offer-data", resource: toHex(R), units: batch }, createMessage("DATA_PUT", { resourceId: R, objects: batch.map((u) => u.bytes) }));
|
|
1324
|
+
batch = [];
|
|
1325
|
+
size = 0;
|
|
1326
|
+
};
|
|
1327
|
+
for (const u of units) {
|
|
1328
|
+
if (batch.length >= OFFER_BATCH || size + u.bytes.length > limit)
|
|
1329
|
+
send();
|
|
1330
|
+
batch.push(u);
|
|
1331
|
+
size += u.bytes.length;
|
|
1332
|
+
}
|
|
1333
|
+
send();
|
|
1334
|
+
}
|
|
1335
|
+
/**
|
|
1336
|
+
* §68.1, §86: a server that lacked Control Records or units may have lost
|
|
1337
|
+
* Key Packages too: the ones we sent or that are addressed to us are
|
|
1338
|
+
* uploaded again, once per open. A package the server stores is answered
|
|
1339
|
+
* as the first time (§70).
|
|
1340
|
+
*/
|
|
1341
|
+
async #offerKeys(ctx) {
|
|
1342
|
+
if (ctx.keysOffered || this.#connection.state !== "READY")
|
|
1343
|
+
return;
|
|
1344
|
+
ctx.keysOffered = true;
|
|
1345
|
+
const R = ctx.binding.resourceId;
|
|
1346
|
+
const me = toHex(this.#o.signer.descriptor.principalId);
|
|
1347
|
+
const packages = (await this.#o.storage.keyPackages.list(R)).filter((k) => toHex(k.sender) === me || toHex(k.recipient) === me);
|
|
1348
|
+
const limit = this.#maxMessageBytes - 1024;
|
|
1349
|
+
for (let i = 0; i < packages.length;) {
|
|
1350
|
+
const batch = [];
|
|
1351
|
+
let size = 0;
|
|
1352
|
+
while (i < packages.length && batch.length < OFFER_BATCH) {
|
|
1353
|
+
const bytes = packages[i].bytes;
|
|
1354
|
+
if (batch.length > 0 && size + bytes.length > limit)
|
|
1355
|
+
break;
|
|
1356
|
+
batch.push(bytes);
|
|
1357
|
+
size += bytes.length;
|
|
1358
|
+
i++;
|
|
1359
|
+
}
|
|
1360
|
+
this.#request({ kind: "offer-keys", resource: toHex(R) }, createMessage("KEY_PACKAGE_PUT", { resourceId: R, objects: batch }));
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* A refused offer. A DATA_PUT is all-or-nothing (§51): each unit of a
|
|
1365
|
+
* refused batch is offered again alone. ACTOR_EQUIVOCATION for a relayed
|
|
1366
|
+
* unit is expected when the server holds the other unit of a pair, and
|
|
1367
|
+
* UNKNOWN_PREVIOUS when it lacks an earlier unit we do not hold either:
|
|
1368
|
+
* neither is an alarm. A refused Control Record (the server's head moved)
|
|
1369
|
+
* is caught up by the next Control round.
|
|
1370
|
+
*/
|
|
1371
|
+
#onOfferNack(request, code, diagnostic) {
|
|
1372
|
+
const ctx = this.#resources.get(request.resource);
|
|
1373
|
+
if (ctx === undefined)
|
|
1374
|
+
return;
|
|
1375
|
+
if (request.kind === "offer-data") {
|
|
1376
|
+
for (const u of request.units)
|
|
1377
|
+
ctx.offering.delete(u.id);
|
|
1378
|
+
if (request.units.length > 1) {
|
|
1379
|
+
for (const u of request.units)
|
|
1380
|
+
this.#offerUnits(ctx, [u]);
|
|
1381
|
+
return;
|
|
1382
|
+
}
|
|
1383
|
+
if (code === "ACTOR_EQUIVOCATION" || code === "UNKNOWN_PREVIOUS")
|
|
1384
|
+
return;
|
|
1385
|
+
}
|
|
1386
|
+
if (request.kind === "offer-control" && code === "CONTROL_HEAD_MISMATCH")
|
|
1387
|
+
return;
|
|
1388
|
+
this.#error(code, `an object offered to the server was refused (§68.1)${diagnostic ? `: ${diagnostic}` : ""}`, ctx.binding.resourceId);
|
|
1095
1389
|
}
|
|
1096
1390
|
}
|
|
1097
1391
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openlfcp/client",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "LFCP client: session, Control Plane and Data Plane synchronization.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"openlfcp",
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"tag": "latest"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@openlfcp/core": "^0.1.
|
|
46
|
-
"@openlfcp/
|
|
47
|
-
"@openlfcp/
|
|
48
|
-
"@openlfcp/wire": "^0.1.
|
|
45
|
+
"@openlfcp/core": "^0.1.3",
|
|
46
|
+
"@openlfcp/crypto": "^0.1.3",
|
|
47
|
+
"@openlfcp/storage": "^0.1.3",
|
|
48
|
+
"@openlfcp/wire": "^0.1.3"
|
|
49
49
|
}
|
|
50
50
|
}
|