@serfab/cadre-core 1.16.0 → 1.18.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 (268) hide show
  1. package/README.md +4 -3
  2. package/dist/bootstrap-peer-store-file.js +1 -1
  3. package/dist/bootstrap-peer-store.d.ts +1 -1
  4. package/dist/bootstrap-peer-store.js +4 -4
  5. package/dist/cadre-invite-protocol.d.ts +81 -142
  6. package/dist/cadre-invite-protocol.js +168 -458
  7. package/dist/cadre-invite-protocol.js.map +1 -1
  8. package/dist/cadre-join-request.d.ts +15 -2
  9. package/dist/cadre-join-request.js +22 -3
  10. package/dist/cadre-join-request.js.map +1 -1
  11. package/dist/cadre-node.d.ts +1089 -475
  12. package/dist/cadre-node.js +2170 -1037
  13. package/dist/cadre-node.js.map +1 -1
  14. package/dist/control-authorization.d.ts +36 -14
  15. package/dist/control-authorization.js +20 -3
  16. package/dist/control-authorization.js.map +1 -1
  17. package/dist/control-cohort.d.ts +88 -32
  18. package/dist/control-cohort.js +91 -33
  19. package/dist/control-cohort.js.map +1 -1
  20. package/dist/control-database.d.ts +856 -318
  21. package/dist/control-database.js +1422 -701
  22. package/dist/control-database.js.map +1 -1
  23. package/dist/control-formation-recorder.d.ts +46 -43
  24. package/dist/control-formation-recorder.js +77 -78
  25. package/dist/control-formation-recorder.js.map +1 -1
  26. package/dist/control-protocol-guard.d.ts +11 -4
  27. package/dist/control-protocol-guard.js +20 -5
  28. package/dist/control-protocol-guard.js.map +1 -1
  29. package/dist/control-schema-version.d.ts +97 -0
  30. package/dist/control-schema-version.js +225 -0
  31. package/dist/control-schema-version.js.map +1 -0
  32. package/dist/control-schema.d.ts +45 -5
  33. package/dist/control-schema.js +262 -78
  34. package/dist/control-schema.js.map +1 -1
  35. package/dist/control-write-retry.d.ts +45 -10
  36. package/dist/control-write-retry.js +92 -22
  37. package/dist/control-write-retry.js.map +1 -1
  38. package/dist/delegate-admission.d.ts +13 -0
  39. package/dist/delegate-admission.js +27 -0
  40. package/dist/delegate-admission.js.map +1 -1
  41. package/dist/ed25519-key.d.ts +10 -7
  42. package/dist/ed25519-key.js +23 -20
  43. package/dist/ed25519-key.js.map +1 -1
  44. package/dist/edge-node/config.d.ts +122 -0
  45. package/dist/edge-node/config.js +199 -0
  46. package/dist/edge-node/config.js.map +1 -0
  47. package/dist/edge-node/dissolve.d.ts +95 -0
  48. package/dist/edge-node/dissolve.js +184 -0
  49. package/dist/edge-node/dissolve.js.map +1 -0
  50. package/dist/edge-node/edge-join.d.ts +44 -0
  51. package/dist/edge-node/edge-join.js +227 -0
  52. package/dist/edge-node/edge-join.js.map +1 -0
  53. package/dist/edge-node/edge-node.d.ts +295 -0
  54. package/dist/edge-node/edge-node.js +704 -0
  55. package/dist/edge-node/edge-node.js.map +1 -0
  56. package/dist/edge-node/index.d.ts +18 -0
  57. package/dist/edge-node/index.js +18 -0
  58. package/dist/edge-node/index.js.map +1 -0
  59. package/dist/edge-node/join-credential.d.ts +11 -0
  60. package/dist/edge-node/join-credential.js +11 -0
  61. package/dist/edge-node/join-credential.js.map +1 -0
  62. package/dist/edge-node/lazy-raw-storage.d.ts +40 -0
  63. package/dist/edge-node/lazy-raw-storage.js +134 -0
  64. package/dist/edge-node/lazy-raw-storage.js.map +1 -0
  65. package/dist/edge-node/node-local.d.ts +60 -0
  66. package/dist/edge-node/node-local.js +58 -0
  67. package/dist/edge-node/node-local.js.map +1 -0
  68. package/dist/edge-node/options.d.ts +83 -0
  69. package/dist/edge-node/options.js +110 -0
  70. package/dist/edge-node/options.js.map +1 -0
  71. package/dist/edge-node/owner-grant.d.ts +41 -0
  72. package/dist/edge-node/owner-grant.js +38 -0
  73. package/dist/edge-node/owner-grant.js.map +1 -0
  74. package/dist/edge-node/platform.d.ts +140 -0
  75. package/dist/edge-node/platform.js +12 -0
  76. package/dist/edge-node/platform.js.map +1 -0
  77. package/dist/edge-node/strand-move.d.ts +115 -0
  78. package/dist/edge-node/strand-move.js +223 -0
  79. package/dist/edge-node/strand-move.js.map +1 -0
  80. package/dist/edge-node/strands.d.ts +28 -0
  81. package/dist/edge-node/strands.js +60 -0
  82. package/dist/edge-node/strands.js.map +1 -0
  83. package/dist/enrolled-machine-store.d.ts +3 -3
  84. package/dist/enrolled-machine-store.js +3 -3
  85. package/dist/formation-token.d.ts +48 -0
  86. package/dist/formation-token.js +26 -0
  87. package/dist/formation-token.js.map +1 -0
  88. package/dist/index.d.ts +27 -17
  89. package/dist/index.js +39 -17
  90. package/dist/index.js.map +1 -1
  91. package/dist/joined-strand-store.d.ts +29 -12
  92. package/dist/joined-strand-store.js +66 -27
  93. package/dist/joined-strand-store.js.map +1 -1
  94. package/dist/key-store.d.ts +9 -4
  95. package/dist/key-store.js +9 -4
  96. package/dist/key-store.js.map +1 -1
  97. package/dist/link-budget.d.ts +9 -7
  98. package/dist/link-budget.js +9 -7
  99. package/dist/link-budget.js.map +1 -1
  100. package/dist/machine-removal.d.ts +19 -39
  101. package/dist/machine-removal.js +24 -98
  102. package/dist/machine-removal.js.map +1 -1
  103. package/dist/member-redemption.d.ts +256 -0
  104. package/dist/member-redemption.js +490 -0
  105. package/dist/member-redemption.js.map +1 -0
  106. package/dist/membership-view.d.ts +91 -0
  107. package/dist/membership-view.js +110 -0
  108. package/dist/membership-view.js.map +1 -0
  109. package/dist/owner-anchor-sync.d.ts +4 -4
  110. package/dist/owner-anchor-sync.js +16 -15
  111. package/dist/owner-anchor-sync.js.map +1 -1
  112. package/dist/owner-grant-protocol.d.ts +251 -0
  113. package/dist/owner-grant-protocol.js +344 -0
  114. package/dist/owner-grant-protocol.js.map +1 -0
  115. package/dist/owner-key-export.d.ts +70 -0
  116. package/dist/owner-key-export.js +279 -0
  117. package/dist/owner-key-export.js.map +1 -0
  118. package/dist/owner-key-flows.d.ts +55 -0
  119. package/dist/owner-key-flows.js +60 -0
  120. package/dist/owner-key-flows.js.map +1 -0
  121. package/dist/owner-key-store.d.ts +31 -0
  122. package/dist/owner-key-store.js +81 -0
  123. package/dist/owner-key-store.js.map +1 -0
  124. package/dist/owner-keys.d.ts +78 -0
  125. package/dist/owner-keys.js +133 -0
  126. package/dist/owner-keys.js.map +1 -0
  127. package/dist/owner-signer.d.ts +54 -0
  128. package/dist/owner-signer.js +51 -0
  129. package/dist/owner-signer.js.map +1 -0
  130. package/dist/peer-addr-book.d.ts +10 -1
  131. package/dist/peer-addr-book.js +4 -3
  132. package/dist/peer-addr-book.js.map +1 -1
  133. package/dist/peer-authorization.d.ts +83 -18
  134. package/dist/peer-authorization.js +123 -53
  135. package/dist/peer-authorization.js.map +1 -1
  136. package/dist/peer-join-backfill.d.ts +89 -20
  137. package/dist/peer-join-backfill.js +265 -108
  138. package/dist/peer-join-backfill.js.map +1 -1
  139. package/dist/pending-join-runner.d.ts +48 -18
  140. package/dist/pending-join-runner.js +74 -56
  141. package/dist/pending-join-runner.js.map +1 -1
  142. package/dist/provider/rent-node.js +1 -1
  143. package/dist/provider/rent-node.js.map +1 -1
  144. package/dist/seed-bootstrap.d.ts +162 -133
  145. package/dist/seed-bootstrap.js +300 -283
  146. package/dist/seed-bootstrap.js.map +1 -1
  147. package/dist/seed-trust-policy.js +4 -4
  148. package/dist/strand-addr-protocol.d.ts +4 -1
  149. package/dist/strand-addr-protocol.js +8 -2
  150. package/dist/strand-addr-protocol.js.map +1 -1
  151. package/dist/strand-cohort.d.ts +3 -0
  152. package/dist/strand-cohort.js.map +1 -1
  153. package/dist/strand-database.d.ts +24 -2
  154. package/dist/strand-database.js +29 -6
  155. package/dist/strand-database.js.map +1 -1
  156. package/dist/strand-first-sync-gate.d.ts +176 -88
  157. package/dist/strand-first-sync-gate.js +288 -121
  158. package/dist/strand-first-sync-gate.js.map +1 -1
  159. package/dist/strand-formation-deadlines.d.ts +9 -9
  160. package/dist/strand-formation-deadlines.js +9 -9
  161. package/dist/strand-formation-manager.d.ts +23 -17
  162. package/dist/strand-formation-manager.js +46 -44
  163. package/dist/strand-formation-manager.js.map +1 -1
  164. package/dist/strand-formation-protocol.d.ts +5 -8
  165. package/dist/strand-formation-protocol.js +101 -16
  166. package/dist/strand-formation-protocol.js.map +1 -1
  167. package/dist/strand-instance-manager.d.ts +27 -18
  168. package/dist/strand-instance-manager.js +119 -50
  169. package/dist/strand-instance-manager.js.map +1 -1
  170. package/dist/strand-membership-reconciler.d.ts +16 -13
  171. package/dist/strand-membership-reconciler.js +30 -22
  172. package/dist/strand-membership-reconciler.js.map +1 -1
  173. package/dist/strand-membership-writer.d.ts +45 -22
  174. package/dist/strand-membership-writer.js +226 -131
  175. package/dist/strand-membership-writer.js.map +1 -1
  176. package/dist/strand-move.d.ts +6 -5
  177. package/dist/strand-move.js +36 -32
  178. package/dist/strand-move.js.map +1 -1
  179. package/dist/strand-peer-redial.js +2 -3
  180. package/dist/strand-peer-redial.js.map +1 -1
  181. package/dist/strand-revocation-enforcer.d.ts +134 -60
  182. package/dist/strand-revocation-enforcer.js +144 -71
  183. package/dist/strand-revocation-enforcer.js.map +1 -1
  184. package/dist/strand-solicitation.d.ts +50 -39
  185. package/dist/strand-solicitation.js +10 -3
  186. package/dist/strand-solicitation.js.map +1 -1
  187. package/dist/strand-watcher.d.ts +80 -13
  188. package/dist/strand-watcher.js +152 -48
  189. package/dist/strand-watcher.js.map +1 -1
  190. package/dist/table-change-watch.d.ts +63 -0
  191. package/dist/table-change-watch.js +209 -0
  192. package/dist/table-change-watch.js.map +1 -0
  193. package/dist/timeout-scheduler.d.ts +4 -3
  194. package/dist/timeout-scheduler.js +4 -3
  195. package/dist/timeout-scheduler.js.map +1 -1
  196. package/dist/types.d.ts +374 -150
  197. package/dist/types.js +4 -4
  198. package/dist/types.js.map +1 -1
  199. package/package.json +17 -8
  200. package/src/bootstrap-peer-store-file.ts +44 -44
  201. package/src/bootstrap-peer-store.ts +214 -214
  202. package/src/cadre-invite-protocol.ts +740 -1052
  203. package/src/cadre-join-request.ts +24 -3
  204. package/src/cadre-node.ts +2402 -1079
  205. package/src/control-authorization.ts +47 -14
  206. package/src/control-cohort.ts +204 -128
  207. package/src/control-database.ts +1749 -820
  208. package/src/control-formation-recorder.ts +359 -353
  209. package/src/control-protocol-guard.ts +23 -6
  210. package/src/control-schema-version.ts +301 -0
  211. package/src/control-schema.ts +273 -78
  212. package/src/control-write-retry.ts +641 -567
  213. package/src/delegate-admission.ts +351 -314
  214. package/src/ed25519-key.ts +174 -171
  215. package/src/edge-node/config.ts +277 -0
  216. package/src/edge-node/dissolve.ts +251 -0
  217. package/src/edge-node/edge-join.ts +319 -0
  218. package/src/edge-node/edge-node.ts +1045 -0
  219. package/src/edge-node/index.ts +59 -0
  220. package/src/edge-node/join-credential.ts +16 -0
  221. package/src/edge-node/lazy-raw-storage.ts +178 -0
  222. package/src/edge-node/node-local.ts +84 -0
  223. package/src/edge-node/options.ts +179 -0
  224. package/src/edge-node/owner-grant.ts +63 -0
  225. package/src/edge-node/platform.ts +151 -0
  226. package/src/edge-node/strand-move.ts +270 -0
  227. package/src/edge-node/strands.ts +72 -0
  228. package/src/enrolled-machine-store.ts +3 -3
  229. package/src/formation-token.ts +63 -0
  230. package/src/index.ts +899 -789
  231. package/src/joined-strand-store.ts +73 -28
  232. package/src/key-store.ts +10 -4
  233. package/src/link-budget.ts +9 -7
  234. package/src/machine-removal.ts +26 -111
  235. package/src/member-redemption.ts +649 -0
  236. package/src/membership-view.ts +144 -0
  237. package/src/owner-anchor-sync.ts +19 -18
  238. package/src/owner-grant-protocol.ts +547 -0
  239. package/src/owner-key-export.ts +348 -0
  240. package/src/owner-key-flows.ts +109 -0
  241. package/src/owner-key-store.ts +85 -0
  242. package/src/owner-keys.ts +178 -0
  243. package/src/owner-signer.ts +94 -0
  244. package/src/peer-addr-book.ts +4 -3
  245. package/src/peer-authorization.ts +558 -488
  246. package/src/peer-join-backfill.ts +971 -761
  247. package/src/pending-join-runner.ts +76 -53
  248. package/src/provider/rent-node.ts +1 -1
  249. package/src/seed-bootstrap.ts +343 -349
  250. package/src/seed-trust-policy.ts +4 -4
  251. package/src/strand-addr-protocol.ts +547 -538
  252. package/src/strand-cohort.ts +4 -0
  253. package/src/strand-database.ts +352 -319
  254. package/src/strand-first-sync-gate.ts +334 -133
  255. package/src/strand-formation-deadlines.ts +227 -227
  256. package/src/strand-formation-manager.ts +731 -725
  257. package/src/strand-formation-protocol.ts +122 -24
  258. package/src/strand-instance-manager.ts +2092 -2026
  259. package/src/strand-membership-reconciler.ts +33 -25
  260. package/src/strand-membership-writer.ts +2093 -1974
  261. package/src/strand-move.ts +50 -38
  262. package/src/strand-peer-redial.ts +2 -3
  263. package/src/strand-revocation-enforcer.ts +740 -654
  264. package/src/strand-solicitation.ts +61 -34
  265. package/src/strand-watcher.ts +175 -56
  266. package/src/table-change-watch.ts +285 -0
  267. package/src/timeout-scheduler.ts +29 -28
  268. package/src/types.ts +391 -152
