@serfab/quereus-plugin-sereus 1.4.0 → 1.6.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@serfab/quereus-plugin-sereus",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "type": "module",
5
5
  "description": "Quereus plugin that connects to a Sereus strand for SQL access",
6
6
  "main": "dist/index.js",
@@ -67,13 +67,13 @@
67
67
  "@libp2p/circuit-relay-v2": "^4.1.3",
68
68
  "@libp2p/interface": "^3.1.0",
69
69
  "@libp2p/websockets": "^10.1.3",
70
- "@optimystic/db-core": "^1.5.0",
71
- "@optimystic/db-p2p": "^1.5.0",
72
- "@optimystic/db-p2p-storage-fs": "^1.5.0",
73
- "@optimystic/db-p2p-storage-web": "^1.5.0",
74
- "@optimystic/quereus-plugin-crypto": "^1.5.0",
75
- "@optimystic/quereus-plugin-optimystic": "^1.5.0",
76
- "@quereus/quereus": "^4.19.4",
70
+ "@optimystic/db-core": "^1.7.0",
71
+ "@optimystic/db-p2p": "^1.7.0",
72
+ "@optimystic/db-p2p-storage-fs": "^1.7.0",
73
+ "@optimystic/db-p2p-storage-web": "^1.7.0",
74
+ "@optimystic/quereus-plugin-crypto": "^1.7.0",
75
+ "@optimystic/quereus-plugin-optimystic": "^1.7.0",
76
+ "@quereus/quereus": "^4.20.0",
77
77
  "debug": "^4.4.3"
78
78
  },
