@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.
- package/dist/announce-addrs.d.ts +49 -0
- package/dist/announce-addrs.js +78 -0
- package/dist/announce-addrs.js.map +1 -0
- package/dist/cadre-node.d.ts +281 -30
- package/dist/cadre-node.js +513 -103
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +155 -23
- package/dist/control-database.js +272 -104
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +103 -0
- package/dist/control-read-retry.js +145 -0
- package/dist/control-read-retry.js.map +1 -0
- package/dist/control-retry.d.ts +101 -0
- package/dist/control-retry.js +108 -0
- package/dist/control-retry.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +20 -4
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +59 -35
- package/dist/control-write-retry.js +21 -92
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +17 -15
- package/dist/delegate-admission.js +17 -15
- package/dist/delegate-admission.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +17 -8
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +211 -25
- package/dist/membership-connection-gater.js +309 -29
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/peer-record.d.ts +26 -0
- package/dist/peer-record.js +32 -0
- package/dist/peer-record.js.map +1 -1
- package/dist/relay-addrs.d.ts +76 -26
- package/dist/relay-addrs.js +115 -27
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +20 -15
- package/dist/relay-reservation.js +18 -14
- package/dist/relay-reservation.js.map +1 -1
- package/dist/strand-addr-protocol.js +16 -0
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-backfill.js +13 -4
- package/dist/strand-backfill.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +10 -9
- package/dist/strand-formation-manager.js +10 -9
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +7 -7
- package/dist/strand-formation-protocol.js +8 -8
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +4 -3
- package/dist/strand-instance-manager.js +31 -6
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +106 -14
- package/dist/strand-membership-writer.js +180 -14
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +3 -3
- package/dist/strand-solicitation.js +4 -5
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +47 -2
- package/dist/strand-wake-protocol.js +76 -6
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +74 -29
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/announce-addrs.ts +93 -0
- package/src/cadre-node.ts +5603 -5158
- package/src/control-database.ts +2584 -2406
- package/src/control-read-retry.ts +167 -0
- package/src/control-retry.ts +190 -0
- package/src/control-schema.ts +20 -4
- package/src/control-write-retry.ts +30 -130
- package/src/delegate-admission.ts +17 -15
- package/src/index.ts +435 -407
- package/src/membership-connection-gater.ts +533 -204
- package/src/peer-record.ts +33 -0
- package/src/relay-addrs.ts +218 -111
- package/src/relay-reservation.ts +828 -823
- package/src/strand-addr-protocol.ts +17 -0
- package/src/strand-backfill.ts +389 -366
- package/src/strand-formation-manager.ts +492 -491
- package/src/strand-formation-protocol.ts +8 -8
- package/src/strand-instance-manager.ts +636 -611
- package/src/strand-membership-writer.ts +209 -14
- package/src/strand-solicitation.ts +6 -7
- package/src/strand-wake-protocol.ts +94 -8
- package/src/types.ts +74 -29
package/dist/control-database.js
CHANGED
|
@@ -9,6 +9,7 @@ import { canonicalDatetime } from './canonical-datetime.js';
|
|
|
9
9
|
import { controlAuthorizationFields, CONTROL_TABLES } from './control-authorization.js';
|
|
10
10
|
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
11
11
|
import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
|
|
12
|
+
import { retryControlRead } from './control-read-retry.js';
|
|
12
13
|
const log = debug('sereus:cadre:control-db');
|
|
13
14
|
const timing = debug('sereus:cadre:timing');
|
|
14
15
|
/**
|
|
@@ -248,6 +249,13 @@ export class ControlDatabase {
|
|
|
248
249
|
* having to restate the policy's attempts or classifier.
|
|
249
250
|
*/
|
|
250
251
|
this.controlWriteRetryPacing = {};
|
|
252
|
+
/**
|
|
253
|
+
* Pacing seams for {@link readRows}'s retry — the read twin of
|
|
254
|
+
* {@link controlWriteRetryPacing}, with the same contract: production leaves this
|
|
255
|
+
* empty, specs inject a recorded `sleep` / fake `now` so no test waits out a real
|
|
256
|
+
* backoff. Applied OVER the read policy's defaults but UNDER the call site's `label`.
|
|
257
|
+
*/
|
|
258
|
+
this.controlReadRetryPacing = {};
|
|
251
259
|
this.config = config;
|
|
252
260
|
}
|
|
253
261
|
/**
|
|
@@ -396,13 +404,126 @@ export class ControlDatabase {
|
|
|
396
404
|
await this.lockedWithRetry(() => this.db.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
|
|
397
405
|
log('Schema loaded and executed');
|
|
398
406
|
}
|
|
407
|
+
/**
|
|
408
|
+
* One UNRETRIED drain of a control read to rows — every read in this class bottoms out
|
|
409
|
+
* here (almost all via {@link readRows}, which adds the transient-failure retry), and
|
|
410
|
+
* this is the ONE place the committed-read opt-in is spelled.
|
|
411
|
+
*
|
|
412
|
+
* Reached only through {@link readRows}, which drops straight here for the call sites
|
|
413
|
+
* that pass `retry: false` — reads issued from INSIDE a locked write body, where a
|
|
414
|
+
* backoff sleep would hold the write lock and the write funnel re-runs the read anyway
|
|
415
|
+
* (see the NOTE on {@link readRows}).
|
|
416
|
+
*
|
|
417
|
+
* A control read normally queues on the database's exec mutex, so it does not answer
|
|
418
|
+
* until whatever statement holds that mutex finishes. When a control WRITE is parked
|
|
419
|
+
* against an unresponsive cohort member that is minutes, and a read that only wants
|
|
420
|
+
* the last committed state waits the whole time for no reason
|
|
421
|
+
* (`complete/control-reads-blocked-by-stalled-write`).
|
|
422
|
+
*
|
|
423
|
+
* Quereus's remedy is `readConcurrency: 'committed'` — a PER-CALL opt-in
|
|
424
|
+
* (`Statement.tryRouteConcurrent` refuses anything that did not ask for it by name)
|
|
425
|
+
* that runs an eligible read off the mutex against each table's last committed state.
|
|
426
|
+
* Routing is best-effort and never an error: an ineligible statement falls back to
|
|
427
|
+
* the serialized path silently — any side-effecting node, any table-valued function
|
|
428
|
+
* call (pure ones included), any table whose module does not declare
|
|
429
|
+
* `readCommittedSnapshot`, or an EXPLICIT transaction being open on this database.
|
|
430
|
+
*
|
|
431
|
+
* **It is asked for only while a write is in flight, and that condition is the whole
|
|
432
|
+
* point.** The opt-in does not merely move the read off the mutex: it connects the
|
|
433
|
+
* table with `_readCommitted`, and the optimystic vtab serves such a connection from
|
|
434
|
+
* a pinned pre-transaction snapshot that NEVER refreshes from the network, where an
|
|
435
|
+
* ordinary read calls `update()` on the collection first. Taking that path
|
|
436
|
+
* unconditionally therefore trades "reads block behind a stalled write" for "reads
|
|
437
|
+
* stop seeing what other machines wrote" — measured, not theorised: with every read
|
|
438
|
+
* opted in, `control-write-degraded-cohort-member` could no longer observe a sibling
|
|
439
|
+
* publishing its own `CadrePeer` row and failed at suite setup on two runs out of two.
|
|
440
|
+
*
|
|
441
|
+
* `getAutocommit()` reports the whole `Database` rather than this call (the same
|
|
442
|
+
* property {@link assertCommitBoundary} leans on), which is exactly what is wanted:
|
|
443
|
+
* false means some writer's transaction is open, which is the only situation where a
|
|
444
|
+
* read would have blocked. The check races that writer harmlessly in both directions
|
|
445
|
+
* — losing it either reads normally (today's behaviour) or reads committed state.
|
|
446
|
+
*
|
|
447
|
+
* What a committed read gives up is observing a write that has not finished
|
|
448
|
+
* committing, and that is safe for every caller here: an AWAITED write is committed by
|
|
449
|
+
* the time it returns, and a read inside {@link inTransaction} is disqualified by the
|
|
450
|
+
* open explicit transaction and stays serialized, so it still sees its own
|
|
451
|
+
* transaction's rows. A read after an UNAWAITED write on this database would be wrong
|
|
452
|
+
* — no such caller exists, and {@link withWriteLock} is what keeps it that way.
|
|
453
|
+
*/
|
|
454
|
+
async readRowsOnce(sql, params) {
|
|
455
|
+
let iterator;
|
|
456
|
+
if (this.db.getAutocommit()) {
|
|
457
|
+
iterator = this.db.eval(sql, params);
|
|
458
|
+
}
|
|
459
|
+
else {
|
|
460
|
+
log('read-eval: a write is in flight, asking for a committed read: %s', sql);
|
|
461
|
+
iterator = this.db.eval(sql, params, { readConcurrency: 'committed' });
|
|
462
|
+
}
|
|
463
|
+
const rows = [];
|
|
464
|
+
for await (const row of iterator) {
|
|
465
|
+
rows.push(row);
|
|
466
|
+
}
|
|
467
|
+
return rows;
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* {@link readRowsOnce} plus the bounded transient-failure retry
|
|
471
|
+
* ({@link retryControlRead}) — the funnel every UNLOCKED control read goes through, so a
|
|
472
|
+
* read that failed because the cluster could not be asked properly (a stream reset
|
|
473
|
+
* mid-scan, a partially unreachable cohort) is re-presented a moment later instead of
|
|
474
|
+
* surfacing to every reader.
|
|
475
|
+
*
|
|
476
|
+
* MATERIALIZES the read rather than returning a lazy iterator, and that shape is what
|
|
477
|
+
* makes the retry possible at all: the failure happens during ITERATION, not at call
|
|
478
|
+
* time, and a half-consumed iterator cannot be retried without re-yielding rows the
|
|
479
|
+
* caller already saw. Materializing is safe here — every caller either drains to an
|
|
480
|
+
* array anyway or takes the first row of a statement that yields at most one (a
|
|
481
|
+
* primary-key lookup or a `count(1)`); nothing streams.
|
|
482
|
+
*
|
|
483
|
+
* Each ATTEMPT is a fresh {@link readRowsOnce} call, so the committed-read opt-in
|
|
484
|
+
* (`getAutocommit()`) is re-evaluated per attempt — a write can finish between attempts
|
|
485
|
+
* and change the right answer.
|
|
486
|
+
*
|
|
487
|
+
* The retry's budget ({@link CONTROL_READ_RETRY_BUDGET_MS}, 1.5 s) is deliberately far
|
|
488
|
+
* under the write funnel's: the tightest caller deadline over a control read is the
|
|
489
|
+
* inbound admission gate's 2 s FAIL-OPEN timeout, and a read that outlives it spends its
|
|
490
|
+
* retries after the gate has already admitted (see `control-read-retry.ts`).
|
|
491
|
+
*
|
|
492
|
+
* `label` names the read in the retry loop's `Control read [<label>] …` debug lines and
|
|
493
|
+
* nothing else; several reads are in flight concurrently in a real party, so an
|
|
494
|
+
* unlabelled line cannot be attributed — keep new call sites labelled.
|
|
495
|
+
*
|
|
496
|
+
* `retry: false` drops straight to {@link readRowsOnce}: reads issued from INSIDE a
|
|
497
|
+
* locked write body must not retry on their own. `retryControlWrite`'s contract is that
|
|
498
|
+
* backoff sleeps happen with NO lock held; a read retrying its own backoff inside a
|
|
499
|
+
* locked body would sleep holding the write lock (stalling every other local writer),
|
|
500
|
+
* and the write funnel already re-runs the body's reads when it re-runs the body. See
|
|
501
|
+
* {@link queryStampId} / {@link assertSeatRemains} / {@link queryCadrePeers} for the
|
|
502
|
+
* per-call opt-outs.
|
|
503
|
+
*
|
|
504
|
+
* NOTE: nothing structural enforces that opt-out — it is per-call because an unlocked
|
|
505
|
+
* read runs CONCURRENTLY with a locked body, so `this`-state cannot tell the two apart,
|
|
506
|
+
* and there is no async-context primitive available on every target platform (browser,
|
|
507
|
+
* React Native) to carry the answer. Reads reached through a CALLBACK invoked under the
|
|
508
|
+
* lock are the easy miss: `notifyMembershipChanged` runs its listener with the lock
|
|
509
|
+
* held, and its membership read is opted out at {@link queryCadrePeers}. If another
|
|
510
|
+
* under-lock callback seam is ever added, audit its read graph the same way.
|
|
511
|
+
*/
|
|
512
|
+
readRows(sql, params, label, retry = true) {
|
|
513
|
+
if (!retry) {
|
|
514
|
+
return this.readRowsOnce(sql, params);
|
|
515
|
+
}
|
|
516
|
+
// The label lands LAST so the call site's name survives the spec-injected pacing —
|
|
517
|
+
// it changes log attribution only, never behaviour (same layering as lockedWithRetry).
|
|
518
|
+
return retryControlRead(() => this.readRowsOnce(sql, params), { ...this.controlReadRetryPacing, label });
|
|
519
|
+
}
|
|
399
520
|
/**
|
|
400
521
|
* Query all strands from the control database
|
|
401
522
|
*/
|
|
402
523
|
async queryStrands() {
|
|
403
524
|
this.ensureInitialized();
|
|
404
525
|
const results = [];
|
|
405
|
-
for
|
|
526
|
+
for (const row of await this.readRows('select Id, MemberPrivateKey, Type from CadreControl.Strand', undefined, 'strands')) {
|
|
406
527
|
results.push({
|
|
407
528
|
Id: row.Id,
|
|
408
529
|
MemberPrivateKey: row.MemberPrivateKey,
|
|
@@ -419,7 +540,7 @@ export class ControlDatabase {
|
|
|
419
540
|
*/
|
|
420
541
|
async queryStrand(strandId) {
|
|
421
542
|
this.ensureInitialized();
|
|
422
|
-
for
|
|
543
|
+
for (const row of await this.readRows('select Id, MemberPrivateKey, Type from CadreControl.Strand where Id = ?', [strandId], 'strand')) {
|
|
423
544
|
return {
|
|
424
545
|
Id: row.Id,
|
|
425
546
|
MemberPrivateKey: row.MemberPrivateKey,
|
|
@@ -444,7 +565,7 @@ export class ControlDatabase {
|
|
|
444
565
|
if (!CONTROL_TABLE_SET.has(table)) {
|
|
445
566
|
throw new Error(`Unknown CadreControl table: ${table}`);
|
|
446
567
|
}
|
|
447
|
-
for
|
|
568
|
+
for (const row of await this.readRows(`select count(1) as Count from CadreControl.${table}`, undefined, 'count-rows')) {
|
|
448
569
|
return row.Count ?? 0;
|
|
449
570
|
}
|
|
450
571
|
return 0;
|
|
@@ -462,7 +583,7 @@ export class ControlDatabase {
|
|
|
462
583
|
*/
|
|
463
584
|
async hasOwnerKey() {
|
|
464
585
|
this.ensureInitialized();
|
|
465
|
-
for
|
|
586
|
+
for (const row of await this.readRows('select count(1) as Count from CadreControl.OwnerKey', undefined, 'owner-key-exists')) {
|
|
466
587
|
return row.Count > 0;
|
|
467
588
|
}
|
|
468
589
|
return false;
|
|
@@ -493,7 +614,7 @@ export class ControlDatabase {
|
|
|
493
614
|
async getOwnerKeys() {
|
|
494
615
|
this.ensureInitialized();
|
|
495
616
|
const keys = new Set();
|
|
496
|
-
for
|
|
617
|
+
for (const row of await this.readRows('select Key from CadreControl.OwnerKey', undefined, 'owner-keys')) {
|
|
497
618
|
keys.add(row.Key);
|
|
498
619
|
}
|
|
499
620
|
return keys;
|
|
@@ -522,12 +643,21 @@ export class ControlDatabase {
|
|
|
522
643
|
* carried into both, or those suites keep passing against a contract the database no
|
|
523
644
|
* longer honours. Fine while the filter is one `Set.has` on `StampId`; if it grows a
|
|
524
645
|
* second dimension, give the fakes a shared fixture or a contract test instead.
|
|
646
|
+
*
|
|
647
|
+
* `retry: false` is passed by exactly one caller —
|
|
648
|
+
* `CadreNode.refreshAuthorizedControlPeers`, the membership-gate snapshot refresh —
|
|
649
|
+
* because {@link notifyMembershipChanged} invokes that listener with the write lock
|
|
650
|
+
* HELD, and a retrying read there would sleep its backoff holding the lock (see the
|
|
651
|
+
* NOTE on {@link readRows}). It costs nothing: that refresh is best-effort, keeps the
|
|
652
|
+
* previous snapshot on failure, and is re-driven by the next write and by the timed
|
|
653
|
+
* reconcile. BOTH of this method's reads take the flag — retrying either one under the
|
|
654
|
+
* lock is the same defect.
|
|
525
655
|
*/
|
|
526
|
-
async queryCadrePeers() {
|
|
656
|
+
async queryCadrePeers(retry = true) {
|
|
527
657
|
this.ensureInitialized();
|
|
528
|
-
const revoked = await this.queryRevokedStamps('CadrePeer');
|
|
658
|
+
const revoked = await this.queryRevokedStamps('CadrePeer', retry);
|
|
529
659
|
const rows = [];
|
|
530
|
-
for
|
|
660
|
+
for (const row of await this.readRows('select PeerId, Multiaddr, StampId, VouchOwner, VouchSig from CadreControl.CadrePeer', undefined, 'cadre-peers', retry)) {
|
|
531
661
|
const stampId = row.StampId ?? null;
|
|
532
662
|
if (stampId !== null && revoked.has(stampId)) {
|
|
533
663
|
continue;
|
|
@@ -554,7 +684,7 @@ export class ControlDatabase {
|
|
|
554
684
|
async queryValidationKeys() {
|
|
555
685
|
this.ensureInitialized();
|
|
556
686
|
const keys = [];
|
|
557
|
-
for
|
|
687
|
+
for (const row of await this.readRows('select Key from CadreControl.ValidationKey', undefined, 'validation-keys')) {
|
|
558
688
|
keys.push(row.Key);
|
|
559
689
|
}
|
|
560
690
|
return keys.sort();
|
|
@@ -573,11 +703,21 @@ export class ControlDatabase {
|
|
|
573
703
|
* `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
|
|
574
704
|
* come from closed literal unions — no caller-supplied string can reach the statement
|
|
575
705
|
* (same injection-surface discipline as {@link countRows}'s `CONTROL_TABLE_SET` guard).
|
|
706
|
+
*
|
|
707
|
+
* NOTE: `retry: false` is passed by exactly the call sites that run INSIDE a locked
|
|
708
|
+
* write body — {@link deleteGuardedRow} and {@link insertCadrePeer}'s insert-if-absent
|
|
709
|
+
* guard. A read retrying there would sleep its backoff HOLDING the write lock
|
|
710
|
+
* (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
|
|
711
|
+
* the write funnel already re-runs the body's reads when it re-runs the body. The
|
|
712
|
+
* unlocked callers — the public stamp readers, {@link reauthorizeCadrePeer} and
|
|
713
|
+
* {@link reapRevokedRow}, whose stamp reads run BEFORE their locks are taken — keep the
|
|
714
|
+
* default retried path. Explicit per call rather than an ambient flag: unlocked reads
|
|
715
|
+
* run concurrently with a locked body, so `this`-state cannot tell the two apart.
|
|
576
716
|
*/
|
|
577
|
-
async queryStampId(table, keyValue) {
|
|
717
|
+
async queryStampId(table, keyValue, retry = true) {
|
|
578
718
|
this.ensureInitialized();
|
|
579
719
|
const sql = `select StampId from CadreControl.${table} where ${GUARDED_KEY_COLUMN[table]} = ?`;
|
|
580
|
-
for
|
|
720
|
+
for (const row of await this.readRows(sql, [keyValue], `stamp-${table}`, retry)) {
|
|
581
721
|
return row.StampId ?? null;
|
|
582
722
|
}
|
|
583
723
|
return null;
|
|
@@ -622,11 +762,14 @@ export class ControlDatabase {
|
|
|
622
762
|
* NOTE: re-reads the whole retired set on every call, and the caller runs per inbound
|
|
623
763
|
* gate request while the table only ever grows. Cheap today (a cadre removes peers
|
|
624
764
|
* rarely); if removals ever become routine, cache the set and invalidate it on write.
|
|
765
|
+
*
|
|
766
|
+
* `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
|
|
767
|
+
* under the write lock — see that method's note.
|
|
625
768
|
*/
|
|
626
|
-
async queryRevokedStamps(tableName) {
|
|
769
|
+
async queryRevokedStamps(tableName, retry = true) {
|
|
627
770
|
this.ensureInitialized();
|
|
628
771
|
const stamps = new Set();
|
|
629
|
-
for
|
|
772
|
+
for (const row of await this.readRows('select StampId from CadreControl.Revocation where TableName = ?', [tableName], 'revoked-stamps', retry)) {
|
|
630
773
|
stamps.add(row.StampId);
|
|
631
774
|
}
|
|
632
775
|
return stamps;
|
|
@@ -642,7 +785,7 @@ export class ControlDatabase {
|
|
|
642
785
|
async queryRevocations() {
|
|
643
786
|
this.ensureInitialized();
|
|
644
787
|
const rows = [];
|
|
645
|
-
for
|
|
788
|
+
for (const row of await this.readRows('select TableName, RowKey, StampId, ReissuedAt from CadreControl.Revocation', undefined, 'revocations')) {
|
|
646
789
|
rows.push({
|
|
647
790
|
tableName: row.TableName,
|
|
648
791
|
rowKey: row.RowKey,
|
|
@@ -663,7 +806,7 @@ export class ControlDatabase {
|
|
|
663
806
|
* {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
|
|
664
807
|
* that seam (see {@link queryCadrePeers}).
|
|
665
808
|
*
|
|
666
|
-
* Missing/
|
|
809
|
+
* Missing/null column values are coalesced
|
|
667
810
|
* to their empty form (`''` key/sig, `[]` addrs, `0` stamp) so the caller's
|
|
668
811
|
* verify/freshness gates uniformly reject an unpublished or malformed row.
|
|
669
812
|
* The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
|
|
@@ -673,7 +816,7 @@ export class ControlDatabase {
|
|
|
673
816
|
async queryPeerRecord(peerId) {
|
|
674
817
|
this.ensureInitialized();
|
|
675
818
|
const revoked = await this.queryRevokedStamps('CadrePeer');
|
|
676
|
-
for
|
|
819
|
+
for (const row of await this.readRows('select PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId from CadreControl.CadrePeer where PeerId = ?', [peerId], 'peer-record')) {
|
|
677
820
|
const stampId = row.StampId ?? null;
|
|
678
821
|
if (stampId !== null && revoked.has(stampId)) {
|
|
679
822
|
return null;
|
|
@@ -715,18 +858,18 @@ export class ControlDatabase {
|
|
|
715
858
|
async updateSelfPeerRecord(record) {
|
|
716
859
|
this.ensureInitialized();
|
|
717
860
|
const multiaddr = record.addrs.join(',');
|
|
718
|
-
await this.execWrite(`
|
|
719
|
-
update CadreControl.CadrePeer
|
|
720
|
-
with context OwnerKey = null, Signature = ?
|
|
721
|
-
set Multiaddr = ?, UpdatedAt = ?, Sig = ?
|
|
722
|
-
where PeerId = ?
|
|
861
|
+
await this.execWrite(`
|
|
862
|
+
update CadreControl.CadrePeer
|
|
863
|
+
with context OwnerKey = null, Signature = ?
|
|
864
|
+
set Multiaddr = ?, UpdatedAt = ?, Sig = ?
|
|
865
|
+
where PeerId = ?
|
|
723
866
|
`, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId], 'self-record-update');
|
|
724
867
|
log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
|
|
725
868
|
}
|
|
726
869
|
/**
|
|
727
870
|
* Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
|
|
728
871
|
*
|
|
729
|
-
* Returns null when no row exists. Missing/
|
|
872
|
+
* Returns null when no row exists. Missing/null column values are coalesced to
|
|
730
873
|
* their empty form (`''` token/sig, `0` stamp) so the caller's verify/freshness
|
|
731
874
|
* gates uniformly reject an unpublished or malformed row. `platform` is returned
|
|
732
875
|
* verbatim (the resolver validates it against {@link PushPlatform} and re-verifies
|
|
@@ -738,7 +881,7 @@ export class ControlDatabase {
|
|
|
738
881
|
*/
|
|
739
882
|
async queryDeviceToken(peerId) {
|
|
740
883
|
this.ensureInitialized();
|
|
741
|
-
for
|
|
884
|
+
for (const row of await this.readRows('select PeerId, Platform, Token, UpdatedAt, Sig, StampId from CadreControl.DeviceToken where PeerId = ?', [peerId], 'device-token')) {
|
|
742
885
|
return {
|
|
743
886
|
peerId: row.PeerId,
|
|
744
887
|
platform: (row.Platform ?? ''),
|
|
@@ -763,11 +906,11 @@ export class ControlDatabase {
|
|
|
763
906
|
*/
|
|
764
907
|
async updateSelfDeviceToken(record) {
|
|
765
908
|
this.ensureInitialized();
|
|
766
|
-
await this.execWrite(`
|
|
767
|
-
update CadreControl.DeviceToken
|
|
768
|
-
with context OwnerKey = null, Signature = ?
|
|
769
|
-
set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
|
|
770
|
-
where PeerId = ?
|
|
909
|
+
await this.execWrite(`
|
|
910
|
+
update CadreControl.DeviceToken
|
|
911
|
+
with context OwnerKey = null, Signature = ?
|
|
912
|
+
set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
|
|
913
|
+
where PeerId = ?
|
|
771
914
|
`, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId], 'device-token-update');
|
|
772
915
|
log('Self device token updated: %s (platform=%s, updatedAt=%d)', record.peerId, record.platform, record.updatedAt);
|
|
773
916
|
}
|
|
@@ -786,10 +929,10 @@ export class ControlDatabase {
|
|
|
786
929
|
// StampId in the row's own column to satisfy the not-null/unique anti-replay constraint —
|
|
787
930
|
// the StampId is a real column value, not the optimystic `StampId()` SQL function.
|
|
788
931
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
789
|
-
await this.execWrite(`
|
|
790
|
-
insert into CadreControl.OwnerKey (Key, StampId)
|
|
791
|
-
with context OwnerKey = null, Signature = null
|
|
792
|
-
values (?, ?)
|
|
932
|
+
await this.execWrite(`
|
|
933
|
+
insert into CadreControl.OwnerKey (Key, StampId)
|
|
934
|
+
with context OwnerKey = null, Signature = null
|
|
935
|
+
values (?, ?)
|
|
793
936
|
`, [key, stampId], 'owner-key-insert');
|
|
794
937
|
log('Owner key inserted');
|
|
795
938
|
}
|
|
@@ -819,10 +962,10 @@ export class ControlDatabase {
|
|
|
819
962
|
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
820
963
|
const signature = signMessage(message);
|
|
821
964
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
822
|
-
await this.execWrite(`
|
|
823
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
824
|
-
with context OwnerKey = ?, Signature = ?
|
|
825
|
-
values (?, ?, ?, ?)
|
|
965
|
+
await this.execWrite(`
|
|
966
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
967
|
+
with context OwnerKey = ?, Signature = ?
|
|
968
|
+
values (?, ?, ?, ?)
|
|
826
969
|
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId], 'strand-insert');
|
|
827
970
|
log('Strand inserted: %s', strandId);
|
|
828
971
|
}
|
|
@@ -868,10 +1011,10 @@ export class ControlDatabase {
|
|
|
868
1011
|
// Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
|
|
869
1012
|
const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
|
|
870
1013
|
const signature = signMessage(message);
|
|
871
|
-
await this.execWrite(`
|
|
872
|
-
insert into CadreControl.ValidationKey (Key, StampId)
|
|
873
|
-
with context OwnerKey = ?, Signature = ?
|
|
874
|
-
values (?, ?)
|
|
1014
|
+
await this.execWrite(`
|
|
1015
|
+
insert into CadreControl.ValidationKey (Key, StampId)
|
|
1016
|
+
with context OwnerKey = ?, Signature = ?
|
|
1017
|
+
values (?, ?)
|
|
875
1018
|
`, [ownerKey, signature, key, stampId], 'validation-key-insert');
|
|
876
1019
|
log('Validation key inserted: %s', key);
|
|
877
1020
|
}
|
|
@@ -938,7 +1081,8 @@ export class ControlDatabase {
|
|
|
938
1081
|
const stampId = generateStampId(row.peerId);
|
|
939
1082
|
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [row.peerId, stampId]));
|
|
940
1083
|
return await this.mutateCadrePeer('peer-insert', async () => {
|
|
941
|
-
|
|
1084
|
+
// retry: false — this guard runs inside the locked write body (see queryStampId's NOTE).
|
|
1085
|
+
if (await this.queryStampId('CadrePeer', row.peerId, false) !== null) {
|
|
942
1086
|
log('CadrePeer row already present for %s; insert skipped (already a member)', row.peerId);
|
|
943
1087
|
return false;
|
|
944
1088
|
}
|
|
@@ -948,10 +1092,10 @@ export class ControlDatabase {
|
|
|
948
1092
|
// Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig) —
|
|
949
1093
|
// identical to the context pair, which the AuthorizedInsert constraint binds — so a
|
|
950
1094
|
// reader can later re-check the voucher against its node-local trusted-owner anchor.
|
|
951
|
-
await this.db.exec(`
|
|
952
|
-
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
|
|
953
|
-
with context OwnerKey = ?, Signature = ?
|
|
954
|
-
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
1095
|
+
await this.db.exec(`
|
|
1096
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
|
|
1097
|
+
with context OwnerKey = ?, Signature = ?
|
|
1098
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
955
1099
|
`, [ownerKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, ownerKey, signature]);
|
|
956
1100
|
return true;
|
|
957
1101
|
});
|
|
@@ -1014,11 +1158,11 @@ export class ControlDatabase {
|
|
|
1014
1158
|
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId]));
|
|
1015
1159
|
await this.mutateCadrePeer('peer-reauthorize', async () => {
|
|
1016
1160
|
// Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
|
|
1017
|
-
await this.db.exec(`
|
|
1018
|
-
update CadreControl.CadrePeer
|
|
1019
|
-
with context OwnerKey = ?, Signature = ?
|
|
1020
|
-
set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
|
|
1021
|
-
where PeerId = ?
|
|
1161
|
+
await this.db.exec(`
|
|
1162
|
+
update CadreControl.CadrePeer
|
|
1163
|
+
with context OwnerKey = ?, Signature = ?
|
|
1164
|
+
set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
|
|
1165
|
+
where PeerId = ?
|
|
1022
1166
|
`, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
|
|
1023
1167
|
});
|
|
1024
1168
|
return true;
|
|
@@ -1097,7 +1241,8 @@ export class ControlDatabase {
|
|
|
1097
1241
|
*/
|
|
1098
1242
|
async deleteGuardedRow(table, keyValue, ownerKey, signMessage) {
|
|
1099
1243
|
this.ensureInitialized();
|
|
1100
|
-
|
|
1244
|
+
// retry: false — this runs inside the locked write body (see queryStampId's NOTE).
|
|
1245
|
+
const stampId = await this.queryStampId(table, keyValue, false);
|
|
1101
1246
|
if (stampId === null) {
|
|
1102
1247
|
log('delete %s: no row for %s (already absent)', table, keyValue);
|
|
1103
1248
|
return false;
|
|
@@ -1109,18 +1254,18 @@ export class ControlDatabase {
|
|
|
1109
1254
|
// cover it — the digests are domain-separated and neither replays as the other.
|
|
1110
1255
|
const revocationSignature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [table, keyValue, stampId]));
|
|
1111
1256
|
await this.inTransaction(`delete ${table}`, async () => {
|
|
1112
|
-
await this.db.exec(`
|
|
1113
|
-
delete from CadreControl.${table}
|
|
1114
|
-
with context OwnerKey = ?, Signature = ?
|
|
1115
|
-
where ${GUARDED_KEY_COLUMN[table]} = ?
|
|
1257
|
+
await this.db.exec(`
|
|
1258
|
+
delete from CadreControl.${table}
|
|
1259
|
+
with context OwnerKey = ?, Signature = ?
|
|
1260
|
+
where ${GUARDED_KEY_COLUMN[table]} = ?
|
|
1116
1261
|
`, [ownerKey, signature, keyValue]);
|
|
1117
1262
|
// ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
|
|
1118
1263
|
// than leaning on the column default — the seat-at-zero rule is load-bearing for
|
|
1119
1264
|
// reissueRevocations' monotonic bump, so state it at the write site.
|
|
1120
|
-
await this.db.exec(`
|
|
1121
|
-
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1122
|
-
with context OwnerKey = ?, Signature = ?
|
|
1123
|
-
values (?, ?, ?, 0)
|
|
1265
|
+
await this.db.exec(`
|
|
1266
|
+
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1267
|
+
with context OwnerKey = ?, Signature = ?
|
|
1268
|
+
values (?, ?, ?, 0)
|
|
1124
1269
|
`, [ownerKey, revocationSignature, table, keyValue, stampId]);
|
|
1125
1270
|
});
|
|
1126
1271
|
log('%s deleted: %s (stamp retired)', table, keyValue);
|
|
@@ -1197,10 +1342,10 @@ export class ControlDatabase {
|
|
|
1197
1342
|
log('reap %s: %s holds a fresh incarnation (live stamp differs from the tombstoned one); leaving it', table, rowKey);
|
|
1198
1343
|
return false;
|
|
1199
1344
|
}
|
|
1200
|
-
const sql = `
|
|
1201
|
-
delete from CadreControl.${table}
|
|
1202
|
-
with context OwnerKey = null, Signature = null
|
|
1203
|
-
where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
|
|
1345
|
+
const sql = `
|
|
1346
|
+
delete from CadreControl.${table}
|
|
1347
|
+
with context OwnerKey = null, Signature = null
|
|
1348
|
+
where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
|
|
1204
1349
|
`;
|
|
1205
1350
|
if (table === 'CadrePeer') {
|
|
1206
1351
|
// Through mutateCadrePeer so the "EVERY CadrePeer writer goes through here"
|
|
@@ -1358,11 +1503,11 @@ export class ControlDatabase {
|
|
|
1358
1503
|
// output minted per row incarnation (generateStampId), so it identifies one
|
|
1359
1504
|
// row across all TableName values. Do NOT "fix" this by adding TableName to
|
|
1360
1505
|
// the where clause.
|
|
1361
|
-
await this.db.exec(`
|
|
1362
|
-
update CadreControl.Revocation
|
|
1363
|
-
with context OwnerKey = ?, Signature = ?
|
|
1364
|
-
set ReissuedAt = ?
|
|
1365
|
-
where StampId = ?
|
|
1506
|
+
await this.db.exec(`
|
|
1507
|
+
update CadreControl.Revocation
|
|
1508
|
+
with context OwnerKey = ?, Signature = ?
|
|
1509
|
+
set ReissuedAt = ?
|
|
1510
|
+
where StampId = ?
|
|
1366
1511
|
`, [ownerKey, signature, reissuedAt, row.stampId]);
|
|
1367
1512
|
executed++;
|
|
1368
1513
|
}
|
|
@@ -1484,7 +1629,8 @@ export class ControlDatabase {
|
|
|
1484
1629
|
* {@link assertCommitBoundary} (any `CadrePeer` path) or silently join each other's
|
|
1485
1630
|
* open transaction — a torn commit if either side rolls back. This lock closes that
|
|
1486
1631
|
* class: every public write method of this class runs its statement(s) under it, and
|
|
1487
|
-
* `SeedBootstrapService`
|
|
1632
|
+
* `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
|
|
1633
|
+
* `Database` directly, so it is covered too. The race is
|
|
1488
1634
|
* real, not theoretical: the background self-record publish on a control connection
|
|
1489
1635
|
* opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
|
|
1490
1636
|
* a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
|
|
@@ -1632,7 +1778,8 @@ export class ControlDatabase {
|
|
|
1632
1778
|
* `StrandId` binds the invite to a pre-existing host strand (provision-then-record):
|
|
1633
1779
|
* when set, a responder redeeming this token records a `FormationUsage` against that
|
|
1634
1780
|
* strand and returns it (see {@link ControlFormationUsageRecorder.resolveStrand}); a
|
|
1635
|
-
* null `StrandId` (the default)
|
|
1781
|
+
* null `StrandId` (the default) takes the unbound responder-provisions path: the
|
|
1782
|
+
* responder provisions a fresh open strand and atomically records its one consent row.
|
|
1636
1783
|
* Like ValidationUrl it is a nullable bound field, signed as `''` when absent.
|
|
1637
1784
|
*
|
|
1638
1785
|
* The ExpiresAt and TotalUses message fields must byte-match what the (auto-deferred,
|
|
@@ -1677,10 +1824,10 @@ export class ControlDatabase {
|
|
|
1677
1824
|
// Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
|
|
1678
1825
|
// signed source-of-truth and the stored value are produced once. StampId is a real,
|
|
1679
1826
|
// unique column (single-use anti-replay), no longer a context value.
|
|
1680
|
-
await this.execWrite(`
|
|
1681
|
-
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
1682
|
-
with context OwnerKey = ?, Signature = ?
|
|
1683
|
-
values (?, ?, ?, ?, ?, ?, ?)
|
|
1827
|
+
await this.execWrite(`
|
|
1828
|
+
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
1829
|
+
with context OwnerKey = ?, Signature = ?
|
|
1830
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
1684
1831
|
`, [
|
|
1685
1832
|
ownerKey, signature,
|
|
1686
1833
|
token, sAppId,
|
|
@@ -1747,10 +1894,10 @@ export class ControlDatabase {
|
|
|
1747
1894
|
// 1. Strand row — authorised by the FormationUsage branch (no owner sig),
|
|
1748
1895
|
// still carrying a fresh unique StampId for the anti-replay column.
|
|
1749
1896
|
// Hard-coded open + keyless: the consent branch admits no other shape.
|
|
1750
|
-
await this.db.exec(`
|
|
1751
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
1752
|
-
with context OwnerKey = null, Signature = null
|
|
1753
|
-
values (?, 'o', null, ?)
|
|
1897
|
+
await this.db.exec(`
|
|
1898
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
1899
|
+
with context OwnerKey = null, Signature = null
|
|
1900
|
+
values (?, 'o', null, ?)
|
|
1754
1901
|
`, [strandId, strandStampId]);
|
|
1755
1902
|
// 2. FormationUsage row — authorised by the matching FormationInvite, and
|
|
1756
1903
|
// carrying the strand's stamp so it authorizes THIS row and no other.
|
|
@@ -1844,13 +1991,17 @@ export class ControlDatabase {
|
|
|
1844
1991
|
// is still open (tracked by `debt-composite-pk-point-lookup-unreliable-untracked`). A
|
|
1845
1992
|
// spurious empty result only costs the attempt its NAMED exhaustion error, reverting it to
|
|
1846
1993
|
// today's generic `Authorized` refusal — never a seat the invite does not have.
|
|
1994
|
+
// Both reads pass retry: false — assertSeatRemains only ever runs inside the locked
|
|
1995
|
+
// write bodies of redeemInvitation / recordFormationUsage, where a read retrying its
|
|
1996
|
+
// own backoff would sleep holding the write lock and the write funnel re-runs these
|
|
1997
|
+
// reads anyway (see the NOTE on readRows).
|
|
1847
1998
|
const totalUses = knownTotalUses !== undefined
|
|
1848
1999
|
? knownTotalUses
|
|
1849
|
-
: (await this.queryFormationInvite(token))?.totalUses ?? null;
|
|
2000
|
+
: (await this.queryFormationInvite(token, false))?.totalUses ?? null;
|
|
1850
2001
|
if (totalUses == null) {
|
|
1851
2002
|
return;
|
|
1852
2003
|
}
|
|
1853
|
-
const used = await this.countFormationUsage(token);
|
|
2004
|
+
const used = await this.countFormationUsage(token, false);
|
|
1854
2005
|
if (used >= totalUses) {
|
|
1855
2006
|
throw new InvitationExhaustedError(token, used, totalUses);
|
|
1856
2007
|
}
|
|
@@ -1866,10 +2017,10 @@ export class ControlDatabase {
|
|
|
1866
2017
|
// robustness/consistency change, matching the strand layer's `consumeInvite`,
|
|
1867
2018
|
// not a fix for an observable mis-ordering.
|
|
1868
2019
|
const nowCanonical = await canonicalDatetime(this.db, opts.nowMs);
|
|
1869
|
-
await this.db.exec(`
|
|
1870
|
-
insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
|
|
1871
|
-
with context Now = ?, ValidationKey = ?, ValidationSignature = ?
|
|
1872
|
-
values (?, ?, ?, ?, ?, ?, ?)
|
|
2020
|
+
await this.db.exec(`
|
|
2021
|
+
insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
|
|
2022
|
+
with context Now = ?, ValidationKey = ?, ValidationSignature = ?
|
|
2023
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
1873
2024
|
`, [
|
|
1874
2025
|
nowCanonical, opts.validationKey, opts.validationSignature,
|
|
1875
2026
|
opts.token, opts.usageStampId, opts.peerKey,
|
|
@@ -1880,10 +2031,15 @@ export class ControlDatabase {
|
|
|
1880
2031
|
* Read a `FormationInvite` row by token, or null when absent. `expiresAtMs` is
|
|
1881
2032
|
* the parsed epoch-ms of the stored `datetime` (null when the invite never
|
|
1882
2033
|
* expires); the caller compares it against the wall clock for freshness.
|
|
2034
|
+
*
|
|
2035
|
+
* `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
|
|
2036
|
+
* locked write body — same per-call opt-out, and for the same reason, as
|
|
2037
|
+
* {@link queryStampId}'s. Every unlocked caller keeps the default retried path.
|
|
1883
2038
|
*/
|
|
1884
|
-
async queryFormationInvite(token) {
|
|
2039
|
+
async queryFormationInvite(token, retry = true) {
|
|
1885
2040
|
this.ensureInitialized();
|
|
1886
|
-
|
|
2041
|
+
const sql = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite where Token = ?';
|
|
2042
|
+
for (const row of await this.readRows(sql, [token], 'formation-invite', retry)) {
|
|
1887
2043
|
return {
|
|
1888
2044
|
token: row.Token,
|
|
1889
2045
|
sAppId: row.sAppId,
|
|
@@ -1898,25 +2054,31 @@ export class ControlDatabase {
|
|
|
1898
2054
|
/**
|
|
1899
2055
|
* Count `FormationUsage` rows recorded against a token (uses consumed so far).
|
|
1900
2056
|
*
|
|
1901
|
-
*
|
|
1902
|
-
*
|
|
1903
|
-
*
|
|
1904
|
-
*
|
|
1905
|
-
*
|
|
1906
|
-
*
|
|
1907
|
-
*
|
|
1908
|
-
*
|
|
1909
|
-
*
|
|
1910
|
-
*
|
|
1911
|
-
*
|
|
1912
|
-
*
|
|
1913
|
-
*
|
|
1914
|
-
*
|
|
1915
|
-
*
|
|
2057
|
+
* This count IS the seat cap (`enforceFormationUseCap`), so an under-report admits a seat
|
|
2058
|
+
* the invitation never paid for. It is served by a table SCAN, deliberately: a bare
|
|
2059
|
+
* equality on a non-key column is a shape the optimystic vtab declines to claim, so the
|
|
2060
|
+
* engine applies the filter itself over a full read of the table.
|
|
2061
|
+
*
|
|
2062
|
+
* NOTE: it was briefly an index seek, and that is what the scan is protecting against.
|
|
2063
|
+
* `formation-unique-token-redesign` took `Token` out of the primary key and declared
|
|
2064
|
+
* `FormationUsageByToken` purely to spare this scan, recording as a tripwire that an
|
|
2065
|
+
* under-reporting descent would admit an unpaid-for seat — "a failure mode a scan could
|
|
2066
|
+
* not produce". The tripwire fired. Across machines a secondary-index descent returns only
|
|
2067
|
+
* the rows the READING machine wrote, so this count under-reported without bound on a
|
|
2068
|
+
* multi-machine party and the cap over-admitted. The index was removed 2026-08-25; the
|
|
2069
|
+
* measurement is recorded in the block comment at the `FormationUsage` declaration in
|
|
2070
|
+
* `schemas/control.qsql`, and the engine defect in
|
|
2071
|
+
* `blocked/secondary-index-seek-blind-to-sibling-rows`. Do NOT re-declare the index to
|
|
2072
|
+
* speed this up while that ticket is open.
|
|
2073
|
+
*
|
|
2074
|
+
* `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
|
|
2075
|
+
* locked write body — same per-call opt-out, and for the same reason, as
|
|
2076
|
+
* {@link queryStampId}'s. Every unlocked caller keeps the default retried path.
|
|
1916
2077
|
*/
|
|
1917
|
-
async countFormationUsage(token) {
|
|
2078
|
+
async countFormationUsage(token, retry = true) {
|
|
1918
2079
|
this.ensureInitialized();
|
|
1919
|
-
|
|
2080
|
+
const sql = 'select count(1) as Count from CadreControl.FormationUsage where Token = ?';
|
|
2081
|
+
for (const row of await this.readRows(sql, [token], 'formation-usage-count', retry)) {
|
|
1920
2082
|
return row.Count ?? 0;
|
|
1921
2083
|
}
|
|
1922
2084
|
return 0;
|
|
@@ -1947,7 +2109,7 @@ export class ControlDatabase {
|
|
|
1947
2109
|
// prune redeemed/expired rows.
|
|
1948
2110
|
const metered = [];
|
|
1949
2111
|
let unlimitedOutstanding = false;
|
|
1950
|
-
for
|
|
2112
|
+
for (const row of await this.readRows('select Token, ExpiresAt, TotalUses from CadreControl.FormationInvite', undefined, 'outstanding-invites')) {
|
|
1951
2113
|
const expiresAtMs = parseNullableStoredDatetimeMs(row.ExpiresAt);
|
|
1952
2114
|
if (expiresAtMs !== null && expiresAtMs <= nowMs) {
|
|
1953
2115
|
continue;
|
|
@@ -1963,6 +2125,12 @@ export class ControlDatabase {
|
|
|
1963
2125
|
if (unlimitedOutstanding) {
|
|
1964
2126
|
return true;
|
|
1965
2127
|
}
|
|
2128
|
+
// NOTE: this loop issues one retried read per metered invite, each carrying its own
|
|
2129
|
+
// CONTROL_READ_RETRY_BUDGET_MS, on a path the inbound admission gate awaits under a
|
|
2130
|
+
// 2 s FAIL-OPEN deadline — so with N unexpired metered invites the worst case is
|
|
2131
|
+
// (1 + N) budgets, not one. Fine while a cadre holds a couple of live invites and
|
|
2132
|
+
// transient read failures are rare; if a party ever keeps many metered invites open,
|
|
2133
|
+
// give this whole method one shared deadline rather than raising the per-read budget.
|
|
1966
2134
|
for (const invite of metered) {
|
|
1967
2135
|
if (await this.countFormationUsage(invite.token) < invite.totalUses) {
|
|
1968
2136
|
return true;
|