@@ -11,16 +11,16 @@
11
11
  * founder from a joiner; sereus can, because the launch knows whether it is founding.
12
12
  *
13
13
  * The invariant this module enforces: **a machine that has never held this strand's
14
- * `Strand.Header` row, and has not read every `App` table once, must not commit to it.**
15
- * The founder writes the Header in its bootstrap; every other machine can only receive it
16
- * from a peer, so "no local Header" means "never synced". The gate therefore holds the
14
+ * `Strand.Header` row, and has not read every `Strand` and `App` table once, must not commit
15
+ * to it.** The founder writes the Header in its bootstrap; every other machine can only receive
16
+ * it from a peer, so "no local Header" means "never synced". The gate therefore holds the
17
17
  * freshly initialized `StrandDatabase` back from the app — `StrandInstance.database` stays
18
- * unset and the instance reports `'syncing'` — and probes on a cadence until the Header is
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 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.
18
+ * unset and the instance reports `'syncing'` — and probes, whenever data lands in the strand's
19
+ * store and on a backstop timer, until the Header is readable AND a read of each other table
20
+ * settles, at which point the database is published and the instance goes `'active'`. A
21
+ * machine whose store already holds the Header and every other table (a founder, and normally
22
+ * a restart or a hibernation resume) passes the probe on the first try and is never gated, so
23
+ * offline-first writes on a machine that has synced before keep working.
24
24
  *
