@serfab/cadre-core 1.4.0 → 1.5.0

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.
Files changed (63) hide show
  1. package/dist/cadre-node.d.ts +38 -15
  2. package/dist/cadre-node.js +77 -29
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-database.d.ts +24 -10
  5. package/dist/control-database.js +48 -16
  6. package/dist/control-database.js.map +1 -1
  7. package/dist/control-read-retry.d.ts +12 -0
  8. package/dist/control-read-retry.js +12 -0
  9. package/dist/control-read-retry.js.map +1 -1
  10. package/dist/control-write-retry.d.ts +5 -4
  11. package/dist/control-write-retry.js +24 -7
  12. package/dist/control-write-retry.js.map +1 -1
  13. package/dist/index.d.ts +3 -2
  14. package/dist/index.js +5 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/link-budget.d.ts +182 -0
  17. package/dist/link-budget.js +195 -0
  18. package/dist/link-budget.js.map +1 -0
  19. package/dist/peer-dial.d.ts +36 -17
  20. package/dist/peer-dial.js +37 -17
  21. package/dist/peer-dial.js.map +1 -1
  22. package/dist/peer-join-backfill.d.ts +58 -4
  23. package/dist/peer-join-backfill.js +165 -20
  24. package/dist/peer-join-backfill.js.map +1 -1
  25. package/dist/relay-reservation.d.ts +59 -11
  26. package/dist/relay-reservation.js +169 -43
  27. package/dist/relay-reservation.js.map +1 -1
  28. package/dist/seed-bootstrap.js +2 -1
  29. package/dist/seed-bootstrap.js.map +1 -1
  30. package/dist/strand-first-sync-gate.d.ts +43 -5
  31. package/dist/strand-first-sync-gate.js +55 -7
  32. package/dist/strand-first-sync-gate.js.map +1 -1
  33. package/dist/strand-instance-manager.d.ts +27 -2
  34. package/dist/strand-instance-manager.js +56 -13
  35. package/dist/strand-instance-manager.js.map +1 -1
  36. package/dist/strand-membership-reconciler.d.ts +200 -45
  37. package/dist/strand-membership-reconciler.js +302 -86
  38. package/dist/strand-membership-reconciler.js.map +1 -1
  39. package/dist/strand-membership-writer.d.ts +3 -2
  40. package/dist/strand-membership-writer.js +67 -90
  41. package/dist/strand-membership-writer.js.map +1 -1
  42. package/dist/strand-network-config.d.ts +2 -2
  43. package/dist/strand-network-config.js +2 -2
  44. package/dist/types.d.ts +96 -9
  45. package/dist/types.js +14 -4
  46. package/dist/types.js.map +1 -1
  47. package/package.json +8 -8
  48. package/src/cadre-node.ts +7445 -7397
  49. package/src/control-database.ts +48 -16
  50. package/src/control-read-retry.ts +214 -202
  51. package/src/control-write-retry.ts +24 -7
  52. package/src/index.ts +581 -563
  53. package/src/link-budget.ts +217 -0
  54. package/src/peer-dial.ts +39 -17
  55. package/src/peer-join-backfill.ts +192 -23
  56. package/src/relay-reservation.ts +1119 -951
  57. package/src/seed-bootstrap.ts +2 -1
  58. package/src/strand-first-sync-gate.ts +55 -7
  59. package/src/strand-instance-manager.ts +1516 -1458
  60. package/src/strand-membership-reconciler.ts +334 -92
  61. package/src/strand-membership-writer.ts +1962 -1987
  62. package/src/strand-network-config.ts +2 -2
  63. package/src/types.ts +95 -8
