@serfab/cadre-core 1.10.0 → 1.11.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 (61) hide show
  1. package/dist/cadre-node.d.ts +0 -27
  2. package/dist/cadre-node.js +2 -94
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-schema.js +1066 -1066
  5. package/dist/diagnostics/connection-path.d.ts +1 -18
  6. package/dist/diagnostics/connection-path.js +1 -20
  7. package/dist/diagnostics/connection-path.js.map +1 -1
  8. package/dist/identity-key.d.ts +0 -30
  9. package/dist/identity-key.js +7 -40
  10. package/dist/identity-key.js.map +1 -1
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.js +6 -4
  13. package/dist/index.js.map +1 -1
  14. package/dist/relay-stun.d.ts +24 -0
  15. package/dist/relay-stun.js +51 -0
  16. package/dist/relay-stun.js.map +1 -0
  17. package/dist/seed-bootstrap.js +4 -4
  18. package/dist/strand-membership-writer.js +32 -32
  19. package/package.json +2 -2
  20. package/src/cadre-node.ts +8654 -8753
  21. package/src/control-authorization.ts +117 -117
  22. package/src/control-cohort.ts +128 -128
  23. package/src/control-formation-recorder.ts +353 -353
  24. package/src/control-read-retry.ts +216 -216
  25. package/src/control-schema.ts +1078 -1078
  26. package/src/control-write-retry.ts +567 -567
  27. package/src/diagnostics/connection-path.ts +1 -29
  28. package/src/formation-approval.ts +610 -610
  29. package/src/hibernation-manager.ts +638 -638
  30. package/src/identity-key.ts +7 -62
  31. package/src/index.ts +644 -647
  32. package/src/invitation-bootstrap.ts +40 -40
  33. package/src/joined-strand-store.ts +498 -498
  34. package/src/link-budget.ts +684 -684
  35. package/src/membership-connection-gater.ts +570 -570
  36. package/src/node-local-snapshot.ts +298 -298
  37. package/src/peer-dial.ts +315 -315
  38. package/src/peer-join-backfill.ts +710 -710
  39. package/src/pending-join-runner.ts +735 -735
  40. package/src/relay-reservation.ts +1121 -1121
  41. package/src/relay-server.ts +98 -98
  42. package/src/relay-stun.ts +58 -0
  43. package/src/seed-bootstrap.ts +1448 -1448
  44. package/src/strand-addr-protocol.ts +547 -547
  45. package/src/strand-database.ts +287 -287
  46. package/src/strand-first-sync-gate.ts +391 -391
  47. package/src/strand-formation-deadlines.ts +227 -227
  48. package/src/strand-formation-manager.ts +725 -725
  49. package/src/strand-fret-addrs.ts +145 -145
  50. package/src/strand-instance-manager.ts +1789 -1789
  51. package/src/strand-membership-writer.ts +1974 -1974
  52. package/src/strand-network-state-file.ts +40 -40
  53. package/src/strand-network-state.ts +243 -243
  54. package/src/strand-revocation-enforcer.ts +654 -654
  55. package/src/strand-solicitation.ts +532 -532
  56. package/src/strand-wake-protocol.ts +401 -401
  57. package/src/types.ts +2222 -2222
  58. package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
  59. package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
  60. package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
  61. package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