25
25
  * "Has synced before" is decided by what the local store actually holds, not by whether this
26
26
  * machine once read the strand: a machine that left soon after its first sync can hold less
@@ -29,22 +29,28 @@
29
29
  * it kept, without the `Strand.Header` collection it had read before it left. Gating it is
30
30
  * right: writing into a collection this machine does not hold would fork it.
31
31
  *
32
- * Why the app tables too: the Header is ONE collection. It reaches a joiner ahead of the
33
- * founder's app-table collections (peer-join backfill and pull-on-read deliver those
34
- * separately), and a write to a table whose collection this machine has not yet fetched
35
- * invents one exactly as a lone joiner did. Measured on the reference chat schema
36
- * (2026-09-16, direct connections, Header-only probe): the joiner's participant + message
37
- * written the instant `addStrand` resolved diverged in 3 of 4 runs. Reading each table once
38
- * pulls its collection while the founder is reachable, so the write that follows appends.
32
+ * Why the other tables too: the Header is ONE collection. It reaches a joiner ahead of the
33
+ * founder's other collections (peer-join backfill and pull-on-read deliver those separately),
34
+ * and a write to a table whose collection this machine has not yet fetched invents one exactly
35
+ * as a lone joiner did. Measured on the reference chat schema (2026-09-16, direct connections,
36
+ * Header-only probe): the joiner's participant + message written the instant `addStrand`
37
+ * resolved diverged in 3 of 4 runs. The same holds for the `Strand` tables, which reach a joiner
38
+ * by the same backfill: the membership loop writes `ConsumedInvite`, `Member` and `MemberPeer`
39
+ * about a second after publication, and a manager writes `Invite`, `CancelledInvite`, `Manager`
40
+ * and `Revocation`. Reading each table once pulls its collection while the founder is
41
+ * reachable, so the write that follows appends.
39
42
  *
