@serfab/cadre-core 1.17.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 (144) hide show
  1. package/README.md +3 -2
  2. package/dist/cadre-invite-protocol.d.ts +35 -13
  3. package/dist/cadre-invite-protocol.js +90 -25
  4. package/dist/cadre-invite-protocol.js.map +1 -1
  5. package/dist/cadre-node.d.ts +586 -255
  6. package/dist/cadre-node.js +1176 -564
  7. package/dist/cadre-node.js.map +1 -1
  8. package/dist/control-cohort.d.ts +66 -9
  9. package/dist/control-cohort.js +67 -9
  10. package/dist/control-cohort.js.map +1 -1
  11. package/dist/control-database.d.ts +451 -193
  12. package/dist/control-database.js +701 -359
  13. package/dist/control-database.js.map +1 -1
  14. package/dist/control-formation-recorder.d.ts +46 -43
  15. package/dist/control-formation-recorder.js +77 -78
  16. package/dist/control-formation-recorder.js.map +1 -1
  17. package/dist/control-protocol-guard.d.ts +6 -0
  18. package/dist/control-protocol-guard.js +14 -2
  19. package/dist/control-protocol-guard.js.map +1 -1
  20. package/dist/control-schema-version.d.ts +48 -31
  21. package/dist/control-schema-version.js +67 -45
  22. package/dist/control-schema-version.js.map +1 -1
  23. package/dist/control-schema.d.ts +8 -8
  24. package/dist/control-schema.js +16 -16
  25. package/dist/control-schema.js.map +1 -1
  26. package/dist/control-write-retry.d.ts +42 -7
  27. package/dist/control-write-retry.js +87 -19
  28. package/dist/control-write-retry.js.map +1 -1
  29. package/dist/enrolled-machine-store.d.ts +3 -3
  30. package/dist/enrolled-machine-store.js +3 -3
  31. package/dist/formation-token.d.ts +48 -0
  32. package/dist/formation-token.js +26 -0
  33. package/dist/formation-token.js.map +1 -0
  34. package/dist/index.d.ts +14 -13
  35. package/dist/index.js +16 -13
  36. package/dist/index.js.map +1 -1
  37. package/dist/joined-strand-store.d.ts +26 -9
  38. package/dist/joined-strand-store.js +63 -24
  39. package/dist/joined-strand-store.js.map +1 -1
  40. package/dist/link-budget.d.ts +9 -7
  41. package/dist/link-budget.js +9 -7
  42. package/dist/link-budget.js.map +1 -1
  43. package/dist/member-redemption.d.ts +15 -9
  44. package/dist/member-redemption.js +13 -8
  45. package/dist/member-redemption.js.map +1 -1
  46. package/dist/membership-view.d.ts +91 -0
  47. package/dist/membership-view.js +110 -0
  48. package/dist/membership-view.js.map +1 -0
  49. package/dist/owner-grant-protocol.d.ts +11 -7
  50. package/dist/owner-grant-protocol.js +31 -17
  51. package/dist/owner-grant-protocol.js.map +1 -1
  52. package/dist/peer-addr-book.d.ts +10 -1
  53. package/dist/peer-addr-book.js +4 -3
  54. package/dist/peer-addr-book.js.map +1 -1
  55. package/dist/peer-join-backfill.d.ts +89 -20
  56. package/dist/peer-join-backfill.js +265 -108
  57. package/dist/peer-join-backfill.js.map +1 -1
  58. package/dist/pending-join-runner.d.ts +48 -18
  59. package/dist/pending-join-runner.js +74 -56
  60. package/dist/pending-join-runner.js.map +1 -1
  61. package/dist/seed-bootstrap.d.ts +47 -40
  62. package/dist/seed-bootstrap.js +66 -61
  63. package/dist/seed-bootstrap.js.map +1 -1
  64. package/dist/strand-database.d.ts +23 -2
  65. package/dist/strand-database.js +26 -6
  66. package/dist/strand-database.js.map +1 -1
  67. package/dist/strand-first-sync-gate.d.ts +176 -89
  68. package/dist/strand-first-sync-gate.js +288 -122
  69. package/dist/strand-first-sync-gate.js.map +1 -1
  70. package/dist/strand-formation-deadlines.d.ts +9 -9
  71. package/dist/strand-formation-deadlines.js +9 -9
  72. package/dist/strand-formation-manager.d.ts +23 -17
  73. package/dist/strand-formation-manager.js +46 -44
  74. package/dist/strand-formation-manager.js.map +1 -1
  75. package/dist/strand-formation-protocol.d.ts +5 -8
  76. package/dist/strand-formation-protocol.js +101 -16
  77. package/dist/strand-formation-protocol.js.map +1 -1
  78. package/dist/strand-instance-manager.d.ts +26 -17
  79. package/dist/strand-instance-manager.js +119 -50
  80. package/dist/strand-instance-manager.js.map +1 -1
  81. package/dist/strand-membership-reconciler.d.ts +16 -13
  82. package/dist/strand-membership-reconciler.js +30 -22
  83. package/dist/strand-membership-reconciler.js.map +1 -1
  84. package/dist/strand-membership-writer.d.ts +40 -28
  85. package/dist/strand-membership-writer.js +212 -153
  86. package/dist/strand-membership-writer.js.map +1 -1
  87. package/dist/strand-move.js +5 -2
  88. package/dist/strand-move.js.map +1 -1
  89. package/dist/strand-revocation-enforcer.d.ts +134 -60
  90. package/dist/strand-revocation-enforcer.js +144 -71
  91. package/dist/strand-revocation-enforcer.js.map +1 -1
  92. package/dist/strand-solicitation.d.ts +50 -39
  93. package/dist/strand-solicitation.js +10 -3
  94. package/dist/strand-solicitation.js.map +1 -1
  95. package/dist/strand-watcher.d.ts +80 -13
  96. package/dist/strand-watcher.js +152 -48
  97. package/dist/strand-watcher.js.map +1 -1
  98. package/dist/table-change-watch.d.ts +63 -0
  99. package/dist/table-change-watch.js +209 -0
  100. package/dist/table-change-watch.js.map +1 -0
  101. package/dist/timeout-scheduler.d.ts +4 -3
  102. package/dist/timeout-scheduler.js +4 -3
  103. package/dist/timeout-scheduler.js.map +1 -1
  104. package/dist/types.d.ts +90 -49
  105. package/dist/types.js +4 -4
  106. package/dist/types.js.map +1 -1
  107. package/package.json +8 -8
  108. package/src/cadre-invite-protocol.ts +119 -31
  109. package/src/cadre-node.ts +1282 -584
  110. package/src/control-cohort.ts +86 -9
  111. package/src/control-database.ts +848 -403
  112. package/src/control-formation-recorder.ts +359 -353
  113. package/src/control-protocol-guard.ts +16 -2
  114. package/src/control-schema-version.ts +101 -51
  115. package/src/control-schema.ts +16 -16
  116. package/src/control-write-retry.ts +91 -19
  117. package/src/delegate-admission.ts +351 -351
  118. package/src/enrolled-machine-store.ts +3 -3
  119. package/src/formation-token.ts +63 -0
  120. package/src/index.ts +30 -17
  121. package/src/joined-strand-store.ts +69 -24
  122. package/src/link-budget.ts +9 -7
  123. package/src/member-redemption.ts +20 -13
  124. package/src/membership-view.ts +144 -0
  125. package/src/owner-grant-protocol.ts +33 -18
  126. package/src/peer-addr-book.ts +4 -3
  127. package/src/peer-join-backfill.ts +971 -761
  128. package/src/pending-join-runner.ts +76 -53
  129. package/src/seed-bootstrap.ts +72 -67
  130. package/src/strand-database.ts +352 -322
  131. package/src/strand-first-sync-gate.ts +334 -134
  132. package/src/strand-formation-deadlines.ts +227 -227
  133. package/src/strand-formation-manager.ts +731 -725
  134. package/src/strand-formation-protocol.ts +122 -24
  135. package/src/strand-instance-manager.ts +2092 -2026
  136. package/src/strand-membership-reconciler.ts +33 -25
  137. package/src/strand-membership-writer.ts +2093 -2019
  138. package/src/strand-move.ts +5 -2
  139. package/src/strand-revocation-enforcer.ts +740 -654
  140. package/src/strand-solicitation.ts +61 -34
  141. package/src/strand-watcher.ts +175 -56
  142. package/src/table-change-watch.ts +285 -0
  143. package/src/timeout-scheduler.ts +29 -28
  144. package/src/types.ts +89 -51