79
79
  "quereus": {
@@ -7,6 +7,83 @@ import type { NodeOptions } from '@optimystic/db-p2p';
7
7
  */
8
8
  export const MIN_CLUSTER_SIZE = 2;
9
9
 
10
+ /**
11
+ * How long ONE cohort peer gets to answer ONE read-path request, in milliseconds — both
12
+ * networks' `clusterPolicy.cohortQueryTimeoutMs`. It sets the latest-revision query a read
13
+ * runs when its local copy may be stale (`CoordinatorRepo.queryClusterForLatest`) and the
14
+ * archive fetch that follows it (`fetchArchiveFromPeer`); Optimystic ties the two to one
15
+ * field because they are the same round trip to the same peer. Optimystic's own default is
16
+ * 1000 ms, a LAN-shaped budget, and it deliberately left that default alone — choosing the
17
+ * value for a deployment of phones is the embedder's job.
18
+ *
19
+ * **Why 5000.** Optimystic GitHub #22, reported by a sereus-based chat app: two parties that
20
+ * reach each other only through a public circuit relay have a round trip near 1.8 s, and a
21
+ * fresh stream (dial or reuse, protocol select, send, receive) cannot finish inside a second.
22
+ * At a cohort of two there is exactly one peer to ask, so one late answer leaves the read with
23
+ * nothing to corroborate against and it is declined. 5000 covers 1.8 s with room for the Noise
24
+ * handshake, the relay hops and a phone's pure-JS crypto (cadre-core's
25
+ * `NetworkConfig.noiseCrypto`), and it is the value Optimystic's own note advises for that
26
+ * link. The whole-pass reconcile bound Optimystic derives from it (`max(5000, 5 x this)`)
27
+ * becomes 25 s.
28
+ *
29
+ * **Measured** 2026-09-26, one Windows developer machine, two relay-only `CadreNode`s on one
30
+ * shared loopback dedicated relay, the one-way outbound frame delay raised to 900 ms
31
+ * (`pipelined` mode — see `docs/testing.md` → "Where measurements live") AFTER the strand had
32
+ * formed and first synced, for a round trip near 1.8 s. A machine that had joined, gone away
33
+ * while the other wrote, then re-attached over its own stale store and read the row written
34
+ * while it was away. Declined reads over that journey, after the delay was raised: **18 at
35
+ * Optimystic's 1000 ms, 2 at 5000 ms.** A declined read is
36
+ * `cluster-fetch:peers-silent { silent: 1, consulted: 2 }` followed by
37
+ * `cluster-fetch:no-quorum`, retried about a second later until one lands; at 5000 the
38
+ * corroboration succeeded instead (`cluster-fetch:local-current`). Both journeys COMPLETED at
39
+ * either value, which is why no committed scenario gates this — 900 ms is not where it breaks,
40
+ * and above it a run breaks on redialling instead. The recipe for re-measuring is in
41
+ * `docs/testing.md` → "Where measurements live".
42
+ *
43
+ * **What it costs**, in the terms Optimystic's own field doc uses: a peer that is truly gone
44
+ * now holds a read of a block missing locally for up to 5 s before the read is declined
45
+ * instead of 1 s. A joining machine's first sync runs several such consults, so it is the cost
46
+ * that binds — and the first-sync band measured at BOTH deadlines, against the budget it has
47
+ * to fit, is recorded once, on `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS` in cadre-core's
48
+ * `strand-first-sync-gate.ts`. At 5000 against the 30 s budget that preceded it, this change
49
+ * would have turned a slow-but-working join into the very `StrandAwaitingFirstSyncError` the
50
+ * report named. 3000 is the fallback if that margin ever goes — still 1.7x the reported round
51
+ * trip, and a doomed consult costs 40% less.
52
+ *
53
+ * **What else it lengthens, which the first-sync budget is only the largest case of.** Every
54
+ * cadre-core deadline that wraps an Optimystic read or commit was sized when one silent peer
55
+ * cost 1 s and a whole reconcile pass cost 5 s; those are now 5 s and 25 s. Two consequences
56
+ * are already true rather than hypothetical:
57
+ *
58
+ * - The inbound membership admission gate reads the control database uncached and FAILS OPEN
59
+ * after 2 s (`ADMISSION_DECISION_TIMEOUT_MS`, cadre-core's
60
+ * `membership-connection-gater.ts`), so a decision whose read consults a silent peer can no
61
+ * longer settle inside that deadline at all: an unplaced peer is admitted at the connection
62
+ * layer for the whole 5 s instead of roughly 1 s. It is admitted to nothing more than a
63
+ * connection — the per-protocol stream gates still decide, and unplaced relay reservations
64
+ * stay capped (`MAX_UNAUTHORIZED_RELAY_RESERVATIONS`) — which is why this is a widened cost
65
+ * and not a hole. `backlog/debt-cadre-deadlines-sized-against-old-optimystic-bounds` owns
66
+ * the audit of the rest.
67
+ * - `CONTROL_READ_RETRY_BUDGET_MS` (1500 ms) now terminates the control-read retry loop after
68
+ * the FIRST attempt whenever that attempt burned this deadline, so a read failing that way
69
+ * gets no second presentation. Its own doc carries the coupling.
70
+ *
71
+ * **One value for both networks, not two.** The reason to widen is the link, and a phone's
72
+ * control node and its strand nodes share it; nothing about control traffic or strand traffic
73
+ * argues for different numbers. A host that needs another value moves both at once —
74
+ * cadre-core's `NetworkConfig.cohortQueryTimeoutMs`, threaded to {@link controlClusterPolicy}
75
+ * and {@link strandClusterPolicy}.
76
+ *
77
+ * NOTE: this number and the first-sync budget are coupled; the margin between that budget and
78
+ * the slowest first sync measured is stated on it. Re-measure the first-sync band before raising
79
+ * this again, or when a deployment's first sync grows more collections than the two-table
80
+ * scenario measured above — `DEFAULT_STRAND_FIRST_SYNC_TIMEOUT_MS` carries the result and
81
+ * `docs/testing.md` the recipe.
82
+ * Nothing warns when the margin goes; the symptom is `StrandAwaitingFirstSyncError` on a join
83
+ * that was progressing normally, which is the failure this pair of numbers exists to end.
84
+ */
85
+ export const COHORT_READ_DEADLINE_MS = 5000;
86
+
10
87
  /**
11
88
  * How many nodes each block of the **control** (cadre membership) database is replicated
12
89
  * to. Chosen to exceed any party's node count, so in practice every control block lands
@@ -113,6 +190,10 @@ export const CONTROL_REPLICATION_BREADTH = 16;
113
190
  * whether this node believes a repair answer), which is exactly why the two are now separate
114
191
  * numbers rather than one.
115
192
  *
193
+ * `cohortQueryTimeoutMs` is declared for the same reason it is declared on the strand policy —
194
+ * the link, not the network ({@link COHORT_READ_DEADLINE_MS}). Absent it, Optimystic's LAN-shaped
195
+ * 1000 ms would leave a control read over a relayed link with no corroborating answer in time.
196
+ *
116
197
  * The `satisfies` is load-bearing: without it a mistyped or obsolete key would compile at both
117
198
  * this definition and every consumer, because TypeScript only excess-property-checks fresh
118
199
  * object literals, not a shared constant handed to `clusterPolicy`.
@@ -121,6 +202,7 @@ export const CONTROL_CLUSTER_POLICY = Object.freeze({
121
202
  allowDownsize: true,
122
203
  sizeTolerance: 0.5,
123
204
  assumedClusterSize: 2,
205
+ cohortQueryTimeoutMs: COHORT_READ_DEADLINE_MS,
124
206
  } satisfies NonNullable<NodeOptions['clusterPolicy']>);
125
207
 
126
208
  /**
@@ -263,12 +345,18 @@ export const DEFAULT_STRAND_CLUSTER_SIZE = 4;
263
345
  * promise that `ceil(0.75 x N)` of its machines are awake, and the admission gate would refuse
264
346
  * its writes. Same reason and same warning as {@link CONTROL_CLUSTER_POLICY}'s.
265
347
  *
348
+ * `cohortQueryTimeoutMs` is {@link COHORT_READ_DEADLINE_MS}, the same value the control policy
349
+ * declares. That is the one field the two networks deliberately agree on by argument rather than
350
+ * by coincidence: it is a property of the LINK, and a phone's control node and its strand nodes
351
+ * ride the same one.
352
+ *
266
353
  * The `satisfies` is load-bearing for the same reason as {@link CONTROL_CLUSTER_POLICY}'s.
267
354
  */
268
355
  export const STRAND_CLUSTER_POLICY = Object.freeze({
269
356
  allowDownsize: true,
270
357
  sizeTolerance: 0.5,
271
358
  assumedClusterSize: MIN_CLUSTER_SIZE,
359
+ cohortQueryTimeoutMs: COHORT_READ_DEADLINE_MS,
272
360
  } satisfies NonNullable<NodeOptions['clusterPolicy']>);
273
361
 
274
362
  /**
@@ -410,49 +498,101 @@ function asKnownMachineCount(servingMachines?: number): number | undefined {
410
498
  }
411
499
 
412
500
  /**
413
- * {@link CONTROL_CLUSTER_POLICY} with the repair yardstick declared from `enrolledMachines`
414
- * (see {@link resolveRepairYardstick}); the frozen base object ITSELF when the count is unknown,
415
- * so the cold path is provably today's behaviour and identity assertions keep holding.
416
- *
417
- * The parameter keeps the name `enrolledMachines`, unlike {@link strandClusterPolicy}'s, because
418
- * for the CONTROL network the two quantities are one and the same by construction: every
419
- * enrolled machine runs the control node, so the machines enrolled in the party ARE the machines
420
- * serving this network.
501
+ * The per-network declarations `controlClusterPolicy` and `strandClusterPolicy` accept, reduced to
502
+ * the two things they have in common. Named rather than positional at the public builders because
503
+ * every one of these is a `number` and they mean unrelated things: a machine COUNT and a
504
+ * millisecond DURATION transposed would compile, and would put a 5 ms read deadline or a yardstick
505
+ * of 5000 on a real node. A third number is already queued for these builders
506
+ * (`backlog/feat-strand-yardstick-from-serving-machines`).
421
507
  */
422
- export function controlClusterPolicy(enrolledMachines?: number): NonNullable<NodeOptions['clusterPolicy']> {
423
- const known = asKnownMachineCount(enrolledMachines);
424
- if (known === undefined) {
425
- return CONTROL_CLUSTER_POLICY;
508
+ interface PolicyDeclarations {
509
+ /** The frozen base policy to declare onto. */
510
+ base: NonNullable<NodeOptions['clusterPolicy']>;
511
+ /** Ceiling on the repair yardstick — a block never lives on more machines than the cohort is wide. */
512
+ yardstickCap: number;
513
+ /** Machines serving this network; a degenerate value is treated as absent. */
514
+ machines: number | undefined;
515
+ /** The host's per-peer read deadline in place of {@link COHORT_READ_DEADLINE_MS}. */
516
+ cohortQueryTimeoutMs: number | undefined;
517
+ }
518
+
519
+ /**
520
+ * The base policy with whatever was declared onto it — or the base object ITSELF, by identity,
521
+ * when nothing was, which is the production path for both networks today and so is provably
522
+ * byte-for-byte the old behaviour.
523
+ *
524
+ * The two spreads are independent, so neither declaration can shadow the other: they arrive from
525
+ * unrelated sources (a node-local machine record, the host's network config) and either may be
526
+ * present alone.
527
+ *
528
+ * `cohortQueryTimeoutMs` is passed through UNVALIDATED, by design. Optimystic already refuses a
529
+ * value that is not a finite number above zero, or is above its `MAX_COHORT_QUERY_TIMEOUT_MS`, and
530
+ * it THROWS where the libp2p node is constructed rather than falling back to its own default — so a
531
+ * second check here would only duplicate a message the host already gets, at a site that cannot
532
+ * name the node being built.
533
+ */
534
+ function declarePolicy(
535
+ { base, yardstickCap, machines, cohortQueryTimeoutMs }: PolicyDeclarations
536
+ ): NonNullable<NodeOptions['clusterPolicy']> {
537
+ const known = asKnownMachineCount(machines);
538
+ if (known === undefined && cohortQueryTimeoutMs === undefined) {
539
+ return base;
426
540
  }
427
541
  return Object.freeze({
428
- ...CONTROL_CLUSTER_POLICY,
429
- repairCorroborationClusterSize: resolveRepairYardstick(known, CONTROL_REPLICATION_BREADTH)
542
+ ...base,
543
+ ...(known !== undefined && {
544
+ repairCorroborationClusterSize: resolveRepairYardstick(known, yardstickCap)
545
+ }),
546
+ ...(cohortQueryTimeoutMs !== undefined && { cohortQueryTimeoutMs })
430
547
  } satisfies NonNullable<NodeOptions['clusterPolicy']>);
431
548
  }
432
549
 
433
550
  /**
434
- * {@link STRAND_CLUSTER_POLICY} with the repair yardstick declared from `servingMachines` — the
435
- * machines that serve THIS strand — and this strand's own `clusterSize` (already resolved by
436
- * {@link resolveStrandClusterSize}, so it is at least {@link MIN_CLUSTER_SIZE} and the formula's
437
- * two clamps can never cross); the frozen base object ITSELF when the count is unknown.
438
- *
439
- * **The unknown path is the production path today.** No authenticated per-strand serving count
551
+ * {@link CONTROL_CLUSTER_POLICY} with the block-repair corroboration yardstick declared from
552
+ * `enrolledMachines` (see {@link resolveRepairYardstick}) and the per-peer read deadline replaced
553
+ * by the host's `cohortQueryTimeoutMs`; the frozen base object ITSELF when neither is declared, so
554
+ * identity assertions keep holding — including for the host that passes a `network` block with
555
+ * neither field in it, whose declarations arrive present-but-`undefined`.
556
+ *
557
+ * The count is named `enrolledMachines` here, unlike {@link strandClusterPolicy}'s, because for the
558
+ * CONTROL network the two quantities are one and the same by construction: every enrolled machine
559
+ * runs the control node, so the machines enrolled in the party ARE the machines serving this
560
+ * network.
561
+ */
562
+ export function controlClusterPolicy(
563
+ declared: { enrolledMachines?: number; cohortQueryTimeoutMs?: number } = {}
564
+ ): NonNullable<NodeOptions['clusterPolicy']> {
565
+ return declarePolicy({
566
+ base: CONTROL_CLUSTER_POLICY,
567
+ yardstickCap: CONTROL_REPLICATION_BREADTH,
568
+ machines: declared.enrolledMachines,
569
+ cohortQueryTimeoutMs: declared.cohortQueryTimeoutMs
570
+ });
571
+ }
572
+
573
+ /**
574
+ * {@link STRAND_CLUSTER_POLICY} with the block-repair corroboration yardstick declared from
575
+ * `servingMachines` — the machines that serve THIS strand — capped at this strand's own
576
+ * `clusterSize` (already resolved by {@link resolveStrandClusterSize}, so it is at least
577
+ * {@link MIN_CLUSTER_SIZE} and the formula's two clamps can never cross), and the per-peer read
578
+ * deadline replaced by the host's `cohortQueryTimeoutMs`; the frozen base object ITSELF when
579
+ * neither is declared.
580
+ *
581
+ * **The no-count path is the production path today.** No authenticated per-strand serving count
440
582
  * exists, and the party's enrolled-machine count is emphatically not one (see
441
- * {@link resolveRepairYardstick}'s contract section), so cadre-core passes nothing and every
442
- * strand node runs the frozen constant. The parameter and its threading through
443
- * `StartStrandConfig.servingMachines` stay in place as the seam that
583
+ * {@link resolveRepairYardstick}'s contract section), so cadre-core declares no count and every
584
+ * strand node of a host that declared no deadline runs the frozen constant. The field and its
585
+ * threading through `StartStrandConfig.servingMachines` stay in place as the seam that
444
586
  * `backlog/feat-strand-yardstick-from-serving-machines` plugs a real count into.
445
587
  */
446
588
  export function strandClusterPolicy(
447
589
  clusterSize: number,
448
- servingMachines?: number
590
+ declared: { servingMachines?: number; cohortQueryTimeoutMs?: number } = {}
449
591
  ): NonNullable<NodeOptions['clusterPolicy']> {
450
- const known = asKnownMachineCount(servingMachines);
451
- if (known === undefined) {
452
- return STRAND_CLUSTER_POLICY;
453
- }
454
- return Object.freeze({
455
- ...STRAND_CLUSTER_POLICY,
456
- repairCorroborationClusterSize: resolveRepairYardstick(known, clusterSize)
457
- } satisfies NonNullable<NodeOptions['clusterPolicy']>);
592
+ return declarePolicy({
593
+ base: STRAND_CLUSTER_POLICY,
594
+ yardstickCap: clusterSize,
595
+ machines: declared.servingMachines,
596
+ cohortQueryTimeoutMs: declared.cohortQueryTimeoutMs
597
+ });
458
598
  }
@@ -246,6 +246,14 @@ export async function composeStrand(
246
246
  coordinatedRepo = options.coordinatedRepo;
247
247
  log('Using injected libp2p node');
248
248
  } else {
249
+ // NOTE: this path takes the frozen policy whole, so it takes COHORT_READ_DEADLINE_MS
250
+ // with no way to override it — `StrandConnectionOptions` has no counterpart to
251
+ // cadre-core's `NetworkConfig.cohortQueryTimeoutMs`. Fine today: every production
252
+ // strand comes up through cadre-core, which does thread the field, and this path is
253
+ // the plugin's own connect/e2e route where 5000 ms is the right answer anyway. If a
254
+ // plugin embedder ever needs a different deadline,
255
+ // `strandClusterPolicy(clusterSize, { cohortQueryTimeoutMs: <ms> })` is the call to
256
+ // reach for here.
249
257
  const created = await platform.createNode({ networkName, bootstrapNodes, fretProfile, port, clusterSize, clusterPolicy: STRAND_CLUSTER_POLICY, storage });
250
258
  createdNode = created;
251
259
  node = created;
@@ -307,6 +315,17 @@ export async function composeStrand(
307
315
  // SEAM: this and the Strand apply above are the single composition point
308
316
  // where strand-level declarative DDL is applied. Keep new schema wiring
309
317
  // here so it stays a one-location edit across Node, browser, and cadre-core.
318
+ //
319
+ // NOTE: neither apply is retried — a failure tears the strand down and throws,
320
+ // and a later connect builds a fresh `Database` and re-hydrates from storage. A
321
+ // failed apply is normally taken back whole (Quereus unwinds its migration steps
322
+ // and verifies the catalog against a pre-apply fingerprint), so the next connect
323
+ // diffs against the state the apply started from. The sApp schema is the one
324
+ // supplied by the EMBEDDER, and the steps that DISCARD data — dropping a table,
325
+ // dropping a column, narrowing a column's type — are irreversible to Quereus's
326
+ // differ: once one has run, any later step that fails leaves the schema partially
327
+ // migrated instead of restored. No schema in this repo evolves that way, so this
328
+ // is a note for whoever first ships a migration that does.
310
329
  if (schema) {
311
330
  log('Applying sApp schema for strand %s', strandId);
312
331
  await db.exec(`
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { connectToStrand } from './connect.js';
2
2
  export {
3
3
  MIN_CLUSTER_SIZE,
4
+ COHORT_READ_DEADLINE_MS,
4
5
  CONTROL_REPLICATION_BREADTH,
5
6
  CONTROL_CLUSTER_POLICY,
6
7
  DEFAULT_STRAND_CLUSTER_SIZE,