40
- * Every probe is a read (`Strand.Header`, then `select count(1)` on each `App` table). Reads
41
- * never invent a collection (Optimystic's `Collection.open` resolves undefined on an
42
- * authoritatively absent header, and throws on an unreachable one), so probing is safe to
43
- * repeat and a throw is just "not yet". A table nobody has written yet reads as absent on
44
- * every machine; the first write to it still creates its collection — see `docs/strands.md`
45
- * ("Joining") for that residual.
43
+ * Every probe read is `select 1 from <table> limit 1` (`Strand.Header`, then each other `Strand`
44
+ * table, then each `App` table, skipping those already seen settled): opening the table fetches
45
+ * its collection header, and one row walks the root to the first leaf. Reads never commit a
46
+ * collection (one ruled authoritatively absent is staged empty in memory only, and an
47
+ * unreachable one throws), so probing is safe to repeat and a throw is just "not yet". Every
48
+ * table declared at founding has a collection from the start (the founder bootstrap commits the
49
+ * rest empty before its founding transaction writes the Header and the membership rows), so a
50
+ * probe read fetches it; a table a later schema version adds reads as absent until its first
51
+ * write creates the collection — see `docs/strands.md` ("Joining") for that residual.
46
52
  *
47
- * Owned by `StrandInstanceManager`: created in `buildStrandRuntime` for every launch, armed
53
+ * Owned by `StrandInstanceManager`: created in `buildStrandDatabase` for every database build, armed
48
54
  * only for a non-founder launch whose first probe fails, stopped and dropped in
49
55
  * `releaseRuntime` (so a quiesce → resume rebuild re-probes over the same store), and opened
50
56
  * by `publishDatabase` whenever the database is published without its loop — a launch that
@@ -66,12 +72,25 @@
66
72
  import debug from 'debug';
67
73
  import type { Database } from '@quereus/quereus';
68
74
  import type { StrandDatabase } from './strand-database.js';
