@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 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.8`, not every deferred WIRE-01 feature. It does not
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.1`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
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 ? {} : { pending: "buffered by the profile" }),
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
- op: "set-data-unit-status",
331
- unitId: r.unitId,
332
- status: "profile-pending",
333
- detail: result.pending ?? "buffered by the profile",
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 = merged.length > 0 ? await this.#retryHeld(view, [r.actor]) : [];
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
- ...pendingNow
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 = await this.#retryHeld(view, actors);
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. Throws
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. Throws
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", {
@@ -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 attempt of the item, at most
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 attempts = Math.max(...m.itemIds.map((id) => (due.find((o) => bytesEqual(o.itemId, id))?.attempts ?? 0) + 1));
176
- const wait = Math.min(this.#timeout.maxMs, this.#timeout.baseMs * 2 ** (attempts - 1));
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 ["merged", "profile-pending", "profile-rejected", "seen", "held"])
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);
@@ -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.
@@ -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
- await this.#o.storage.commit([{ op: "put-epoch", resourceId: R, epoch: next }]);
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.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.1",
46
- "@openlfcp/storage": "^0.1.1",
47
- "@openlfcp/crypto": "^0.1.1",
48
- "@openlfcp/wire": "^0.1.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
  }