@serfab/cadre-core 0.11.0 → 0.12.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 (86) hide show
  1. package/dist/announce-addrs.d.ts +49 -0
  2. package/dist/announce-addrs.js +78 -0
  3. package/dist/announce-addrs.js.map +1 -0
  4. package/dist/cadre-node.d.ts +281 -30
  5. package/dist/cadre-node.js +513 -103
  6. package/dist/cadre-node.js.map +1 -1
  7. package/dist/control-database.d.ts +155 -23
  8. package/dist/control-database.js +272 -104
  9. package/dist/control-database.js.map +1 -1
  10. package/dist/control-read-retry.d.ts +103 -0
  11. package/dist/control-read-retry.js +145 -0
  12. package/dist/control-read-retry.js.map +1 -0
  13. package/dist/control-retry.d.ts +101 -0
  14. package/dist/control-retry.js +108 -0
  15. package/dist/control-retry.js.map +1 -0
  16. package/dist/control-schema.d.ts +1 -1
  17. package/dist/control-schema.js +20 -4
  18. package/dist/control-schema.js.map +1 -1
  19. package/dist/control-write-retry.d.ts +59 -35
  20. package/dist/control-write-retry.js +21 -92
  21. package/dist/control-write-retry.js.map +1 -1
  22. package/dist/delegate-admission.d.ts +17 -15
  23. package/dist/delegate-admission.js +17 -15
  24. package/dist/delegate-admission.js.map +1 -1
  25. package/dist/index.d.ts +6 -4
  26. package/dist/index.js +17 -8
  27. package/dist/index.js.map +1 -1
  28. package/dist/membership-connection-gater.d.ts +211 -25
  29. package/dist/membership-connection-gater.js +309 -29
  30. package/dist/membership-connection-gater.js.map +1 -1
  31. package/dist/peer-record.d.ts +26 -0
  32. package/dist/peer-record.js +32 -0
  33. package/dist/peer-record.js.map +1 -1
  34. package/dist/relay-addrs.d.ts +76 -26
  35. package/dist/relay-addrs.js +115 -27
  36. package/dist/relay-addrs.js.map +1 -1
  37. package/dist/relay-reservation.d.ts +20 -15
  38. package/dist/relay-reservation.js +18 -14
  39. package/dist/relay-reservation.js.map +1 -1
  40. package/dist/strand-addr-protocol.js +16 -0
  41. package/dist/strand-addr-protocol.js.map +1 -1
  42. package/dist/strand-backfill.js +13 -4
  43. package/dist/strand-backfill.js.map +1 -1
  44. package/dist/strand-formation-manager.d.ts +10 -9
  45. package/dist/strand-formation-manager.js +10 -9
  46. package/dist/strand-formation-manager.js.map +1 -1
  47. package/dist/strand-formation-protocol.d.ts +7 -7
  48. package/dist/strand-formation-protocol.js +8 -8
  49. package/dist/strand-formation-protocol.js.map +1 -1
  50. package/dist/strand-instance-manager.d.ts +4 -3
  51. package/dist/strand-instance-manager.js +31 -6
  52. package/dist/strand-instance-manager.js.map +1 -1
  53. package/dist/strand-membership-writer.d.ts +106 -14
  54. package/dist/strand-membership-writer.js +180 -14
  55. package/dist/strand-membership-writer.js.map +1 -1
  56. package/dist/strand-solicitation.d.ts +3 -3
  57. package/dist/strand-solicitation.js +4 -5
  58. package/dist/strand-solicitation.js.map +1 -1
  59. package/dist/strand-wake-protocol.d.ts +47 -2
  60. package/dist/strand-wake-protocol.js +76 -6
  61. package/dist/strand-wake-protocol.js.map +1 -1
  62. package/dist/types.d.ts +74 -29
  63. package/dist/types.js.map +1 -1
  64. package/package.json +8 -8
  65. package/src/announce-addrs.ts +93 -0
  66. package/src/cadre-node.ts +5603 -5158
  67. package/src/control-database.ts +2584 -2406
  68. package/src/control-read-retry.ts +167 -0
  69. package/src/control-retry.ts +190 -0
  70. package/src/control-schema.ts +20 -4
  71. package/src/control-write-retry.ts +30 -130
  72. package/src/delegate-admission.ts +17 -15
  73. package/src/index.ts +435 -407
  74. package/src/membership-connection-gater.ts +533 -204
  75. package/src/peer-record.ts +33 -0
  76. package/src/relay-addrs.ts +218 -111
  77. package/src/relay-reservation.ts +828 -823
  78. package/src/strand-addr-protocol.ts +17 -0
  79. package/src/strand-backfill.ts +389 -366
  80. package/src/strand-formation-manager.ts +492 -491
  81. package/src/strand-formation-protocol.ts +8 -8
  82. package/src/strand-instance-manager.ts +636 -611
  83. package/src/strand-membership-writer.ts +209 -14
  84. package/src/strand-solicitation.ts +6 -7
  85. package/src/strand-wake-protocol.ts +94 -8
  86. package/src/types.ts +74 -29
