@serfab/cadre-core 1.5.0 → 1.7.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 (68) hide show
  1. package/dist/cadre-node.d.ts +206 -67
  2. package/dist/cadre-node.js +399 -98
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/index.d.ts +6 -1
  5. package/dist/index.js +17 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/joined-strand-store.d.ts +112 -0
  8. package/dist/joined-strand-store.js +263 -0
  9. package/dist/joined-strand-store.js.map +1 -0
  10. package/dist/key-store.d.ts +6 -0
  11. package/dist/key-store.js.map +1 -1
  12. package/dist/link-budget.d.ts +95 -53
  13. package/dist/link-budget.js +97 -53
  14. package/dist/link-budget.js.map +1 -1
  15. package/dist/node-local-snapshot.d.ts +5 -4
  16. package/dist/node-local-snapshot.js +6 -3
  17. package/dist/node-local-snapshot.js.map +1 -1
  18. package/dist/peer-dial.d.ts +3 -3
  19. package/dist/peer-dial.js +3 -3
  20. package/dist/peer-join-backfill.d.ts +35 -16
  21. package/dist/peer-join-backfill.js +117 -57
  22. package/dist/peer-join-backfill.js.map +1 -1
  23. package/dist/relay-reservation.d.ts +2 -2
  24. package/dist/relay-reservation.js +2 -2
  25. package/dist/seed-bootstrap.js +1 -1
  26. package/dist/strand-first-sync-gate.d.ts +88 -43
  27. package/dist/strand-first-sync-gate.js +92 -46
  28. package/dist/strand-first-sync-gate.js.map +1 -1
  29. package/dist/strand-instance-manager.d.ts +39 -0
  30. package/dist/strand-instance-manager.js +80 -3
  31. package/dist/strand-instance-manager.js.map +1 -1
  32. package/dist/strand-peer-book-file.d.ts +12 -0
  33. package/dist/strand-peer-book-file.js +37 -0
  34. package/dist/strand-peer-book-file.js.map +1 -0
  35. package/dist/strand-peer-book-protocol.d.ts +159 -0
  36. package/dist/strand-peer-book-protocol.js +360 -0
  37. package/dist/strand-peer-book-protocol.js.map +1 -0
  38. package/dist/strand-peer-book-swap.d.ts +104 -0
  39. package/dist/strand-peer-book-swap.js +284 -0
  40. package/dist/strand-peer-book-swap.js.map +1 -0
  41. package/dist/strand-peer-book.d.ts +159 -0
  42. package/dist/strand-peer-book.js +354 -0
  43. package/dist/strand-peer-book.js.map +1 -0
  44. package/dist/strand-peer-observer.d.ts +88 -0
  45. package/dist/strand-peer-observer.js +180 -0
  46. package/dist/strand-peer-observer.js.map +1 -0
  47. package/dist/types.d.ts +84 -17
  48. package/dist/types.js +6 -0
  49. package/dist/types.js.map +1 -1
  50. package/package.json +11 -7
  51. package/src/cadre-node.ts +7776 -7445
  52. package/src/index.ts +68 -0
  53. package/src/joined-strand-store.ts +308 -0
  54. package/src/key-store.ts +6 -0
  55. package/src/link-budget.ts +100 -53
  56. package/src/node-local-snapshot.ts +293 -289
  57. package/src/peer-dial.ts +3 -3
  58. package/src/peer-join-backfill.ts +709 -647
  59. package/src/relay-reservation.ts +2 -2
  60. package/src/seed-bootstrap.ts +1 -1
  61. package/src/strand-first-sync-gate.ts +352 -306
  62. package/src/strand-instance-manager.ts +106 -3
  63. package/src/strand-peer-book-file.ts +39 -0
  64. package/src/strand-peer-book-protocol.ts +463 -0
  65. package/src/strand-peer-book-swap.ts +338 -0
  66. package/src/strand-peer-book.ts +468 -0
  67. package/src/strand-peer-observer.ts +235 -0
  68. package/src/types.ts +86 -17
@@ -17,9 +17,17 @@
17
17
  * freshly initialized `StrandDatabase` back from the app — `StrandInstance.database` stays
18
18
  * unset and the instance reports `'syncing'` — and probes on a cadence until the Header is
19
19
  * readable AND a read of each app table settles, at which point the database is published
20
- * and the instance goes `'active'`. A machine that already holds the Header (a restart, a
21
- * hibernation resume, a founder) passes the probe on the first try and is never gated, so
22
- * offline-first writes on a machine that has synced before keep working.
20
+ * and the instance goes `'active'`. A machine whose store already holds the Header and every
21
+ * app table (a founder, and normally a restart or a hibernation resume) passes the probe on
22
+ * the first try and is never gated, so offline-first writes on a machine that has synced
23
+ * before keep working.
24
+ *
25
+ * "Has synced before" is decided by what the local store actually holds, not by whether this
26
+ * machine once read the strand: a machine that left soon after its first sync can hold less
27
+ * than it read, and is then gated like a joiner. The re-attach measurement on
28
+ * {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS} saw exactly that — a machine back over the store
29
+ * it kept, without the `Strand.Header` collection it had read before it left. Gating it is
30
+ * right: writing into a collection this machine does not hold would fork it.
23
31
  *
24
32
  * Why the app tables too: the Header is ONE collection. It reaches a joiner ahead of the
25
33
  * founder's app-table collections (peer-join backfill and pull-on-read deliver those