@@ -1,391 +1,391 @@
1
- /**
2
- * The first-sync write gate for a JOINING machine.
3
- *
4
- * A machine that has never received anything from another member of a strand must not
5
- * commit to it. With no strand peer connected, Optimystic's cohort for every block is the
6
- * machine itself, so the first write to a table finds no collection locally and INVENTS
7
- * one — exactly what a founder writing alone legitimately does. When the connection comes
8
- * up, two independently created histories share one collection id: Optimystic keeps one and
9
- * drops the other's commits, or never reconciles them at all. The joiner's own rows silently
10
- * vanish (`joining-machine-writes-before-first-sync-fork-tables`). Optimystic cannot tell a
11
- * founder from a joiner; sereus can, because the launch knows whether it is founding.
12
- *
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
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.
24
- *
25
- * "Has synced before" is decided by what the local store actually holds, not by whether this
26
- * machine once read the strand: a machine that left soon after its first sync can hold less
27
- * than it read, and is then gated like a joiner. The re-attach measurement on
28
- * {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS} saw exactly that — a machine back over the store
29
- * it kept, without the `Strand.Header` collection it had read before it left. Gating it is
30
- * right: writing into a collection this machine does not hold would fork it.
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.
39
- *
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.
46
- *
47
- * Owned by `StrandInstanceManager`: created in `buildStrandRuntime` for every launch, armed
48
- * only for a non-founder launch whose first probe fails, stopped and dropped in
49
- * `releaseRuntime` (so a quiesce → resume rebuild re-probes over the same store), and opened
50
- * by `publishDatabase` whenever the database is published without its loop — a launch that
51
- * needed no gate, or a founder request that ran the bootstrap against the still-gated
52
- * database (`foundExistingStrand`).
53
- *
54
- * NOTE: the gate covers app and membership tables, not the schema catalog. Every launch —
55
- * joiner or founder — writes the catalog collection (`optimystic/schema` plus two
56
- * hash-named blocks, measured 2026-09-16) alone, during `connectToStrand`'s schema apply,
57
- * before any peer contact. That is the same "two histories under one id" shape this gate
58
- * exists to prevent, and it is only safe because both sides derive byte-identical content
59
- * from the same schema text. If the catalog ever carries per-machine or ordering-dependent
60
- * content (optimystic's own `0.5-same-named-tables-in-two-schemas-share-storage` re-keys
61
- * it), a joiner's catalog will fork against the founder's — gate the schema apply on the
62
- * Header too, or have optimystic open the catalog read-only when a peer already holds it.
63
- */
64
-
65
- import debug from 'debug';
66
- import type { Database } from '@quereus/quereus';
67
- import type { StrandDatabase } from './strand-database.js';
68
- import { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
69
-
70
- const log = debug('sereus:cadre:strand-first-sync');
71
-
72
- /** How often a gated launch re-probes (`Strand.Header`, then every `App` table) while waiting for its first sync. */
73
- export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
74
-
75
- /**
76
- * How long `CadreNode.addStrand` waits for a machine's first sync before rejecting with
77
- * {@link StrandAwaitingFirstSyncError}. It has to cover the slowest attach sereus carries,
78
- * which is not a first join: a machine RE-attaching through a relay on a slow link can take
79
- * longer than one joining for the first time.
80
- *
81
- * Measured over a DIRECT connection (2026-09-16): the joiner reads the founder's rows about
82
- * 1.3 s after `addStrand` would previously have resolved. That is the fast case, and it is not
83
- * what this budget has to cover.
84
- *
85
- * Every harness figure below was taken on one shape: one Windows developer machine, two
86
- * relay-only `CadreNode`s (`listenAddrs: []`) on a shared loopback dedicated relay, with a
87
- * one-way per-frame outbound delay applied to every websocket by
88
- * `integration-tests/src/harness/ws-latency.ts` in `pipelined` mode — frames stay overlapped
89
- * in flight, so the figure is latency and bandwidth stays unlimited; the harness's other mode
90
- * (`serial`) is a per-socket frame-rate cap and its delays are NOT comparable
91
- * (`docs/testing.md` → "Link latency"). Two delays were used: 900 ms one-way, a round trip
92
- * near 1.8 s, the band sereus supported first; and 1 500 ms one-way, the supported 3-second
93
- * round trip. Times run from the `addStrand` call to writable.
94
- *
95
- * FRESH JOIN at 900 ms (2026-09-26), a machine holding nothing of the strand yet: 23, 27, 31
96
- * and 41 s over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42 and 46 s
97
- * over three runs at a 5000 ms one; the re-attach scenario below later took 38.7, 38.7 and
98
- * 56.9 s for the same fresh join. At that delay the 5000 ms band, 35-57 s, was the one in
99
- * force: sereus then declared 5000 ms, because 1000 ms is shorter than one round trip on
100
- * that link and leaves every cohort read with no answer to corroborate against. Widening that
101
- * deadline makes a consult against a peer that cannot answer cost longer, and this phase runs
102
- * several of those — which is why both bands are recorded here, and why a change to either
103
- * number has to be weighed against the other.
104
- *
105
- * AT THE SUPPORTED LINK ON `@optimystic/*` 1.8.0 (2026-09-29), 1 500 ms one-way, the read
106
- * deadline derived from the declared link at 7 000 ms (`cohortReadDeadlineMs` in
107
- * `link-budget.ts`; the plugin's `COHORT_READ_DEADLINE_MS` is the same number), and cadre
108
- * stating its declared link to Optimystic, the same scenario, all under the coordinator debug
109
- * channel:
110
- * - Fresh join: writable at 63.9 and 63.8 s over two runs, the row A wrote before the join
111
- * readable at 88.0 s in both. B's strand node connected to A's at 18.3-18.4 s. No read was
112
- * declined: every consult on B completed, 6.03-6.05 s after the one before it (two link
113
- * round trips each). The time did not fall, because B's consults ran one after another and
114
- * the gate opened only after the seventh.
115
- * - Re-attach over the store B kept: no longer bimodal. All 3 runs held everything and came
116
- * up writable at launch (9.2 s), and the row A wrote while B was away was readable at
117
- * 35.6 s in each, read with one consult for the collection and its data block together. No
118
- * read was declined during the attach. The empty-store arm was not re-run.
119
- *
120
- * AT THE SUPPORTED LINK ON `@optimystic/*` 1.7.0 (2026-09-29), same delay, deadline and
121
- * scenario, before cadre stated its link to Optimystic:
122
- * - Fresh join: writable at 52.1 and 82.1 s over two runs, and 63.6 s in a third run under
123
- * the coordinator debug channel; a fourth run with the deadline set back to 5 000 ms took
124
- * 70.2 s. B's strand node connected to A's at 18.2-18.3 s in every run at this delay. The
125
- * row A wrote before the join was readable at 67-124 s.
126
- * - Re-attach over the store B kept: bimodal again. 1 of 3 runs held everything and came up
127
- * writable at launch (9.1 s; the row at 33.4 s). The other 2 were gated and writable at
128
- * 75.7 and 78.7 s (the row at 120.8 and 175.0 s). The empty-store arm was not re-run; at
129
- * 900 ms it matched a fresh join.
130
- * - What that band was NOT: a measure of the 7 000 ms deadline. Every cohort consult in those
131
- * runs ended 3.00-3.02 s after the one before it, at 5 000 and 7 000 alike, because
132
- * Optimystic's then-fixed 3 000 ms request dial deadline also bounded the protocol
133
- * negotiation on an already-open connection, and one negotiation is one link round trip —
134
- * 3 s here. So it was the band of a join whose every consult failed and whose blocks
135
- * arrived by peer-join backfill.
136
- *
137
- * RE-ATTACH at 900 ms (2026-09-26, the opt-in
138
- * `integration-tests/src/scenarios/strand-reattach-first-sync-measure.integration.ts` — re-run
139
- * it before changing this number): B attached once on an undelayed link, left with
140
- * `stopStrand`, A wrote while it was away, the delay was raised, and B called `addStrand`
141
- * again.
142
- * - Over an EMPTY store (one minted per launch, cadre's default with no storage provider):
143
- * gated in all four runs and writable at 38.7-56.8 s, the same as a fresh join.
144
- * - Over the store it KEPT (a phone with durable storage), bimodal. In 3 of 8 runs the store
145
- * held everything and the launch came up writable (5.6 s, the launch itself). In the other
146
- * 5 it lacked the `Strand.Header` collection B had read on its first attach, so B was gated
147
- * like a joiner and became writable at 64.1-78.7 s — the slowest attach measured, about
148
- * twice a fresh join. Why syncing over a partial store is slower sits inside optimystic
149
- * (`tickets/blocked/report-reattach-over-partial-replica-to-optimystic`).
150
- * - Neither a redial nor catching up on missed writes explains the time: B's strand node
151
- * connected to A's at 11 s in every arm, fresh join included, and a run with 20 missed
152
- * writes fell inside the run-to-run spread of those with 1.
153
- * This budget covers WRITABLE, not caught up: in the gated kept-store runs the row A wrote
154
- * while B was away became readable only at 140-162 s (121-175 s at the supported link).
155
- *
156
- * Two samples from outside this harness, neither reproduced here and neither discarded: a
157
- * reporter's re-attach of the same shape (optimystic #22; their harness, storage and delay
158
- * injector are unknown to us) opened the gate at about 150 s, after the previous 120 s budget
159
- * had already rejected; and a real Galaxy S7 joining fresh through relay.sereus.org took 178 s.
160
- *
161
- * 300 s clears the worst harness sample (82 s, a fresh join at the supported link on 1.7.0; the
162
- * worst on 1.8.0 is 64 s) by about 3.6x, the reporter's re-attach by 2x and the device join by
163
- * about 1.7x. Kept at 300 s on 2026-09-29, and again after the 1.8.0 re-measure the same day,
164
- * by the rule that re-measure applied and the next one should: keep it while the
165
- * worst harness sample is at most 100 s, which holds a margin of at least 3x; otherwise raise
166
- * it to 3x the worst sample, rounded up to the next whole minute. 240 s was rejected earlier:
167
- * it clears the device join by only 1.35x, and that sample was not a re-attach. History: the
168
- * original 30 s sat INSIDE the 1000 ms fresh band and refused about half of those joins while
169
- * their sync was progressing normally; 120 s was sized from fresh joins only and refused the
170
- * reporter's re-attach.
171
- *
172
- * NOTE: accepted tradeoff — what 300 s costs. This wait is what an app's `addStrand` sits in
173
- * before it is told "not yet", so a strand none of whose members is reachable at all takes five
174
- * minutes to report instead of two. The slow report was weighed against refusing attaches that
175
- * were working, and the refusal is the worse failure. The cost is bounded — the rejection is
176
- * retryable, the strand stays launched and keeps probing, and `strand:writable` fires the
177
- * moment the sync lands, so an app that listens for the event rather than awaiting the call is
178
- * unaffected either way. Revisit if the gate ever learns whether ANY other member is connected:
179
- * "no peer at all" could then be reported at once and this budget would only ever be spent on a
180
- * sync that is actually in progress — worth more the longer this budget gets.
181
- */
182
- // 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
183
- export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 300_000;
184
-
185
- /** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
186
- export interface StrandFirstSyncConfig {
187
- /**
188
- * Default wait for `CadreNode.addStrand` (and `whenStrandWritable`) before a machine whose
189
- * first sync has not completed is reported as not yet writable. Default
190
- * {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}.
191
- */
192
- timeoutMs?: number;
193
- /** Probe cadence while gated. Default {@link DEFAULT_STRAND_FIRST_SYNC_POLL_MS}. */
194
- pollIntervalMs?: number;
195
- }
196
-
197
- /**
198
- * Thrown by `CadreNode.addStrand` / `whenStrandWritable` when a machine has not received the
199
- * strand's data from another member within the wait budget — because no other member is
200
- * reachable, or because one is and the sync is still arriving over a slow link; the gate
201
- * cannot tell the two apart. RETRYABLE: the strand stays launched and keeps probing, so a
202
- * later `addStrand` (or `whenStrandWritable`) for the same strand completes the attach once
203
- * the data has arrived; the `strand:writable` event fires at that moment too.
204
- */
205
- export class StrandAwaitingFirstSyncError extends Error {
206
- readonly strandId: string;
207
- readonly waitedMs: number;
208
-
209
- constructor(strandId: string, waitedMs: number) {
210
- super(
211
- `Strand ${strandId} is not writable yet: this machine has not yet received the strand's data ` +
212
- `from another member (waited ${waitedMs} ms). Either no other member is reachable, or the ` +
213
- 'link to one is slow and the sync is still in progress. The strand stays launched and keeps ' +
214
- 'trying — call addStrand or whenStrandWritable(strandId) again to keep waiting, or listen ' +
215
- "for the 'strand:writable' event."
216
- );
217
- this.name = 'StrandAwaitingFirstSyncError';
218
- this.strandId = strandId;
219
- this.waitedMs = waitedMs;
220
- }
221
- }
222
-
223
- /**
224
- * Whether this machine holds the strand's `Strand.Header` row — the "has synced at least
225
- * once (or founded)" signal. A read, never a write: it cannot invent the collection. A
226
- * throwing read (an unreachable cohort mid-sync, a store still hydrating) reports `false`
227
- * with a log, since the caller's only response to either answer is "probe again".
228
- */
229
- export async function strandHeaderHeld(db: Database, label: string): Promise<boolean> {
230
- try {
231
- for await (const row of db.eval('select count(1) as Count from Strand.Header')) {
232
- return ((row.Count as number) ?? 0) > 0;
233
- }
234
- return false;
235
- } catch (error) {
236
- log('[%s] Header probe failed (treated as not yet held): %s', label,
237
- error instanceof Error ? error.message : String(error));
238
- return false;
239
- }
240
- }
241
-
242
- /** The tables the sApp declared in the `App` schema, by name; views excluded. */
243
- function appTableNames(db: Database): string[] {
244
- const app = db.schemaManager.getSchema('App');
245
- return app ? Array.from(app.getAllTables()).filter((table) => !table.isView).map((table) => table.name) : [];
246
- }
247
-
248
- /**
249
- * Whether a read of every `App` table settles on this machine — the "each app collection
250
- * has been fetched from the cohort, or is authoritatively absent" signal. Only a throwing
251
- * read (an unreachable cohort) reports `false`; an empty table is a settled read.
252
- */
253
- export async function appTablesReadable(db: Database, label: string): Promise<boolean> {
254
- for (const table of appTableNames(db)) {
255
- try {
256
- for await (const _row of db.eval(`select count(1) as Count from App."${table}"`)) {
257
- break;
258
- }
259
- } catch (error) {
260
- log('[%s] App.%s probe failed (treated as not yet synced): %s', label, table,
261
- error instanceof Error ? error.message : String(error));
262
- return false;
263
- }
264
- }
265
- return true;
266
- }
267
-
268
- /**
269
- * The gate's whole probe: the Header is held AND every app table has been read once. The
270
- * app tables are read only once the Header is — a joiner with no peer would otherwise pay
271
- * one failing network read per table per probe for nothing.
272
- */
273
- export async function strandFirstSyncComplete(db: Database, label: string): Promise<boolean> {
274
- return await strandHeaderHeld(db, label) && await appTablesReadable(db, label);
275
- }
276
-
277
- export interface StrandFirstSyncGateDeps {
278
- /** Log tag naming which strand this gate holds (the strand id). */
279
- label: string;
280
- /** The initialized database being held back from the app until the Header is held. */
281
- database: StrandDatabase;
282
- /**
283
- * Called exactly once, on the gate's own probe loop, when the first sync completes.
284
- * NOT called by {@link StrandFirstSyncGate.open} — a caller that force-opens the gate
285
- * (a founder bootstrap it just ran) publishes the database itself.
286
- */
287
- onHeaderHeld: () => void;
288
- /** Timer seam for the probe loop; omit for real (unref'd) timeouts. */
289
- scheduler?: TimeoutScheduler;
290
- }
291
-
292
- /**
293
- * Holds one launch's `StrandDatabase` until {@link strandFirstSyncComplete}. Probes are
294
- * sequential (the next is scheduled only after the previous read settles), so a slow
295
- * network read never stacks probes.
296
- */
297
- export class StrandFirstSyncGate {
298
- private readonly scheduler: TimeoutScheduler;
299
- private readonly pollIntervalMs: number;
300
- private timer: unknown;
301
- private stopped = false;
302
- private opened = false;
303
- private probing = false;
304
-
305
- constructor(private readonly deps: StrandFirstSyncGateDeps, config?: StrandFirstSyncConfig) {
306
- this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
307
- this.pollIntervalMs = config?.pollIntervalMs ?? DEFAULT_STRAND_FIRST_SYNC_POLL_MS;
308
- }
309
-
310
- /** The database this gate is holding back. */
311
- get database(): StrandDatabase {
312
- return this.deps.database;
313
- }
314
-
315
- /** True once the Header was seen (or the gate was force-opened); the loop is then done. */
316
- get isOpen(): boolean {
317
- return this.opened;
318
- }
319
-
320
- /** Arm the probe loop. The first probe runs after one interval — the caller already probed once. */
321
- start(): void {
322
- if (this.stopped || this.opened || this.timer !== undefined) return;
323
- this.schedule();
324
- log('[%s] first-sync gate armed: no Strand.Header held yet; probing every %dms',
325
- this.deps.label, this.pollIntervalMs);
326
- }
327
-
328
- /**
329
- * Open without the probe loop: the caller is publishing the database itself — a launch
330
- * whose first probe passed (or a founder's), which never armed the loop, or a founder
331
- * bootstrap just run against the gated database. Stops the loop; `onHeaderHeld` is NOT
332
- * invoked.
333
- */
334
- open(): void {
335
- if (this.opened) return;
336
- this.opened = true;
337
- this.stop();
338
- log('[%s] database published by the caller; first-sync gate not needed or force-opened', this.deps.label);
339
- }
340
-
341
- /** Disarm the loop; a probe already in flight completes and is then ignored. */
342
- stop(): void {
343
- this.stopped = true;
344
- if (this.timer !== undefined) {
345
- this.scheduler.clearTimeout(this.timer);
346
- this.timer = undefined;
347
- }
348
- }
349
-
350
- private schedule(): void {
351
- this.timer = this.scheduler.setTimeout(() => {
352
- this.timer = undefined;
353
- void this.probe();
354
- }, this.pollIntervalMs);
355
- }
356
-
357
- private async probe(): Promise<void> {
358
- if (this.stopped || this.opened || this.probing) return;
359
- this.probing = true;
360
- const held = await this.probeHeld();
361
- // Re-check after the await: a release or a force-open may have landed mid-read.
362
- if (this.stopped || this.opened) return;
363
- if (!held) {
364
- this.schedule();
365
- return;
366
- }
367
- this.opened = true;
368
- this.stop();
369
- log('[%s] first-sync gate opened: Strand.Header and every App table read from a peer', this.deps.label);
370
- this.deps.onHeaderHeld();
371
- }
372
-
373
- /**
374
- * One probe's answer, with a throw from OUTSIDE {@link strandFirstSyncComplete}'s own read
375
- * guards — the `App` schema lookup, or `getDatabase()` itself — reported as "not yet" like
376
- * any failing read. The loop is scheduled from `void this.probe()`, so an escaping rejection
377
- * would be unhandled AND leave nothing scheduled: the strand would stay gated for the whole
378
- * `timeoutMs` and never recover, which is the one failure this gate must not have.
379
- */
380
- private async probeHeld(): Promise<boolean> {
381
- try {
382
- return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label);
383
- } catch (error) {
384
- log('[%s] first-sync probe threw (treated as not yet synced): %s', this.deps.label,
385
- error instanceof Error ? error.message : String(error));
386
- return false;
387
- } finally {
388
- this.probing = false;
389
- }
390
- }
391
- }
1
+ /**
2
+ * The first-sync write gate for a JOINING machine.
3
+ *
4
+ * A machine that has never received anything from another member of a strand must not
5
+ * commit to it. With no strand peer connected, Optimystic's cohort for every block is the
6
+ * machine itself, so the first write to a table finds no collection locally and INVENTS
7
+ * one — exactly what a founder writing alone legitimately does. When the connection comes
8
+ * up, two independently created histories share one collection id: Optimystic keeps one and
9
+ * drops the other's commits, or never reconciles them at all. The joiner's own rows silently
10
+ * vanish (`joining-machine-writes-before-first-sync-fork-tables`). Optimystic cannot tell a
11
+ * founder from a joiner; sereus can, because the launch knows whether it is founding.
12
+ *
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
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.
24
+ *
25
+ * "Has synced before" is decided by what the local store actually holds, not by whether this
26
+ * machine once read the strand: a machine that left soon after its first sync can hold less
27
+ * than it read, and is then gated like a joiner. The re-attach measurement on
28
+ * {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS} saw exactly that — a machine back over the store
29
+ * it kept, without the `Strand.Header` collection it had read before it left. Gating it is
30
+ * right: writing into a collection this machine does not hold would fork it.
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.
39
+ *
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.
46
+ *
47
+ * Owned by `StrandInstanceManager`: created in `buildStrandRuntime` for every launch, armed
48
+ * only for a non-founder launch whose first probe fails, stopped and dropped in
49
+ * `releaseRuntime` (so a quiesce → resume rebuild re-probes over the same store), and opened
50
+ * by `publishDatabase` whenever the database is published without its loop — a launch that
51
+ * needed no gate, or a founder request that ran the bootstrap against the still-gated
52
+ * database (`foundExistingStrand`).
53
+ *
54
+ * NOTE: the gate covers app and membership tables, not the schema catalog. Every launch —
55
+ * joiner or founder — writes the catalog collection (`optimystic/schema` plus two
56
+ * hash-named blocks, measured 2026-09-16) alone, during `connectToStrand`'s schema apply,
57
+ * before any peer contact. That is the same "two histories under one id" shape this gate
58
+ * exists to prevent, and it is only safe because both sides derive byte-identical content
59
+ * from the same schema text. If the catalog ever carries per-machine or ordering-dependent
60
+ * content (optimystic's own `0.5-same-named-tables-in-two-schemas-share-storage` re-keys
61
+ * it), a joiner's catalog will fork against the founder's — gate the schema apply on the
62
+ * Header too, or have optimystic open the catalog read-only when a peer already holds it.
63
+ */
64
+
65
+ import debug from 'debug';
66
+ import type { Database } from '@quereus/quereus';
67
+ import type { StrandDatabase } from './strand-database.js';
68
+ import { defaultTimeoutScheduler, type TimeoutScheduler } from './timeout-scheduler.js';
69
+
70
+ const log = debug('sereus:cadre:strand-first-sync');
71
+
72
+ /** How often a gated launch re-probes (`Strand.Header`, then every `App` table) while waiting for its first sync. */
73
+ export const DEFAULT_STRAND_FIRST_SYNC_POLL_MS = 500;
74
+
75
+ /**
76
+ * How long `CadreNode.addStrand` waits for a machine's first sync before rejecting with
77
+ * {@link StrandAwaitingFirstSyncError}. It has to cover the slowest attach sereus carries,
78
+ * which is not a first join: a machine RE-attaching through a relay on a slow link can take
79
+ * longer than one joining for the first time.
80
+ *
81
+ * Measured over a DIRECT connection (2026-09-16): the joiner reads the founder's rows about
82
+ * 1.3 s after `addStrand` would previously have resolved. That is the fast case, and it is not
83
+ * what this budget has to cover.
84
+ *
85
+ * Every harness figure below was taken on one shape: one Windows developer machine, two
86
+ * relay-only `CadreNode`s (`listenAddrs: []`) on a shared loopback dedicated relay, with a
87
+ * one-way per-frame outbound delay applied to every websocket by
88
+ * `integration-tests/src/harness/ws-latency.ts` in `pipelined` mode — frames stay overlapped
89
+ * in flight, so the figure is latency and bandwidth stays unlimited; the harness's other mode
90
+ * (`serial`) is a per-socket frame-rate cap and its delays are NOT comparable
91
+ * (`docs/testing.md` → "Link latency"). Two delays were used: 900 ms one-way, a round trip
92
+ * near 1.8 s, the band sereus supported first; and 1 500 ms one-way, the supported 3-second
93
+ * round trip. Times run from the `addStrand` call to writable.
94
+ *
95
+ * FRESH JOIN at 900 ms (2026-09-26), a machine holding nothing of the strand yet: 23, 27, 31
96
+ * and 41 s over four runs at optimystic's 1000 ms cohort read deadline, and 35, 42 and 46 s
97
+ * over three runs at a 5000 ms one; the re-attach scenario below later took 38.7, 38.7 and
98
+ * 56.9 s for the same fresh join. At that delay the 5000 ms band, 35-57 s, was the one in
99
+ * force: sereus then declared 5000 ms, because 1000 ms is shorter than one round trip on
100
+ * that link and leaves every cohort read with no answer to corroborate against. Widening that
101
+ * deadline makes a consult against a peer that cannot answer cost longer, and this phase runs
102
+ * several of those — which is why both bands are recorded here, and why a change to either
103
+ * number has to be weighed against the other.
104
+ *
105
+ * AT THE SUPPORTED LINK ON `@optimystic/*` 1.8.0 (2026-09-29), 1 500 ms one-way, the read
106
+ * deadline derived from the declared link at 7 000 ms (`cohortReadDeadlineMs` in
107
+ * `link-budget.ts`; the plugin's `COHORT_READ_DEADLINE_MS` is the same number), and cadre
108
+ * stating its declared link to Optimystic, the same scenario, all under the coordinator debug
109
+ * channel:
110
+ * - Fresh join: writable at 63.9 and 63.8 s over two runs, the row A wrote before the join
111
+ * readable at 88.0 s in both. B's strand node connected to A's at 18.3-18.4 s. No read was
112
+ * declined: every consult on B completed, 6.03-6.05 s after the one before it (two link
113
+ * round trips each). The time did not fall, because B's consults ran one after another and
114
+ * the gate opened only after the seventh.
115
+ * - Re-attach over the store B kept: no longer bimodal. All 3 runs held everything and came
116
+ * up writable at launch (9.2 s), and the row A wrote while B was away was readable at
117
+ * 35.6 s in each, read with one consult for the collection and its data block together. No
118
+ * read was declined during the attach. The empty-store arm was not re-run.
119
+ *
120
+ * AT THE SUPPORTED LINK ON `@optimystic/*` 1.7.0 (2026-09-29), same delay, deadline and
121
+ * scenario, before cadre stated its link to Optimystic:
122
+ * - Fresh join: writable at 52.1 and 82.1 s over two runs, and 63.6 s in a third run under
123
+ * the coordinator debug channel; a fourth run with the deadline set back to 5 000 ms took
124
+ * 70.2 s. B's strand node connected to A's at 18.2-18.3 s in every run at this delay. The
125
+ * row A wrote before the join was readable at 67-124 s.
126
+ * - Re-attach over the store B kept: bimodal again. 1 of 3 runs held everything and came up
127
+ * writable at launch (9.1 s; the row at 33.4 s). The other 2 were gated and writable at
128
+ * 75.7 and 78.7 s (the row at 120.8 and 175.0 s). The empty-store arm was not re-run; at
129
+ * 900 ms it matched a fresh join.
130
+ * - What that band was NOT: a measure of the 7 000 ms deadline. Every cohort consult in those
131
+ * runs ended 3.00-3.02 s after the one before it, at 5 000 and 7 000 alike, because
132
+ * Optimystic's then-fixed 3 000 ms request dial deadline also bounded the protocol
133
+ * negotiation on an already-open connection, and one negotiation is one link round trip —
134
+ * 3 s here. So it was the band of a join whose every consult failed and whose blocks
135
+ * arrived by peer-join backfill.
136
+ *
137
+ * RE-ATTACH at 900 ms (2026-09-26, the opt-in
138
+ * `integration-tests/src/scenarios/strand-reattach-first-sync-measure.integration.ts` — re-run
139
+ * it before changing this number): B attached once on an undelayed link, left with
140
+ * `stopStrand`, A wrote while it was away, the delay was raised, and B called `addStrand`
141
+ * again.
142
+ * - Over an EMPTY store (one minted per launch, cadre's default with no storage provider):
143
+ * gated in all four runs and writable at 38.7-56.8 s, the same as a fresh join.
144
+ * - Over the store it KEPT (a phone with durable storage), bimodal. In 3 of 8 runs the store
145
+ * held everything and the launch came up writable (5.6 s, the launch itself). In the other
146
+ * 5 it lacked the `Strand.Header` collection B had read on its first attach, so B was gated
147
+ * like a joiner and became writable at 64.1-78.7 s — the slowest attach measured, about
148
+ * twice a fresh join. Why syncing over a partial store is slower sits inside optimystic
149
+ * (`tickets/blocked/report-reattach-over-partial-replica-to-optimystic`).
150
+ * - Neither a redial nor catching up on missed writes explains the time: B's strand node
151
+ * connected to A's at 11 s in every arm, fresh join included, and a run with 20 missed
152
+ * writes fell inside the run-to-run spread of those with 1.
153
+ * This budget covers WRITABLE, not caught up: in the gated kept-store runs the row A wrote
154
+ * while B was away became readable only at 140-162 s (121-175 s at the supported link).
155
+ *
156
+ * Two samples from outside this harness, neither reproduced here and neither discarded: a
157
+ * reporter's re-attach of the same shape (optimystic #22; their harness, storage and delay
158
+ * injector are unknown to us) opened the gate at about 150 s, after the previous 120 s budget
159
+ * had already rejected; and a real Galaxy S7 joining fresh through relay.sereus.org took 178 s.
160
+ *
161
+ * 300 s clears the worst harness sample (82 s, a fresh join at the supported link on 1.7.0; the
162
+ * worst on 1.8.0 is 64 s) by about 3.6x, the reporter's re-attach by 2x and the device join by
163
+ * about 1.7x. Kept at 300 s on 2026-09-29, and again after the 1.8.0 re-measure the same day,
164
+ * by the rule that re-measure applied and the next one should: keep it while the
165
+ * worst harness sample is at most 100 s, which holds a margin of at least 3x; otherwise raise
166
+ * it to 3x the worst sample, rounded up to the next whole minute. 240 s was rejected earlier:
167
+ * it clears the device join by only 1.35x, and that sample was not a re-attach. History: the
168
+ * original 30 s sat INSIDE the 1000 ms fresh band and refused about half of those joins while
169
+ * their sync was progressing normally; 120 s was sized from fresh joins only and refused the
170
+ * reporter's re-attach.
171
+ *
172
+ * NOTE: accepted tradeoff — what 300 s costs. This wait is what an app's `addStrand` sits in
173
+ * before it is told "not yet", so a strand none of whose members is reachable at all takes five
174
+ * minutes to report instead of two. The slow report was weighed against refusing attaches that
175
+ * were working, and the refusal is the worse failure. The cost is bounded — the rejection is
176
+ * retryable, the strand stays launched and keeps probing, and `strand:writable` fires the
177
+ * moment the sync lands, so an app that listens for the event rather than awaiting the call is
178
+ * unaffected either way. Revisit if the gate ever learns whether ANY other member is connected:
179
+ * "no peer at all" could then be reported at once and this budget would only ever be spent on a
180
+ * sync that is actually in progress — worth more the longer this budget gets.
181
+ */
182
+ // 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
183
+ export const DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS = 300_000;
184
+
185
+ /** Embedder-facing tuning for the gate, threaded from `CadreNodeConfig.strandFirstSync`. */
186
+ export interface StrandFirstSyncConfig {
187
+ /**
188
+ * Default wait for `CadreNode.addStrand` (and `whenStrandWritable`) before a machine whose
189
+ * first sync has not completed is reported as not yet writable. Default
190
+ * {@link DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS}.
191
+ */
192
+ timeoutMs?: number;
193
+ /** Probe cadence while gated. Default {@link DEFAULT_STRAND_FIRST_SYNC_POLL_MS}. */
194
+ pollIntervalMs?: number;
195
+ }
196
+
197
+ /**
198
+ * Thrown by `CadreNode.addStrand` / `whenStrandWritable` when a machine has not received the
199
+ * strand's data from another member within the wait budget — because no other member is
200
+ * reachable, or because one is and the sync is still arriving over a slow link; the gate
201
+ * cannot tell the two apart. RETRYABLE: the strand stays launched and keeps probing, so a
202
+ * later `addStrand` (or `whenStrandWritable`) for the same strand completes the attach once
203
+ * the data has arrived; the `strand:writable` event fires at that moment too.
204
+ */
205
+ export class StrandAwaitingFirstSyncError extends Error {
206
+ readonly strandId: string;
207
+ readonly waitedMs: number;
208
+
209
+ constructor(strandId: string, waitedMs: number) {
210
+ super(
211
+ `Strand ${strandId} is not writable yet: this machine has not yet received the strand's data ` +
212
+ `from another member (waited ${waitedMs} ms). Either no other member is reachable, or the ` +
213
+ 'link to one is slow and the sync is still in progress. The strand stays launched and keeps ' +
214
+ 'trying — call addStrand or whenStrandWritable(strandId) again to keep waiting, or listen ' +
215
+ "for the 'strand:writable' event."
216
+ );
217
+ this.name = 'StrandAwaitingFirstSyncError';
218
+ this.strandId = strandId;
219
+ this.waitedMs = waitedMs;
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Whether this machine holds the strand's `Strand.Header` row — the "has synced at least
225
+ * once (or founded)" signal. A read, never a write: it cannot invent the collection. A
226
+ * throwing read (an unreachable cohort mid-sync, a store still hydrating) reports `false`
227
+ * with a log, since the caller's only response to either answer is "probe again".
228
+ */
229
+ export async function strandHeaderHeld(db: Database, label: string): Promise<boolean> {
230
+ try {
231
+ for await (const row of db.eval('select count(1) as Count from Strand.Header')) {
232
+ return ((row.Count as number) ?? 0) > 0;
233
+ }
234
+ return false;
235
+ } catch (error) {
236
+ log('[%s] Header probe failed (treated as not yet held): %s', label,
237
+ error instanceof Error ? error.message : String(error));
238
+ return false;
239
+ }
240
+ }
241
+
242
+ /** The tables the sApp declared in the `App` schema, by name; views excluded. */
243
+ function appTableNames(db: Database): string[] {
244
+ const app = db.schemaManager.getSchema('App');
245
+ return app ? Array.from(app.getAllTables()).filter((table) => !table.isView).map((table) => table.name) : [];
246
+ }
247
+
248
+ /**
249
+ * Whether a read of every `App` table settles on this machine — the "each app collection
250
+ * has been fetched from the cohort, or is authoritatively absent" signal. Only a throwing
251
+ * read (an unreachable cohort) reports `false`; an empty table is a settled read.
252
+ */
253
+ export async function appTablesReadable(db: Database, label: string): Promise<boolean> {
254
+ for (const table of appTableNames(db)) {
255
+ try {
256
+ for await (const _row of db.eval(`select count(1) as Count from App."${table}"`)) {
257
+ break;
258
+ }
259
+ } catch (error) {
260
+ log('[%s] App.%s probe failed (treated as not yet synced): %s', label, table,
261
+ error instanceof Error ? error.message : String(error));
262
+ return false;
263
+ }
264
+ }
265
+ return true;
266
+ }
267
+
268
+ /**
269
+ * The gate's whole probe: the Header is held AND every app table has been read once. The
270
+ * app tables are read only once the Header is — a joiner with no peer would otherwise pay
271
+ * one failing network read per table per probe for nothing.
272
+ */
273
+ export async function strandFirstSyncComplete(db: Database, label: string): Promise<boolean> {
274
+ return await strandHeaderHeld(db, label) && await appTablesReadable(db, label);
275
+ }
276
+
277
+ export interface StrandFirstSyncGateDeps {
278
+ /** Log tag naming which strand this gate holds (the strand id). */
279
+ label: string;
280
+ /** The initialized database being held back from the app until the Header is held. */
281
+ database: StrandDatabase;
282
+ /**
283
+ * Called exactly once, on the gate's own probe loop, when the first sync completes.
284
+ * NOT called by {@link StrandFirstSyncGate.open} — a caller that force-opens the gate
285
+ * (a founder bootstrap it just ran) publishes the database itself.
286
+ */
287
+ onHeaderHeld: () => void;
288
+ /** Timer seam for the probe loop; omit for real (unref'd) timeouts. */
289
+ scheduler?: TimeoutScheduler;
290
+ }
291
+
292
+ /**
293
+ * Holds one launch's `StrandDatabase` until {@link strandFirstSyncComplete}. Probes are
294
+ * sequential (the next is scheduled only after the previous read settles), so a slow
295
+ * network read never stacks probes.
296
+ */
297
+ export class StrandFirstSyncGate {
298
+ private readonly scheduler: TimeoutScheduler;
299
+ private readonly pollIntervalMs: number;
300
+ private timer: unknown;
301
+ private stopped = false;
302
+ private opened = false;
303
+ private probing = false;
304
+
305
+ constructor(private readonly deps: StrandFirstSyncGateDeps, config?: StrandFirstSyncConfig) {
306
+ this.scheduler = deps.scheduler ?? defaultTimeoutScheduler;
307
+ this.pollIntervalMs = config?.pollIntervalMs ?? DEFAULT_STRAND_FIRST_SYNC_POLL_MS;
308
+ }
309
+
310
+ /** The database this gate is holding back. */
311
+ get database(): StrandDatabase {
312
+ return this.deps.database;
313
+ }
314
+
315
+ /** True once the Header was seen (or the gate was force-opened); the loop is then done. */
316
+ get isOpen(): boolean {
317
+ return this.opened;
318
+ }
319
+
320
+ /** Arm the probe loop. The first probe runs after one interval — the caller already probed once. */
321
+ start(): void {
322
+ if (this.stopped || this.opened || this.timer !== undefined) return;
323
+ this.schedule();
324
+ log('[%s] first-sync gate armed: no Strand.Header held yet; probing every %dms',
325
+ this.deps.label, this.pollIntervalMs);
326
+ }
327
+
328
+ /**
329
+ * Open without the probe loop: the caller is publishing the database itself — a launch
330
+ * whose first probe passed (or a founder's), which never armed the loop, or a founder
331
+ * bootstrap just run against the gated database. Stops the loop; `onHeaderHeld` is NOT
332
+ * invoked.
333
+ */
334
+ open(): void {
335
+ if (this.opened) return;
336
+ this.opened = true;
337
+ this.stop();
338
+ log('[%s] database published by the caller; first-sync gate not needed or force-opened', this.deps.label);
339
+ }
340
+
341
+ /** Disarm the loop; a probe already in flight completes and is then ignored. */
342
+ stop(): void {
343
+ this.stopped = true;
344
+ if (this.timer !== undefined) {
345
+ this.scheduler.clearTimeout(this.timer);
346
+ this.timer = undefined;
347
+ }
348
+ }
349
+
350
+ private schedule(): void {
351
+ this.timer = this.scheduler.setTimeout(() => {
352
+ this.timer = undefined;
353
+ void this.probe();
354
+ }, this.pollIntervalMs);
355
+ }
356
+
357
+ private async probe(): Promise<void> {
358
+ if (this.stopped || this.opened || this.probing) return;
359
+ this.probing = true;
360
+ const held = await this.probeHeld();
361
+ // Re-check after the await: a release or a force-open may have landed mid-read.
362
+ if (this.stopped || this.opened) return;
363
+ if (!held) {
364
+ this.schedule();
365
+ return;
366
+ }
367
+ this.opened = true;
368
+ this.stop();
369
+ log('[%s] first-sync gate opened: Strand.Header and every App table read from a peer', this.deps.label);
370
+ this.deps.onHeaderHeld();
371
+ }
372
+
373
+ /**
374
+ * One probe's answer, with a throw from OUTSIDE {@link strandFirstSyncComplete}'s own read
375
+ * guards — the `App` schema lookup, or `getDatabase()` itself — reported as "not yet" like
376
+ * any failing read. The loop is scheduled from `void this.probe()`, so an escaping rejection
377
+ * would be unhandled AND leave nothing scheduled: the strand would stay gated for the whole
378
+ * `timeoutMs` and never recover, which is the one failure this gate must not have.
379
+ */
380
+ private async probeHeld(): Promise<boolean> {
381
+ try {
382
+ return await strandFirstSyncComplete(this.deps.database.getDatabase(), this.deps.label);
383
+ } catch (error) {
384
+ log('[%s] first-sync probe threw (treated as not yet synced): %s', this.deps.label,
385
+ error instanceof Error ? error.message : String(error));
386
+ return false;
387
+ } finally {
388
+ this.probing = false;
389
+ }
390
+ }
391
+ }