@@ -227,12 +227,111 @@ export declare class ControlDatabase {
227
227
  * having to restate the policy's attempts or classifier.
228
228
  */
229
229
  private controlWriteRetryPacing;
230
+ /**
231
+ * Pacing seams for {@link readRows}'s retry — the read twin of
232
+ * {@link controlWriteRetryPacing}, with the same contract: production leaves this
233
+ * empty, specs inject a recorded `sleep` / fake `now` so no test waits out a real
234
+ * backoff. Applied OVER the read policy's defaults but UNDER the call site's `label`.
235
+ */
236
+ private controlReadRetryPacing;
230
237
  constructor(config: ControlDatabaseConfig);
231
238
  /**
232
239
  * Initialize the database - load schema and register plugins
233
240
  */
234
241
  initialize(): Promise<void>;
235
242
  private loadSchema;
243
+ /**
244
+ * One UNRETRIED drain of a control read to rows — every read in this class bottoms out
245
+ * here (almost all via {@link readRows}, which adds the transient-failure retry), and
246
+ * this is the ONE place the committed-read opt-in is spelled.
247
+ *
248
+ * Reached only through {@link readRows}, which drops straight here for the call sites
249
+ * that pass `retry: false` — reads issued from INSIDE a locked write body, where a
250
+ * backoff sleep would hold the write lock and the write funnel re-runs the read anyway
251
+ * (see the NOTE on {@link readRows}).
252
+ *
253
+ * A control read normally queues on the database's exec mutex, so it does not answer
254
+ * until whatever statement holds that mutex finishes. When a control WRITE is parked
255
+ * against an unresponsive cohort member that is minutes, and a read that only wants
256
+ * the last committed state waits the whole time for no reason
257
+ * (`complete/control-reads-blocked-by-stalled-write`).
258
+ *
259
+ * Quereus's remedy is `readConcurrency: 'committed'` — a PER-CALL opt-in
260
+ * (`Statement.tryRouteConcurrent` refuses anything that did not ask for it by name)
261
+ * that runs an eligible read off the mutex against each table's last committed state.
262
+ * Routing is best-effort and never an error: an ineligible statement falls back to
263
+ * the serialized path silently — any side-effecting node, any table-valued function
264
+ * call (pure ones included), any table whose module does not declare
265
+ * `readCommittedSnapshot`, or an EXPLICIT transaction being open on this database.
266
+ *
267
+ * **It is asked for only while a write is in flight, and that condition is the whole
268
+ * point.** The opt-in does not merely move the read off the mutex: it connects the
269
+ * table with `_readCommitted`, and the optimystic vtab serves such a connection from
270
+ * a pinned pre-transaction snapshot that NEVER refreshes from the network, where an
271
+ * ordinary read calls `update()` on the collection first. Taking that path
272
+ * unconditionally therefore trades "reads block behind a stalled write" for "reads
273
+ * stop seeing what other machines wrote" — measured, not theorised: with every read
274
+ * opted in, `control-write-degraded-cohort-member` could no longer observe a sibling
275
+ * publishing its own `CadrePeer` row and failed at suite setup on two runs out of two.
276
+ *
277
+ * `getAutocommit()` reports the whole `Database` rather than this call (the same
278
+ * property {@link assertCommitBoundary} leans on), which is exactly what is wanted:
279
+ * false means some writer's transaction is open, which is the only situation where a
280
+ * read would have blocked. The check races that writer harmlessly in both directions
281
+ * — losing it either reads normally (today's behaviour) or reads committed state.
282
+ *
283
+ * What a committed read gives up is observing a write that has not finished
284
+ * committing, and that is safe for every caller here: an AWAITED write is committed by
285
+ * the time it returns, and a read inside {@link inTransaction} is disqualified by the
286
+ * open explicit transaction and stays serialized, so it still sees its own
287
+ * transaction's rows. A read after an UNAWAITED write on this database would be wrong
288
+ * — no such caller exists, and {@link withWriteLock} is what keeps it that way.
289
+ */
290
+ private readRowsOnce;
291
+ /**
292
+ * {@link readRowsOnce} plus the bounded transient-failure retry
293
+ * ({@link retryControlRead}) — the funnel every UNLOCKED control read goes through, so a
294
+ * read that failed because the cluster could not be asked properly (a stream reset
295
+ * mid-scan, a partially unreachable cohort) is re-presented a moment later instead of
296
+ * surfacing to every reader.
297
+ *
298
+ * MATERIALIZES the read rather than returning a lazy iterator, and that shape is what
299
+ * makes the retry possible at all: the failure happens during ITERATION, not at call
300
+ * time, and a half-consumed iterator cannot be retried without re-yielding rows the
301
+ * caller already saw. Materializing is safe here — every caller either drains to an
302
+ * array anyway or takes the first row of a statement that yields at most one (a
303
+ * primary-key lookup or a `count(1)`); nothing streams.
304
+ *
305
+ * Each ATTEMPT is a fresh {@link readRowsOnce} call, so the committed-read opt-in
306
+ * (`getAutocommit()`) is re-evaluated per attempt — a write can finish between attempts
307
+ * and change the right answer.
308
+ *
309
+ * The retry's budget ({@link CONTROL_READ_RETRY_BUDGET_MS}, 1.5 s) is deliberately far
310
+ * under the write funnel's: the tightest caller deadline over a control read is the
311
+ * inbound admission gate's 2 s FAIL-OPEN timeout, and a read that outlives it spends its
312
+ * retries after the gate has already admitted (see `control-read-retry.ts`).
313
+ *
314
+ * `label` names the read in the retry loop's `Control read [<label>] …` debug lines and
315
+ * nothing else; several reads are in flight concurrently in a real party, so an
316
+ * unlabelled line cannot be attributed — keep new call sites labelled.
317
+ *
318
+ * `retry: false` drops straight to {@link readRowsOnce}: reads issued from INSIDE a
319
+ * locked write body must not retry on their own. `retryControlWrite`'s contract is that
320
+ * backoff sleeps happen with NO lock held; a read retrying its own backoff inside a
321
+ * locked body would sleep holding the write lock (stalling every other local writer),
322
+ * and the write funnel already re-runs the body's reads when it re-runs the body. See
323
+ * {@link queryStampId} / {@link assertSeatRemains} / {@link queryCadrePeers} for the
324
+ * per-call opt-outs.
325
+ *
326
+ * NOTE: nothing structural enforces that opt-out — it is per-call because an unlocked
327
+ * read runs CONCURRENTLY with a locked body, so `this`-state cannot tell the two apart,
328
+ * and there is no async-context primitive available on every target platform (browser,
329
+ * React Native) to carry the answer. Reads reached through a CALLBACK invoked under the
330
+ * lock are the easy miss: `notifyMembershipChanged` runs its listener with the lock
331
+ * held, and its membership read is opted out at {@link queryCadrePeers}. If another
332
+ * under-lock callback seam is ever added, audit its read graph the same way.
333
+ */
334
+ private readRows;
236
335
  /**
237
336
  * Query all strands from the control database
238
337
  */
@@ -304,8 +403,17 @@ export declare class ControlDatabase {
304
403
  * carried into both, or those suites keep passing against a contract the database no
305
404
  * longer honours. Fine while the filter is one `Set.has` on `StampId`; if it grows a
306
405
  * second dimension, give the fakes a shared fixture or a contract test instead.
406
+ *
407
+ * `retry: false` is passed by exactly one caller —
408
+ * `CadreNode.refreshAuthorizedControlPeers`, the membership-gate snapshot refresh —
409
+ * because {@link notifyMembershipChanged} invokes that listener with the write lock
410
+ * HELD, and a retrying read there would sleep its backoff holding the lock (see the
411
+ * NOTE on {@link readRows}). It costs nothing: that refresh is best-effort, keeps the
412
+ * previous snapshot on failure, and is re-driven by the next write and by the timed
413
+ * reconcile. BOTH of this method's reads take the flag — retrying either one under the
414
+ * lock is the same defect.
307
415
  */
308
- queryCadrePeers(): Promise<CadrePeerRow[]>;
416
+ queryCadrePeers(retry?: boolean): Promise<CadrePeerRow[]>;
309
417
  /**
310
418
  * Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
311
419
  * set a `ValidationUrl` redemption's approval signature must be signed by (see
@@ -330,6 +438,16 @@ export declare class ControlDatabase {
330
438
  * `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
331
439
  * come from closed literal unions — no caller-supplied string can reach the statement
332
440
  * (same injection-surface discipline as {@link countRows}'s `CONTROL_TABLE_SET` guard).
441
+ *
442
+ * NOTE: `retry: false` is passed by exactly the call sites that run INSIDE a locked
443
+ * write body — {@link deleteGuardedRow} and {@link insertCadrePeer}'s insert-if-absent
444
+ * guard. A read retrying there would sleep its backoff HOLDING the write lock
445
+ * (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
446
+ * the write funnel already re-runs the body's reads when it re-runs the body. The
447
+ * unlocked callers — the public stamp readers, {@link reauthorizeCadrePeer} and
448
+ * {@link reapRevokedRow}, whose stamp reads run BEFORE their locks are taken — keep the
449
+ * default retried path. Explicit per call rather than an ambient flag: unlocked reads
450
+ * run concurrently with a locked body, so `this`-state cannot tell the two apart.
333
451
  */
334
452
  private queryStampId;
335
453
  /**
@@ -364,8 +482,11 @@ export declare class ControlDatabase {
364
482
  * NOTE: re-reads the whole retired set on every call, and the caller runs per inbound
365
483
  * gate request while the table only ever grows. Cheap today (a cadre removes peers
366
484
  * rarely); if removals ever become routine, cache the set and invalidate it on write.
485
+ *
486
+ * `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
487
+ * under the write lock — see that method's note.
367
488
  */
368
- queryRevokedStamps(tableName: RevocableTable): Promise<Set<string>>;
489
+ queryRevokedStamps(tableName: RevocableTable, retry?: boolean): Promise<Set<string>>;
369
490
  /**
370
491
  * Every locally-held `CadreControl.Revocation` tombstone — identity triple plus its
371
492
  * `ReissuedAt` counter. Consumed by the cohort-growth re-issue sweep, which
@@ -386,7 +507,7 @@ export declare class ControlDatabase {
386
507
  * {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
387
508
  * that seam (see {@link queryCadrePeers}).
388
509
  *
389
- * Missing/legacy column values are coalesced
510
+ * Missing/null column values are coalesced
390
511
  * to their empty form (`''` key/sig, `[]` addrs, `0` stamp) so the caller's
391
512
  * verify/freshness gates uniformly reject an unpublished or malformed row.
392
513
  * The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
@@ -421,7 +542,7 @@ export declare class ControlDatabase {
421
542
  /**
422
543
  * Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
423
544
  *
424
- * Returns null when no row exists. Missing/legacy column values are coalesced to
545
+ * Returns null when no row exists. Missing/null column values are coalesced to
425
546
  * their empty form (`''` token/sig, `0` stamp) so the caller's verify/freshness
426
547
  * gates uniformly reject an unpublished or malformed row. `platform` is returned
427
548
  * verbatim (the resolver validates it against {@link PushPlatform} and re-verifies
@@ -890,7 +1011,8 @@ export declare class ControlDatabase {
890
1011
  * {@link assertCommitBoundary} (any `CadrePeer` path) or silently join each other's
891
1012
  * open transaction — a torn commit if either side rolls back. This lock closes that
892
1013
  * class: every public write method of this class runs its statement(s) under it, and
893
- * `SeedBootstrapService` wraps its direct `db.exec` writes in it too. The race is
1014
+ * `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
1015
+ * `Database` directly, so it is covered too. The race is
894
1016
  * real, not theoretical: the background self-record publish on a control connection
895
1017
  * opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
896
1018
  * a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
@@ -1008,7 +1130,8 @@ export declare class ControlDatabase {
1008
1130
  * `StrandId` binds the invite to a pre-existing host strand (provision-then-record):
1009
1131
  * when set, a responder redeeming this token records a `FormationUsage` against that
1010
1132
  * strand and returns it (see {@link ControlFormationUsageRecorder.resolveStrand}); a
1011
- * null `StrandId` (the default) leaves the legacy responder-provisions path in place.
1133
+ * null `StrandId` (the default) takes the unbound responder-provisions path: the
1134
+ * responder provisions a fresh open strand and atomically records its one consent row.
1012
1135
  * Like ValidationUrl it is a nullable bound field, signed as `''` when absent.
1013
1136
  *
1014
1137
  * The ExpiresAt and TotalUses message fields must byte-match what the (auto-deferred,
@@ -1173,8 +1296,12 @@ export declare class ControlDatabase {
1173
1296
  * Read a `FormationInvite` row by token, or null when absent. `expiresAtMs` is
1174
1297
  * the parsed epoch-ms of the stored `datetime` (null when the invite never
1175
1298
  * expires); the caller compares it against the wall clock for freshness.
1299
+ *
1300
+ * `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
1301
+ * locked write body — same per-call opt-out, and for the same reason, as
1302
+ * {@link queryStampId}'s. Every unlocked caller keeps the default retried path.
1176
1303
  */
1177
- queryFormationInvite(token: string): Promise<{
1304
+ queryFormationInvite(token: string, retry?: boolean): Promise<{
1178
1305
  token: string;
1179
1306
  sAppId: string;
1180
1307
  expiresAtMs: number | null;
@@ -1185,23 +1312,28 @@ export declare class ControlDatabase {
1185
1312
  /**
1186
1313
  * Count `FormationUsage` rows recorded against a token (uses consumed so far).
1187
1314
  *
1188
- * NOTE: `Token` left the primary key in `formation-unique-token-redesign` and gained the
1189
- * `FormationUsageByToken` secondary index, so this filter is now served by an index SEEK
1190
- * (a descent through the index collection) where it used to be a leading-key partial match
1191
- * the optimystic vtab declined and the engine served by scan. That matters because the seat
1192
- * cap is this count: a descent that under-reports admits a seat the invite does not have,
1193
- * where a scan could not. Descent misses have been observed on a NETWORKED store (tracked
1194
- * by `debt-composite-pk-point-lookup-unreliable-untracked`, for full-primary-key lookups);
1195
- * no miss has been observed on this index on a SINGLE node. Across nodes the index is
1196
- * currently blind: a sibling never learns index entries for rows written elsewhere (the
1197
- * distributed commit drops the index update — measured 2026-08-12, tracked by
1198
- * `blocked/secondary-index-seek-blind-to-sibling-rows`), so on a multi-machine party this
1199
- * count under-reports and the cap over-admits until the upstream fix lands. If
1200
- * over-admission ever shows up on a converged single node — a token with more usage rows
1201
- * than its `TotalUses` and no concurrent redeemers to explain it — suspect this seek first
1202
- * and drop the index to fall back to the scan.
1315
+ * This count IS the seat cap (`enforceFormationUseCap`), so an under-report admits a seat
1316
+ * the invitation never paid for. It is served by a table SCAN, deliberately: a bare
1317
+ * equality on a non-key column is a shape the optimystic vtab declines to claim, so the
1318
+ * engine applies the filter itself over a full read of the table.
1319
+ *
1320
+ * NOTE: it was briefly an index seek, and that is what the scan is protecting against.
1321
+ * `formation-unique-token-redesign` took `Token` out of the primary key and declared
1322
+ * `FormationUsageByToken` purely to spare this scan, recording as a tripwire that an
1323
+ * under-reporting descent would admit an unpaid-for seat — "a failure mode a scan could
1324
+ * not produce". The tripwire fired. Across machines a secondary-index descent returns only
1325
+ * the rows the READING machine wrote, so this count under-reported without bound on a
1326
+ * multi-machine party and the cap over-admitted. The index was removed 2026-08-25; the
1327
+ * measurement is recorded in the block comment at the `FormationUsage` declaration in
1328
+ * `schemas/control.qsql`, and the engine defect in
1329
+ * `blocked/secondary-index-seek-blind-to-sibling-rows`. Do NOT re-declare the index to
1330
+ * speed this up while that ticket is open.
1331
+ *
1332
+ * `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
1333
+ * locked write body — same per-call opt-out, and for the same reason, as
1334
+ * {@link queryStampId}'s. Every unlocked caller keeps the default retried path.
1203
1335
  */
1204
- countFormationUsage(token: string): Promise<number>;
1336
+ countFormationUsage(token: string, retry?: boolean): Promise<number>;
1205
1337
  /**
1206
1338
  * Is any `FormationInvite` row still redeemable — unexpired AND with usage
1207
1339
  * below its `TotalUses`? A null `ExpiresAt` never expires and a null