@@ -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,23 +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". Every table declared at founding has a collection
44
- * from the start (the founder bootstrap commits them all before the Header), so a probe read
45
- * fetches it; a table a later schema version adds reads as absent until its first write
46
- * creates the collection — see `docs/strands.md` ("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.
47
52
  *
48
- * Owned by `StrandInstanceManager`: created in `buildStrandRuntime` for every launch, armed
53
+ * Owned by `StrandInstanceManager`: created in `buildStrandDatabase` for every database build, armed
49
54
  * only for a non-founder launch whose first probe fails, stopped and dropped in
50
55
  * `releaseRuntime` (so a quiesce → resume rebuild re-probes over the same store), and opened
51
56
  * by `publishDatabase` whenever the database is published without its loop — a launch that
@@ -67,12 +72,25 @@
67
72
  import debug from 'debug';
68
73
  import type { Database } from '@quereus/quereus';
69
74
  import type { StrandDatabase } from './strand-database.js';
75
+ import { scheduleRuns, TABLE_CHANGE_COALESCE_MS, type TableChangeWatch } from './table-change-watch.js';
70
76
  import { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
71
77
 
72
78
  const log = debug('sereus:cadre:strand-first-sync');
73
79
 
74
- /** How often a gated launch re-probes (`Strand.Header`, then every `App` table) while waiting for its first sync. */
75
- 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;
76
94
 
77
95
  /**
78
96
  * How long `CadreNode.addStrand` waits for a machine's first sync before rejecting with
@@ -104,6 +122,37 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
104
122
  * several of those — which is why both bands are recorded here, and why a change to either
105
123
  * number has to be weighed against the other.
106
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
+ *
107
156
  * AT THE SUPPORTED LINK ON `@optimystic/*` 1.8.0 (2026-09-29), 1 500 ms one-way, the read
108
157
  * deadline derived from the declared link at 7 000 ms (`cohortReadDeadlineMs` in
109
158
  * `link-budget.ts`; the plugin's `COHORT_READ_DEADLINE_MS` is the same number), and cadre
@@ -160,19 +209,19 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
160
209
  * injector are unknown to us) opened the gate at about 150 s, after the previous 120 s budget
161
210
  * had already rejected; and a real Galaxy S7 joining fresh through relay.sereus.org took 178 s.
162
211
  *
163
- * 300 s clears the worst harness sample (82 s, a fresh join at the supported link on 1.7.0; the
164
- * worst on 1.8.0 is 64 s) by about 3.6x, the reporter's re-attach by 2x and the device join by
165
- * about 1.7x. Kept at 300 s on 2026-09-29, and again after the 1.8.0 re-measure the same day,
166
- * by the rule that re-measure applied and the next one should: keep it while the
167
- * worst harness sample is at most 100 s, which holds a margin of at least 3x; otherwise raise
168
- * it to 3x the worst sample, rounded up to the next whole minute. 240 s was rejected earlier:
169
- * it clears the device join by only 1.35x, and that sample was not a re-attach. History: the
170
- * original 30 s sat INSIDE the 1000 ms fresh band and refused about half of those joins while
171
- * their sync was progressing normally; 120 s was sized from fresh joins only and refused the
172
- * 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.
173
222
  *
174
- * NOTE: accepted tradeoff — what 300 s costs. This wait is what an app's `addStrand` sits in
175
- * 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
176
225
  * minutes to report instead of two. The slow report was weighed against refusing attaches that
177
226
  * were working, and the refusal is the worse failure. The cost is bounded — the rejection is
178
227
  * retryable, the strand stays launched and keeps probing, and `strand:writable` fires the
@@ -182,7 +231,7 @@ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
182
231
  * sync that is actually in progress — worth more the longer this budget gets.
183
232
  */
184
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
185
- export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 300_000;
234
+ export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 360_000;
186
235
 
187
236
  /** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
188
237
  export interface StrandFirstSyncConfig {
@@ -192,8 +241,11 @@ export interface StrandFirstSyncConfig {
192
241
  * {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}.
193
242
  */
194
243
  timeoutMs?: number;
195
- /** Probe cadence while gated. Default {@link DEFAULT_STRAND_FIRST_SYNC_POLL_MS}. */
196
- 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;
197
249
  }
198
250
 
199
251
  /**
@@ -223,15 +275,43 @@ export class StrandAwaitingFirstSyncError extends Error {
223
275
  }
224
276
 
225
277
  /**
226
- * Whether this machine holds the strand's `Strand.Header` row — the "has synced at least
227
- * once (or founded)" signal. A read, never a write: it cannot invent the collection. A
228
- * throwing read (an unreachable cohort mid-sync, a store still hydrating) reports `false`
229
- * 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.
230
282
  */
231
- 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> {
232
312
  try {
233
- for await (const row of db.eval('select count(1) as Count from Strand.Header')) {
234
- return ((row.Count as number) ?? 0) > 0;
313
+ for await (const _row of db.eval(probeSql(STRAND_HEADER))) {
314
+ return true;
235
315
  }
236
316
  return false;
237
317
  } catch (error) {
@@ -241,78 +321,156 @@ export async function strandHeaderHeld(db: Database, label: string): Promise<boo
241
321
  }
242
322
  }
243
323
 
244
- /** The tables the sApp declared in the `App` schema, by name; views excluded. */
245
- function appTableNames(db: Database): string[] {
246
- const app = db.schemaManager.getSchema('App');
247
- return app ? Array.from(app.getAllTables()).filter((table) => !table.isView).map((table) => table.name) : [];
248
- }
249
-
250
324
  /**
251
- * Whether a read of every `App` table settles on this machine — the "each app collection
252
- * has been fetched from the cohort, or is authoritatively absent" signal. Only a throwing
253
- * 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.
254
329
  */
255
- export async function appTablesReadable(db: Database, label: string): Promise<boolean> {
256
- for (const table of appTableNames(db)) {
257
- try {
258
- for await (const _row of db.eval(`select count(1) as Count from App."${table}"`)) {
259
- break;
260
- }
261
- } catch (error) {
262
- log('[%s] App.%s probe failed (treated as not yet synced): %s', label, table,
263
- error instanceof Error ? error.message : String(error));
264
- 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;
265
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;
266
340
  }
267
- 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
+ ];
268
363
  }
269
364
 
270
365
  /**
271
- * The gate's whole probe: the Header is held AND every app table has been read once. The
272
- * app tables are read only once the Header is — a joiner with no peer would otherwise pay
273
- * 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.
274
384
  */
275
- export async function strandFirstSyncComplete(db: Database, label: string): Promise<boolean> {
276
- 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;
277
403
  }
278
404
 
279
405
  export interface StrandFirstSyncGateDeps {
280
406
  /** Log tag naming which strand this gate holds (the strand id). */
281
407
  label: string;
282
- /** 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. */
283
409
  database: StrandDatabase;
284
410
  /**
285
- * Called exactly once, on the gate's own probe loop, when the first sync completes.
286
- * NOT called by {@link StrandFirstSyncGate.open} — a caller that force-opens the gate
287
- * (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.
288
414
  */
289
415
  onHeaderHeld: () => void;
290
- /** 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. */
291
424
  scheduler?: TimeoutScheduler;
292
425
  }
293
426
 
294
427
  /**
295
- * Holds one launch's `StrandDatabase` until {@link strandFirstSyncComplete}. Probes are
296
- * sequential (the next is scheduled only after the previous read settles), so a slow
297
- * 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.
298
447
  *
299
448
  * NOTE: a probe's reads dial the founder, through the relay when that is the only route, even
300
449
  * while the founder is unreachable: one run of `joiner-first-commit-over-slow-relay` logged 17
301
450
  * `hop connect denied` at the relay in about 10 s, each a few frames on the joiner's relay
302
- * connection. If that connection ever shows saturated while no strand member is connected, hold
303
- * 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.
304
455
  */
305
456
  export class StrandFirstSyncGate {
306
457
  private readonly scheduler: TimeoutScheduler;
307
- private readonly pollIntervalMs: number;
308
- 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;
309
468
  private stopped = false;
310
469
  private opened = false;
311
- private probing = false;
312
470
 
313
471
  constructor(private readonly deps: StrandFirstSyncGateDeps, config?: StrandFirstSyncConfig) {
314
472
  this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
315
- this.pollIntervalMs = config?.pollIntervalMs ?? DEFAULT_STRAND_FIRST_SYNC_POLL_MS;
473
+ this.backstopMs = config?.backstopMs ?? DEFAULT_STRAND_FIRST_SYNC_BACKSTOP_MS;
316
474
  }
317
475
 
318
476
  /** The database this gate is holding back. */
@@ -320,24 +478,53 @@ export class StrandFirstSyncGate {
320
478
  return this.deps.database;
321
479
  }
322
480
 
323
- /** 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. */
324
482
  get isOpen(): boolean {
325
483
  return this.opened;
326
484
  }
327
485
 
328
- /** 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
+ */
329
509
  start(): void {
330
- if (this.stopped || this.opened || this.timer !== undefined) return;
331
- this.schedule();
332
- log('[%s] first-sync gate armed: no Strand.Header held yet; probing every %dms',
333
- 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);
334
522
  }
335
523
 
336
524
  /**
337
- * Open without the probe loop: the caller is publishing the database itself — a launch
338
- * whose first probe passed (or a founder's), which never armed the loop, or a founder
339
- * bootstrap just run against the gated database. Stops the loop; `onHeaderHeld` is NOT
340
- * 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.
341
528
  */
342
529
  open(): void {
343
530
  if (this.opened) return;
@@ -346,54 +533,67 @@ export class StrandFirstSyncGate {
346
533
  log('[%s] database published by the caller; first-sync gate not needed or force-opened', this.deps.label);
347
534
  }
348
535
 
349
- /** 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. */
350
537
  stop(): void {
351
538
  this.stopped = true;
352
- if (this.timer !== undefined) {
353
- this.scheduler.clearTimeout(this.timer);
354
- this.timer = undefined;
355
- }
356
- }
357
-
358
- private schedule(): void {
359
- this.timer = this.scheduler.setTimeout(() => {
360
- this.timer = undefined;
361
- void this.probe();
362
- }, this.pollIntervalMs);
363
- }
364
-
365
- private async probe(): Promise<void> {
366
- if (this.stopped || this.opened || this.probing) return;
367
- this.probing = true;
368
- const held = await this.probeHeld();
369
- // Re-check after the await: a release or a force-open may have landed mid-read.
370
- if (this.stopped || this.opened) return;
371
- if (!held) {
372
- this.schedule();
373
- return;
374
- }
375
- this.opened = true;
376
- this.stop();
377
- log('[%s] first-sync gate opened: Strand.Header and every App table read from a peer', this.deps.label);
378
- this.deps.onHeaderHeld();
539
+ this.unsubscribeStorage?.();
540
+ this.unsubscribeStorage = undefined;
541
+ this.probes?.dispose();
542
+ this.probes = undefined;
379
543
  }
380
544
 
381
545
  /**
382
- * One probe's answer, with a throw from OUTSIDE {@link strandFirstSyncComplete}'s own read
383
- * guards — the `App` schema lookup, or `getDatabase()` itself — reported as "not yet" like
384
- * any failing read. The loop is scheduled from `void this.probe()`, so an escaping rejection
385
- * would be unhandled AND leave nothing scheduled: the strand would stay gated for the whole
386
- * `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.
387
550
  */
388
- private async probeHeld(): Promise<boolean> {
551
+ async probeOnce(): Promise<boolean> {
552
+ this.landedSinceProbe = false;
389
553
  try {
390
- return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label);
554
+ return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label, this.progress);
391
555
  } catch (error) {
392
556
  log('[%s] first-sync probe threw (treated as not yet synced): %s', this.deps.label,
393
557
  error instanceof Error ? error.message : String(error));
394
558
  return false;
395
- } finally {
396
- this.probing = false;
397
559
  }
398
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
+ }
399
599
  }