@@ -811,7 +811,8 @@ export class SeedBootstrapService {
811
811
  // (`dialPeerAddrs`), so an owner whose first address never answers neither
812
812
  // stalls seed application nor goes undialed at its other addresses.
813
813
  // NOTE: `handleSeedStream` acks only after this loop, so an unreachable owner
814
- // can hold the ack for up to `dialBudget.totalMs` (30 s) each, past a sender's
814
+ // can hold the ack for up to `dialBudget.totalMs` each — 32 s at the default declared link
815
+ // round trip, more on a host that declared a slower one (`link-budget.ts`) — past a sender's
815
816
  // 10 s `seedDeliverTimeoutMs`; the seed is still applied. If senders start
816
817
  // reporting accepted seeds as timed out, ack before dialing.
817
818
  const selfPeerId = this.libp2pNode.peerId?.toString();
@@ -64,12 +64,42 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
64
64
 
65
65
  /**
66
66
  * How long `CadreNode.addStrand` waits for a joining machine's first sync before rejecting
67
- * with {@link StrandAwaitingFirstSyncError}. Measured: over a direct connection the
68
- * joiner reads the founder's rows about 1.3 s after `addStrand` would previously have
69
- * resolved; a relayed path is slower, and a phone that just redeemed an invitation has the
70
- * host reachable moments ago, so 30 s is generous without leaving an app hanging.
67
+ * with {@link StrandAwaitingFirstSyncError}.
68
+ *
69
+ * Measured over a DIRECT connection (2026-09-16): the joiner reads the founder's rows about
70
+ * 1.3 s after `addStrand` would previously have resolved. That is still the fast case, and it
71
+ * is not what this budget has to cover.
72
+ *
73
+ * Measured over a RELAYED SLOW LINK (2026-09-26): one Windows developer machine, two
74
+ * relay-only `CadreNode`s (`listenAddrs: []`) on a shared loopback dedicated relay, with a
75
+ * 900 ms one-way per-frame outbound delay applied to every websocket by
76
+ * `integration-tests/src/harness/ws-latency.ts` in `pipelined` mode — frames stay overlapped
77
+ * in flight, so the figure is latency and bandwidth stays unlimited; the harness's other mode
78
+ * (`serial`) is a per-socket frame-rate cap and its delays are NOT comparable
79
+ * (`docs/testing.md` → "Link latency"). A round trip therefore costs about
80
+ * 1.8 s. Time from `addStrand` to writable, for a machine holding nothing of the strand yet:
81
+ * 23, 27, 31 and 41 s over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42
82
+ * and 46 s over three runs at a 5000 ms one. **The second band is the one in force**: sereus
83
+ * declares 5000 ms (`COHORT_READ_DEADLINE_MS`, `quereus-plugin-sereus/src/cluster-size.ts`),
84
+ * because 1000 ms is shorter than one round trip on that link and leaves every cohort read with
85
+ * no answer to corroborate against. Widening that deadline makes a consult against a peer that
86
+ * cannot answer cost longer, and this phase runs several of those — which is why the two bands
87
+ * are both recorded here, and why a change to either number has to be weighed against the
88
+ * other. 120 s clears the worst sample of the band in force by about 2.6x. The previous 30 s
89
+ * default sat INSIDE the first band, so roughly half of those joins were rejected as "not
90
+ * writable yet" while the sync was progressing normally and went on to complete.
91
+ *
92
+ * NOTE: accepted tradeoff — what 120 s costs. This wait is what an app's `addStrand` sits in
93
+ * before it is told "not yet", so a strand none of whose members is reachable at all takes two
94
+ * minutes to report instead of thirty seconds. The slow report was weighed against refusing
95
+ * joins that were working, and the refusal is the worse failure. The cost is bounded — the
96
+ * rejection is retryable, the strand stays launched and keeps probing, and `strand:writable`
97
+ * fires the moment the sync lands, so an app that listens for the event rather than awaiting
98
+ * the call is unaffected either way. Revisit if the gate ever learns whether ANY other member
99
+ * is connected: "no peer at all" could then be reported at once and this budget would only
100
+ * ever be spent on a sync that is actually in progress.
71
101
  */
72
- export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 30_000;
102
+ export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 120_000;
73
103
 
74
104
  /** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
75
105
  export interface StrandFirstSyncConfig {
@@ -242,8 +272,7 @@ export class StrandFirstSyncGate {
242
272
  private async probe(): Promise<void> {
243
273
  if (this.stopped || this.opened || this.probing) return;
244
274
  this.probing = true;
245
- const held = await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label)
246
- .finally(() => { this.probing = false; });
275
+ const held = await this.probeHeld();
247
276
  // Re-check after the await: a release or a force-open may have landed mid-read.
248
277
  if (this.stopped || this.opened) return;
249
278
  if (!held) {
@@ -255,4 +284,23 @@ export class StrandFirstSyncGate {
255
284
  log('[%s] first-sync gate opened: Strand.Header and every App table read from a peer', this.deps.label);
256
285
  this.deps.onHeaderHeld();
257
286
  }
287
+
288
+ /**
289
+ * One probe's answer, with a throw from OUTSIDE {@link strandFirstSyncComplete}'s own read
290
+ * guards — the `App` schema lookup, or `getDatabase()` itself — reported as "not yet" like
291
+ * any failing read. The loop is scheduled from `void this.probe()`, so an escaping rejection
292
+ * would be unhandled AND leave nothing scheduled: the strand would stay gated for the whole
293
+ * `timeoutMs` and never recover, which is the one failure this gate must not have.
294
+ */
295
+ private async probeHeld(): Promise<boolean> {
296
+ try {
297
+ return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label);
298
+ } catch (error) {
299
+ log('[%s] first-sync probe threw (treated as not yet synced): %s', this.deps.label,
300
+ error instanceof Error ? error.message : String(error));
301
+ return false;
302
+ } finally {
303
+ this.probing = false;
304
+ }
305
+ }
258
306
  }