@@ -36,10 +44,12 @@
36
44
  * every machine; the first write to it still creates its collection — see `docs/strands.md`
37
45
  * ("Joining") for that residual.
38
46
  *
39
- * Owned by `StrandInstanceManager`: created in `buildStrandRuntime` for a non-founder launch
40
- * whose first probe finds no Header, stopped and dropped in `releaseRuntime` (so a quiesce →
41
- * resume rebuild re-probes over the same store), and force-opened when a founder request
42
- * runs the bootstrap against the still-gated database (`foundExistingStrand`).
47
+ * Owned by `StrandInstanceManager`: created in `buildStrandRuntime` for every launch, armed
48
+ * only for a non-founder launch whose first probe fails, stopped and dropped in
49
+ * `releaseRuntime` (so a quiesce → resume rebuild re-probes over the same store), and opened
50
+ * by `publishDatabase` whenever the database is published without its loop — a launch that
51
+ * needed no gate, or a founder request that ran the bootstrap against the still-gated
52
+ * database (`foundExistingStrand`).
43
53
  *
44
54
  * NOTE: the gate covers app and membership tables, not the schema catalog. Every launch —
45
55
  * joiner or founder — writes the catalog collection (`optimystic/schema` plus two
@@ -57,48 +67,80 @@ import { type TimeoutScheduler } from './timeout-scheduler.js';
57
67
  /** How often a gated launch re-probes (`Strand.Header`, then every `App` table) while waiting for its first sync. */
58
68
  export declare const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
59
69
  /**
60
- * How long `CadreNode.addStrand` waits for a joining machine's first sync before rejecting
61
- * with {@link StrandAwaitingFirstSyncError}.
70
+ * How long `CadreNode.addStrand` waits for a machine's first sync before rejecting with
71
+ * {@link StrandAwaitingFirstSyncError}. It has to cover the slowest attach sereus carries,
72
+ * which is not a first join: a machine RE-attaching through a relay on a slow link can take
73
+ * longer than one joining for the first time.
62
74
  *
63
75
  * Measured over a DIRECT connection (2026-09-16): the joiner reads the founder's rows about
64
- * 1.3 s after `addStrand` would previously have resolved. That is still the fast case, and it
65
- * is not what this budget has to cover.
76
+ * 1.3 s after `addStrand` would previously have resolved. That is the fast case, and it is not
77
+ * what this budget has to cover.
66
78
  *
67
- * Measured over a RELAYED SLOW LINK (2026-09-26): one Windows developer machine, two
79
+ * Every harness figure below was taken on one shape: one Windows developer machine, two
68
80
  * relay-only `CadreNode`s (`listenAddrs: []`) on a shared loopback dedicated relay, with a
69
81
  * 900 ms one-way per-frame outbound delay applied to every websocket by
70
82
  * `integration-tests/src/harness/ws-latency.ts` in `pipelined` mode — frames stay overlapped
71
83
  * in flight, so the figure is latency and bandwidth stays unlimited; the harness's other mode
72
84
  * (`serial`) is a per-socket frame-rate cap and its delays are NOT comparable
73
- * (`docs/testing.md` → "Link latency"). A round trip therefore costs about
74
- * 1.8 s. Time from `addStrand` to writable, for a machine holding nothing of the strand yet:
75
- * 23, 27, 31 and 41 s over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42
76
- * and 46 s over three runs at a 5000 ms one. **The second band is the one in force**: sereus
77
- * declares 5000 ms (`COHORT_READ_DEADLINE_MS`, `quereus-plugin-sereus/src/cluster-size.ts`),
78
- * because 1000 ms is shorter than one round trip on that link and leaves every cohort read with
79
- * no answer to corroborate against. Widening that deadline makes a consult against a peer that
80
- * cannot answer cost longer, and this phase runs several of those — which is why the two bands
81
- * are both recorded here, and why a change to either number has to be weighed against the
82
- * other. 120 s clears the worst sample of the band in force by about 2.6x. The previous 30 s
83
- * default sat INSIDE the first band, so roughly half of those joins were rejected as "not
84
- * writable yet" while the sync was progressing normally and went on to complete.
85
+ * (`docs/testing.md` → "Link latency"). A round trip therefore costs about 1.8 s. Times run
86
+ * from the `addStrand` call to writable.
87
+ *
88
+ * FRESH JOIN (2026-09-26), a machine holding nothing of the strand yet: 23, 27, 31 and 41 s
89
+ * over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42 and 46 s over three
90
+ * runs at a 5000 ms one; the re-attach scenario below later took 38.7, 38.7 and 56.9 s for
91
+ * the same fresh join. **The 5000 ms band, 35-57 s, is the one in force**: sereus declares
92
+ * 5000 ms (`COHORT_READ_DEADLINE_MS`, `quereus-plugin-sereus/src/cluster-size.ts`), because
93
+ * 1000 ms is shorter than one round trip on that link and leaves every cohort read with no
94
+ * answer to corroborate against. Widening that deadline makes a consult against a peer that
95
+ * cannot answer cost longer, and this phase runs several of those — which is why both bands
96
+ * are recorded here, and why a change to either number has to be weighed against the other.
97
+ *
98
+ * RE-ATTACH (2026-09-26, the opt-in
99
+ * `integration-tests/src/scenarios/strand-reattach-first-sync-measure.integration.ts` — re-run
100
+ * it before changing this number): B attached once on an undelayed link, left with
101
+ * `stopStrand`, A wrote while it was away, the delay was raised, and B called `addStrand`
102
+ * again.
103
+ * - Over an EMPTY store (one minted per launch, cadre's default with no storage provider):
104
+ * gated in all four runs and writable at 38.7-56.8 s, the same as a fresh join.
105
+ * - Over the store it KEPT (a phone with durable storage), bimodal. In 3 of 8 runs the store
106
+ * held everything and the launch came up writable (5.6 s, the launch itself). In the other
107
+ * 5 it lacked the `Strand.Header` collection B had read on its first attach, so B was gated
108
+ * like a joiner and became writable at 64.1-78.7 s — the slowest attach measured, about
109
+ * twice a fresh join. Why syncing over a partial store is slower sits inside optimystic
110
+ * (`tickets/blocked/report-reattach-over-partial-replica-to-optimystic`).
111
+ * - Neither a redial nor catching up on missed writes explains the time: B's strand node
112
+ * connected to A's at 11 s in every arm, fresh join included, and a run with 20 missed
113
+ * writes fell inside the run-to-run spread of those with 1.
114
+ * This budget covers WRITABLE, not caught up: in the gated kept-store runs the row A wrote
115
+ * while B was away became readable only at 140-162 s.
116
+ *
117
+ * Two samples from outside this harness, neither reproduced here and neither discarded: a
118
+ * reporter's re-attach of the same shape (optimystic #22; their harness, storage and delay
119
+ * injector are unknown to us) opened the gate at about 150 s, after the previous 120 s budget
120
+ * had already rejected; and a real Galaxy S7 joining fresh through relay.sereus.org took 178 s.
121
+ *
122
+ * 300 s clears the worst harness sample (79 s) by about 3.8x, the reporter's re-attach by 2x
123
+ * and the device join by about 1.7x. 240 s was rejected: it clears the device join by only
124
+ * 1.35x, and that sample was not a re-attach. History: the original 30 s sat INSIDE the 1000 ms
125
+ * fresh band and refused about half of those joins while their sync was progressing normally;
126
+ * 120 s was sized from fresh joins only and refused the reporter's re-attach.
85
127
  *
86
- * NOTE: accepted tradeoff — what 120 s costs. This wait is what an app's `addStrand` sits in
87
- * before it is told "not yet", so a strand none of whose members is reachable at all takes two
88
- * minutes to report instead of thirty seconds. The slow report was weighed against refusing
89
- * joins that were working, and the refusal is the worse failure. The cost is bounded — the
90
- * rejection is retryable, the strand stays launched and keeps probing, and `strand:writable`
91
- * fires the moment the sync lands, so an app that listens for the event rather than awaiting
92
- * the call is unaffected either way. Revisit if the gate ever learns whether ANY other member
93
- * is connected: "no peer at all" could then be reported at once and this budget would only
94
- * ever be spent on a sync that is actually in progress.
128
+ * NOTE: accepted tradeoff — what 300 s costs. This wait is what an app's `addStrand` sits in
129
+ * before it is told "not yet", so a strand none of whose members is reachable at all takes five
130
+ * minutes to report instead of two. The slow report was weighed against refusing attaches that
131
+ * were working, and the refusal is the worse failure. The cost is bounded — the rejection is
132
+ * retryable, the strand stays launched and keeps probing, and `strand:writable` fires the
133
+ * moment the sync lands, so an app that listens for the event rather than awaiting the call is
134
+ * unaffected either way. Revisit if the gate ever learns whether ANY other member is connected:
135
+ * "no peer at all" could then be reported at once and this budget would only ever be spent on a
136
+ * sync that is actually in progress — worth more the longer this budget gets.
95
137
  */
96
- export declare const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 120000;
138
+ export declare const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 300000;
97
139
  /** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
98
140
  export interface StrandFirstSyncConfig {
99
141
  /**
100
- * Default wait for `CadreNode.addStrand` (and `whenStrandWritable`) before a joining
101
- * machine that has reached no other member is reported as not yet writable. Default
142
+ * Default wait for `CadreNode.addStrand` (and `whenStrandWritable`) before a machine whose
143
+ * first sync has not completed is reported as not yet writable. Default
102
144
  * {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}.
103
145
  */
104
146
  timeoutMs?: number;
@@ -106,11 +148,12 @@ export interface StrandFirstSyncConfig {
106
148
  pollIntervalMs?: number;
107
149
  }
108
150
  /**
109
- * Thrown by `CadreNode.addStrand` / `whenStrandWritable` when a joining machine has not
110
- * received the strand's data from any other member within the wait budget. RETRYABLE: the
111
- * strand stays launched and keeps probing, so a later `addStrand` (or `whenStrandWritable`)
112
- * for the same strand completes the attach once another member is reachable; the
113
- * `strand:writable` event fires at that moment too.
151
+ * Thrown by `CadreNode.addStrand` / `whenStrandWritable` when a machine has not received the
152
+ * strand's data from another member within the wait budget — because no other member is
153
+ * reachable, or because one is and the sync is still arriving over a slow link; the gate
154
+ * cannot tell the two apart. RETRYABLE: the strand stays launched and keeps probing, so a
155
+ * later `addStrand` (or `whenStrandWritable`) for the same strand completes the attach once
156
+ * the data has arrived; the `strand:writable` event fires at that moment too.
114
157
  */
115
158
  export declare class StrandAwaitingFirstSyncError extends Error {
116
159
  readonly strandId: string;
@@ -171,8 +214,10 @@ export declare class StrandFirstSyncGate {
171
214
  /** Arm the probe loop. The first probe runs after one interval — the caller already probed once. */
172
215
  start(): void;
173
216
  /**
174
- * Force-open: the caller has just written the Header itself (a founder bootstrap run
175
- * against the gated database). Stops the loop; `onHeaderHeld` is NOT invoked.
217
+ * Open without the probe loop: the caller is publishing the database itself — a launch
218
+ * whose first probe passed (or a founder's), which never armed the loop, or a founder
219
+ * bootstrap just run against the gated database. Stops the loop; `onHeaderHeld` is NOT
220
+ * invoked.
176
221
  */
177
222
  open(): void;
178
223
  /** Disarm the loop; a probe already in flight completes and is then ignored. */
@@ -17,9 +17,17 @@
17
17
  * freshly initialized `StrandDatabase` back from the app — `StrandInstance.database` stays
18
18
  * unset and the instance reports `'syncing'` — and probes on a cadence until the Header is
19
19
  * readable AND a read of each app table settles, at which point the database is published
20
- * and the instance goes `'active'`. A machine that already holds the Header (a restart, a
21
- * hibernation resume, a founder) passes the probe on the first try and is never gated, so
22
- * offline-first writes on a machine that has synced before keep working.
20
+ * and the instance goes `'active'`. A machine whose store already holds the Header and every
21
+ * app table (a founder, and normally a restart or a hibernation resume) passes the probe on
22
+ * the first try and is never gated, so offline-first writes on a machine that has synced
23
+ * before keep working.
24
+ *
25
+ * "Has synced before" is decided by what the local store actually holds, not by whether this
26
+ * machine once read the strand: a machine that left soon after its first sync can hold less
27
+ * than it read, and is then gated like a joiner. The re-attach measurement on
28
+ * {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS} saw exactly that — a machine back over the store
29
+ * it kept, without the `Strand.Header` collection it had read before it left. Gating it is
30
+ * right: writing into a collection this machine does not hold would fork it.
23
31
  *
24
32
  * Why the app tables too: the Header is ONE collection. It reaches a joiner ahead of the
25
33
  * founder's app-table collections (peer-join backfill and pull-on-read deliver those
@@ -36,10 +44,12 @@
36
44
  * every machine; the first write to it still creates its collection — see `docs/strands.md`
37
45
  * ("Joining") for that residual.
38
46
  *
39
- * Owned by `StrandInstanceManager`: created in `buildStrandRuntime` for a non-founder launch
40
- * whose first probe finds no Header, stopped and dropped in `releaseRuntime` (so a quiesce →
41
- * resume rebuild re-probes over the same store), and force-opened when a founder request
42
- * runs the bootstrap against the still-gated database (`foundExistingStrand`).
47
+ * Owned by `StrandInstanceManager`: created in `buildStrandRuntime` for every launch, armed
48
+ * only for a non-founder launch whose first probe fails, stopped and dropped in
49
+ * `releaseRuntime` (so a quiesce → resume rebuild re-probes over the same store), and opened
50
+ * by `publishDatabase` whenever the database is published without its loop — a launch that
51
+ * needed no gate, or a founder request that ran the bootstrap against the still-gated
52
+ * database (`foundExistingStrand`).
43
53
  *
44
54
  * NOTE: the gate covers app and membership tables, not the schema catalog. Every launch —
45
55
  * joiner or founder — writes the catalog collection (`optimystic/schema` plus two
@@ -57,56 +67,90 @@ const log = debug('sereus:cadre:strand-first-sync');
57
67
  /** How often a gated launch re-probes (`Strand.Header`, then every `App` table) while waiting for its first sync. */
58
68
  export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
59
69
  /**
60
- * How long `CadreNode.addStrand` waits for a joining machine's first sync before rejecting
61
- * with {@link StrandAwaitingFirstSyncError}.
70
+ * How long `CadreNode.addStrand` waits for a machine's first sync before rejecting with
71
+ * {@link StrandAwaitingFirstSyncError}. It has to cover the slowest attach sereus carries,
72
+ * which is not a first join: a machine RE-attaching through a relay on a slow link can take
73
+ * longer than one joining for the first time.
62
74
  *
63
75
  * Measured over a DIRECT connection (2026-09-16): the joiner reads the founder's rows about
64
- * 1.3 s after `addStrand` would previously have resolved. That is still the fast case, and it
65
- * is not what this budget has to cover.
76
+ * 1.3 s after `addStrand` would previously have resolved. That is the fast case, and it is not
77
+ * what this budget has to cover.
66
78
  *
67
- * Measured over a RELAYED SLOW LINK (2026-09-26): one Windows developer machine, two
79
+ * Every harness figure below was taken on one shape: one Windows developer machine, two
68
80
  * relay-only `CadreNode`s (`listenAddrs: []`) on a shared loopback dedicated relay, with a
69
81
  * 900 ms one-way per-frame outbound delay applied to every websocket by
70
82
  * `integration-tests/src/harness/ws-latency.ts` in `pipelined` mode — frames stay overlapped
71
83
  * in flight, so the figure is latency and bandwidth stays unlimited; the harness's other mode
72
84
  * (`serial`) is a per-socket frame-rate cap and its delays are NOT comparable
73
- * (`docs/testing.md` → "Link latency"). A round trip therefore costs about
74
- * 1.8 s. Time from `addStrand` to writable, for a machine holding nothing of the strand yet:
75
- * 23, 27, 31 and 41 s over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42
76
- * and 46 s over three runs at a 5000 ms one. **The second band is the one in force**: sereus
77
- * declares 5000 ms (`COHORT_READ_DEADLINE_MS`, `quereus-plugin-sereus/src/cluster-size.ts`),
78
- * because 1000 ms is shorter than one round trip on that link and leaves every cohort read with
79
- * no answer to corroborate against. Widening that deadline makes a consult against a peer that
80
- * cannot answer cost longer, and this phase runs several of those — which is why the two bands
81
- * are both recorded here, and why a change to either number has to be weighed against the
82
- * other. 120 s clears the worst sample of the band in force by about 2.6x. The previous 30 s
83
- * default sat INSIDE the first band, so roughly half of those joins were rejected as "not
84
- * writable yet" while the sync was progressing normally and went on to complete.
85
+ * (`docs/testing.md` → "Link latency"). A round trip therefore costs about 1.8 s. Times run
86
+ * from the `addStrand` call to writable.
87
+ *
88
+ * FRESH JOIN (2026-09-26), a machine holding nothing of the strand yet: 23, 27, 31 and 41 s
89
+ * over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42 and 46 s over three
90
+ * runs at a 5000 ms one; the re-attach scenario below later took 38.7, 38.7 and 56.9 s for
91
+ * the same fresh join. **The 5000 ms band, 35-57 s, is the one in force**: sereus declares
92
+ * 5000 ms (`COHORT_READ_DEADLINE_MS`, `quereus-plugin-sereus/src/cluster-size.ts`), because
93
+ * 1000 ms is shorter than one round trip on that link and leaves every cohort read with no
94
+ * answer to corroborate against. Widening that deadline makes a consult against a peer that
95
+ * cannot answer cost longer, and this phase runs several of those — which is why both bands
96
+ * are recorded here, and why a change to either number has to be weighed against the other.
97
+ *
98
+ * RE-ATTACH (2026-09-26, the opt-in
99
+ * `integration-tests/src/scenarios/strand-reattach-first-sync-measure.integration.ts` — re-run
100
+ * it before changing this number): B attached once on an undelayed link, left with
101
+ * `stopStrand`, A wrote while it was away, the delay was raised, and B called `addStrand`
102
+ * again.
103
+ * - Over an EMPTY store (one minted per launch, cadre's default with no storage provider):
104
+ * gated in all four runs and writable at 38.7-56.8 s, the same as a fresh join.
105
+ * - Over the store it KEPT (a phone with durable storage), bimodal. In 3 of 8 runs the store
106
+ * held everything and the launch came up writable (5.6 s, the launch itself). In the other
107
+ * 5 it lacked the `Strand.Header` collection B had read on its first attach, so B was gated
108
+ * like a joiner and became writable at 64.1-78.7 s — the slowest attach measured, about
109
+ * twice a fresh join. Why syncing over a partial store is slower sits inside optimystic
110
+ * (`tickets/blocked/report-reattach-over-partial-replica-to-optimystic`).
111
+ * - Neither a redial nor catching up on missed writes explains the time: B's strand node
112
+ * connected to A's at 11 s in every arm, fresh join included, and a run with 20 missed
113
+ * writes fell inside the run-to-run spread of those with 1.
114
+ * This budget covers WRITABLE, not caught up: in the gated kept-store runs the row A wrote
115
+ * while B was away became readable only at 140-162 s.
116
+ *
117
+ * Two samples from outside this harness, neither reproduced here and neither discarded: a
118
+ * reporter's re-attach of the same shape (optimystic #22; their harness, storage and delay
119
+ * injector are unknown to us) opened the gate at about 150 s, after the previous 120 s budget
120
+ * had already rejected; and a real Galaxy S7 joining fresh through relay.sereus.org took 178 s.
121
+ *
122
+ * 300 s clears the worst harness sample (79 s) by about 3.8x, the reporter's re-attach by 2x
123
+ * and the device join by about 1.7x. 240 s was rejected: it clears the device join by only
124
+ * 1.35x, and that sample was not a re-attach. History: the original 30 s sat INSIDE the 1000 ms
125
+ * fresh band and refused about half of those joins while their sync was progressing normally;
126
+ * 120 s was sized from fresh joins only and refused the reporter's re-attach.
85
127
  *
86
- * NOTE: accepted tradeoff — what 120 s costs. This wait is what an app's `addStrand` sits in
87
- * before it is told "not yet", so a strand none of whose members is reachable at all takes two
88
- * minutes to report instead of thirty seconds. The slow report was weighed against refusing
89
- * joins that were working, and the refusal is the worse failure. The cost is bounded — the
90
- * rejection is retryable, the strand stays launched and keeps probing, and `strand:writable`
91
- * fires the moment the sync lands, so an app that listens for the event rather than awaiting
92
- * the call is unaffected either way. Revisit if the gate ever learns whether ANY other member
93
- * is connected: "no peer at all" could then be reported at once and this budget would only
94
- * ever be spent on a sync that is actually in progress.
128
+ * NOTE: accepted tradeoff — what 300 s costs. This wait is what an app's `addStrand` sits in
129
+ * before it is told "not yet", so a strand none of whose members is reachable at all takes five
130
+ * minutes to report instead of two. The slow report was weighed against refusing attaches that
131
+ * were working, and the refusal is the worse failure. The cost is bounded — the rejection is
132
+ * retryable, the strand stays launched and keeps probing, and `strand:writable` fires the
133
+ * moment the sync lands, so an app that listens for the event rather than awaiting the call is
134
+ * unaffected either way. Revisit if the gate ever learns whether ANY other member is connected:
135
+ * "no peer at all" could then be reported at once and this budget would only ever be spent on a
136
+ * sync that is actually in progress — worth more the longer this budget gets.
95
137
  */
96
- export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 120000;
138
+ export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 300000;
97
139
  /**
98
- * Thrown by `CadreNode.addStrand` / `whenStrandWritable` when a joining machine has not
99
- * received the strand's data from any other member within the wait budget. RETRYABLE: the
100
- * strand stays launched and keeps probing, so a later `addStrand` (or `whenStrandWritable`)
101
- * for the same strand completes the attach once another member is reachable; the
102
- * `strand:writable` event fires at that moment too.
140
+ * Thrown by `CadreNode.addStrand` / `whenStrandWritable` when a machine has not received the
141
+ * strand's data from another member within the wait budget — because no other member is
142
+ * reachable, or because one is and the sync is still arriving over a slow link; the gate
143
+ * cannot tell the two apart. RETRYABLE: the strand stays launched and keeps probing, so a
144
+ * later `addStrand` (or `whenStrandWritable`) for the same strand completes the attach once
145
+ * the data has arrived; the `strand:writable` event fires at that moment too.
103
146
  */
104
147
  export class StrandAwaitingFirstSyncError extends Error {
105
148
  constructor(strandId, waitedMs) {
106
- super(`Strand ${strandId} is not writable yet: no member of this strand has been reachable ` +
107
- `since this machine joined, so it has not received the strand's data (waited ${waitedMs} ms). ` +
108
- 'The strand stays launched and keeps trying — call addStrand again, or wait for the ' +
109
- "'strand:writable' event, once another member is reachable.");
149
+ super(`Strand ${strandId} is not writable yet: this machine has not yet received the strand's data ` +
150
+ `from another member (waited ${waitedMs} ms). Either no other member is reachable, or the ` +
151
+ 'link to one is slow and the sync is still in progress. The strand stays launched and keeps ' +
152
+ 'trying — call addStrand or whenStrandWritable(strandId) again to keep waiting, or listen ' +
153
+ "for the 'strand:writable' event.");
110
154
  this.name = 'StrandAwaitingFirstSyncError';
111
155
  this.strandId = strandId;
112
156
  this.waitedMs = waitedMs;
@@ -192,15 +236,17 @@ export class StrandFirstSyncGate {
192
236
  log('[%s] first-sync gate armed: no Strand.Header held yet; probing every %dms', this.deps.label, this.pollIntervalMs);
193
237
  }
194
238
  /**
195
- * Force-open: the caller has just written the Header itself (a founder bootstrap run
196
- * against the gated database). Stops the loop; `onHeaderHeld` is NOT invoked.
239
+ * Open without the probe loop: the caller is publishing the database itself — a launch
240
+ * whose first probe passed (or a founder's), which never armed the loop, or a founder
241
+ * bootstrap just run against the gated database. Stops the loop; `onHeaderHeld` is NOT
242
+ * invoked.
197
243
  */
198
244
  open() {
199
245
  if (this.opened)
200
246
  return;
201
247
  this.opened = true;
202
248
  this.stop();
203
- log('[%s] first-sync gate opened by the caller (Header written locally)', this.deps.label);
249
+ log('[%s] database published by the caller; first-sync gate not needed or force-opened', this.deps.label);
204
250
  }
205
251
  /** Disarm the loop; a probe already in flight completes and is then ignored. */
206
252
  stop() {
@@ -1 +1 @@
1
- {"version":3,"file":"strand-first-sync-gate.js","sourceRoot":"","sources":["../src/strand-first-sync-gate.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"strand-first-sync-gate.js","sourceRoot":"","sources":["../src/strand-first-sync-gate.ts"],"names":[],"mappings":"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"}
@@ -1,5 +1,7 @@
1
1
  import type { PrivateKey } from '@libp2p/interface';
2
2
  import { type PeerJoinBackfillConfig } from './peer-join-backfill.js';
3
+ import { type StrandPeerObservation } from './strand-peer-observer.js';
4
+ import type { StrandPeerBookStore } from './strand-peer-book.js';
3
5
  import { type StrandRevocationEnforcementConfig } from './strand-revocation-enforcer.js';
4
6
  import { type PendingMembershipInviteSource, type StrandMembershipReconciliationConfig } from './strand-membership-reconciler.js';
5
7
  import { type StrandFirstSyncConfig } from './strand-first-sync-gate.js';
@@ -200,6 +202,26 @@ export interface StartStrandConfig {
200
202
  * hibernation wake's rebuilt supervisors carry it too.
201
203
  */
202
204
  announceDelegateToRelay?: (strandId: string, relayAddr: string, delegatePeerId: string) => Promise<void>;
205
+ /**
206
+ * Called when the strand's libp2p node identifies a STRAND peer — one whose
207
+ * identify result names this strand's block-transfer protocol, so never the
208
+ * circuit relay or a bootstrap node — with the addresses it is dialable at
209
+ * (`strand-peer-observer.ts`). Throttled per peer; must not throw. `CadreNode`
210
+ * wires it to the node-local strand peer book, which is what lets a restarted
211
+ * machine dial the peers it was talking to. Absent ⇒ nothing is observed.
212
+ * Retained with the launch config, so a hibernation wake re-arms it on the
213
+ * rebuilt node.
214
+ */
215
+ onStrandPeerIdentified?: (strandId: string, observation: StrandPeerObservation) => void;
216
+ /**
217
+ * The node-local strand peer book the book SWAP reads and writes
218
+ * (`strand-peer-book-swap.ts`): the strand node signs its own addresses with
219
+ * {@link privateKey}, exchanges signed entries with every strand peer it identifies,
220
+ * and files what verifies. Absent ⇒ no swap runs — a peer that dials this node's
221
+ * swap handler gets no answer. `CadreNode` passes its book store. Retained with the
222
+ * launch config, so a hibernation wake re-arms the swap on the rebuilt node.
223
+ */
224
+ strandPeerBook?: StrandPeerBookStore;
203
225
  }
204
226
  /**
205
227
  * Volatile inputs re-resolved when resuming a quiesced strand — the ones that can
@@ -259,6 +281,23 @@ export declare class StrandInstanceManager {
259
281
  * missed writes).
260
282
  */
261
283
  private backfills;
284
+ /**
285
+ * The per-strand peer-book observation writer (`strand-peer-observer.ts`), keyed
286
+ * by strand id. Same lifecycle rationale as {@link backfills}: started in
287
+ * `buildStrandRuntime` right after the libp2p node exists, stopped and dropped in
288
+ * `releaseRuntime`, so quiesce → resume re-arms it on the rebuilt node. Only
289
+ * present when the launch config supplied `onStrandPeerIdentified`.
290
+ */
291
+ private peerObservers;
292
+ /**
293
+ * Per-strand strand peer book SWAP driver ({@link StrandPeerBookSwap}) — the signed
294
+ * exchange of book entries between strand peers. Same lifetime as the peer-book
295
+ * observer above: armed in `buildStrandRuntime` right after the libp2p node exists
296
+ * (its protocol handler must be registered before an early peer dials it), stopped
297
+ * and dropped in `releaseRuntime`. Only present when the launch config supplied
298
+ * `strandPeerBook`.
299
+ */
300
+ private peerBookSwaps;
262
301
  /**
263
302
  * The per-strand revoked-peer enforcer (closed strands only), keyed by strand
264
303
  * id. Same lifecycle rationale as {@link backfills}: created in
@@ -3,6 +3,8 @@ import { createLibp2pNode } from '@optimystic/db-p2p';
3
3
  import { wrapStorageWithCache, disposeStorageCache } from '@serfab/quereus-plugin-sereus';
4
4
  import { StrandDatabase } from './strand-database.js';
5
5
  import { PeerJoinBackfill } from './peer-join-backfill.js';
6
+ import { StrandPeerObserver } from './strand-peer-observer.js';
7
+ import { StrandPeerBookSwap } from './strand-peer-book-swap.js';
6
8
  import { StrandRevocationEnforcer, createRevocationConnectionGater, readStrandRevocationRows } from './strand-revocation-enforcer.js';
7
9
  import { StrandMembershipReconciler } from './strand-membership-reconciler.js';
8
10
  import { removeMemberPeer } from './strand-membership-writer.js';
@@ -13,7 +15,7 @@ import { StrandFirstSyncGate, StrandAwaitingFirstSyncError, strandFirstSyncCompl
13
15
  import { DEFAULT_CONNECTION_MONITOR, resolveStrandClusterSize, strandClusterPolicy } from './types.js';
14
16
  import { strandNodeAddrs } from './strand-network-config.js';
15
17
  import { superviseRelayReservation } from './relay-reservation.js';
16
- import { peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
18
+ import { connectionManagerTimeouts, peerJoinPushBudget, relayReservationBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
17
19
  const log = debug('sereus:cadre:strand-manager');
18
20
  const timing = debug('sereus:cadre:timing');
19
21
  /**
@@ -119,6 +121,23 @@ export class StrandInstanceManager {
119
121
  * missed writes).
120
122
  */
121
123
  this.backfills = new Map();
124
+ /**
125
+ * The per-strand peer-book observation writer (`strand-peer-observer.ts`), keyed
126
+ * by strand id. Same lifecycle rationale as {@link backfills}: started in
127
+ * `buildStrandRuntime` right after the libp2p node exists, stopped and dropped in
128
+ * `releaseRuntime`, so quiesce → resume re-arms it on the rebuilt node. Only
129
+ * present when the launch config supplied `onStrandPeerIdentified`.
130
+ */
131
+ this.peerObservers = new Map();
132
+ /**
133
+ * Per-strand strand peer book SWAP driver ({@link StrandPeerBookSwap}) — the signed
134
+ * exchange of book entries between strand peers. Same lifetime as the peer-book
135
+ * observer above: armed in `buildStrandRuntime` right after the libp2p node exists
136
+ * (its protocol handler must be registered before an early peer dials it), stopped
137
+ * and dropped in `releaseRuntime`. Only present when the launch config supplied
138
+ * `strandPeerBook`.
139
+ */
140
+ this.peerBookSwaps = new Map();
122
141
  /**
123
142
  * The per-strand revoked-peer enforcer (closed strands only), keyed by strand
124
143
  * id. Same lifecycle rationale as {@link backfills}: created in
@@ -433,6 +452,10 @@ export class StrandInstanceManager {
433
452
  // widened ping deadline has to reach the strand nodes too (see
434
453
  // DEFAULT_CONNECTION_MONITOR).
435
454
  connectionMonitor: config.network?.connectionMonitor ?? DEFAULT_CONNECTION_MONITOR,
455
+ // The same declared-link limits the control node takes, and for the same reason: a
456
+ // strand node is the listener for every other member's strand node (`link-budget.ts`,
457
+ // `connectionManagerTimeouts`).
458
+ connectionManager: connectionManagerTimeouts(config.network?.linkRoundTripMs),
436
459
  // Listen entries plus the WebSocket transport switch they imply — a strand node
437
460
  // announces nothing the operator configured (`strand-network-config.ts`), and
438
461
  // spreads AFTER `transports` above because the switch is a no-op whenever the
@@ -450,6 +473,46 @@ export class StrandInstanceManager {
450
473
  });
451
474
  timing('[buildStrandRuntime:%s] createLibp2pNode: %dms', strandId, Math.round(performance.now() - t0));
452
475
  instance.libp2pNode = node;
476
+ // The peer-book observation writer, armed BEFORE the database bring-up below
477
+ // (unlike the backfill) because the bootstrap dials that follow `libp2p.start()`
478
+ // land during that bring-up, and they are exactly the peers worth remembering:
479
+ // identify for an early peer may already have fired by the time anything after
480
+ // the database initializes runs. Registered in the map immediately so the
481
+ // failure rollback (releaseRuntime) stops it like every other runtime component.
482
+ const onStrandPeerIdentified = config.onStrandPeerIdentified;
483
+ if (onStrandPeerIdentified) {
484
+ const observer = new StrandPeerObserver({
485
+ label: strandId,
486
+ libp2p: node,
487
+ protocolPrefix,
488
+ onObserved: (observation) => onStrandPeerIdentified(strandId, observation)
489
+ });
490
+ this.peerObservers.set(strandId, observer);
491
+ observer.start();
492
+ }
493
+ // The strand peer book SWAP, armed at the same moment and for the same reason as
494
+ // the observer: a peer that dials this node's swap handler before it is registered
495
+ // fails its exchange and sits out a ten-minute throttle. The own entry is signed
496
+ // now and re-signed on every address change, which covers the relay reservation
497
+ // landing below (`self:peer:update` fires when the circuit addr is added), so no
498
+ // separate post-relay step is needed. Registered in the map immediately so the
499
+ // failure rollback (releaseRuntime) stops it like every other runtime component.
500
+ //
501
+ // NOTE: this is a third `peer:identify` listener per running strand, beside the
502
+ // observer's and the backfill's — see the backfill's NOTE below for when to fold
503
+ // them into one dispatcher.
504
+ if (config.strandPeerBook) {
505
+ const swap = new StrandPeerBookSwap({
506
+ strandId,
507
+ libp2p: node,
508
+ protocolPrefix,
509
+ store: config.strandPeerBook,
510
+ privateKey: config.privateKey,
511
+ linkRoundTripMs: config.network?.linkRoundTripMs
512
+ });
513
+ this.peerBookSwaps.set(strandId, swap);
514
+ swap.start();
515
+ }
453
516
  // One reservation supervisor PER RELAY, started now so the first attempts
454
517
  // overlap the database bring-up below (the relay is never in a strand's
455
518
  // Optimystic cohort — its protocol ids are namespaced `/optimystic/strand-<id>/…`
@@ -562,7 +625,7 @@ export class StrandInstanceManager {
562
625
  // peer-join-backfill.ts for the strand-side argument (and why the control
563
626
  // network, which DOES gate, is different).
564
627
  //
565
- // NOTE: cost is one PeerJoinBackfill object + one `connection:open` listener
628
+ // NOTE: cost is one PeerJoinBackfill object + one `peer:identify` listener
566
629
  // per running strand — linear in strand count, negligible at the handful a
567
630
  // device or host runs today. If a node ever hosts strands by the hundred,
568
631
  // move to one shared listener that dispatches by strand id.
@@ -600,7 +663,7 @@ export class StrandInstanceManager {
600
663
  // failing here would only trade that for `StrandWatcher`'s full-rebuild retry.
601
664
  //
602
665
  // NOTE: a relay that is down costs this launch one full drive — the reservation budget
603
- // counted from the declared link round trip, 8 s at its default (`link-budget.ts`) — and
666
+ // counted from the declared link round trip, 14 s at its default (`link-budget.ts`) — and
604
667
  // `StrandWatcher` launches strands one at a time, so N strands cost N of those in
605
668
  // bring-up during a relay outage, and MORE on a host that declared a slower link. If that
606
669
  // ever matters, stop awaiting here (the circuit addr then lands after `active`) rather
@@ -657,6 +720,20 @@ export class StrandInstanceManager {
657
720
  backfill.stop();
658
721
  this.backfills.delete(instance.strandId);
659
722
  }
723
+ // The peer-book observer goes with the node it listened on; a resume re-arms
724
+ // one on the rebuilt node.
725
+ const peerObserver = this.peerObservers.get(instance.strandId);
726
+ if (peerObserver) {
727
+ peerObserver.stop();
728
+ this.peerObservers.delete(instance.strandId);
729
+ }
730
+ // The book swap likewise: unsubscribed and its handler unregistered while the node is
731
+ // still up; a resume re-arms one, which re-signs the own entry over the new addresses.
732
+ const peerBookSwap = this.peerBookSwaps.get(instance.strandId);
733
+ if (peerBookSwap) {
734
+ this.peerBookSwaps.delete(instance.strandId);
735
+ await peerBookSwap.stop();
736
+ }
660
737
  // The revoked-peer enforcer goes with the runtime it gated: a resume
661
738
  // rebuilds it with a fresh (initially empty, fail-open) snapshot.
662
739
  const revocationEnforcer = this.revocationEnforcers.get(instance.strandId);