75
+ import { scheduleRuns, TABLE_CHANGE_COALESCE_MS, type TableChangeWatch } from './table-change-watch.js';
69
76
  import { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
70
77
 
71
78
  const log = debug('sereus:cadre:strand-first-sync');
72
79
 
73
- /** How often a gated launch re-probes (`Strand.Header`, then every `App` table) while waiting for its first sync. */
74
- export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
80
+ /**
81
+ * How long a gated launch goes without probing when no data lands in the strand's store: the
82
+ * gate probes when data arrives, and this long after its last probe settled when none has. It
83
+ * covers a probe that failed for a reason no storage event follows — a consult that missed its
84
+ * deadline while the founder held the block, or a push that never comes.
85
+ *
86
+ * Why 2 s: arrivals wake the gate, so this timer is the fallback rather than the main path. At
87
+ * the supported 3 s link round trip one consult takes about 6 s (two round trips, per the 1.8.0
88
+ * measurements on {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}), so a 2 s idle gap after a failed
89
+ * probe adds at most a third of one probe round, and it is under 1% of that 360 s budget. Shorter
90
+ * brings back the cost of polling (the gate re-probed every 500 ms before it woke on arrivals);
91
+ * much longer (5 s) delays the case with no storage event by most of a probe round for nothing.
92
+ */
93
+ export const DEFAULT_STRAND_FIRST_SYNC_BACKSTOP_MS = 2_000;
75
94
 
76
95
  /**
77
96
  * How long `CadreNode.addStrand` waits for a machine's first sync before rejecting with
@@ -103,6 +122,37 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
103
122
  * several of those — which is why both bands are recorded here, and why a change to either
104
123
  * number has to be weighed against the other.
105
124
  *
125
+ * AT THE SUPPORTED LINK ON `@optimystic/*` 1.17.0 (2026-10-21), 1 500 ms one-way, the same
126
+ * scenario, one run per arm, with the probe reading every `Strand` table as well as the `App`
127
+ * one (`debt-first-sync-gate-reads-every-strand-table`); the kept-store arm was not run:
128
+ * - Fresh join: writable at 28.7 s and NOT gated, as on 1.15.0.
129
+ * - Re-attach over an EMPTY store: writable at 105.8 and 105.9 s over two runs, the row A wrote
130
+ * while B was away readable at the same moment. B's first database build fails at 7.0 s (no
131
+ * seed peer has answered; `StrandInstanceManager.deferUnreachableJoin` retries it), B's strand
132
+ * node connects to A's at 15.1 s, the retried build's `connectToStrand` takes 21.2 s, and the
133
+ * launch's first probe then takes 72.6 s and passes with no read failing, so the gate never
134
+ * arms.
135
+ * - The same arm with the probe held to the Header and the `App` table (one run, otherwise the
136
+ * same build): that probe took 12.1 s, writable at 45.4 s, the row at 63.5 s. So the seven
137
+ * `Strand` reads cost about 60 s here, about 8.6 s each, against about 6 s for each of the
138
+ * baseline's two reads — more than the 42 s estimated from one 6 s consult per read.
139
+ *
140
+ * AT THE SUPPORTED LINK ON `@optimystic/*` 1.15.0 (2026-10-20), 1 500 ms one-way, the same
141
+ * scenario, one fresh-join run, with the one-row probes woken by data landing in the store
142
+ * (`first-sync-gate-probes-cheaply`; the full-count probes polled every 500 ms gave the same
143
+ * figures):
144
+ * - Fresh join: writable at 28.8 s, and NOT gated. The launch waits 27.3 s in
145
+ * `connectToStrand` for its seed peer (it hydrates the founder's catalog, 9 tables), and by
146
+ * then the store holds the strand: the first probe passed in 24 ms.
147
+ * - The strand case of `message-cost-accounting` (two relay-only parties on loopback) agrees:
148
+ * B's `addStrand` took 471 ms, with 13 `sync` streams to A, 4 block reads from the Header
149
+ * probe and 8 from the table probes. Its two tables are empty, so a full count would fetch
150
+ * the same blocks as a one-row read.
151
+ * - Neither arms the gate, so neither times the woken path. That was checked on
152
+ * `harness-topology`'s cross-party share (a joiner launched with no seed, dialed afterwards)
153
+ * with the backstop raised to 10 s: the founder's join backfill push opened the gate 2.1 s
154
+ * after it armed.
155
+ *
106
156
  * AT THE SUPPORTED LINK ON `@optimystic/*` 1.8.0 (2026-09-29), 1 500 ms one-way, the read
107
157
  * deadline derived from the declared link at 7 000 ms (`cohortReadDeadlineMs` in
108
158
  * `link-budget.ts`; the plugin's `COHORT_READ_DEADLINE_MS` is the same number), and cadre
@@ -159,19 +209,19 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
159
209
  * injector are unknown to us) opened the gate at about 150 s, after the previous 120 s budget
160
210
  * had already rejected; and a real Galaxy S7 joining fresh through relay.sereus.org took 178 s.
161
211
  *
162
- * 300 s clears the worst harness sample (82 s, a fresh join at the supported link on 1.7.0; the
163
- * worst on 1.8.0 is 64 s) by about 3.6x, the reporter's re-attach by 2x and the device join by
164
- * about 1.7x. Kept at 300 s on 2026-09-29, and again after the 1.8.0 re-measure the same day,
165
- * by the rule that re-measure applied and the next one should: keep it while the
166
- * worst harness sample is at most 100 s, which holds a margin of at least 3x; otherwise raise
167
- * it to 3x the worst sample, rounded up to the next whole minute. 240 s was rejected earlier:
168
- * it clears the device join by only 1.35x, and that sample was not a re-attach. History: the
169
- * original 30 s sat INSIDE the 1000 ms fresh band and refused about half of those joins while
170
- * their sync was progressing normally; 120 s was sized from fresh joins only and refused the
171
- * reporter's re-attach.
212
+ * 360 s clears the worst harness sample (105.9 s, a re-attach over an empty store at the
213
+ * supported link on 1.17.0) by about 3.4x, the reporter's re-attach by 2.4x and the device join
214
+ * by about 2x. Set by the rule every re-measure applies: keep it while the worst harness sample
215
+ * is at most 100 s, which holds a margin of at least 3x; otherwise raise it to 3x the worst
216
+ * sample, rounded up to the next whole minute. The 1.17.0 sample crossed 100 s, and 3x 105.9 s
217
+ * is 318 s, so 360 s (2026-10-21); the 1.7.0 and 1.8.0 re-measures (worst 82 s and 64 s) had
218
+ * kept the 300 s before it. 240 s was rejected earlier: it clears the device join by only 1.35x,
219
+ * and that sample was not a re-attach. History: the original 30 s sat INSIDE the 1000 ms fresh
220
+ * band and refused about half of those joins while their sync was progressing normally; 120 s
221
+ * was sized from fresh joins only and refused the reporter's re-attach.
172
222
  *
173
- * NOTE: accepted tradeoff — what 300 s costs. This wait is what an app's `addStrand` sits in
174
- * before it is told "not yet", so a strand none of whose members is reachable at all takes five
223
+ * NOTE: accepted tradeoff — what 360 s costs. This wait is what an app's `addStrand` sits in
224
+ * before it is told "not yet", so a strand none of whose members is reachable at all takes six
175
225
  * minutes to report instead of two. The slow report was weighed against refusing attaches that
176
226
  * were working, and the refusal is the worse failure. The cost is bounded — the rejection is
177
227
  * retryable, the strand stays launched and keeps probing, and `strand:writable` fires the
@@ -181,7 +231,7 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
181
231
  * sync that is actually in progress — worth more the longer this budget gets.
182
232
  */
183
233
  // eslint-disable-next-line no-restricted-syntax -- measured, not derived: the bands above, from integration-tests' strand-reattach-first-sync-measure.integration.ts; a whole first sync has no round-trip count
184
- export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 300_000;
234
+ export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 360_000;
185
235
 
186
236
  /** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
187
237
  export interface StrandFirstSyncConfig {
@@ -191,8 +241,11 @@ export interface StrandFirstSyncConfig {
191
241
  * {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}.
192
242
  */
193
243
  timeoutMs?: number;
194
- /** Probe cadence while gated. Default {@link DEFAULT_STRAND_FIRST_SYNC_POLL_MS}. */
195
- pollIntervalMs?: number;
244
+ /**
245
+ * While gated, probe this long after the last probe settled when no data has landed in the
246
+ * strand's store. Default {@link DEFAULT_STRAND_FIRST_SYNC_BACKSTOP_MS}.
247
+ */
248
+ backstopMs?: number;
196
249
  }
197
250
 
198
251
  /**
@@ -222,15 +275,43 @@ export class StrandAwaitingFirstSyncError extends Error {
222
275
  }
223
276
 
224
277
  /**
225
- * Whether this machine holds the strand's `Strand.Header` row — the "has synced at least
226
- * once (or founded)" signal. A read, never a write: it cannot invent the collection. A
227
- * throwing read (an unreachable cohort mid-sync, a store still hydrating) reports `false`
228
- * with a log, since the caller's only response to either answer is "probe again".
278
+ * What one gate has established, kept for the gate's lifetime so a later probe reads only what
279
+ * is still missing: whether the Header is held, and the tables (qualified, `'App.Message'`)
280
+ * whose probe read settled. It only ever gains entries; a collection this machine holds stays
281
+ * held.
229
282
  */
230
- export async function strandHeaderHeld(db: Database, label: string): Promise<boolean> {
283
+ interface FirstSyncProgress {
284
+ header: boolean;
285
+ tables: Set<string>;
286
+ }
287
+
288
+ /** A table the probe reads. */
289
+ interface ProbedTable {
290
+ schema: string;
291
+ name: string;
292
+ }
293
+
294
+ const STRAND_HEADER: ProbedTable = { schema: 'Strand', name: 'Header' };
295
+
296
+ function qualifiedName(table: ProbedTable): string {
297
+ return `${table.schema}.${table.name}`;
298
+ }
299
+
300
+ /** The read `openTables` in `quereus-plugin-sereus/src/empty-collections.ts` opens a collection with. */
301
+ function probeSql(table: ProbedTable): string {
302
+ return `select 1 from ${table.schema}."${table.name}" limit 1`;
303
+ }
304
+
305
+ /**
306
+ * Whether this machine holds the strand's `Strand.Header` row — the "has synced at least once
307
+ * (or founded)" signal: held when the read returns a row. A throwing read (an unreachable cohort
308
+ * mid-sync, a store still hydrating) reports `false` with a log, since the caller's only
309
+ * response to either answer is "probe again".
310
+ */
311
+ async function headerHeld(db: Database, label: string): Promise<boolean> {
231
312
  try {
232
- for await (const row of db.eval('select count(1) as Count from Strand.Header')) {
233
- return ((row.Count as number) ?? 0) > 0;
313
+ for await (const _row of db.eval(probeSql(STRAND_HEADER))) {
314
+ return true;
234
315
  }
235
316
  return false;
236
317
  } catch (error) {
@@ -240,78 +321,156 @@ export async function strandHeaderHeld(db: Database, label: string): Promise<boo
240
321
  }
241
322
  }
242
323
 
243
- /** The tables the sApp declared in the `App` schema, by name; views excluded. */
244
- function appTableNames(db: Database): string[] {
245
- const app = db.schemaManager.getSchema('App');
246
- return app ? Array.from(app.getAllTables()).filter((table) => !table.isView).map((table) => table.name) : [];
247
- }
248
-
249
324
  /**
250
- * Whether a read of every `App` table settles on this machine — the "each app collection
251
- * has been fetched from the cohort, or is authoritatively absent" signal. Only a throwing
252
- * read (an unreachable cohort) reports `false`; an empty table is a settled read.
325
+ * Whether a read of `table` settles on this machine — its collection has been fetched from the
326
+ * cohort, or is authoritatively absent. Only a throwing read (an unreachable cohort, a block not
327
+ * yet arrived) reports `false`; an empty table is a settled read, because the founder commits
328
+ * an empty collection for every declared table.
253
329
  */
254
- export async function appTablesReadable(db: Database, label: string): Promise<boolean> {
255
- for (const table of appTableNames(db)) {
256
- try {
257
- for await (const _row of db.eval(`select count(1) as Count from App."${table}"`)) {
258
- break;
259
- }
260
- } catch (error) {
261
- log('[%s] App.%s probe failed (treated as not yet synced): %s', label, table,
262
- error instanceof Error ? error.message : String(error));
263
- return false;
330
+ async function tableReadable(db: Database, table: ProbedTable, label: string): Promise<boolean> {
331
+ try {
332
+ for await (const _row of db.eval(probeSql(table))) {
333
+ break;
264
334
  }
335
+ return true;
336
+ } catch (error) {
337
+ log('[%s] %s probe failed (treated as not yet synced): %s', label, qualifiedName(table),
338
+ error instanceof Error ? error.message : String(error));
339
+ return false;
265
340
  }
266
- return true;
341
+ }
342
+
343
+ /** The tables `schemaName` declares, views excluded; none when the schema is absent. */
344
+ function schemaTables(db: Database, schemaName: string): ProbedTable[] {
345
+ const schema = db.schemaManager.getSchema(schemaName);
346
+ return schema
347
+ ? Array.from(schema.getAllTables()).filter((table) => !table.isView).map((table) => ({ schema: schemaName, name: table.name }))
348
+ : [];
349
+ }
350
+
351
+ /**
352
+ * Every table this machine may write, in probe order: the membership tables in `Strand` (a fixed
353
+ * set every strand has) and then the sApp's in `App`. The Header is left out, compared
354
+ * case-insensitively as SQL identifiers are, because it is read first and alone, and its read
355
+ * means something else (held = a row came back).
356
+ */
357
+ function writableTables(db: Database): ProbedTable[] {
358
+ const header = STRAND_HEADER.name.toLowerCase();
359
+ return [
360
+ ...schemaTables(db, STRAND_HEADER.schema).filter((table) => table.name.toLowerCase() !== header),
361
+ ...schemaTables(db, 'App')
362
+ ];
267
363
  }
268
364
 
269
365
  /**
270
- * The gate's whole probe: the Header is held AND every app table has been read once. The
271
- * app tables are read only once the Header is — a joiner with no peer would otherwise pay
272
- * one failing network read per table per probe for nothing.
366
+ * The gate's whole probe: the Header is held AND a read of every other `Strand` table and every
367
+ * `App` table has settled. Reads only what `progress` does not record yet, and records what it
368
+ * establishes.
369
+ *
370
+ * The Header is read first and alone: a joiner with no peer would otherwise pay one failing
371
+ * network read per table for nothing. The tables are then read one after another, stopping at
372
+ * the first that fails — the next probe starts there, and while the cohort is unreachable every
373
+ * read of a block this machine lacks fails the same way, each after a consult.
374
+ *
375
+ * NOTE: the reads are serialized (Quereus's default), not `readConcurrency: 'committed'`, though
376
+ * committed reads would let the table reads overlap. The Optimystic plugin's committed read
377
+ * serves the tree the table already holds and never refreshes it from the network, and schema
378
+ * apply initializes every table when the joiner has no catalog to hydrate, over an empty
379
+ * collection staged in memory when no peer could answer. A committed probe of such a table reads
380
+ * that empty collection as settled and opens the gate onto a fork. A serialized read refreshes
381
+ * the tree first (`Collection.update`), which adopts the founder's log once a peer holds it.
382
+ * Revisit if the plugin's committed read ever refreshes a tree it has never synced, or the gate
383
+ * can ask Optimystic directly whether a collection is held.
273
384
  */
274
- export async function strandFirstSyncComplete(db: Database, label: string): Promise<boolean> {
275
- return await strandHeaderHeld(db, label) && await appTablesReadable(db, label);
385
+ async function strandFirstSyncComplete(db: Database, label: string, progress: FirstSyncProgress): Promise<boolean> {
386
+ if (!progress.header) {
387
+ if (!await headerHeld(db, label)) {
388
+ return false;
389
+ }
390
+ progress.header = true;
391
+ }
392
+ for (const table of writableTables(db)) {
393
+ const name = qualifiedName(table);
394
+ if (progress.tables.has(name)) {
395
+ continue;
396
+ }
397
+ if (!await tableReadable(db, table, label)) {
398
+ return false;
399
+ }
400
+ progress.tables.add(name);
401
+ }
402
+ return true;
276
403
  }
277
404
 
278
405
  export interface StrandFirstSyncGateDeps {
279
406
  /** Log tag naming which strand this gate holds (the strand id). */
280
407
  label: string;
281
- /** The initialized database being held back from the app until the Header is held. */
408
+ /** The initialized database being held back from the app until the first sync completes. */
282
409
  database: StrandDatabase;
283
410
  /**
284
- * Called exactly once, on the gate's own probe loop, when the first sync completes.
285
- * NOT called by {@link StrandFirstSyncGate.open} — a caller that force-opens the gate
286
- * (a founder bootstrap it just ran) publishes the database itself.
411
+ * Called exactly once, from the gate's own probe, when the first sync completes. NOT called
412
+ * by {@link StrandFirstSyncGate.open} — a caller that force-opens the gate (a founder
413
+ * bootstrap it just ran) publishes the database itself.
287
414
  */
288
415
  onHeaderHeld: () => void;
289
- /** Timer seam for the probe loop; omit for real (unref'd) timeouts. */
416
+ /**
417
+ * Subscribe `onChange` to every block landing in this strand's store — a push such as join
418
+ * backfill, a fetched block, a commit — and return the unsubscribe. `buildStrandDatabase`
419
+ * passes the strand node's `storageRepo.onAnyCollectionChange`. Omitted (test doubles, a node
420
+ * without it), the gate probes on its backstop alone.
421
+ */
422
+ subscribeStorageChanges?: (onChange: () => void) => () => void;
423
+ /** Timer seam for the probe schedule; omit for real (unref'd) timeouts. */
290
424
  scheduler?: TimeoutScheduler;
291
425
  }
292
426
 
293
427
  /**
294
- * Holds one launch's `StrandDatabase` until {@link strandFirstSyncComplete}. Probes are
295
- * sequential (the next is scheduled only after the previous read settles), so a slow
296
- * network read never stacks probes.
428
+ * Holds one launch's `StrandDatabase` until the first sync completes: the `Strand.Header` row is
429
+ * held and a read of every `Strand` and `App` table has settled. What a probe established is
430
+ * remembered for the gate's lifetime, so a later probe — the gate's own, after the caller's
431
+ * {@link probeOnce} — reads only what is still missing. A gate lives for one database build, so a
432
+ * hibernation wake or a deferred-join retry starts over, and over a store that holds everything
433
+ * every read answers locally.
434
+ *
435
+ * While armed it probes when data lands in the strand's store
436
+ * ({@link StrandFirstSyncGateDeps.subscribeStorageChanges}, coalesced over
437
+ * `TABLE_CHANGE_COALESCE_MS`), and {@link StrandFirstSyncConfig.backstopMs} after its last probe
438
+ * settled when nothing has. Probes run on `scheduleRuns`: one at a time, and data landing during
439
+ * one produces exactly one more.
440
+ *
441
+ * The wake source is the node's storage-wide change feed rather than a table watch
442
+ * (`StrandDatabase.watchTables`): the Optimystic plugin arms a table's replicated-change bridge
443
+ * only after the table initializes successfully, and the tables the gate waits on are the ones
444
+ * whose collection could not be fetched yet, so a table watch would not hear the push that
445
+ * delivers them. Each strand has its own libp2p node and store, so the feed hears this strand
446
+ * only.
297
447
  *
298
448
  * NOTE: a probe's reads dial the founder, through the relay when that is the only route, even
299
449
  * while the founder is unreachable: one run of `joiner-first-commit-over-slow-relay` logged 17
300
450
  * `hop connect denied` at the relay in about 10 s, each a few frames on the joiner's relay
301
- * connection. If that connection ever shows saturated while no strand member is connected, hold
302
- * the probe while the strand member redial (`strand-peer-redial.ts`) reports none connected.
451
+ * connection, when the gate re-probed every 500 ms and re-read every table. It now probes on
452
+ * arrivals and a 2 s backstop and skips what it holds, so expect fewer. If that connection ever
453
+ * shows saturated while no strand member is connected, hold the probe while the strand member
454
+ * redial (`strand-peer-redial.ts`) reports none connected.
303
455
  */
304
456
  export class StrandFirstSyncGate {
305
457
  private readonly scheduler: TimeoutScheduler;
306
- private readonly pollIntervalMs: number;
307
- private timer: unknown;
458
+ private readonly backstopMs: number;
459
+ private readonly progress: FirstSyncProgress = { header: false, tables: new Set() };
460
+ private probes: TableChangeWatch | undefined;
461
+ private unsubscribeStorage: (() => void) | undefined;
462
+ private listening = false;
463
+ /**
464
+ * Data landed since the last probe started (or, before any probe, since {@link listen}), so one
465
+ * is already due and will read it.
466
+ */
467
+ private landedSinceProbe = false;
308
468
  private stopped = false;
309
469
  private opened = false;
310
- private probing = false;
311
470
 
312
471
  constructor(private readonly deps: StrandFirstSyncGateDeps, config?: StrandFirstSyncConfig) {
313
472
  this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
314
- this.pollIntervalMs = config?.pollIntervalMs ?? DEFAULT_STRAND_FIRST_SYNC_POLL_MS;
473
+ this.backstopMs = config?.backstopMs ?? DEFAULT_STRAND_FIRST_SYNC_BACKSTOP_MS;
315
474
  }
316
475
 
317
476
  /** The database this gate is holding back. */
@@ -319,24 +478,53 @@ export class StrandFirstSyncGate {
319
478
  return this.deps.database;
320
479
  }
321
480
 
322
- /** True once the Header was seen (or the gate was force-opened); the loop is then done. */
481
+ /** True once the first sync completed (or the gate was force-opened); probing is then done. */
323
482
  get isOpen(): boolean {
324
483
  return this.opened;
325
484
  }
326
485
 
327
- /** Arm the probe loop. The first probe runs after one interval — the caller already probed once. */
486
+ /** Whether a block landed since the last probe started, or since {@link listen} when none has. */
487
+ get heardDataSinceProbe(): boolean {
488
+ return this.landedSinceProbe;
489
+ }
490
+
491
+ /**
492
+ * Subscribe to the storage change feed ahead of {@link start}, so a block landing before the
493
+ * gate is armed is heard: {@link heardDataSinceProbe} reports it, and `start` then probes at
494
+ * once. For a first join, which skips the caller's first probe unless a member's push already
495
+ * landed while the database was initializing.
496
+ */
497
+ listen(): void {
498
+ if (this.stopped || this.opened || this.listening) return;
499
+ this.listening = true;
500
+ this.unsubscribeStorage = this.subscribeStorage();
501
+ }
502
+
503
+ /**
504
+ * Arm the probe schedule. It probes when data lands, or one backstop later; and at once when
505
+ * data already landed since the last probe (or since {@link listen}). Without `listen`, data
506
+ * that landed between the caller's probe and this call raised no event the gate heard, so
507
+ * that first backstop is what catches it.
508
+ */
328
509
  start(): void {
329
- if (this.stopped || this.opened || this.timer !== undefined) return;
330
- this.schedule();
331
- log('[%s] first-sync gate armed: no Strand.Header held yet; probing every %dms',
332
- this.deps.label, this.pollIntervalMs);
510
+ if (this.stopped || this.opened || this.probes !== undefined) return;
511
+ this.probes = scheduleRuns(() => this.probe(), {
512
+ label: `first-sync:${this.deps.label}`,
513
+ backstopMs: this.backstopMs,
514
+ scheduler: this.scheduler,
515
+ });
516
+ this.listen();
517
+ if (this.landedSinceProbe) {
518
+ this.probes.trigger(TABLE_CHANGE_COALESCE_MS);
519
+ }
520
+ log('[%s] first-sync gate armed: probing when data lands, and %dms after the last probe otherwise',
521
+ this.deps.label, this.backstopMs);
333
522
  }
334
523
 
335
524
  /**
336
- * Open without the probe loop: the caller is publishing the database itself — a launch
337
- * whose first probe passed (or a founder's), which never armed the loop, or a founder
338
- * bootstrap just run against the gated database. Stops the loop; `onHeaderHeld` is NOT
339
- * invoked.
525
+ * Open without probing: the caller is publishing the database itself — a launch whose first
526
+ * probe passed (or a founder's), which never armed the schedule, or a founder bootstrap just
527
+ * run against the gated database. Stops probing; `onHeaderHeld` is NOT invoked.
340
528
  */
341
529
  open(): void {
342
530
  if (this.opened) return;
@@ -345,54 +533,67 @@ export class StrandFirstSyncGate {
345
533
  log('[%s] database published by the caller; first-sync gate not needed or force-opened', this.deps.label);
346
534
  }
347
535
 
348
- /** Disarm the loop; a probe already in flight completes and is then ignored. */
536
+ /** Disarm: unsubscribe and drop the schedule; a probe already in flight completes and is then ignored. */
349
537
  stop(): void {
350
538
  this.stopped = true;
351
- if (this.timer !== undefined) {
352
- this.scheduler.clearTimeout(this.timer);
353
- this.timer = undefined;
354
- }
355
- }
356
-
357
- private schedule(): void {
358
- this.timer = this.scheduler.setTimeout(() => {
359
- this.timer = undefined;
360
- void this.probe();
361
- }, this.pollIntervalMs);
362
- }
363
-
364
- private async probe(): Promise<void> {
365
- if (this.stopped || this.opened || this.probing) return;
366
- this.probing = true;
367
- const held = await this.probeHeld();
368
- // Re-check after the await: a release or a force-open may have landed mid-read.
369
- if (this.stopped || this.opened) return;
370
- if (!held) {
371
- this.schedule();
372
- return;
373
- }
374
- this.opened = true;
375
- this.stop();
376
- log('[%s] first-sync gate opened: Strand.Header and every App table read from a peer', this.deps.label);
377
- this.deps.onHeaderHeld();
539
+ this.unsubscribeStorage?.();
540
+ this.unsubscribeStorage = undefined;
541
+ this.probes?.dispose();
542
+ this.probes = undefined;
378
543
  }
379
544
 
380
545
  /**
381
- * One probe's answer, with a throw from OUTSIDE {@link strandFirstSyncComplete}'s own read
382
- * guards — the `App` schema lookup, or `getDatabase()` itself — reported as "not yet" like
383
- * any failing read. The loop is scheduled from `void this.probe()`, so an escaping rejection
384
- * would be unhandled AND leave nothing scheduled: the strand would stay gated for the whole
385
- * `timeoutMs` and never recover, which is the one failure this gate must not have.
546
+ * One probe's answer, reading only what this gate has not yet seen settled. A throw from
547
+ * OUTSIDE the probe's own read guards — a schema lookup, or `getDatabase()` itself — is
548
+ * reported as "not yet" like any failing read, so the caller's first probe cannot fail the
549
+ * launch over it and the gate's log says what happened.
386
550
  */
387
- private async probeHeld(): Promise<boolean> {
551
+ async probeOnce(): Promise<boolean> {
552
+ this.landedSinceProbe = false;
388
553
  try {
389
- return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label);
554
+ return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label, this.progress);
390
555
  } catch (error) {
391
556
  log('[%s] first-sync probe threw (treated as not yet synced): %s', this.deps.label,
392
557
  error instanceof Error ? error.message : String(error));
393
558
  return false;
394
- } finally {
395
- this.probing = false;
396
559
  }
397
560
  }
561
+
562
+ /**
563
+ * NOTE: a probe's own reads can land blocks (a fetched block, a read-repair), which fires this
564
+ * feed and schedules one follow-up probe. Each follow-up means a block newly arrived, so they
565
+ * stop when nothing new lands; if gated launches ever show probe storms in the logs, ignore
566
+ * events while a probe is in flight.
567
+ */
568
+ private subscribeStorage(): (() => void) | undefined {
569
+ const subscribe = this.deps.subscribeStorageChanges;
570
+ if (!subscribe) {
571
+ log('[%s] no storage change feed on this node; probing every %dms only', this.deps.label, this.backstopMs);
572
+ return undefined;
573
+ }
574
+ return subscribe(() => this.onDataLanded());
575
+ }
576
+
577
+ /**
578
+ * Called synchronously by the store after each block lands, so it only arms a timer. A push
579
+ * lands its blocks one by one; only the first since the last probe started arms one, since
580
+ * that probe has not started yet and will read the rest.
581
+ */
582
+ private onDataLanded(): void {
583
+ if (this.landedSinceProbe) return;
584
+ this.landedSinceProbe = true;
585
+ this.probes?.trigger(TABLE_CHANGE_COALESCE_MS);
586
+ }
587
+
588
+ private async probe(): Promise<void> {
589
+ if (this.stopped || this.opened) return;
590
+ log('[%s] first-sync probe (%s)', this.deps.label, this.landedSinceProbe ? 'data landed' : 'backstop');
591
+ const held = await this.probeOnce();
592
+ // Re-check after the await: a release or a force-open may have landed mid-read.
593
+ if (this.stopped || this.opened || !held) return;
594
+ this.opened = true;
595
+ this.stop();
596
+ log('[%s] first-sync gate opened: Strand.Header, every Strand table and every App table read from a peer', this.deps.label);
597
+ this.deps.onHeaderHeld();
598
+ }
398
599
  }