@serfab/cadre-core 1.17.0 → 1.19.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/README.md +3 -2
- package/dist/cadre-invite-protocol.d.ts +35 -13
- package/dist/cadre-invite-protocol.js +90 -25
- package/dist/cadre-invite-protocol.js.map +1 -1
- package/dist/cadre-node.d.ts +586 -255
- package/dist/cadre-node.js +1182 -566
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-cohort.d.ts +66 -9
- package/dist/control-cohort.js +67 -9
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +451 -193
- package/dist/control-database.js +701 -359
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +46 -43
- package/dist/control-formation-recorder.js +77 -78
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-protocol-guard.d.ts +6 -0
- package/dist/control-protocol-guard.js +14 -2
- package/dist/control-protocol-guard.js.map +1 -1
- package/dist/control-schema-version.d.ts +48 -31
- package/dist/control-schema-version.js +67 -45
- package/dist/control-schema-version.js.map +1 -1
- package/dist/control-schema.d.ts +8 -8
- package/dist/control-schema.js +16 -16
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +42 -7
- package/dist/control-write-retry.js +87 -19
- package/dist/control-write-retry.js.map +1 -1
- package/dist/enrolled-machine-store.d.ts +3 -3
- package/dist/enrolled-machine-store.js +3 -3
- package/dist/formation-token.d.ts +48 -0
- package/dist/formation-token.js +26 -0
- package/dist/formation-token.js.map +1 -0
- package/dist/index.d.ts +15 -14
- package/dist/index.js +17 -14
- package/dist/index.js.map +1 -1
- package/dist/joined-strand-store.d.ts +26 -9
- package/dist/joined-strand-store.js +63 -24
- package/dist/joined-strand-store.js.map +1 -1
- package/dist/link-budget.d.ts +9 -7
- package/dist/link-budget.js +9 -7
- package/dist/link-budget.js.map +1 -1
- package/dist/member-redemption.d.ts +15 -9
- package/dist/member-redemption.js +13 -8
- package/dist/member-redemption.js.map +1 -1
- package/dist/membership-view.d.ts +91 -0
- package/dist/membership-view.js +110 -0
- package/dist/membership-view.js.map +1 -0
- package/dist/owner-grant-protocol.d.ts +11 -7
- package/dist/owner-grant-protocol.js +31 -17
- package/dist/owner-grant-protocol.js.map +1 -1
- package/dist/peer-addr-book.d.ts +10 -1
- package/dist/peer-addr-book.js +4 -3
- package/dist/peer-addr-book.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +96 -30
- package/dist/peer-join-backfill.js +272 -128
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/pending-join-runner.d.ts +48 -18
- package/dist/pending-join-runner.js +74 -56
- package/dist/pending-join-runner.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +47 -40
- package/dist/seed-bootstrap.js +66 -61
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/self-address-update.d.ts +13 -0
- package/dist/self-address-update.js +19 -0
- package/dist/self-address-update.js.map +1 -0
- package/dist/strand-database.d.ts +23 -2
- package/dist/strand-database.js +26 -6
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +176 -89
- package/dist/strand-first-sync-gate.js +288 -122
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-formation-deadlines.d.ts +9 -9
- package/dist/strand-formation-deadlines.js +9 -9
- package/dist/strand-formation-manager.d.ts +23 -17
- package/dist/strand-formation-manager.js +46 -44
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +5 -8
- package/dist/strand-formation-protocol.js +101 -16
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +26 -17
- package/dist/strand-instance-manager.js +119 -50
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +16 -13
- package/dist/strand-membership-reconciler.js +28 -22
- package/dist/strand-membership-reconciler.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +40 -28
- package/dist/strand-membership-writer.js +212 -153
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-move.js +5 -2
- package/dist/strand-move.js.map +1 -1
- package/dist/strand-revocation-enforcer.d.ts +134 -60
- package/dist/strand-revocation-enforcer.js +144 -71
- package/dist/strand-revocation-enforcer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +50 -39
- package/dist/strand-solicitation.js +10 -3
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-watcher.d.ts +80 -13
- package/dist/strand-watcher.js +152 -48
- package/dist/strand-watcher.js.map +1 -1
- package/dist/table-change-watch.d.ts +63 -0
- package/dist/table-change-watch.js +209 -0
- package/dist/table-change-watch.js.map +1 -0
- package/dist/timeout-scheduler.d.ts +4 -3
- package/dist/timeout-scheduler.js +4 -3
- package/dist/timeout-scheduler.js.map +1 -1
- package/dist/types.d.ts +90 -49
- package/dist/types.js +4 -4
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/cadre-invite-protocol.ts +119 -31
- package/src/cadre-node.ts +1289 -588
- package/src/control-cohort.ts +86 -9
- package/src/control-database.ts +848 -403
- package/src/control-formation-recorder.ts +359 -353
- package/src/control-protocol-guard.ts +16 -2
- package/src/control-schema-version.ts +101 -51
- package/src/control-schema.ts +16 -16
- package/src/control-write-retry.ts +91 -19
- package/src/delegate-admission.ts +351 -351
- package/src/enrolled-machine-store.ts +3 -3
- package/src/formation-token.ts +63 -0
- package/src/index.ts +30 -18
- package/src/joined-strand-store.ts +69 -24
- package/src/link-budget.ts +9 -7
- package/src/member-redemption.ts +20 -13
- package/src/membership-view.ts +144 -0
- package/src/owner-grant-protocol.ts +33 -18
- package/src/peer-addr-book.ts +4 -3
- package/src/peer-join-backfill.ts +961 -761
- package/src/pending-join-runner.ts +76 -53
- package/src/seed-bootstrap.ts +72 -67
- package/src/self-address-update.ts +19 -0
- package/src/strand-database.ts +352 -322
- package/src/strand-first-sync-gate.ts +334 -134
- package/src/strand-formation-deadlines.ts +227 -227
- package/src/strand-formation-manager.ts +731 -725
- package/src/strand-formation-protocol.ts +122 -24
- package/src/strand-instance-manager.ts +2092 -2026
- package/src/strand-membership-reconciler.ts +31 -25
- package/src/strand-membership-writer.ts +2093 -2019
- package/src/strand-move.ts +5 -2
- package/src/strand-revocation-enforcer.ts +740 -654
- package/src/strand-solicitation.ts +61 -34
- package/src/strand-watcher.ts +175 -56
- package/src/table-change-watch.ts +285 -0
- package/src/timeout-scheduler.ts +29 -28
- package/src/types.ts +89 -51
package/src/control-database.ts
CHANGED
|
@@ -7,8 +7,10 @@ import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
|
|
|
7
7
|
import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
|
|
8
8
|
import type { Libp2p } from '@libp2p/interface';
|
|
9
9
|
import type { IRepo } from '@optimystic/db-core';
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
10
|
+
import { createEmptyCollections } from '@serfab/quereus-plugin-sereus';
|
|
11
|
+
import type { CollectionRegistrySource } from '@serfab/quereus-plugin-sereus';
|
|
12
|
+
import type { StrandRow, JoinRequestRow, JoinOutcome, PendingJoin, NodeProfile, PeerAddressRecord, CadrePeerRow, OwnerKeyRow, RevocationRow, DeviceTokenRecord, DeviceTokenRow, PushPlatform, CadreInviteRow, CadreInviteUsageRow, CadreInviteRedemptionResult, CadreInviteStatus, OwnerGrantRow, OwnerGrantUsageRow, OwnerGrantRedemptionResult, OwnerGrantStatus, SeedPeer, MachineRemovalResult } from './types.js';
|
|
13
|
+
import { APPLY_CONTROL_SCHEMA, CONTROL_SCHEMA_DECLARATION, CONTROL_SCHEMA_NAME, controlSchemaFileDeclaration } from './control-schema.js';
|
|
12
14
|
import { refuseIncompatibleControlStore } from './control-schema-version.js';
|
|
13
15
|
import { canonicalDatetime } from './canonical-datetime.js';
|
|
14
16
|
import { controlAuthorizationFields, unboundAuthorizationFields, cadreInviteRowFields, ownerGrantRowFields, CONTROL_TABLES } from './control-authorization.js';
|
|
@@ -16,12 +18,16 @@ import type { ControlTable, RevocableTable, BoundControlDomain, UnboundControlDo
|
|
|
16
18
|
import { requireEd25519PublicKeyB64 } from './ed25519-key.js';
|
|
17
19
|
import { verifyCadreInviteRow, verifyOwnerGrantRow } from './peer-authorization.js';
|
|
18
20
|
import { OWNER_KEY_MAX_LABEL } from './owner-key-export.js';
|
|
19
|
-
import { retryControlWrite, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
|
|
21
|
+
import { retryControlWrite, LOST_UPDATE_RETRY_POLICY, SCHEMA_INIT_RETRY_POLICY } from './control-write-retry.js';
|
|
20
22
|
import type { ControlWriteRetryOptions } from './control-write-retry.js';
|
|
21
23
|
import { isCohortUnreachableRead, retryControlRead } from './control-read-retry.js';
|
|
22
24
|
import type { ControlReadRetryOptions } from './control-read-retry.js';
|
|
23
25
|
import { chainMessages } from './control-retry.js';
|
|
24
26
|
import type { ControlRetryAbandonment } from './control-retry.js';
|
|
27
|
+
import { TableWatchRegistry } from './table-change-watch.js';
|
|
28
|
+
import type { TableChangeWatch, TableChangeWatchOptions } from './table-change-watch.js';
|
|
29
|
+
import { MembershipView } from './membership-view.js';
|
|
30
|
+
import type { FormationInviteFacts } from './formation-token.js';
|
|
25
31
|
|
|
26
32
|
export type { ControlTable, RevocableTable, ControlDomain, ControlAction } from './control-authorization.js';
|
|
27
33
|
|
|
@@ -58,12 +64,11 @@ export function generateStampId(peerId: string): string {
|
|
|
58
64
|
* present locally.
|
|
59
65
|
*
|
|
60
66
|
* Consent is bound to the strand's one-off `StampId` (see the consent branch of
|
|
61
|
-
* `Strand.AuthorizedInsert`), so
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* CHECK at commit (which would drop the write with a far less legible error).
|
|
67
|
+
* `Strand.AuthorizedInsert`), so a writer not handed the stamp reads the live row before
|
|
68
|
+
* inserting, and throws this when the row is absent rather than leaving it to fail the
|
|
69
|
+
* deferred `StrandExists` CHECK at commit (a far less legible error). The formation
|
|
70
|
+
* responder hands the stamp over from `ControlFormationUsageRecorder.resolveStrand`, which
|
|
71
|
+
* reports an absent row as `missing`; a row removed after that read fails the CHECK instead.
|
|
67
72
|
*/
|
|
68
73
|
export class MissingHostStrandError extends Error {
|
|
69
74
|
constructor(readonly strandId: string, readonly token: string) {
|
|
@@ -106,16 +111,16 @@ export class FormationAbortedError extends Error {
|
|
|
106
111
|
* the write, inside {@link ControlDatabase.redeemInvitation} /
|
|
107
112
|
* {@link ControlDatabase.recordFormationUsage}, off the same check for a cadre invitation
|
|
108
113
|
* ({@link ControlDatabase.redeemCadreInvite}) and an owner grant
|
|
109
|
-
* ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked
|
|
110
|
-
* `ControlFormationUsageRecorder.authorizeUsage` runs
|
|
111
|
-
* pass — and off nothing else. It exists because
|
|
114
|
+
* ({@link ControlDatabase.redeemOwnerGrant}), and off the unlocked re-check
|
|
115
|
+
* `ControlFormationUsageRecorder.authorizeUsage` runs after an outside approval, before the
|
|
116
|
+
* manager issues a membership pass — and off nothing else. It exists because
|
|
112
117
|
* without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
|
|
113
118
|
* schema's own count-based cap clause, which the manager reports as a retryable
|
|
114
119
|
* `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
|
|
115
120
|
* Two redemptions of one token racing on the SAME node serialize behind the local write
|
|
116
|
-
* queue, so the loser reads the winner's committed row and is refused here by name —
|
|
117
|
-
* `StrandFormationManager.validateToken`
|
|
118
|
-
*
|
|
121
|
+
* queue, so the loser reads the winner's committed row and is refused here by name — the
|
|
122
|
+
* use count `StrandFormationManager.validateToken` reads cannot catch that, since it was
|
|
123
|
+
* read before either redemption wrote anything.
|
|
119
124
|
*/
|
|
120
125
|
export class InvitationExhaustedError extends Error {
|
|
121
126
|
constructor(
|
|
@@ -484,6 +489,8 @@ interface OptimysticPluginResult {
|
|
|
484
489
|
* compose-strand.ts).
|
|
485
490
|
*/
|
|
486
491
|
hydrate: (db: Database) => Promise<{ tables: number; indexes: number }>;
|
|
492
|
+
/** The transaction bridge, whose registry holds every collection a control table opened; see {@link ControlDatabase.ensureOwnerKey}. */
|
|
493
|
+
txnBridge: CollectionRegistrySource;
|
|
487
494
|
[key: string]: unknown;
|
|
488
495
|
}
|
|
489
496
|
|
|
@@ -542,10 +549,9 @@ interface GuardedRemoval {
|
|
|
542
549
|
|
|
543
550
|
/**
|
|
544
551
|
* The exact bytes an owner signs to append one `Revocation` tombstone — `Revocation.Authorized`
|
|
545
|
-
* binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by
|
|
546
|
-
* tombstone
|
|
547
|
-
*
|
|
548
|
-
* `revocationDigest`.
|
|
552
|
+
* binds the whole (TableName, RowKey, StampId) row under its own domain tag. Shared by both
|
|
553
|
+
* tombstone writers (a guarded removal, an issued row's withdrawal) so the field order lives
|
|
554
|
+
* in one place; the base64url verifier is `peer-authorization.ts`'s `revocationDigest`.
|
|
549
555
|
*/
|
|
550
556
|
function revocationTombstoneMessage(partyId: string, ref: { tableName: string; rowKey: string; stampId: string }): Uint8Array {
|
|
551
557
|
return buildAuthorizationMessage('CadreControl.Revocation', 'remove', partyId, [ref.tableName, ref.rowKey, ref.stampId]);
|
|
@@ -587,6 +593,63 @@ export function ownerKeyLabel(label: string | null | undefined): string | null {
|
|
|
587
593
|
*/
|
|
588
594
|
export type ControlSign = (message: Uint8Array) => string | Promise<string>;
|
|
589
595
|
|
|
596
|
+
/**
|
|
597
|
+
* Signs this node's own address record over its `CadrePeer` row as it stands —
|
|
598
|
+
* {@link ControlDatabase.updateSelfPeerRecord}'s callback, run inside the locked write body on
|
|
599
|
+
* every attempt, so the record written is always stamped past the row that was actually read.
|
|
600
|
+
*/
|
|
601
|
+
export type SelfRecordSigner = (current: PeerAddressRecord) => PeerAddressRecord;
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Which owner re-touch {@link ControlDatabase.reauthorizeCadrePeer} makes over a `CadrePeer` row:
|
|
605
|
+
*
|
|
606
|
+
* - `'rebroadcast'` — the write-while-alone drain re-emitting a row that committed local-only. It
|
|
607
|
+
* bumps `UpdatedAt` so the row goes out as a fresh transaction, and writes only while the row's
|
|
608
|
+
* `Sig` is null: once the machine has self-published, the row is its to refresh, and a bump
|
|
609
|
+
* without a re-sign would leave its signature unverifiable.
|
|
610
|
+
* - `'revouch'` — an owner rebinding the row's voucher to its own key before the vouching key is
|
|
611
|
+
* removed (`CadreNode.removeOwner`). Written whether or not the machine has self-published, and
|
|
612
|
+
* leaves `UpdatedAt` untouched, so a self-signed record stays verifiable.
|
|
613
|
+
*/
|
|
614
|
+
export type CadrePeerRetouchMode = 'rebroadcast' | 'revouch';
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* What {@link ControlDatabase.reauthorizeCadrePeer} found and did: the row was re-touched; no
|
|
618
|
+
* readable row exists (absent, or its stamp retired); or — `'rebroadcast'` only — the machine has
|
|
619
|
+
* self-published since, so the row is no longer the owner's to re-emit. The two no-write outcomes
|
|
620
|
+
* are decided over the row as read inside the locked body, so a caller can act on them.
|
|
621
|
+
*/
|
|
622
|
+
export type CadrePeerRetouchOutcome = 'retouched' | 'absent' | 'self-published';
|
|
623
|
+
|
|
624
|
+
/** A formation invitation written with a founding ({@link FoundingRows}), bound to the strand founded. */
|
|
625
|
+
export interface FoundingInvite {
|
|
626
|
+
/** The invitation token, the `FormationInvite` primary key. */
|
|
627
|
+
token: string;
|
|
628
|
+
/** The sApp a redeemed strand will use. */
|
|
629
|
+
sAppId: string;
|
|
630
|
+
/** Epoch ms after which it cannot be redeemed; omit for never. */
|
|
631
|
+
expiresAtMs?: number;
|
|
632
|
+
/** How many redemptions it allows; omit for unlimited. */
|
|
633
|
+
totalUses?: number;
|
|
634
|
+
/** Web hook sent the disclosure; a `ValidationKey` then signs off each redemption. */
|
|
635
|
+
validationUrl?: string;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/** The control rows one founding writes together; see {@link ControlDatabase.insertFoundingRows}. */
|
|
639
|
+
export interface FoundingRows {
|
|
640
|
+
strand: { id: string; type: 'o' | 'c'; memberPrivateKey?: string };
|
|
641
|
+
/** This party's membership identity key for a closed strand; absent for an open strand, or when a stored key is adopted. */
|
|
642
|
+
partyKey?: string;
|
|
643
|
+
/** A formation invitation bound to this strand (its `StrandId` is `strand.id`). */
|
|
644
|
+
formationInvite?: FoundingInvite;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/** One owner-signed insert, stamped and signed: the statement and parameters to run under the write lock. */
|
|
648
|
+
interface SignedInsert {
|
|
649
|
+
sql: string;
|
|
650
|
+
params: SqlParameters;
|
|
651
|
+
}
|
|
652
|
+
|
|
590
653
|
/** What an owner asks {@link ControlDatabase.insertCadreInvite} (or {@link ControlDatabase.signCadreInvite}) to sign. */
|
|
591
654
|
export interface CadreInviteFields {
|
|
592
655
|
/** The invitation public key (base64url ed25519), the row's primary key. */
|
|
@@ -615,7 +678,7 @@ async function signGuardedRemoval(
|
|
|
615
678
|
}
|
|
616
679
|
|
|
617
680
|
/** The control tables keyed by a strand id whose repeat insert a caller may treat as idempotent. */
|
|
618
|
-
type StrandIdTable = Extract<RevocableTable, 'Strand' | 'JoinedStrand'>;
|
|
681
|
+
type StrandIdTable = Extract<RevocableTable, 'Strand' | 'StrandPartyKey' | 'JoinedStrand'>;
|
|
619
682
|
|
|
620
683
|
/** The control tables whose repeat insert of a held key a caller may classify. */
|
|
621
684
|
type KeyConflictTable = StrandIdTable | IssuedTable | Extract<RevocableTable, 'JoinRequest'>;
|
|
@@ -642,7 +705,8 @@ function keyConflictPattern(table: KeyConflictTable): RegExp {
|
|
|
642
705
|
* {@link CadreNode.publishStrand} uses this to tell "my own earlier publish already
|
|
643
706
|
* landed" (re-read, and no-op when the row matches) apart from every other rejection —
|
|
644
707
|
* an unauthorized signer, a retired stamp — which must keep surfacing. For `JoinedStrand`
|
|
645
|
-
* the same test tells "another machine of this party already published this join" apart
|
|
708
|
+
* the same test tells "another machine of this party already published this join" apart, and
|
|
709
|
+
* for `StrandPartyKey` it tells a founding that this party's key for the id is already seated.
|
|
646
710
|
*
|
|
647
711
|
* Matched by TEXT, not by type: the typed engine error does not survive the trip out of
|
|
648
712
|
* optimystic (same constraint the retry classifiers in `control-write-retry.ts` document).
|
|
@@ -709,31 +773,17 @@ export class PendingJoinChangedError extends Error {
|
|
|
709
773
|
}
|
|
710
774
|
|
|
711
775
|
/**
|
|
712
|
-
*
|
|
713
|
-
*
|
|
714
|
-
*
|
|
715
|
-
*
|
|
776
|
+
* A `CadreControl.Revocation` row that retires nothing, which parties founded before
|
|
777
|
+
* `control-collections-created-at-cadre-founding` may hold: an owner filed it so the table
|
|
778
|
+
* had a collection, which genesis now creates. Nothing files it now. `RowIsGone` still admits
|
|
779
|
+
* this exact triple, and {@link ControlDatabase.queryRevocations} skips it.
|
|
716
780
|
*/
|
|
717
|
-
|
|
781
|
+
const REVOCATION_LEDGER_MARKER = {
|
|
718
782
|
tableName: 'Revocation',
|
|
719
783
|
rowKey: 'ledger',
|
|
720
784
|
stampId: 'opened',
|
|
721
785
|
} as const;
|
|
722
786
|
|
|
723
|
-
/**
|
|
724
|
-
* The rejection a second insert of the ledger marker produces: the optimystic vtab's
|
|
725
|
-
* primary-key wording (see {@link keyConflictPattern}), naming both columns of
|
|
726
|
-
* `Revocation`'s composite key. Applied only to the marker's own insert, whose key
|
|
727
|
-
* ('Revocation', 'opened') no row but the marker can hold (`RowIsGone`), and `Revocation`
|
|
728
|
-
* has no other unique constraint.
|
|
729
|
-
*/
|
|
730
|
-
const REVOCATION_LEDGER_CONFLICT = /UNIQUE constraint failed: Revocation\.TableName, Revocation\.StampId\b/i;
|
|
731
|
-
|
|
732
|
-
/** Did the ledger marker's insert fail because the marker is already filed? Text-matched, failing closed, like {@link isStrandIdConflict}. */
|
|
733
|
-
function isRevocationLedgerConflict(error: unknown): boolean {
|
|
734
|
-
return errorChainMatches(error, REVOCATION_LEDGER_CONFLICT);
|
|
735
|
-
}
|
|
736
|
-
|
|
737
787
|
/** Whether any message in `error`'s cause chain matches `pattern`; false for a non-`Error`. */
|
|
738
788
|
function errorChainMatches(error: unknown, pattern: RegExp): boolean {
|
|
739
789
|
return error instanceof Error && chainMessages(error).some(message => pattern.test(message));
|
|
@@ -807,7 +857,91 @@ export type ReapableTable = (typeof REAPABLE_TABLES)[number];
|
|
|
807
857
|
const REAPABLE_TABLE_SET: ReadonlySet<RevocableTable> = new Set<RevocableTable>(REAPABLE_TABLES);
|
|
808
858
|
const isReapableTable = (table: RevocableTable): table is ReapableTable => REAPABLE_TABLE_SET.has(table);
|
|
809
859
|
|
|
810
|
-
/**
|
|
860
|
+
/** One control read's rows, and whether it took the committed-read path ({@link ControlDatabase.readRowsOnce}). */
|
|
861
|
+
interface RoutedRead {
|
|
862
|
+
rows: Record<string, SqlValue>[];
|
|
863
|
+
committed: boolean;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* The full-table scans the `Revocation`, `CadrePeer` and `OwnerKey` membership views load
|
|
868
|
+
* ({@link ControlDatabase.readMembershipRows}); the four chain tables' scans sit beside their
|
|
869
|
+
* row mappers below. Specs rig these reads by their text: `from CadreControl.Revocation`, and
|
|
870
|
+
* the `CadrePeer` scan by its `select PeerId, Multiaddr, StampId` prefix, which no other
|
|
871
|
+
* `CadrePeer` statement shares.
|
|
872
|
+
*/
|
|
873
|
+
const REVOCATION_SELECT_SQL = 'select TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig from CadreControl.Revocation';
|
|
874
|
+
const CADRE_PEER_SELECT_SQL = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
|
|
875
|
+
const OWNER_KEY_SELECT_SQL = 'select Key, Label, StampId, VouchOwner, VouchSig, VouchUsage from CadreControl.OwnerKey';
|
|
876
|
+
|
|
877
|
+
/** A stored `CadrePeer` row as a {@link CadrePeerRow}. */
|
|
878
|
+
function cadrePeerRowOf(row: Record<string, SqlValue>): CadrePeerRow {
|
|
879
|
+
return {
|
|
880
|
+
peerId: row.PeerId as string,
|
|
881
|
+
publicKey: (row.PublicKey as string | null) ?? null,
|
|
882
|
+
multiaddr: (row.Multiaddr as string | null) ?? null,
|
|
883
|
+
updatedAt: (row.UpdatedAt as number | null) ?? 0,
|
|
884
|
+
profile: (row.Profile as NodeProfile | null) ?? null,
|
|
885
|
+
sig: (row.Sig as string | null) ?? null,
|
|
886
|
+
stampId: (row.StampId as string | null) ?? null,
|
|
887
|
+
vouchOwner: (row.VouchOwner as string | null) ?? null,
|
|
888
|
+
vouchSig: (row.VouchSig as string | null) ?? null,
|
|
889
|
+
vouchUsage: (row.VouchUsage as string | null) ?? null,
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** A {@link CadrePeerRow} as the address record its machine signed ({@link PeerAddressRecord}); see {@link ControlDatabase.queryPeerRecord} for the coalescing. */
|
|
894
|
+
function peerAddressRecordOf(row: CadrePeerRow): PeerAddressRecord {
|
|
895
|
+
const multiaddr = row.multiaddr ?? '';
|
|
896
|
+
return {
|
|
897
|
+
peerId: row.peerId,
|
|
898
|
+
publicKey: row.publicKey ?? '',
|
|
899
|
+
addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
|
|
900
|
+
updatedAt: row.updatedAt,
|
|
901
|
+
profile: row.profile,
|
|
902
|
+
sig: row.sig ?? '',
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** The columns {@link ControlDatabase.reauthorizeCadrePeer} signs over and bumps: a row it may write. */
|
|
907
|
+
interface RetouchTarget {
|
|
908
|
+
stampId: string;
|
|
909
|
+
updatedAt: number;
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
/**
|
|
913
|
+
* {@link ControlDatabase.reauthorizeCadrePeer}'s decision over the row as read: the row to write,
|
|
914
|
+
* or the {@link CadrePeerRetouchOutcome} that says why nothing is written — no readable row (a
|
|
915
|
+
* stampless row counts, since the voucher digest binds the stamp), or a self-published row in
|
|
916
|
+
* `'rebroadcast'` mode. One function so the pre-check outside the lock and the re-check inside it
|
|
917
|
+
* cannot disagree.
|
|
918
|
+
*/
|
|
919
|
+
function retouchTarget(row: CadrePeerRow | null, mode: CadrePeerRetouchMode): RetouchTarget | 'absent' | 'self-published' {
|
|
920
|
+
if (row === null || row.stampId === null) {
|
|
921
|
+
return 'absent';
|
|
922
|
+
}
|
|
923
|
+
if (mode === 'rebroadcast' && row.sig !== null) {
|
|
924
|
+
return 'self-published';
|
|
925
|
+
}
|
|
926
|
+
return { stampId: row.stampId, updatedAt: row.updatedAt };
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** A stored `Revocation` row as a {@link RevocationRow}; the caller has already skipped {@link REVOCATION_LEDGER_MARKER}. */
|
|
930
|
+
function revocationRowOf(row: Record<string, SqlValue>): RevocationRow {
|
|
931
|
+
return {
|
|
932
|
+
tableName: row.TableName as RevocableTable,
|
|
933
|
+
rowKey: row.RowKey as string,
|
|
934
|
+
stampId: row.StampId as string,
|
|
935
|
+
reissuedAt: (row.ReissuedAt as number | null) ?? 0,
|
|
936
|
+
signerKey: row.SignerKey as string,
|
|
937
|
+
signerSig: row.SignerSig as string,
|
|
938
|
+
};
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Every `CadreInvite` column, in the order {@link cadreInviteRowOf} reads them: the full scan the
|
|
943
|
+
* `CadreInvite` membership view loads, and the base the point reads append their `where` to.
|
|
944
|
+
*/
|
|
811
945
|
const CADRE_INVITE_SELECT_SQL = 'select Key, PeerId, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.CadreInvite';
|
|
812
946
|
|
|
813
947
|
/**
|
|
@@ -827,6 +961,9 @@ function cadreInviteRowOf(row: Record<string, SqlValue>): CadreInviteRow {
|
|
|
827
961
|
};
|
|
828
962
|
}
|
|
829
963
|
|
|
964
|
+
/** The full scan the `CadreInviteUsage` membership view loads, in the order {@link cadreInviteUsageRowOf} reads it. */
|
|
965
|
+
const CADRE_INVITE_USAGE_SELECT_SQL = 'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage';
|
|
966
|
+
|
|
830
967
|
/** A stored `CadreInviteUsage` row as a {@link CadreInviteUsageRow}. */
|
|
831
968
|
function cadreInviteUsageRowOf(row: Record<string, SqlValue>): CadreInviteUsageRow {
|
|
832
969
|
return {
|
|
@@ -840,7 +977,7 @@ function cadreInviteUsageRowOf(row: Record<string, SqlValue>): CadreInviteUsageR
|
|
|
840
977
|
};
|
|
841
978
|
}
|
|
842
979
|
|
|
843
|
-
/** Every `OwnerGrant` column, in the order {@link ownerGrantRowOf} reads them;
|
|
980
|
+
/** Every `OwnerGrant` column, in the order {@link ownerGrantRowOf} reads them; as {@link CADRE_INVITE_SELECT_SQL}, the view's scan and the point read's base. */
|
|
844
981
|
const OWNER_GRANT_SELECT_SQL = 'select Key, Label, ExpiresAt, TotalUses, IssuerKey, IssuerSig, StampId from CadreControl.OwnerGrant';
|
|
845
982
|
|
|
846
983
|
/** A stored `OwnerGrant` row as an {@link OwnerGrantRow}; `ExpiresAt` kept canonical, as {@link cadreInviteRowOf} keeps it. */
|
|
@@ -856,6 +993,9 @@ function ownerGrantRowOf(row: Record<string, SqlValue>): OwnerGrantRow {
|
|
|
856
993
|
};
|
|
857
994
|
}
|
|
858
995
|
|
|
996
|
+
/** The full scan the `OwnerGrantUsage` membership view loads, in the order {@link ownerGrantUsageRowOf} reads it. */
|
|
997
|
+
const OWNER_GRANT_USAGE_SELECT_SQL = 'select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage';
|
|
998
|
+
|
|
859
999
|
/** A stored `OwnerGrantUsage` row as an {@link OwnerGrantUsageRow}. */
|
|
860
1000
|
function ownerGrantUsageRowOf(row: Record<string, SqlValue>): OwnerGrantUsageRow {
|
|
861
1001
|
return {
|
|
@@ -901,16 +1041,8 @@ function strandRow(row: Record<string, SqlValue>): StrandRow {
|
|
|
901
1041
|
const STRAND_COLUMNS = 'Id, MemberPrivateKey, Type, FounderOwnerKey, FounderPeerId';
|
|
902
1042
|
|
|
903
1043
|
/** A `FormationInvite` row as {@link ControlDatabase.queryFormationInvite} reads it. */
|
|
904
|
-
export interface FormationInviteRow {
|
|
1044
|
+
export interface FormationInviteRow extends FormationInviteFacts {
|
|
905
1045
|
token: string;
|
|
906
|
-
sAppId: string;
|
|
907
|
-
/** Parsed epoch ms of the stored `datetime`; null when the invitation never expires. */
|
|
908
|
-
expiresAtMs: number | null;
|
|
909
|
-
/** Null is unlimited. */
|
|
910
|
-
totalUses: number | null;
|
|
911
|
-
validationUrl: string | null;
|
|
912
|
-
/** A bound invitation's host strand; null on the responder-provisions path. */
|
|
913
|
-
strandId: string | null;
|
|
914
1046
|
}
|
|
915
1047
|
|
|
916
1048
|
const FORMATION_INVITE_SELECT_SQL = 'select Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId from CadreControl.FormationInvite';
|
|
@@ -1120,6 +1252,7 @@ export interface ControlDatabaseConfig {
|
|
|
1120
1252
|
export class ControlDatabase {
|
|
1121
1253
|
private db: Database | null = null;
|
|
1122
1254
|
private collectionFactory: CollectionFactory | null = null;
|
|
1255
|
+
private txnBridge: CollectionRegistrySource | null = null;
|
|
1123
1256
|
private readonly config: ControlDatabaseConfig;
|
|
1124
1257
|
private initialized = false;
|
|
1125
1258
|
private membershipListener: MembershipChangeListener | null = null;
|
|
@@ -1127,6 +1260,26 @@ export class ControlDatabase {
|
|
|
1127
1260
|
private controlWriteAbandonedListener: ControlWriteAbandonedListener | null = null;
|
|
1128
1261
|
/** Tail of the local-write chain — see {@link withWriteLock}. */
|
|
1129
1262
|
private writeQueue: Promise<unknown> = Promise.resolve();
|
|
1263
|
+
/** Subscriptions handed out by {@link watchTables}. */
|
|
1264
|
+
private readonly tableWatches = new TableWatchRegistry();
|
|
1265
|
+
/** The cached membership tables; see {@link readMembershipRows}. */
|
|
1266
|
+
private readonly revocationView = this.membershipView(REVOCATION_SELECT_SQL, 'revocations');
|
|
1267
|
+
private readonly cadrePeerView = this.membershipView(CADRE_PEER_SELECT_SQL, 'cadre-peers');
|
|
1268
|
+
private readonly ownerKeyView = this.membershipView(OWNER_KEY_SELECT_SQL, 'owner-keys');
|
|
1269
|
+
private readonly cadreInviteView = this.membershipView(CADRE_INVITE_SELECT_SQL, 'cadre-invites');
|
|
1270
|
+
private readonly cadreInviteUsageView = this.membershipView(CADRE_INVITE_USAGE_SELECT_SQL, 'cadre-invite-usages');
|
|
1271
|
+
private readonly ownerGrantView = this.membershipView(OWNER_GRANT_SELECT_SQL, 'owner-grants');
|
|
1272
|
+
private readonly ownerGrantUsageView = this.membershipView(OWNER_GRANT_USAGE_SELECT_SQL, 'owner-grant-usages');
|
|
1273
|
+
/** Each membership view by the qualified table it copies, for {@link watchMembershipViews}, {@link watchMembershipTables} and {@link invalidateMembershipViews}. */
|
|
1274
|
+
private readonly membershipViews = new Map<string, MembershipView<Record<string, SqlValue>>>([
|
|
1275
|
+
['CadreControl.Revocation', this.revocationView],
|
|
1276
|
+
['CadreControl.CadrePeer', this.cadrePeerView],
|
|
1277
|
+
['CadreControl.OwnerKey', this.ownerKeyView],
|
|
1278
|
+
['CadreControl.CadreInvite', this.cadreInviteView],
|
|
1279
|
+
['CadreControl.CadreInviteUsage', this.cadreInviteUsageView],
|
|
1280
|
+
['CadreControl.OwnerGrant', this.ownerGrantView],
|
|
1281
|
+
['CadreControl.OwnerGrantUsage', this.ownerGrantUsageView],
|
|
1282
|
+
]);
|
|
1130
1283
|
/**
|
|
1131
1284
|
* Locked write bodies currently running (see {@link runWriteBody}); what
|
|
1132
1285
|
* {@link readRowsOnce} consults to route an unlocked read around a write that has
|
|
@@ -1166,6 +1319,7 @@ export class ControlDatabase {
|
|
|
1166
1319
|
}
|
|
1167
1320
|
|
|
1168
1321
|
log('Initializing ControlDatabase for party: %s', this.config.partyId);
|
|
1322
|
+
this.invalidateMembershipViews();
|
|
1169
1323
|
|
|
1170
1324
|
// Create database instance
|
|
1171
1325
|
this.db = new Database();
|
|
@@ -1199,6 +1353,7 @@ export class ControlDatabase {
|
|
|
1199
1353
|
registerPartyIdFunction(this.db, this.config.partyId);
|
|
1200
1354
|
|
|
1201
1355
|
this.collectionFactory = pluginResult.collectionFactory;
|
|
1356
|
+
this.txnBridge = pluginResult.txnBridge;
|
|
1202
1357
|
|
|
1203
1358
|
// Inject the libp2p node into the collection factory
|
|
1204
1359
|
t0 = performance.now();
|
|
@@ -1255,10 +1410,49 @@ export class ControlDatabase {
|
|
|
1255
1410
|
await this.loadSchema();
|
|
1256
1411
|
timing('[controlDb] loadSchema: %dms', Math.round(performance.now() - t0));
|
|
1257
1412
|
|
|
1413
|
+
this.watchMembershipViews();
|
|
1258
1414
|
this.initialized = true;
|
|
1259
1415
|
log('ControlDatabase initialized successfully');
|
|
1260
1416
|
}
|
|
1261
1417
|
|
|
1418
|
+
/**
|
|
1419
|
+
* Drop a membership view whenever its table changes: a commit made here, or a change this
|
|
1420
|
+
* node's storage takes in from elsewhere. `onInvalidate` is the whole consumer, so the run
|
|
1421
|
+
* does nothing; no backstop, since {@link MEMBERSHIP_VIEW_MAX_AGE_MS} bounds what the
|
|
1422
|
+
* subscription cannot hear. {@link close} disposes it with the rest.
|
|
1423
|
+
*/
|
|
1424
|
+
private watchMembershipViews(): void {
|
|
1425
|
+
this.tableWatches.watch(this.db!, [...this.membershipViews.keys()], () => Promise.resolve(), {
|
|
1426
|
+
label: 'membership-view',
|
|
1427
|
+
onInvalidate: (table) => this.membershipViews.get(table)?.invalidate(),
|
|
1428
|
+
});
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
/**
|
|
1432
|
+
* {@link watchTables} on the membership tables: the seven the membership views copy
|
|
1433
|
+
* (`CadrePeer`, `OwnerKey`, `Revocation`, `CadreInvite`, `CadreInviteUsage`, `OwnerGrant`,
|
|
1434
|
+
* `OwnerGrantUsage`), which are exactly what the membership gate and the owner-anchor sync
|
|
1435
|
+
* read. `CadreNode.start` takes its gate watch here, so the gate hears every table its
|
|
1436
|
+
* reads can depend on and no list of them lives outside this class.
|
|
1437
|
+
*/
|
|
1438
|
+
watchMembershipTables(onChange: () => Promise<void>, options: TableChangeWatchOptions): TableChangeWatch {
|
|
1439
|
+
return this.watchTables([...this.membershipViews.keys()], onChange, options);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Empty every membership view, so the next read of each loads afresh and no load in
|
|
1444
|
+
* flight installs. Besides {@link initialize} and {@link close}, `CadreNode` calls it when
|
|
1445
|
+
* a sibling becomes reachable (its identify on a new connection): a machine that was offline
|
|
1446
|
+
* while that sibling committed learns of the commit only when it next reads the block from
|
|
1447
|
+
* storage, and dropping the views makes the pass that starts then read now rather than after
|
|
1448
|
+
* `MEMBERSHIP_VIEW_MAX_AGE_MS`.
|
|
1449
|
+
*/
|
|
1450
|
+
invalidateMembershipViews(): void {
|
|
1451
|
+
for (const view of this.membershipViews.values()) {
|
|
1452
|
+
view.invalidate();
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1262
1456
|
private async loadSchema(): Promise<void> {
|
|
1263
1457
|
let declaration: string;
|
|
1264
1458
|
if (this.config.schemaPath) {
|
|
@@ -1271,10 +1465,12 @@ export class ControlDatabase {
|
|
|
1271
1465
|
|
|
1272
1466
|
// Declaring runs no DDL; it only records the declaration the check and the apply both read.
|
|
1273
1467
|
await this.db!.exec(declaration);
|
|
1274
|
-
//
|
|
1275
|
-
//
|
|
1276
|
-
//
|
|
1277
|
-
//
|
|
1468
|
+
// The apply below is also the MIGRATION of a warm store written under an older control
|
|
1469
|
+
// schema version: its diff runs each version's changes in order, every one applied to a
|
|
1470
|
+
// table's catalog record without a row rewritten, and ends by recording this build's version
|
|
1471
|
+
// on the store. A store the apply must not touch is refused HERE, before it runs anything: one
|
|
1472
|
+
// a newer release migrated (this machine is the one to update), one from before migrations,
|
|
1473
|
+
// or one whose diff would rewrite or discard rows (`control-schema-version.ts`).
|
|
1278
1474
|
await refuseIncompatibleControlStore(this.db!, this.config.partyId);
|
|
1279
1475
|
await this.applyControlSchema();
|
|
1280
1476
|
log('Schema loaded and executed');
|
|
@@ -1318,6 +1514,20 @@ export class ControlDatabase {
|
|
|
1318
1514
|
// argument is a statement about the pair, not about Quereus alone — pinned by
|
|
1319
1515
|
// `test/control-schema-apply-unwind.spec.ts`.
|
|
1320
1516
|
//
|
|
1517
|
+
// The same argument covers the statements a MIGRATION runs (`control-schema-version.ts`'s
|
|
1518
|
+
// `catalog-only` kinds, from Optimystic 1.16.0): the plugin's `alterTable` arms (add column,
|
|
1519
|
+
// drop column, add and drop CHECK) and the tag change write the table's catalog record under
|
|
1520
|
+
// the batch's per-statement checkpoint, so a step that throws withdraws its own write, and a
|
|
1521
|
+
// batch whose commit fails is discarded whole and the tables it wrote re-persist on their next
|
|
1522
|
+
// touch. A change the record already holds — another machine migrated first — is adopted with
|
|
1523
|
+
// nothing written, and an undo of an adopted change touches this machine's catalog only. The
|
|
1524
|
+
// one arm Quereus marks irreversible is `drop column`: once it has run, a later failure
|
|
1525
|
+
// unwinds nothing (the apply reports the schema as partially migrated), the plugin's
|
|
1526
|
+
// `endSchemaBatch` commits the steps that landed — or, when that commit fails, marks their
|
|
1527
|
+
// tables to re-persist on next touch — and the retry's diff runs only the steps still
|
|
1528
|
+
// missing. No row is lost either way: rows keep a dropped column's field until they are
|
|
1529
|
+
// rewritten, and nothing reads it.
|
|
1530
|
+
//
|
|
1321
1531
|
// Only the "cohort did not answer, nothing committed" class is retried; the classifier
|
|
1322
1532
|
// vetoes indeterminate commits and does not match `Missing block`, which is a durable
|
|
1323
1533
|
// convergence fault a retry cannot heal (tracked separately) and must keep propagating.
|
|
@@ -1409,9 +1619,10 @@ export class ControlDatabase {
|
|
|
1409
1619
|
* behind back-to-back local writes, narrow the count to the span from a body's first
|
|
1410
1620
|
* statement to its transaction opening.
|
|
1411
1621
|
*/
|
|
1412
|
-
private async readRowsOnce(sql: string, params: SqlParameters | undefined, underWriteLock: boolean): Promise<
|
|
1622
|
+
private async readRowsOnce(sql: string, params: SqlParameters | undefined, underWriteLock: boolean): Promise<RoutedRead> {
|
|
1623
|
+
const committed = this.writeInFlight(underWriteLock);
|
|
1413
1624
|
let iterator: AsyncIterableIterator<Record<string, SqlValue>>;
|
|
1414
|
-
if (
|
|
1625
|
+
if (committed) {
|
|
1415
1626
|
log('read-eval: a write is in flight (%s), asking for a committed read: %s',
|
|
1416
1627
|
this.db!.getAutocommit() ? 'locked body running, no transaction open yet' : 'transaction open', sql);
|
|
1417
1628
|
iterator = this.db!.eval(sql, params, { readConcurrency: 'committed' });
|
|
@@ -1422,7 +1633,7 @@ export class ControlDatabase {
|
|
|
1422
1633
|
for await (const row of iterator) {
|
|
1423
1634
|
rows.push(row);
|
|
1424
1635
|
}
|
|
1425
|
-
return rows;
|
|
1636
|
+
return { rows, committed };
|
|
1426
1637
|
}
|
|
1427
1638
|
|
|
1428
1639
|
/**
|
|
@@ -1482,12 +1693,26 @@ export class ControlDatabase {
|
|
|
1482
1693
|
* held, and its membership read is opted out at {@link queryCadrePeers}. If another
|
|
1483
1694
|
* under-lock callback seam is ever added, audit its read graph the same way.
|
|
1484
1695
|
*/
|
|
1485
|
-
private readRows(
|
|
1696
|
+
private async readRows(
|
|
1486
1697
|
sql: string,
|
|
1487
1698
|
params: SqlParameters | undefined,
|
|
1488
1699
|
label: string,
|
|
1489
1700
|
retry = true
|
|
1490
1701
|
): Promise<Record<string, SqlValue>[]> {
|
|
1702
|
+
return (await this.readRowsRouted(sql, params, label, retry)).rows;
|
|
1703
|
+
}
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* {@link readRows}, also saying whether the attempt that answered took the committed-read
|
|
1707
|
+
* path — which a {@link MembershipView} load needs, since it may not keep such rows (see
|
|
1708
|
+
* {@link membershipView}).
|
|
1709
|
+
*/
|
|
1710
|
+
private readRowsRouted(
|
|
1711
|
+
sql: string,
|
|
1712
|
+
params: SqlParameters | undefined,
|
|
1713
|
+
label: string,
|
|
1714
|
+
retry: boolean
|
|
1715
|
+
): Promise<RoutedRead> {
|
|
1491
1716
|
// `retry: false` marks the reads that may run inside a locked write body, which is
|
|
1492
1717
|
// also what readRowsOnce's committed-read routing must know (see writeInFlight).
|
|
1493
1718
|
// NOTE: the membership-gate refresh passes it on EVERY trigger, including the
|
|
@@ -1506,6 +1731,46 @@ export class ControlDatabase {
|
|
|
1506
1731
|
);
|
|
1507
1732
|
}
|
|
1508
1733
|
|
|
1734
|
+
/**
|
|
1735
|
+
* A view over one membership table, loaded by its full scan `sql` through {@link readRows}'s
|
|
1736
|
+
* funnel. Rows a committed read served are never kept: that read may initialize an untouched
|
|
1737
|
+
* table provisionally, and the Optimystic plugin arms a table's replicated-change bridge only
|
|
1738
|
+
* on its full initialization (`OptimysticTable.initializeForCommittedRead`), so no later change
|
|
1739
|
+
* event would ever drop them.
|
|
1740
|
+
*/
|
|
1741
|
+
private membershipView(sql: string, label: string): MembershipView<Record<string, SqlValue>> {
|
|
1742
|
+
return new MembershipView(async (retry) => {
|
|
1743
|
+
const { rows, committed } = await this.readRowsRouted(sql, undefined, label, retry);
|
|
1744
|
+
return { rows, installable: !committed };
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/**
|
|
1749
|
+
* A membership table's raw rows, from its {@link MembershipView}: `Revocation`, `CadrePeer`
|
|
1750
|
+
* and `OwnerKey`, the tables every membership, owner and peer-record read starts from, and
|
|
1751
|
+
* `CadreInvite`, `CadreInviteUsage`, `OwnerGrant` and `OwnerGrantUsage`, the chains every
|
|
1752
|
+
* membership refresh verifies invitation-admitted machines and grant-seated owner keys through.
|
|
1753
|
+
* Callers build fresh result objects from them and never mutate them.
|
|
1754
|
+
*
|
|
1755
|
+
* Reads the table directly instead when a caller under the write lock (`retry: false`) has an
|
|
1756
|
+
* explicit transaction open: such a read sees the transaction's own uncommitted rows, which
|
|
1757
|
+
* the view does not hold, and a rollback fires no change event, so keeping them would cache
|
|
1758
|
+
* rows that never existed. No reader of these tables runs inside {@link inTransaction} today;
|
|
1759
|
+
* this keeps one added later, or a spec's raw-handle transaction, correct.
|
|
1760
|
+
*
|
|
1761
|
+
* Reads that stay plain SQL on purpose: {@link queryStampId} (sees retired rows), the
|
|
1762
|
+
* schema's CHECK constraints, which read inside the committing transaction, the redemption's
|
|
1763
|
+
* one read of the held credential row ({@link queryCadreInvite},
|
|
1764
|
+
* {@link queryOwnerGrant}), and the indexed usage counts.
|
|
1765
|
+
*/
|
|
1766
|
+
private readMembershipRows(view: MembershipView<Record<string, SqlValue>>, retry: boolean): Promise<readonly Record<string, SqlValue>[]> {
|
|
1767
|
+
this.ensureInitialized();
|
|
1768
|
+
if (!retry && !this.db!.getAutocommit()) {
|
|
1769
|
+
return view.readThrough(retry);
|
|
1770
|
+
}
|
|
1771
|
+
return view.read(retry);
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1509
1774
|
/**
|
|
1510
1775
|
* Query all strands from the control database
|
|
1511
1776
|
*/
|
|
@@ -1527,6 +1792,18 @@ export class ControlDatabase {
|
|
|
1527
1792
|
return rows.length === 0 ? null : strandRow(rows[0]!);
|
|
1528
1793
|
}
|
|
1529
1794
|
|
|
1795
|
+
/**
|
|
1796
|
+
* {@link queryStrand} plus the row's `StampId`, in one read: the formation responder
|
|
1797
|
+
* resolves a bound invite's host strand with it and later records consent against that
|
|
1798
|
+
* stamp ({@link recordFormationUsage}'s `strandStampId`) without reading the row again.
|
|
1799
|
+
*/
|
|
1800
|
+
async queryStrandWithStamp(strandId: string): Promise<(StrandRow & { StampId: string }) | null> {
|
|
1801
|
+
this.ensureInitialized();
|
|
1802
|
+
const rows = await this.readRows(
|
|
1803
|
+
`select ${STRAND_COLUMNS}, StampId from CadreControl.Strand where Id = ?`, [strandId], 'strand-with-stamp');
|
|
1804
|
+
return rows.length === 0 ? null : { ...strandRow(rows[0]!), StampId: rows[0]!.StampId as string };
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1530
1807
|
/**
|
|
1531
1808
|
* Every strand this party joined from another party (`CadreControl.JoinedStrand`), shaped
|
|
1532
1809
|
* as a {@link StrandRow} so a reader can treat it like one of the party's own. A joiner
|
|
@@ -1616,13 +1893,13 @@ export class ControlDatabase {
|
|
|
1616
1893
|
*
|
|
1617
1894
|
* Deliberately RAW (no retired-stamp filter, unlike {@link getOwnerKeys}): the genesis
|
|
1618
1895
|
* insert is refused by the schema whenever any physical row exists, so this must see one.
|
|
1896
|
+
* Served from the `OwnerKey` membership view ({@link readMembershipRows}), which holds every
|
|
1897
|
+
* physical row, so a genesis caller's pre-check and {@link ensureOwnerKey}'s re-check share
|
|
1898
|
+
* one storage read.
|
|
1619
1899
|
*/
|
|
1620
1900
|
async hasOwnerKey(): Promise<boolean> {
|
|
1621
1901
|
this.ensureInitialized();
|
|
1622
|
-
|
|
1623
|
-
return (row.Count as number) > 0;
|
|
1624
|
-
}
|
|
1625
|
-
return false;
|
|
1902
|
+
return (await this.readMembershipRows(this.ownerKeyView, true)).length > 0;
|
|
1626
1903
|
}
|
|
1627
1904
|
|
|
1628
1905
|
/**
|
|
@@ -1630,6 +1907,16 @@ export class ControlDatabase {
|
|
|
1630
1907
|
* the party has none yet. Returns true if it inserted, false if an owner
|
|
1631
1908
|
* key already existed (so a repeat `--owner` start is a no-op).
|
|
1632
1909
|
*
|
|
1910
|
+
* Before the insert, gives every control table its empty collection
|
|
1911
|
+
* ({@link commitEmptyControlCollections}). A collection committed while a machine is alone
|
|
1912
|
+
* forks if another machine creates the same collection independently; before genesis the
|
|
1913
|
+
* party has no owner, so no other machine can have been admitted and none can hold any of
|
|
1914
|
+
* its collections. The order makes the step repeatable: a crash between it and the insert
|
|
1915
|
+
* leaves no `OwnerKey` row, so the next start runs genesis again and commits only the
|
|
1916
|
+
* collections still missing, while a founder restart finds the row and commits nothing, so
|
|
1917
|
+
* it can never write an empty second history beside a table another machine has since
|
|
1918
|
+
* written. A failure propagates with nothing inserted.
|
|
1919
|
+
*
|
|
1633
1920
|
* @param label - the user's name for the key ({@link insertOwnerKey}).
|
|
1634
1921
|
*/
|
|
1635
1922
|
async ensureOwnerKey(key: string, label?: string): Promise<boolean> {
|
|
@@ -1639,10 +1926,42 @@ export class ControlDatabase {
|
|
|
1639
1926
|
log('Owner key already present; skipping genesis insert');
|
|
1640
1927
|
return false;
|
|
1641
1928
|
}
|
|
1929
|
+
// Two locked steps one after the other: the lock is not re-entrant, and the insert takes it itself.
|
|
1930
|
+
await this.commitEmptyControlCollections();
|
|
1642
1931
|
await this.insertOwnerKey(trimmed, label);
|
|
1643
1932
|
return true;
|
|
1644
1933
|
}
|
|
1645
1934
|
|
|
1935
|
+
/**
|
|
1936
|
+
* Commit an empty collection for every `CadreControl` table, index tree and unique-enforcement
|
|
1937
|
+
* tree that has none, so a read of a table nobody has written is answered from this machine's
|
|
1938
|
+
* storage instead of asking the block's cohort to prove the collection absent — a round trip
|
|
1939
|
+
* per read on a party of several machines, and an error or a wait while they are offline.
|
|
1940
|
+
* Joining machines get these collections the ordinary way, by reading them from the cohort.
|
|
1941
|
+
*
|
|
1942
|
+
* Under the write lock: the helper commits whatever is staged in those collections outside any
|
|
1943
|
+
* SQL transaction, which is sound only while nothing else writes.
|
|
1944
|
+
*
|
|
1945
|
+
* Parties founded before this step, and tables a later control schema version adds, have no
|
|
1946
|
+
* such collections, and nothing creates them later: the start-time schema apply runs on every
|
|
1947
|
+
* machine, possibly alone, where committing one would be exactly the fork genesis avoids.
|
|
1948
|
+
*
|
|
1949
|
+
* NOTE: one `sync()` per collection, in sequence: 32 collections for today's schema (17 tables,
|
|
1950
|
+
* 3 indexes, 12 `unique` columns). Measured 2026-10-20 on a lone `CadreNode` over the network
|
|
1951
|
+
* transactor (started, then `ensureOwnerKey` alone, 3 runs each): 64–105 ms in memory and
|
|
1952
|
+
* 750–820 ms on `FileRawStorage`, about 25 ms per collection on disk, roughly 1/75 of the edge
|
|
1953
|
+
* node's 60 s `DEFAULT_OWNER_GENESIS_TIMEOUT_MS`. Under the whole cadre-core suite's load (151
|
|
1954
|
+
* geneses) it took a median of 159 ms and at most 4.6 s. If genesis latency starts to matter
|
|
1955
|
+
* (phone flash under launch contention runs 50–90 ms per storage operation, see the NOTE in
|
|
1956
|
+
* `initialize`), ask Optimystic for an API that commits them as one batch, the one strand
|
|
1957
|
+
* founding would use too; do not build a private batching path.
|
|
1958
|
+
*/
|
|
1959
|
+
private async commitEmptyControlCollections(): Promise<void> {
|
|
1960
|
+
const t0 = performance.now();
|
|
1961
|
+
const committed = await this.withWriteLock(() => createEmptyCollections(this.db!, this.txnBridge!, [CONTROL_SCHEMA_NAME]));
|
|
1962
|
+
log('Committed %d empty control collections in %dms', committed, Math.round(performance.now() - t0));
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1646
1965
|
/**
|
|
1647
1966
|
* Collect every owner key (`CadreControl.OwnerKey.Key`) as a set.
|
|
1648
1967
|
*
|
|
@@ -1684,16 +2003,14 @@ export class ControlDatabase {
|
|
|
1684
2003
|
*
|
|
1685
2004
|
* @param retired - the retired `OwnerKey` stamps, when the caller has already read the
|
|
1686
2005
|
* tombstones (`CadreNode.syncOwnerAnchor` reads {@link queryRevocations} for their
|
|
1687
|
-
* signers); omitted, they are read here.
|
|
1688
|
-
*
|
|
1689
|
-
* membership refresh at one such read for the anchor.
|
|
2006
|
+
* signers); omitted, they are read here. Both come from the same `Revocation` view
|
|
2007
|
+
* ({@link readMembershipRows}), so passing the set saves only the filtering.
|
|
1690
2008
|
*/
|
|
1691
2009
|
async queryOwnerKeyRows(retry = true, retired?: ReadonlySet<string>): Promise<OwnerKeyRow[]> {
|
|
1692
2010
|
this.ensureInitialized();
|
|
1693
2011
|
const revoked = retired ?? await this.queryRevokedStamps('OwnerKey', retry);
|
|
1694
2012
|
const rows: OwnerKeyRow[] = [];
|
|
1695
|
-
const
|
|
1696
|
-
for (const row of await this.readRows(sql, undefined, 'owner-keys', retry)) {
|
|
2013
|
+
for (const row of await this.readMembershipRows(this.ownerKeyView, retry)) {
|
|
1697
2014
|
const stampId = (row.StampId as string | null) ?? null;
|
|
1698
2015
|
if (stampId === null) {
|
|
1699
2016
|
log('queryOwnerKeyRows: dropping OwnerKey row %s with no StampId', row.Key);
|
|
@@ -1726,12 +2043,8 @@ export class ControlDatabase {
|
|
|
1726
2043
|
* {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
|
|
1727
2044
|
* that seam to model the replicated live-row-plus-tombstone merge state.
|
|
1728
2045
|
*
|
|
1729
|
-
*
|
|
1730
|
-
*
|
|
1731
|
-
* while `Revocation` has never been written every call consults the cohort about it, and
|
|
1732
|
-
* the ledger marker ({@link openRevocationLedger}) is what makes it a held block. Folding
|
|
1733
|
-
* the exclusion into one statement would not help (it reads the same block), and caching
|
|
1734
|
-
* the set would hide a tombstone arriving by replication.
|
|
2046
|
+
* Both tables come from their membership views ({@link readMembershipRows}), so a call after
|
|
2047
|
+
* the first costs no storage read until one of the two tables changes.
|
|
1735
2048
|
*
|
|
1736
2049
|
* NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
|
|
1737
2050
|
* (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
|
|
@@ -1754,27 +2067,12 @@ export class ControlDatabase {
|
|
|
1754
2067
|
this.ensureInitialized();
|
|
1755
2068
|
const revoked = await this.queryRevokedStamps('CadrePeer', retry);
|
|
1756
2069
|
const rows: CadrePeerRow[] = [];
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
const sql = 'select PeerId, Multiaddr, StampId, VouchOwner, VouchSig, VouchUsage, PublicKey, UpdatedAt, Profile, Sig from CadreControl.CadrePeer';
|
|
1761
|
-
for (const row of await this.readRows(sql, undefined, 'cadre-peers', retry)) {
|
|
1762
|
-
const stampId = (row.StampId as string | null) ?? null;
|
|
1763
|
-
if (stampId !== null && revoked.has(stampId)) {
|
|
2070
|
+
for (const row of await this.readMembershipRows(this.cadrePeerView, retry)) {
|
|
2071
|
+
const peer = cadrePeerRowOf(row);
|
|
2072
|
+
if (peer.stampId !== null && revoked.has(peer.stampId)) {
|
|
1764
2073
|
continue;
|
|
1765
2074
|
}
|
|
1766
|
-
rows.push(
|
|
1767
|
-
peerId: row.PeerId as string,
|
|
1768
|
-
publicKey: (row.PublicKey as string | null) ?? null,
|
|
1769
|
-
multiaddr: (row.Multiaddr as string | null) ?? null,
|
|
1770
|
-
updatedAt: (row.UpdatedAt as number | null) ?? 0,
|
|
1771
|
-
profile: (row.Profile as NodeProfile | null) ?? null,
|
|
1772
|
-
sig: (row.Sig as string | null) ?? null,
|
|
1773
|
-
stampId,
|
|
1774
|
-
vouchOwner: (row.VouchOwner as string | null) ?? null,
|
|
1775
|
-
vouchSig: (row.VouchSig as string | null) ?? null,
|
|
1776
|
-
vouchUsage: (row.VouchUsage as string | null) ?? null,
|
|
1777
|
-
});
|
|
2075
|
+
rows.push(peer);
|
|
1778
2076
|
}
|
|
1779
2077
|
return rows;
|
|
1780
2078
|
}
|
|
@@ -1831,10 +2129,11 @@ export class ControlDatabase {
|
|
|
1831
2129
|
* guard. A read retrying there would sleep its backoff HOLDING the write lock
|
|
1832
2130
|
* (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
|
|
1833
2131
|
* the write funnel already re-runs the body's reads when it re-runs the body. The
|
|
1834
|
-
* unlocked callers — the public stamp readers
|
|
1835
|
-
*
|
|
1836
|
-
*
|
|
1837
|
-
*
|
|
2132
|
+
* unlocked callers — the public stamp readers and {@link reapRevokedRow}, whose stamp
|
|
2133
|
+
* read runs BEFORE its lock is taken — keep the default retried path. Explicit per call
|
|
2134
|
+
* rather than an ambient flag: unlocked reads run concurrently with a locked body, so
|
|
2135
|
+
* `this`-state cannot tell the two apart. The same flag threads through
|
|
2136
|
+
* {@link findCadrePeerRow} for the two writers that read the whole row inside their body.
|
|
1838
2137
|
*/
|
|
1839
2138
|
private async queryStampId(
|
|
1840
2139
|
table: RevocableTable,
|
|
@@ -1852,8 +2151,8 @@ export class ControlDatabase {
|
|
|
1852
2151
|
/**
|
|
1853
2152
|
* `CadrePeer` stamp nonce — read by {@link SeedBootstrapService.removePeer} as its
|
|
1854
2153
|
* row-present gate before it delegates to {@link deleteCadrePeer}. The in-class
|
|
1855
|
-
* `CadrePeer` writers
|
|
1856
|
-
*
|
|
2154
|
+
* `CadrePeer` writers go straight to {@link queryStampId} ({@link insertCadrePeer}) or read
|
|
2155
|
+
* the whole row ({@link reauthorizeCadrePeer}, through {@link findCadrePeerRow}).
|
|
1857
2156
|
*/
|
|
1858
2157
|
queryCadrePeerStampId(peerId: string): Promise<string | null> {
|
|
1859
2158
|
return this.queryStampId('CadrePeer', peerId);
|
|
@@ -1919,18 +2218,17 @@ export class ControlDatabase {
|
|
|
1919
2218
|
* ({@link queryCadrePeers}, {@link queryPeerRecord},
|
|
1920
2219
|
* {@link CadreNode.resolveDeviceToken}) drop any row whose stamp appears here.
|
|
1921
2220
|
*
|
|
1922
|
-
*
|
|
1923
|
-
*
|
|
1924
|
-
*
|
|
1925
|
-
*
|
|
1926
|
-
*
|
|
1927
|
-
*
|
|
1928
|
-
*
|
|
1929
|
-
* row count only grows (append-only) but stays small while removals are rare; revisit if
|
|
2221
|
+
* Served from the `Revocation` membership view ({@link readMembershipRows}), dropped by
|
|
2222
|
+
* every change to the table, local or replicated into this node's storage, so a tombstone
|
|
2223
|
+
* arriving by replication is seen on the next call. The cost that matters is the storage
|
|
2224
|
+
* layer's, paid by a cold view's load: while `Revocation` has never been written, this node
|
|
2225
|
+
* does not hold its block, and Optimystic consults a block's cohort on every read of a block
|
|
2226
|
+
* it does not hold, each a round trip to every other member on a multi-machine party. The row
|
|
2227
|
+
* count only grows (append-only) but stays small while removals are rare; revisit if
|
|
1930
2228
|
* removals become routine and the scan itself shows up in a profile.
|
|
1931
2229
|
*
|
|
1932
2230
|
* An isolated node that has never received the `Revocation` block reads it as holding no
|
|
1933
|
-
* revocations instead of throwing — see {@link
|
|
2231
|
+
* revocations instead of throwing — see {@link readRevocationRowsOrNone}.
|
|
1934
2232
|
*
|
|
1935
2233
|
* `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
|
|
1936
2234
|
* under the write lock — see that method's note.
|
|
@@ -1938,22 +2236,26 @@ export class ControlDatabase {
|
|
|
1938
2236
|
async queryRevokedStamps(tableName: RevocableTable, retry = true): Promise<Set<string>> {
|
|
1939
2237
|
this.ensureInitialized();
|
|
1940
2238
|
const stamps = new Set<string>();
|
|
1941
|
-
for (const row of await this.
|
|
1942
|
-
|
|
2239
|
+
for (const row of await this.readRevocationRowsOrNone(retry)) {
|
|
2240
|
+
if (row.TableName === tableName) {
|
|
2241
|
+
stamps.add(row.StampId as string);
|
|
2242
|
+
}
|
|
1943
2243
|
}
|
|
1944
2244
|
return stamps;
|
|
1945
2245
|
}
|
|
1946
2246
|
|
|
1947
2247
|
/**
|
|
1948
|
-
* The `Revocation`
|
|
1949
|
-
* fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
|
|
2248
|
+
* The `Revocation` rows behind {@link queryRevokedStamps}, answering NO ROWS when the view's
|
|
2249
|
+
* load fails `cohort-unreachable` ({@link isCohortUnreachableRead}): this node does not hold
|
|
1950
2250
|
* the block and could ask no other cohort member about it. Every other failure rethrows.
|
|
1951
2251
|
* The retry in {@link readRows} runs first, so a node whose connectivity returns within
|
|
1952
|
-
* the read budget still gets the real answer.
|
|
2252
|
+
* the read budget still gets the real answer. The empty answer is never installed in the
|
|
2253
|
+
* view (a failed load keeps nothing), so the next call asks again.
|
|
1953
2254
|
*
|
|
1954
2255
|
* Without this, a machine cut off from every other machine in its party before it ever
|
|
1955
|
-
* received the block —
|
|
1956
|
-
*
|
|
2256
|
+
* received the block — a joining machine gets it only by reading it from the cohort, and
|
|
2257
|
+
* on a party founded before genesis created every control collection it exists nowhere
|
|
2258
|
+
* until the first tombstone or the marker ({@link REVOCATION_LEDGER_MARKER}) — cannot
|
|
1957
2259
|
* answer ANY membership, peer-record or device-token lookup, although it holds those
|
|
1958
2260
|
* rows locally.
|
|
1959
2261
|
*
|
|
@@ -1972,19 +2274,19 @@ export class ControlDatabase {
|
|
|
1972
2274
|
*
|
|
1973
2275
|
* NOTE: an isolated node pays the whole read retry (up to
|
|
1974
2276
|
* `CONTROL_READ_RETRY_BUDGET_MS`, plus the slow attempt that exhausted it) before this
|
|
1975
|
-
* answers,
|
|
1976
|
-
* node's lookups are seen exceeding the
|
|
1977
|
-
* (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this
|
|
1978
|
-
* fails `cohort-unreachable`.
|
|
2277
|
+
* answers, on every call since the answer is not kept, and {@link queryPeerRecord} may then
|
|
2278
|
+
* load the `CadrePeer` view on top. If an isolated node's lookups are seen exceeding the
|
|
2279
|
+
* admission gate's 2 s deadline (`ADMISSION_DECISION_TIMEOUT_MS`), skip the retry for this
|
|
2280
|
+
* read when the first attempt fails `cohort-unreachable`.
|
|
1979
2281
|
*/
|
|
1980
|
-
private async
|
|
2282
|
+
private async readRevocationRowsOrNone(retry: boolean): Promise<readonly Record<string, SqlValue>[]> {
|
|
1981
2283
|
try {
|
|
1982
|
-
return await this.
|
|
2284
|
+
return await this.readMembershipRows(this.revocationView, retry);
|
|
1983
2285
|
} catch (error) {
|
|
1984
2286
|
if (!isCohortUnreachableRead(error)) {
|
|
1985
2287
|
throw error;
|
|
1986
2288
|
}
|
|
1987
|
-
log('revoked-stamps
|
|
2289
|
+
log('revoked-stamps: no cohort member reachable and the Revocation block is not held here; reading as no revocations known: %s', error);
|
|
1988
2290
|
return [];
|
|
1989
2291
|
}
|
|
1990
2292
|
}
|
|
@@ -1997,10 +2299,10 @@ export class ControlDatabase {
|
|
|
1997
2299
|
* `where`, so the composite-primary-key point-lookup hazard (see the statement comment in
|
|
1998
2300
|
* {@link reissueRevocations}) does not arise. Unlocked, like every other read.
|
|
1999
2301
|
*
|
|
2000
|
-
* Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER})
|
|
2001
|
-
* neither sweep may reap or re-sign it, and skipping it keeps
|
|
2002
|
-
* a {@link RevocableTable}. Filtered on `TableName` alone, in
|
|
2003
|
-
* admits no other row under `'Revocation'`.
|
|
2302
|
+
* Skips the ledger marker ({@link REVOCATION_LEDGER_MARKER}) an older party may hold: it
|
|
2303
|
+
* retires nothing, so neither sweep may reap or re-sign it, and skipping it keeps
|
|
2304
|
+
* `RevocationRow.tableName` a {@link RevocableTable}. Filtered on `TableName` alone, in
|
|
2305
|
+
* TypeScript: `RowIsGone` admits no other row under `'Revocation'`.
|
|
2004
2306
|
*
|
|
2005
2307
|
* `retry: false` from a caller that reads under the write lock, for the reason on
|
|
2006
2308
|
* {@link queryCadrePeers}.
|
|
@@ -2008,25 +2310,17 @@ export class ControlDatabase {
|
|
|
2008
2310
|
async queryRevocations(retry = true): Promise<RevocationRow[]> {
|
|
2009
2311
|
this.ensureInitialized();
|
|
2010
2312
|
const rows: RevocationRow[] = [];
|
|
2011
|
-
const
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
continue;
|
|
2313
|
+
for (const row of await this.readMembershipRows(this.revocationView, retry)) {
|
|
2314
|
+
if (row.TableName !== REVOCATION_LEDGER_MARKER.tableName) {
|
|
2315
|
+
rows.push(revocationRowOf(row));
|
|
2015
2316
|
}
|
|
2016
|
-
rows.push({
|
|
2017
|
-
tableName: row.TableName as RevocableTable,
|
|
2018
|
-
rowKey: row.RowKey as string,
|
|
2019
|
-
stampId: row.StampId as string,
|
|
2020
|
-
reissuedAt: (row.ReissuedAt as number | null) ?? 0,
|
|
2021
|
-
signerKey: row.SignerKey as string,
|
|
2022
|
-
signerSig: row.SignerSig as string,
|
|
2023
|
-
});
|
|
2024
2317
|
}
|
|
2025
2318
|
return rows;
|
|
2026
2319
|
}
|
|
2027
2320
|
|
|
2028
2321
|
/**
|
|
2029
|
-
* Read a single peer's address record (the full `CadrePeer` row) by PeerId
|
|
2322
|
+
* Read a single peer's address record (the full `CadrePeer` row) by PeerId, found in the
|
|
2323
|
+
* `CadrePeer` membership view ({@link readMembershipRows}).
|
|
2030
2324
|
*
|
|
2031
2325
|
* Returns null when no row exists, or when the row's `StampId` is retired in
|
|
2032
2326
|
* `CadreControl.Revocation` — a revoked peer reads as absent, so the resolver
|
|
@@ -2042,34 +2336,36 @@ export class ControlDatabase {
|
|
|
2042
2336
|
* The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
|
|
2043
2337
|
* the inverse of join-on-`,`), so the resolver re-verifies over the same bytes
|
|
2044
2338
|
* the publisher signed.
|
|
2339
|
+
*
|
|
2340
|
+
* `retry: false` from the one caller that reads under the write lock,
|
|
2341
|
+
* {@link updateSelfPeerRecord}'s body, for the reason on {@link queryCadrePeers}.
|
|
2045
2342
|
*/
|
|
2046
|
-
async queryPeerRecord(peerId: string): Promise<PeerAddressRecord | null> {
|
|
2343
|
+
async queryPeerRecord(peerId: string, retry = true): Promise<PeerAddressRecord | null> {
|
|
2344
|
+
const row = await this.findCadrePeerRow(peerId, retry);
|
|
2345
|
+
return row === null ? null : peerAddressRecordOf(row);
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
/**
|
|
2349
|
+
* One readable `CadrePeer` row by PeerId from the membership view, or null when no row exists
|
|
2350
|
+
* or its stamp is retired in `Revocation` — the single-row read behind {@link queryPeerRecord}
|
|
2351
|
+
* and the two writers that decide over the row inside their locked body
|
|
2352
|
+
* ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}). Same retired-stamp rule as
|
|
2353
|
+
* {@link queryCadrePeers}, and the same `retry` contract.
|
|
2354
|
+
*/
|
|
2355
|
+
private async findCadrePeerRow(peerId: string, retry: boolean): Promise<CadrePeerRow | null> {
|
|
2047
2356
|
this.ensureInitialized();
|
|
2048
|
-
const revoked = await this.queryRevokedStamps('CadrePeer');
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
'peer-record'
|
|
2053
|
-
)) {
|
|
2054
|
-
const stampId = (row.StampId as string | null) ?? null;
|
|
2055
|
-
if (stampId !== null && revoked.has(stampId)) {
|
|
2056
|
-
return null;
|
|
2057
|
-
}
|
|
2058
|
-
const multiaddr = (row.Multiaddr as string | null) ?? '';
|
|
2059
|
-
return {
|
|
2060
|
-
peerId: row.PeerId as string,
|
|
2061
|
-
publicKey: (row.PublicKey as string | null) ?? '',
|
|
2062
|
-
addrs: multiaddr.length > 0 ? multiaddr.split(',') : [],
|
|
2063
|
-
updatedAt: (row.UpdatedAt as number | null) ?? 0,
|
|
2064
|
-
profile: (row.Profile as NodeProfile | null) ?? null,
|
|
2065
|
-
sig: (row.Sig as string | null) ?? '',
|
|
2066
|
-
};
|
|
2357
|
+
const revoked = await this.queryRevokedStamps('CadrePeer', retry);
|
|
2358
|
+
const row = (await this.readMembershipRows(this.cadrePeerView, retry)).find((candidate) => candidate.PeerId === peerId);
|
|
2359
|
+
if (row === undefined) {
|
|
2360
|
+
return null;
|
|
2067
2361
|
}
|
|
2068
|
-
|
|
2362
|
+
const peer = cadrePeerRowOf(row);
|
|
2363
|
+
return peer.stampId !== null && revoked.has(peer.stampId) ? null : peer;
|
|
2069
2364
|
}
|
|
2070
2365
|
|
|
2071
2366
|
/**
|
|
2072
|
-
* Apply a peer's own self-signed address-record update to
|
|
2367
|
+
* Apply a peer's own self-signed address-record update to its existing row: read the row
|
|
2368
|
+
* under the write lock, have `sign` build the record over it, and write that record.
|
|
2073
2369
|
*
|
|
2074
2370
|
* Authorization is carried entirely by the record: the `Sig` column (verified
|
|
2075
2371
|
* by the `AuthorizedUpdate` self-branch against the stored `PublicKey`) plus
|
|
@@ -2078,6 +2374,21 @@ export class ControlDatabase {
|
|
|
2078
2374
|
* exists. `PublicKey` is intentionally not in the SET list (it is immutable on
|
|
2079
2375
|
* self-update and the constraint enforces `new.PublicKey = old.PublicKey`).
|
|
2080
2376
|
*
|
|
2377
|
+
* The read, the decision and the signature all run INSIDE the locked body, which is what
|
|
2378
|
+
* lets it opt into {@link LOST_UPDATE_RETRY_POLICY}: the row has a second writer, the owner's
|
|
2379
|
+
* re-touch ({@link reauthorizeCadrePeer}), and the two fire together while a cadre starts.
|
|
2380
|
+
* When this write loses that race the commit is refused as a lost update and re-run, and the
|
|
2381
|
+
* re-run reads the row the rival left — including its bumped `UpdatedAt`, which a record
|
|
2382
|
+
* signed before the race could no longer exceed — and signs past it. A record signed outside
|
|
2383
|
+
* the body would be re-presented unchanged and refused again, this time by the schema's
|
|
2384
|
+
* monotonic rule, which is correctly never retried. The row's read inside is `retry: false`
|
|
2385
|
+
* (see {@link queryStampId}'s NOTE).
|
|
2386
|
+
*
|
|
2387
|
+
* Returns the record actually written, so the caller's bookkeeping describes the row rather
|
|
2388
|
+
* than the first record it signed; null, and nothing written, when no readable row exists:
|
|
2389
|
+
* the row is absent, or its stamp is retired in `Revocation` (a revoked node reads its own row
|
|
2390
|
+
* as absent every heartbeat, and must not refresh it).
|
|
2391
|
+
*
|
|
2081
2392
|
* Deliberately NOT wrapped in {@link mutateCadrePeer}, the one `CadrePeer` mutator that
|
|
2082
2393
|
* is not: it only ever touches THIS node's own row (sole caller
|
|
2083
2394
|
* `CadreNode.publishSelfRecord`), and `CadreNode.listAuthorizedMembers` filters self out
|
|
@@ -2090,17 +2401,42 @@ export class ControlDatabase {
|
|
|
2090
2401
|
* row, and a self insert happens once at startup, so the wasted refresh is a single read
|
|
2091
2402
|
* — cheaper than a conditional carve-out inside the shared writer. Only the repeating
|
|
2092
2403
|
* path is worth exempting.
|
|
2404
|
+
*
|
|
2405
|
+
* @param peerId - the row to refresh: this node's own.
|
|
2406
|
+
* @param sign - builds the signed record over the row as read; called once per attempt.
|
|
2093
2407
|
*/
|
|
2094
|
-
async updateSelfPeerRecord(
|
|
2408
|
+
async updateSelfPeerRecord(peerId: string, sign: SelfRecordSigner): Promise<PeerAddressRecord | null> {
|
|
2095
2409
|
this.ensureInitialized();
|
|
2096
|
-
const
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2410
|
+
const written = await this.lockedWithRetry(async () => {
|
|
2411
|
+
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
2412
|
+
// NOTE: this read of the Revocation and CadrePeer views is charged to the retry budget,
|
|
2413
|
+
// so a cold view load that waits out a per-peer read deadline leaves the attempt no room
|
|
2414
|
+
// for its one retry (the 14.7 s abandonment the policy's doc cites). The caller's read
|
|
2415
|
+
// outside the funnel warms the views, so this is a cache hit except when a table change
|
|
2416
|
+
// between the two reads dropped them — the racing case itself. If that residual ever
|
|
2417
|
+
// matters, measure which phase of the losing attempt pays the deadline first; the
|
|
2418
|
+
// retired-stamp check cannot simply move outside, since `publishSelfRecord`'s insert
|
|
2419
|
+
// path reads a revoked node's row as absent and relies on this body to skip it.
|
|
2420
|
+
const current = await this.queryPeerRecord(peerId, false);
|
|
2421
|
+
if (current === null) {
|
|
2422
|
+
return null;
|
|
2423
|
+
}
|
|
2424
|
+
const record = sign(current);
|
|
2425
|
+
// Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
|
|
2426
|
+
await this.db!.exec(`
|
|
2427
|
+
update CadreControl.CadrePeer
|
|
2428
|
+
with context OwnerKey = null, Signature = ?
|
|
2429
|
+
set Multiaddr = ?, UpdatedAt = ?, Profile = ?, Sig = ?
|
|
2430
|
+
where PeerId = ?
|
|
2431
|
+
`, [record.sig, record.addrs.join(','), record.updatedAt, record.profile, record.sig, peerId]);
|
|
2432
|
+
return record;
|
|
2433
|
+
}, LOST_UPDATE_RETRY_POLICY, 'self-record-update');
|
|
2434
|
+
if (written === null) {
|
|
2435
|
+
log('Self peer record not updated: no readable CadrePeer row for %s (absent, or its stamp retired)', peerId);
|
|
2436
|
+
} else {
|
|
2437
|
+
log('Self peer record updated: %s (updatedAt=%d)', peerId, written.updatedAt);
|
|
2438
|
+
}
|
|
2439
|
+
return written;
|
|
2104
2440
|
}
|
|
2105
2441
|
|
|
2106
2442
|
/**
|
|
@@ -2366,6 +2702,13 @@ export class ControlDatabase {
|
|
|
2366
2702
|
* body, and a row whose stamp is already retired (held without its delete) is skipped rather
|
|
2367
2703
|
* than tombstoned twice. Nothing to remove writes nothing and returns all false.
|
|
2368
2704
|
*
|
|
2705
|
+
* Opted into {@link LOST_UPDATE_RETRY_POLICY} for the reason on {@link deleteCadrePeer}: every
|
|
2706
|
+
* stamp and signature is minted inside the body, and the invitation keys come from the
|
|
2707
|
+
* caller's plan, which a self-publish does not touch (a withdrawal of an invitation withdrawn
|
|
2708
|
+
* since collides on `Revocation`'s primary key — a constraint failure, never retried, as
|
|
2709
|
+
* before). A lost update after a sibling tree of the transaction already synced arrives as a
|
|
2710
|
+
* partial commit and stays declined.
|
|
2711
|
+
*
|
|
2369
2712
|
* The caller decides which invitations the machine could use, from its plan
|
|
2370
2713
|
* (`CadreNode.planMachineRemoval`).
|
|
2371
2714
|
*/
|
|
@@ -2402,7 +2745,7 @@ export class ControlDatabase {
|
|
|
2402
2745
|
removedDeviceToken: removed.has('DeviceToken'),
|
|
2403
2746
|
withdrawnInvitations: withdrawals.map((withdrawal) => withdrawal.ref.rowKey),
|
|
2404
2747
|
};
|
|
2405
|
-
});
|
|
2748
|
+
}, LOST_UPDATE_RETRY_POLICY);
|
|
2406
2749
|
}
|
|
2407
2750
|
|
|
2408
2751
|
/**
|
|
@@ -2461,7 +2804,19 @@ export class ControlDatabase {
|
|
|
2461
2804
|
): Promise<void> {
|
|
2462
2805
|
this.ensureInitialized();
|
|
2463
2806
|
log('Inserting strand: %s (type: %s)', strandId, type);
|
|
2807
|
+
const insert = await this.signStrandInsert(strandId, type, ownerKey, signMessage, memberPrivateKey);
|
|
2808
|
+
await this.execWrite(insert.sql, insert.params, 'strand-insert');
|
|
2809
|
+
log('Strand inserted: %s', strandId);
|
|
2810
|
+
}
|
|
2464
2811
|
|
|
2812
|
+
/** The stamped, owner-signed `Strand` insert that {@link insertStrand} and {@link insertFoundingRows} run. */
|
|
2813
|
+
private async signStrandInsert(
|
|
2814
|
+
strandId: string,
|
|
2815
|
+
type: 'o' | 'c',
|
|
2816
|
+
ownerKey: string,
|
|
2817
|
+
signMessage: ControlSign,
|
|
2818
|
+
memberPrivateKey?: string
|
|
2819
|
+
): Promise<SignedInsert> {
|
|
2465
2820
|
// Generate a unique stamp ID using the peer ID for distributed uniqueness
|
|
2466
2821
|
const peerId = this.config.libp2pNode.peerId.toString();
|
|
2467
2822
|
const stampId = generateStampId(peerId);
|
|
@@ -2475,13 +2830,55 @@ export class ControlDatabase {
|
|
|
2475
2830
|
|
|
2476
2831
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
2477
2832
|
// FounderOwnerKey must equal the signing owner or the constraint rejects the row.
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2833
|
+
return {
|
|
2834
|
+
sql: `
|
|
2835
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId, FounderOwnerKey, FounderPeerId)
|
|
2836
|
+
with context OwnerKey = ?, Signature = ?
|
|
2837
|
+
values (?, ?, ?, ?, ?, ?)
|
|
2838
|
+
`,
|
|
2839
|
+
params: [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId, ownerKey, peerId],
|
|
2840
|
+
};
|
|
2841
|
+
}
|
|
2483
2842
|
|
|
2484
|
-
|
|
2843
|
+
/**
|
|
2844
|
+
* Insert one founding's control rows — the `Strand` row, a closed strand's `StrandPartyKey`
|
|
2845
|
+
* row, and a formation invitation bound to the strand — in ONE transaction: one commit, and
|
|
2846
|
+
* never a strand row without the key or invitation written beside it.
|
|
2847
|
+
*
|
|
2848
|
+
* Each row is stamped and signed exactly as its single-row writer ({@link insertStrand},
|
|
2849
|
+
* {@link insertStrandPartyKey}, {@link insertFormationInvite}) does it, and all of that — the
|
|
2850
|
+
* invitation's {@link canonicalDatetime} read included — happens BEFORE the write lock is
|
|
2851
|
+
* taken: signing may prompt the user, and the lock held across a prompt would stall every
|
|
2852
|
+
* other control write. A {@link lockedWithRetry} re-run re-presents the same signed rows.
|
|
2853
|
+
*
|
|
2854
|
+
* No row's `AuthorizedInsert` reads another of these rows (`StrandPartyKey` and
|
|
2855
|
+
* `FormationInvite` check only `OwnerKey` and `Revocation`), so their order is free; the
|
|
2856
|
+
* `Strand` row goes first, so a founding repeated over a stored row is refused on `Strand.Id`.
|
|
2857
|
+
*
|
|
2858
|
+
* A uniqueness rejection rolls the whole transaction back. The caller resolves it and writes
|
|
2859
|
+
* again: a `Strand.Id` conflict is another founder's row, a `StrandPartyKey.Id` conflict a key
|
|
2860
|
+
* stored with no strand row ({@link isStrandIdConflict}; see `CadreNode.publishStrand`).
|
|
2861
|
+
*/
|
|
2862
|
+
async insertFoundingRows(rows: FoundingRows, ownerKey: string, signMessage: ControlSign): Promise<void> {
|
|
2863
|
+
this.ensureInitialized();
|
|
2864
|
+
const { strand, partyKey, formationInvite } = rows;
|
|
2865
|
+
log('Inserting founding rows: strand %s (type: %s, party key: %s, invitation: %s)',
|
|
2866
|
+
strand.id, strand.type, partyKey !== undefined, formationInvite !== undefined);
|
|
2867
|
+
const inserts = [await this.signStrandInsert(strand.id, strand.type, ownerKey, signMessage, strand.memberPrivateKey)];
|
|
2868
|
+
if (partyKey !== undefined) {
|
|
2869
|
+
inserts.push(await this.signStrandPartyKeyInsert(strand.id, partyKey, ownerKey, signMessage));
|
|
2870
|
+
}
|
|
2871
|
+
if (formationInvite) {
|
|
2872
|
+
const { token, sAppId, ...options } = formationInvite;
|
|
2873
|
+
inserts.push(await this.signFormationInviteInsert(token, sAppId, ownerKey, signMessage, { ...options, strandId: strand.id }));
|
|
2874
|
+
}
|
|
2875
|
+
// Bare `exec`: the lock is held, and `execWrite` would take it again and self-deadlock.
|
|
2876
|
+
await this.lockedWithRetry(() => this.inTransaction('found strand', async () => {
|
|
2877
|
+
for (const insert of inserts) {
|
|
2878
|
+
await this.db!.exec(insert.sql, insert.params);
|
|
2879
|
+
}
|
|
2880
|
+
}), {}, 'strand-found');
|
|
2881
|
+
log('Founding rows inserted: strand %s', strand.id);
|
|
2485
2882
|
}
|
|
2486
2883
|
|
|
2487
2884
|
/**
|
|
@@ -2590,22 +2987,33 @@ export class ControlDatabase {
|
|
|
2590
2987
|
): Promise<void> {
|
|
2591
2988
|
this.ensureInitialized();
|
|
2592
2989
|
log('Inserting strand party key for strand: %s', strandId);
|
|
2990
|
+
const insert = await this.signStrandPartyKeyInsert(strandId, privateKey, ownerKey, signMessage);
|
|
2991
|
+
await this.execWrite(insert.sql, insert.params, 'strand-party-key-insert');
|
|
2992
|
+
log('Strand party key inserted for strand: %s', strandId);
|
|
2993
|
+
}
|
|
2593
2994
|
|
|
2594
|
-
|
|
2595
|
-
|
|
2995
|
+
/** The stamped, owner-signed `StrandPartyKey` insert that {@link insertStrandPartyKey} and {@link insertFoundingRows} run. */
|
|
2996
|
+
private async signStrandPartyKeyInsert(
|
|
2997
|
+
strandId: string,
|
|
2998
|
+
privateKey: string,
|
|
2999
|
+
ownerKey: string,
|
|
3000
|
+
signMessage: ControlSign
|
|
3001
|
+
): Promise<SignedInsert> {
|
|
3002
|
+
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
2596
3003
|
|
|
2597
3004
|
// Field order MUST match the schema's StrandPartyKey `AuthorizedInsert` verify:
|
|
2598
3005
|
// Id, PrivateKey, StampId.
|
|
2599
3006
|
const message = buildAuthorizationMessage('CadreControl.StrandPartyKey', 'add', this.config.partyId, [strandId, privateKey, stampId]);
|
|
2600
3007
|
const signature = await signMessage(message);
|
|
2601
3008
|
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
3009
|
+
return {
|
|
3010
|
+
sql: `
|
|
3011
|
+
insert into CadreControl.StrandPartyKey (Id, PrivateKey, StampId)
|
|
3012
|
+
with context OwnerKey = ?, Signature = ?
|
|
3013
|
+
values (?, ?, ?)
|
|
3014
|
+
`,
|
|
3015
|
+
params: [ownerKey, signature, strandId, privateKey, stampId],
|
|
3016
|
+
};
|
|
2609
3017
|
}
|
|
2610
3018
|
|
|
2611
3019
|
/**
|
|
@@ -2969,32 +3377,44 @@ export class ControlDatabase {
|
|
|
2969
3377
|
}
|
|
2970
3378
|
|
|
2971
3379
|
/**
|
|
2972
|
-
* Owner "re-touch" of an existing `CadrePeer` membership row:
|
|
2973
|
-
*
|
|
2974
|
-
*
|
|
2975
|
-
* the row is re-emitted as a fresh, broadcasting
|
|
2976
|
-
*
|
|
2977
|
-
*
|
|
2978
|
-
*
|
|
2979
|
-
*
|
|
2980
|
-
*
|
|
2981
|
-
*
|
|
2982
|
-
*
|
|
2983
|
-
*
|
|
2984
|
-
*
|
|
2985
|
-
* `
|
|
2986
|
-
*
|
|
2987
|
-
*
|
|
2988
|
-
*
|
|
2989
|
-
*
|
|
2990
|
-
*
|
|
2991
|
-
*
|
|
2992
|
-
*
|
|
2993
|
-
*
|
|
2994
|
-
*
|
|
2995
|
-
*
|
|
2996
|
-
*
|
|
2997
|
-
*
|
|
3380
|
+
* Owner "re-touch" of an existing `CadrePeer` membership row: rewrite `VouchOwner`/`VouchSig`
|
|
3381
|
+
* (and, re-broadcasting, bump `UpdatedAt`) under the owner branch of
|
|
3382
|
+
* `CadrePeer.AuthorizedUpdate` — a signature over the SAME `'vouch'`-tagged digest
|
|
3383
|
+
* {@link insertCadrePeer} builds — so the row is re-emitted as a fresh, broadcasting
|
|
3384
|
+
* transaction, or stands on a different owner's voucher.
|
|
3385
|
+
*
|
|
3386
|
+
* Re-broadcasting is the write-while-alone re-replication primitive: a membership row that
|
|
3387
|
+
* committed local-only (its block's cluster ≤1 at insert) is pushed to the cohort once it
|
|
3388
|
+
* grows, by re-issuing this monotonic bump. It is an UPDATE (not the original INSERT) because
|
|
3389
|
+
* the row already exists locally; a re-INSERT would hit the `PeerId` PK. Only the freshness
|
|
3390
|
+
* stamp and voucher change — `PublicKey` / `Multiaddr` / `Sig` / `Profile` are left intact
|
|
3391
|
+
* (the owner branch requires them kept: they are the machine's own claim) — so it is safe
|
|
3392
|
+
* over a row whose peer has not self-published (`Sig` null), and this method itself skips a
|
|
3393
|
+
* row that already carries a self-`Sig` (that row is the owning peer's to refresh, and
|
|
3394
|
+
* bumping `UpdatedAt` without re-signing would invalidate its self-signature).
|
|
3395
|
+
*
|
|
3396
|
+
* The two modes ({@link CadrePeerRetouchMode}) differ in what they write: `'rebroadcast'`
|
|
3397
|
+
* bumps `UpdatedAt` to `max(now, current + 1)` from the row read inside the body and writes
|
|
3398
|
+
* only while the row's `Sig` is null; `'revouch'` leaves `UpdatedAt` out of the `set` list and
|
|
3399
|
+
* writes whether or not the machine has self-published (the owner branch has no monotonic
|
|
3400
|
+
* rule, so the column simply keeps its value).
|
|
3401
|
+
*
|
|
3402
|
+
* The row read (stamp, `Sig`, `UpdatedAt`), the mode's decision and the signature over the
|
|
3403
|
+
* row's CURRENT `StampId` (unchanged by the re-touch) all run INSIDE the locked body, which is
|
|
3404
|
+
* what lets both modes opt into {@link LOST_UPDATE_RETRY_POLICY}: the row's other writer is
|
|
3405
|
+
* the machine's own publish ({@link updateSelfPeerRecord}), and the drain's re-broadcast fires
|
|
3406
|
+
* at the moment that publish does. A re-run after losing to it reads the self-signed row and
|
|
3407
|
+
* reports `'self-published'` instead of writing the owner branch over it — which would rewrite
|
|
3408
|
+
* `UpdatedAt` under a `Sig` signed over the old value and make the record unverifiable to
|
|
3409
|
+
* resolvers until the machine's next heartbeat. The decision has to be the body's for that;
|
|
3410
|
+
* a caller's pre-check outside the lock is only a fast path (see below).
|
|
3411
|
+
*
|
|
3412
|
+
* An absent row — none, or its stamp retired, or a stampless row no writer produces — is
|
|
3413
|
+
* `'absent'`. Checked once outside the lock too, as `'self-published'` is: the common case
|
|
3414
|
+
* writes nothing, takes no lock and does NOT notify, so an absent target stays quiet
|
|
3415
|
+
* ({@link SeedBootstrapService.removePeer} keeps the same gate for the same reason). The
|
|
3416
|
+
* in-body re-check covers the race; a no-write outcome reached there notifies spuriously,
|
|
3417
|
+
* which only re-reads the member set.
|
|
2998
3418
|
*
|
|
2999
3419
|
* Notifies like the insert/remove paths even though this is "only" a re-touch: it rewrites
|
|
3000
3420
|
* VouchOwner/VouchSig, which the authorized-membership predicate judges on, so it CAN
|
|
@@ -3017,36 +3437,52 @@ export class ControlDatabase {
|
|
|
3017
3437
|
* VouchOwner/VouchSig) rather than inherit this rebinding.
|
|
3018
3438
|
*
|
|
3019
3439
|
* @param peerId - the membership row to re-touch.
|
|
3020
|
-
* @param
|
|
3440
|
+
* @param mode - re-broadcast or re-vouch ({@link CadrePeerRetouchMode}).
|
|
3021
3441
|
* @param ownerKey - public key of the re-vouching owner, rewritten into `VouchOwner`.
|
|
3022
|
-
* @param signMessage - ed25519-signs the raw message bytes with that owner's private key
|
|
3023
|
-
*
|
|
3442
|
+
* @param signMessage - ed25519-signs the raw message bytes with that owner's private key;
|
|
3443
|
+
* called inside the lock, once per attempt (the NOTE on {@link ControlSign}).
|
|
3444
|
+
* @returns what was found and done ({@link CadrePeerRetouchOutcome}).
|
|
3024
3445
|
*/
|
|
3025
3446
|
async reauthorizeCadrePeer(
|
|
3026
3447
|
peerId: string,
|
|
3027
|
-
|
|
3448
|
+
mode: CadrePeerRetouchMode,
|
|
3028
3449
|
ownerKey: string,
|
|
3029
3450
|
signMessage: ControlSign
|
|
3030
|
-
): Promise<
|
|
3451
|
+
): Promise<CadrePeerRetouchOutcome> {
|
|
3031
3452
|
this.ensureInitialized();
|
|
3032
|
-
const
|
|
3033
|
-
if (
|
|
3034
|
-
log('reauthorizeCadrePeer:
|
|
3035
|
-
return
|
|
3453
|
+
const outside = retouchTarget(await this.findCadrePeerRow(peerId, true), mode);
|
|
3454
|
+
if (typeof outside === 'string') {
|
|
3455
|
+
log('reauthorizeCadrePeer(%s): %s for %s (nothing to write)', mode, outside, peerId);
|
|
3456
|
+
return outside;
|
|
3036
3457
|
}
|
|
3037
|
-
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3458
|
+
return await this.mutateCadrePeer('peer-reauthorize', async () => {
|
|
3459
|
+
// retry: false — inside the locked write body (see queryStampId's NOTE).
|
|
3460
|
+
const target = retouchTarget(await this.findCadrePeerRow(peerId, false), mode);
|
|
3461
|
+
if (typeof target === 'string') {
|
|
3462
|
+
log('reauthorizeCadrePeer(%s): %s for %s since the pre-check (nothing to write)', mode, target, peerId);
|
|
3463
|
+
return target;
|
|
3464
|
+
}
|
|
3465
|
+
const signature = await signMessage(
|
|
3466
|
+
buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', this.config.partyId, [peerId, target.stampId])
|
|
3467
|
+
);
|
|
3041
3468
|
// Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3469
|
+
if (mode === 'rebroadcast') {
|
|
3470
|
+
await this.db!.exec(`
|
|
3471
|
+
update CadreControl.CadrePeer
|
|
3472
|
+
with context OwnerKey = ?, Signature = ?
|
|
3473
|
+
set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?, VouchUsage = null
|
|
3474
|
+
where PeerId = ?
|
|
3475
|
+
`, [ownerKey, signature, Math.max(Date.now(), target.updatedAt + 1), ownerKey, signature, peerId]);
|
|
3476
|
+
} else {
|
|
3477
|
+
await this.db!.exec(`
|
|
3478
|
+
update CadreControl.CadrePeer
|
|
3479
|
+
with context OwnerKey = ?, Signature = ?
|
|
3480
|
+
set VouchOwner = ?, VouchSig = ?, VouchUsage = null
|
|
3481
|
+
where PeerId = ?
|
|
3482
|
+
`, [ownerKey, signature, ownerKey, signature, peerId]);
|
|
3483
|
+
}
|
|
3484
|
+
return 'retouched';
|
|
3485
|
+
}, LOST_UPDATE_RETRY_POLICY);
|
|
3050
3486
|
}
|
|
3051
3487
|
|
|
3052
3488
|
/**
|
|
@@ -3068,6 +3504,12 @@ export class ControlDatabase {
|
|
|
3068
3504
|
*
|
|
3069
3505
|
* A no-op (no throw, no tombstone) when the row does not exist — `false` then, `true`
|
|
3070
3506
|
* when a row was actually removed.
|
|
3507
|
+
*
|
|
3508
|
+
* Opted into {@link LOST_UPDATE_RETRY_POLICY}: the row's machine may publish its own record
|
|
3509
|
+
* at the moment of the removal (its first publish lands about a second after it starts), and
|
|
3510
|
+
* the removal that loses is refused as a lost update. {@link deleteGuardedRow} reads the stamp
|
|
3511
|
+
* and signs inside the body, and a self-publish never changes the stamp the `'remove'` digest
|
|
3512
|
+
* binds, so the re-run reads the published row and removes it.
|
|
3071
3513
|
*/
|
|
3072
3514
|
deleteCadrePeer(
|
|
3073
3515
|
peerId: string,
|
|
@@ -3075,7 +3517,7 @@ export class ControlDatabase {
|
|
|
3075
3517
|
signMessage: ControlSign
|
|
3076
3518
|
): Promise<boolean> {
|
|
3077
3519
|
return this.mutateCadrePeer('peer-remove', () =>
|
|
3078
|
-
this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage));
|
|
3520
|
+
this.deleteGuardedRow('CadrePeer', peerId, ownerKey, signMessage), LOST_UPDATE_RETRY_POLICY);
|
|
3079
3521
|
}
|
|
3080
3522
|
|
|
3081
3523
|
/**
|
|
@@ -3178,9 +3620,8 @@ export class ControlDatabase {
|
|
|
3178
3620
|
/**
|
|
3179
3621
|
* File the owner-signed `Revocation` tombstone retiring one row incarnation — the second
|
|
3180
3622
|
* statement of every guarded removal ({@link execGuardedRemoval}) and the whole of an
|
|
3181
|
-
* issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays
|
|
3182
|
-
*
|
|
3183
|
-
* lock and, where one is needed, the transaction.
|
|
3623
|
+
* issued row's withdrawal ({@link withdrawIssuedRow}), whose row stays. The caller
|
|
3624
|
+
* supplies the lock and, where one is needed, the transaction.
|
|
3184
3625
|
*
|
|
3185
3626
|
* `ReissuedAt` is named explicitly at 0 (the only value `FreshTombstone` accepts) rather
|
|
3186
3627
|
* than leaning on the column default — the seat-at-zero rule is load-bearing for
|
|
@@ -3189,11 +3630,7 @@ export class ControlDatabase {
|
|
|
3189
3630
|
* `SignerKey` / `SignerSig` store the context pair (`Revocation.Authorized` pins them
|
|
3190
3631
|
* equal), so a node that receives the row by replication can tell which owner filed it.
|
|
3191
3632
|
*/
|
|
3192
|
-
private async execTombstone(
|
|
3193
|
-
ref: RevokedRowRef | typeof REVOCATION_LEDGER_MARKER,
|
|
3194
|
-
revocationSignature: string,
|
|
3195
|
-
ownerKey: string
|
|
3196
|
-
): Promise<void> {
|
|
3633
|
+
private async execTombstone(ref: RevokedRowRef, revocationSignature: string, ownerKey: string): Promise<void> {
|
|
3197
3634
|
await this.db!.exec(`
|
|
3198
3635
|
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt, SignerKey, SignerSig)
|
|
3199
3636
|
with context OwnerKey = ?, Signature = ?
|
|
@@ -3239,9 +3676,10 @@ export class ControlDatabase {
|
|
|
3239
3676
|
* statement, and without the clause the reap would delete the owner's brand-new row.
|
|
3240
3677
|
* With it the statement matches nothing and the reap is a silent no-op (the schema
|
|
3241
3678
|
* branch would also refuse that delete, but as a constraint error thrown into a
|
|
3242
|
-
* background sweep — matched-nothing is the better failure mode).
|
|
3243
|
-
* {@link reauthorizeCadrePeer},
|
|
3244
|
-
* harmless — nothing was deleted, and the next sweep
|
|
3679
|
+
* background sweep — matched-nothing is the better failure mode). A race lost AFTER the
|
|
3680
|
+
* guard still returns `true` here (unlike {@link reauthorizeCadrePeer}, which reads inside
|
|
3681
|
+
* its lock and reports what it found); harmless — nothing was deleted, and the next sweep
|
|
3682
|
+
* re-reads.
|
|
3245
3683
|
*
|
|
3246
3684
|
* Writes NOTHING to `Revocation`: `RevocationRecorded` is satisfied by the same
|
|
3247
3685
|
* committed tombstone that authorizes the reap, so a reap files no second tombstone.
|
|
@@ -3268,7 +3706,7 @@ export class ControlDatabase {
|
|
|
3268
3706
|
* present clause are the shape {@link redeemInvitation}'s consent-branch insert ships).
|
|
3269
3707
|
*
|
|
3270
3708
|
* Driven automatically by {@link reapRevokedRows}, which the periodic control-cohort
|
|
3271
|
-
* reconcile pass runs while
|
|
3709
|
+
* reconcile pass runs while a sibling is reachable (`CadreNode.hasReachableControlSibling`).
|
|
3272
3710
|
*/
|
|
3273
3711
|
async reapRevokedRow(table: ReapableTable, rowKey: string, stampId: string): Promise<boolean> {
|
|
3274
3712
|
this.ensureInitialized();
|
|
@@ -3325,8 +3763,8 @@ export class ControlDatabase {
|
|
|
3325
3763
|
* carry the delete (replaying a delete of an already-absent row is a no-op), so a node
|
|
3326
3764
|
* that converged on a removal while holding the removed row keeps that row as inert
|
|
3327
3765
|
* garbage until something walks the tombstones and drops it. `CadreNode`'s periodic
|
|
3328
|
-
* control-cohort reconcile pass is that something, gated on
|
|
3329
|
-
*
|
|
3766
|
+
* control-cohort reconcile pass is that something, gated on a sibling being reachable
|
|
3767
|
+
* (a reap is a write, and a write committed alone is local-only).
|
|
3330
3768
|
*
|
|
3331
3769
|
* Enumerated UNLOCKED, deleted per-row LOCKED: {@link reapRevokedRow} takes the write
|
|
3332
3770
|
* lock itself (via {@link mutateCadrePeer} / {@link execWrite}) and the lock is not
|
|
@@ -3359,12 +3797,11 @@ export class ControlDatabase {
|
|
|
3359
3797
|
*
|
|
3360
3798
|
* NOTE: cost is O(tombstones), not O(live rows) — one {@link queryRevocations} scan plus
|
|
3361
3799
|
* one {@link queryStampId} point lookup per tombstone, and the empty early return makes
|
|
3362
|
-
* the common case (a party that has never revoked anyone
|
|
3363
|
-
*
|
|
3364
|
-
*
|
|
3365
|
-
* ({@link openRevocationLedger}), filed by the same connected pass, makes it a held block.
|
|
3800
|
+
* the common case (a party that has never revoked anyone) a single scan per pass, served
|
|
3801
|
+
* from the `Revocation` membership view
|
|
3802
|
+
* ({@link readMembershipRows}) unless the table changed since the last read.
|
|
3366
3803
|
* But `Revocation` is append-only and unbounded, so this is O(all tombstones ever) point
|
|
3367
|
-
* lookups on every reconcile
|
|
3804
|
+
* lookups on every connected reconcile pass. Fine while revocations stay rare for a cadre-sized
|
|
3368
3805
|
* party; if the table ever grows, persist a node-local high-water mark of what has
|
|
3369
3806
|
* already been reaped instead of re-scanning everything (the same bound the sweep in
|
|
3370
3807
|
* `CadreNode.drainPendingRevocations` wants).
|
|
@@ -3482,78 +3919,6 @@ export class ControlDatabase {
|
|
|
3482
3919
|
}, {}, 'revocation-reissue');
|
|
3483
3920
|
}
|
|
3484
3921
|
|
|
3485
|
-
/**
|
|
3486
|
-
* Owner-signed filing of the singleton `Revocation` ledger marker
|
|
3487
|
-
* ({@link REVOCATION_LEDGER_MARKER}), the one row in that table that retires nothing.
|
|
3488
|
-
*
|
|
3489
|
-
* Why it exists: while `Revocation` has never been written, this node does not hold its
|
|
3490
|
-
* block, and Optimystic consults a block's cohort on EVERY read of a block it does not
|
|
3491
|
-
* hold — and every membership lookup, and every guarded insert's `NotRevoked` check,
|
|
3492
|
-
* reads this table. Once any row exists the block is held and re-checked at most once per
|
|
3493
|
-
* read-repair window, like every other populated table. The schema's table comment says
|
|
3494
|
-
* why the row can never read as a retirement.
|
|
3495
|
-
*
|
|
3496
|
-
* Insert-if-absent, shaped like {@link reissueRevocations}: the signature is minted
|
|
3497
|
-
* OUTSIDE the locked body so a retried attempt re-presents the same one, and the guard
|
|
3498
|
-
* runs INSIDE it so it sees a concurrent local writer's committed row. The guard scans
|
|
3499
|
-
* the `'Revocation'` rows and compares the stamp in TypeScript rather than seeking the
|
|
3500
|
-
* full composite primary key, which is served as a point lookup that can miss an existing
|
|
3501
|
-
* row on a networked database (tickets/backlog/debt-composite-pk-point-lookup-unreliable-untracked).
|
|
3502
|
-
*
|
|
3503
|
-
* Two owners filing at once: the loser either sees the marker in its guard or is refused
|
|
3504
|
-
* on the primary key, and both answer `'already-open'`.
|
|
3505
|
-
*
|
|
3506
|
-
* The caller decides WHEN. A marker committed while the node is alone is local-only and
|
|
3507
|
-
* can fork the collection, so {@link CadreNode} files it only while connected; this
|
|
3508
|
-
* method does not look at connectivity.
|
|
3509
|
-
*
|
|
3510
|
-
* @param ownerKey - owner public key for the write context (`Revocation.Authorized`).
|
|
3511
|
-
* @param signMessage - ed25519-signs the raw message bytes (no pre-hash) with that owner's
|
|
3512
|
-
* private key, returning a base64url signature.
|
|
3513
|
-
* @returns `'opened'` when this call filed the marker, `'already-open'` when it was
|
|
3514
|
-
* already there.
|
|
3515
|
-
*/
|
|
3516
|
-
async openRevocationLedger(
|
|
3517
|
-
ownerKey: string,
|
|
3518
|
-
signMessage: ControlSign
|
|
3519
|
-
): Promise<RevocationLedgerOpenResult> {
|
|
3520
|
-
this.ensureInitialized();
|
|
3521
|
-
const signature = await signMessage(revocationTombstoneMessage(this.config.partyId, REVOCATION_LEDGER_MARKER));
|
|
3522
|
-
try {
|
|
3523
|
-
return await this.lockedWithRetry<RevocationLedgerOpenResult>(async () => {
|
|
3524
|
-
if (await this.revocationLedgerFiled()) {
|
|
3525
|
-
return 'already-open';
|
|
3526
|
-
}
|
|
3527
|
-
// execTombstone is a bare `exec`: already inside the write lock, which is NOT re-entrant.
|
|
3528
|
-
await this.execTombstone(REVOCATION_LEDGER_MARKER, signature, ownerKey);
|
|
3529
|
-
log('Revocation ledger marker filed');
|
|
3530
|
-
return 'opened';
|
|
3531
|
-
}, {}, 'revocation-ledger-open');
|
|
3532
|
-
} catch (error) {
|
|
3533
|
-
if (!isRevocationLedgerConflict(error)) {
|
|
3534
|
-
throw error;
|
|
3535
|
-
}
|
|
3536
|
-
log('Revocation ledger marker already filed (refused on the primary key, most likely by another owner filing first): %s', error);
|
|
3537
|
-
return 'already-open';
|
|
3538
|
-
}
|
|
3539
|
-
}
|
|
3540
|
-
|
|
3541
|
-
/**
|
|
3542
|
-
* Whether the ledger marker is present locally: a scan of the `'Revocation'` rows with the
|
|
3543
|
-
* stamp compared in TypeScript (see {@link openRevocationLedger} for why not a seek).
|
|
3544
|
-
* Called only inside the locked write body, so never retried (see queryStampId's NOTE).
|
|
3545
|
-
*/
|
|
3546
|
-
private async revocationLedgerFiled(): Promise<boolean> {
|
|
3547
|
-
const { tableName, stampId } = REVOCATION_LEDGER_MARKER;
|
|
3548
|
-
const rows = await this.readRows(
|
|
3549
|
-
'select StampId from CadreControl.Revocation where TableName = ?',
|
|
3550
|
-
[tableName],
|
|
3551
|
-
'revocation-ledger',
|
|
3552
|
-
false
|
|
3553
|
-
);
|
|
3554
|
-
return rows.some(row => row.StampId === stampId);
|
|
3555
|
-
}
|
|
3556
|
-
|
|
3557
3922
|
/**
|
|
3558
3923
|
* Run `body` between `beginTransaction` and `commit`, rolling back on failure.
|
|
3559
3924
|
*
|
|
@@ -3600,11 +3965,28 @@ export class ControlDatabase {
|
|
|
3600
3965
|
* {@link CadreNode}, which wires this in `start()` and clears it on teardown. A
|
|
3601
3966
|
* second call replaces the first rather than fanning out, so a stale node can
|
|
3602
3967
|
* never keep receiving notifications for a database it no longer owns.
|
|
3968
|
+
*
|
|
3969
|
+
* Only this database's own writes notify here, awaited inside the write so the gate is
|
|
3970
|
+
* current before the write returns. A membership change replicated in from another
|
|
3971
|
+
* machine reaches the gate through the subscription `CadreNode.start` takes on the
|
|
3972
|
+
* membership tables ({@link watchTables}), a coalesced run up to 50 ms after the change
|
|
3973
|
+
* lands in storage; one storage never hears of waits for the reconcile pass's backstop.
|
|
3603
3974
|
*/
|
|
3604
3975
|
setMembershipChangeListener(listener: MembershipChangeListener | null): void {
|
|
3605
3976
|
this.membershipListener = listener;
|
|
3606
3977
|
}
|
|
3607
3978
|
|
|
3979
|
+
/**
|
|
3980
|
+
* Subscribe `onChange` to changes in control tables (`'CadreControl.Strand'`, …): commits
|
|
3981
|
+
* made here and changes replicated into this node's storage alike, on the single-flight
|
|
3982
|
+
* schedule `table-change-watch.ts` describes. {@link close} disposes every subscription
|
|
3983
|
+
* still live before it drains the write queue.
|
|
3984
|
+
*/
|
|
3985
|
+
watchTables(tables: readonly string[], onChange: () => Promise<void>, options: TableChangeWatchOptions): TableChangeWatch {
|
|
3986
|
+
this.ensureInitialized();
|
|
3987
|
+
return this.tableWatches.watch(this.db!, tables, onChange, options);
|
|
3988
|
+
}
|
|
3989
|
+
|
|
3608
3990
|
/**
|
|
3609
3991
|
* Wire (or clear, with null) the single listener notified after every committed
|
|
3610
3992
|
* guarded-table delete. Same ownership contract as
|
|
@@ -3665,8 +4047,11 @@ export class ControlDatabase {
|
|
|
3665
4047
|
* exhaustion.
|
|
3666
4048
|
*
|
|
3667
4049
|
* @param reason - Label for the log line only (e.g. `'peer-insert'`).
|
|
4050
|
+
* @param policy - The retry policy, default unless the body has earned a wider one: the three
|
|
4051
|
+
* `CadrePeer` writers whose bodies re-read the row and decide inside pass
|
|
4052
|
+
* `LOST_UPDATE_RETRY_POLICY` (its `NOTE:` names them and the rule).
|
|
3668
4053
|
*/
|
|
3669
|
-
async mutateCadrePeer<T>(reason: string, body: () => Promise<T
|
|
4054
|
+
async mutateCadrePeer<T>(reason: string, body: () => Promise<T>, policy: ControlWriteRetryOptions = {}): Promise<T> {
|
|
3670
4055
|
this.ensureInitialized();
|
|
3671
4056
|
return this.lockedWithRetry(async () => {
|
|
3672
4057
|
this.assertCommitBoundary(reason, 'on entry to');
|
|
@@ -3674,7 +4059,7 @@ export class ControlDatabase {
|
|
|
3674
4059
|
this.assertCommitBoundary(reason, 'on return from');
|
|
3675
4060
|
await this.notifyMembershipChanged(reason);
|
|
3676
4061
|
return result;
|
|
3677
|
-
},
|
|
4062
|
+
}, policy, reason);
|
|
3678
4063
|
}
|
|
3679
4064
|
|
|
3680
4065
|
/**
|
|
@@ -3689,8 +4074,8 @@ export class ControlDatabase {
|
|
|
3689
4074
|
* class: every public write method of this class runs its statement(s) under it, and
|
|
3690
4075
|
* `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
|
|
3691
4076
|
* `Database` directly, so it is covered too. The race is
|
|
3692
|
-
* real, not theoretical: the background self-record publish
|
|
3693
|
-
*
|
|
4077
|
+
* real, not theoretical: the background self-record publish when a sibling becomes
|
|
4078
|
+
* reachable (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
|
|
3694
4079
|
* a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
|
|
3695
4080
|
* assert.
|
|
3696
4081
|
*
|
|
@@ -3714,6 +4099,11 @@ export class ControlDatabase {
|
|
|
3714
4099
|
* re-runnable means a retry re-runs the body's reads too, which is what makes it safe.
|
|
3715
4100
|
* Signatures and stamp ids minted OUTSIDE the body are deliberately NOT re-minted per
|
|
3716
4101
|
* attempt: a retry re-presents the exact signed message the first attempt presented.
|
|
4102
|
+
* A body opted into `LOST_UPDATE_RETRY_POLICY` is the exception, and must mint INSIDE:
|
|
4103
|
+
* its re-run is a re-decision over a row another writer changed, not a re-presentation of
|
|
4104
|
+
* the same write, so its read of the row, its decision and its signature all run in the
|
|
4105
|
+
* body ({@link updateSelfPeerRecord}, {@link reauthorizeCadrePeer}, {@link deleteGuardedRow}
|
|
4106
|
+
* under {@link deleteCadrePeer}, {@link removeMachine}).
|
|
3717
4107
|
*/
|
|
3718
4108
|
async withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
3719
4109
|
// Chain behind the current tail regardless of how it settled, then park a
|
|
@@ -3755,9 +4145,14 @@ export class ControlDatabase {
|
|
|
3755
4145
|
* `initialized` is set — so it reaches this method directly rather than through
|
|
3756
4146
|
* {@link execWrite}, whose {@link ensureInitialized} would reject it. The write queue is
|
|
3757
4147
|
* necessarily empty at that point, so the lock is uncontended there; it is taken anyway
|
|
3758
|
-
* rather than carving out a lockless path.
|
|
3759
|
-
*
|
|
3760
|
-
*
|
|
4148
|
+
* rather than carving out a lockless path.
|
|
4149
|
+
*
|
|
4150
|
+
* Two policies reach here besides the default: `SCHEMA_INIT_RETRY_POLICY` from that one
|
|
4151
|
+
* call site, and `LOST_UPDATE_RETRY_POLICY` from the `CadrePeer` writers that re-read the
|
|
4152
|
+
* row inside their body ({@link updateSelfPeerRecord} directly; {@link reauthorizeCadrePeer},
|
|
4153
|
+
* {@link deleteCadrePeer} and {@link removeMachine} through {@link mutateCadrePeer}'s
|
|
4154
|
+
* policy parameter). Everything else takes the default, whose classifier and attempt count
|
|
4155
|
+
* are unchanged by those call sites existing.
|
|
3761
4156
|
*
|
|
3762
4157
|
* `label` names the operation in this loop's debug lines and nothing else
|
|
3763
4158
|
* ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
|
|
@@ -3902,7 +4297,22 @@ export class ControlDatabase {
|
|
|
3902
4297
|
): Promise<void> {
|
|
3903
4298
|
this.ensureInitialized();
|
|
3904
4299
|
log('Inserting formation invite: %s', token);
|
|
4300
|
+
const insert = await this.signFormationInviteInsert(token, sAppId, ownerKey, signMessage, options);
|
|
4301
|
+
await this.execWrite(insert.sql, insert.params, 'formation-invite-insert');
|
|
4302
|
+
log('Formation invite inserted: %s', token);
|
|
4303
|
+
}
|
|
3905
4304
|
|
|
4305
|
+
/**
|
|
4306
|
+
* The stamped, owner-signed `FormationInvite` insert that {@link insertFormationInvite} and
|
|
4307
|
+
* {@link insertFoundingRows} run. Reads the engine once, for the canonical `ExpiresAt`.
|
|
4308
|
+
*/
|
|
4309
|
+
private async signFormationInviteInsert(
|
|
4310
|
+
token: string,
|
|
4311
|
+
sAppId: string,
|
|
4312
|
+
ownerKey: string,
|
|
4313
|
+
signMessage: ControlSign,
|
|
4314
|
+
options: { expiresAtMs?: number; totalUses?: number; validationUrl?: string; strandId?: string }
|
|
4315
|
+
): Promise<SignedInsert> {
|
|
3906
4316
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
3907
4317
|
|
|
3908
4318
|
// Build each bound field exactly as the deferred CHECK sees it post-coercion:
|
|
@@ -3929,21 +4339,22 @@ export class ControlDatabase {
|
|
|
3929
4339
|
// Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
|
|
3930
4340
|
// signed source-of-truth and the stored value are produced once. StampId is a real,
|
|
3931
4341
|
// unique column (single-use anti-replay), no longer a context value.
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
3943
|
-
|
|
3944
|
-
|
|
3945
|
-
|
|
3946
|
-
|
|
4342
|
+
return {
|
|
4343
|
+
sql: `
|
|
4344
|
+
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
4345
|
+
with context OwnerKey = ?, Signature = ?
|
|
4346
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
4347
|
+
`,
|
|
4348
|
+
params: [
|
|
4349
|
+
ownerKey, signature,
|
|
4350
|
+
token, sAppId,
|
|
4351
|
+
expiresAtCanonical,
|
|
4352
|
+
options.totalUses ?? null,
|
|
4353
|
+
options.validationUrl ?? null,
|
|
4354
|
+
options.strandId ?? null,
|
|
4355
|
+
stampId,
|
|
4356
|
+
],
|
|
4357
|
+
};
|
|
3947
4358
|
}
|
|
3948
4359
|
|
|
3949
4360
|
/**
|
|
@@ -4066,14 +4477,13 @@ export class ControlDatabase {
|
|
|
4066
4477
|
* Use {@link redeemInvitation} instead when the strand must be created by
|
|
4067
4478
|
* consent atomically with the usage.
|
|
4068
4479
|
*
|
|
4069
|
-
* The strand's live `StampId` is
|
|
4480
|
+
* The strand's live `StampId` is written onto the usage row:
|
|
4070
4481
|
* `FormationUsage.StrandExists` matches the (id, stamp) PAIR, and
|
|
4071
4482
|
* `Strand.AuthorizedInsert`'s consent branch reads the same pair back, so a consent
|
|
4072
|
-
* record authorizes exactly the strand ROW it was recorded against. A
|
|
4073
|
-
*
|
|
4074
|
-
*
|
|
4075
|
-
*
|
|
4076
|
-
* point is a genuine race and deserves a named error, not a silent rollback.
|
|
4483
|
+
* record authorizes exactly the strand ROW it was recorded against. A caller that already
|
|
4484
|
+
* read the stamp passes it (`strandStampId`); otherwise it is read here first, and a
|
|
4485
|
+
* missing strand THROWS {@link MissingHostStrandError} rather than being left to the
|
|
4486
|
+
* deferred `StrandExists` CHECK.
|
|
4077
4487
|
*
|
|
4078
4488
|
* The row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so this
|
|
4079
4489
|
* path — the one production actually races on (a bound invite published by `cadre-web` /
|
|
@@ -4108,6 +4518,13 @@ export class ControlDatabase {
|
|
|
4108
4518
|
* {@link queryFormationInvite} read; `null` means "no invite row" / unlimited.
|
|
4109
4519
|
*/
|
|
4110
4520
|
totalUses?: number | null;
|
|
4521
|
+
/**
|
|
4522
|
+
* The host strand's `StampId`, when the caller already read the row
|
|
4523
|
+
* ({@link queryStrandWithStamp}); `undefined` reads it here. A stamp is fixed for the life
|
|
4524
|
+
* of its row, so one read earlier is the stamp `StrandExists` compares, unless the row was
|
|
4525
|
+
* removed (and re-seated) in between, which that CHECK refuses at commit.
|
|
4526
|
+
*/
|
|
4527
|
+
strandStampId?: string;
|
|
4111
4528
|
}): Promise<FormationUsageResult> {
|
|
4112
4529
|
this.ensureInitialized();
|
|
4113
4530
|
const {
|
|
@@ -4118,7 +4535,7 @@ export class ControlDatabase {
|
|
|
4118
4535
|
|
|
4119
4536
|
// The host strand is pre-existing and owner-signed; its stamp names the strand ROW the
|
|
4120
4537
|
// consent record authorizes.
|
|
4121
|
-
const strandStampId = await this.queryStrandStampId(strandId);
|
|
4538
|
+
const strandStampId = params.strandStampId ?? await this.queryStrandStampId(strandId);
|
|
4122
4539
|
if (strandStampId === null) {
|
|
4123
4540
|
throw new MissingHostStrandError(strandId, token);
|
|
4124
4541
|
}
|
|
@@ -4262,7 +4679,7 @@ export class ControlDatabase {
|
|
|
4262
4679
|
* permissive PRE-check that runs ahead of it, and a transiently short read costs each of
|
|
4263
4680
|
* them only a worse outcome for the ATTEMPT, never a seat the invitation did not pay for:
|
|
4264
4681
|
* {@link assertSeatRemains} loses its named exhaustion error and falls back to the CHECK's
|
|
4265
|
-
* generic refusal; `ControlFormationUsageRecorder.
|
|
4682
|
+
* generic refusal; `ControlFormationUsageRecorder.readToken` reports a seat free and lets
|
|
4266
4683
|
* the redemption proceed to the CHECK, which decides; {@link hasOutstandingFormationInvite}
|
|
4267
4684
|
* holds the stranger-admission door open slightly longer.
|
|
4268
4685
|
*
|
|
@@ -4296,12 +4713,12 @@ export class ControlDatabase {
|
|
|
4296
4713
|
/**
|
|
4297
4714
|
* Is any `FormationInvite` row still redeemable — unexpired AND with usage
|
|
4298
4715
|
* below its `TotalUses`? A null `ExpiresAt` never expires and a null
|
|
4299
|
-
* `TotalUses` is unlimited, matching
|
|
4300
|
-
*
|
|
4716
|
+
* `TotalUses` is unlimited, matching the per-token semantics of `classifyTokenRead`
|
|
4717
|
+
* (`formation-token.ts`).
|
|
4301
4718
|
*
|
|
4302
4719
|
* Answers the control-network connection gate's coarse "does this node expect
|
|
4303
4720
|
* a stranger?" question, which has no token to ask about. The expiry
|
|
4304
|
-
* comparison is `expiresAtMs <= now` — identical to `
|
|
4721
|
+
* comparison is `expiresAtMs <= now` — identical to `isInviteExpired`'s — so an
|
|
4305
4722
|
* invite the formation handler would reject can never hold the gate open.
|
|
4306
4723
|
*
|
|
4307
4724
|
* The scan is deliberately not pushed into SQL: nothing else here compares a
|
|
@@ -4494,8 +4911,10 @@ export class ControlDatabase {
|
|
|
4494
4911
|
* the row is the holder's proof and never goes away; liveness is {@link hasLiveCadreInvite}'s
|
|
4495
4912
|
* question, and `CadreInviteUsage.Authorized`'s at redemption.
|
|
4496
4913
|
*
|
|
4497
|
-
*
|
|
4498
|
-
*
|
|
4914
|
+
* A redemption reads the row once, before its write, and carries it into
|
|
4915
|
+
* {@link isCadreInviteLive} and {@link redeemCadreInvite}. `retry: false` is passed by
|
|
4916
|
+
* {@link redeemCadreInvite} when it was handed none and reads INSIDE its locked write body —
|
|
4917
|
+
* the same per-call opt-out, for the same reason, as {@link queryStampId}'s.
|
|
4499
4918
|
*/
|
|
4500
4919
|
async queryCadreInvite(key: string, retry = true): Promise<CadreInviteRow | null> {
|
|
4501
4920
|
this.ensureInitialized();
|
|
@@ -4513,23 +4932,16 @@ export class ControlDatabase {
|
|
|
4513
4932
|
* {@link queryCadrePeers}' reason.
|
|
4514
4933
|
*/
|
|
4515
4934
|
async queryCadreInvites(retry = true): Promise<CadreInviteRow[]> {
|
|
4516
|
-
this.
|
|
4517
|
-
const rows = await this.readRows(CADRE_INVITE_SELECT_SQL, undefined, 'cadre-invites', retry);
|
|
4518
|
-
return rows.map(cadreInviteRowOf);
|
|
4935
|
+
return (await this.readMembershipRows(this.cadreInviteView, retry)).map(cadreInviteRowOf);
|
|
4519
4936
|
}
|
|
4520
4937
|
|
|
4521
4938
|
/**
|
|
4522
4939
|
* Every `CadreInviteUsage` row this node holds — the redemption records the membership
|
|
4523
4940
|
* predicate verifies invitation-admitted rows against. Same `retry` contract as
|
|
4524
|
-
* {@link queryCadreInvites}.
|
|
4941
|
+
* {@link queryCadreInvites}; both come from their membership views ({@link readMembershipRows}).
|
|
4525
4942
|
*/
|
|
4526
4943
|
async queryCadreInviteUsages(retry = true): Promise<CadreInviteUsageRow[]> {
|
|
4527
|
-
this.
|
|
4528
|
-
const rows = await this.readRows(
|
|
4529
|
-
'select UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig from CadreControl.CadreInviteUsage',
|
|
4530
|
-
undefined, 'cadre-invite-usages', retry
|
|
4531
|
-
);
|
|
4532
|
-
return rows.map(cadreInviteUsageRowOf);
|
|
4944
|
+
return (await this.readMembershipRows(this.cadreInviteUsageView, retry)).map(cadreInviteUsageRowOf);
|
|
4533
4945
|
}
|
|
4534
4946
|
|
|
4535
4947
|
/**
|
|
@@ -4575,6 +4987,9 @@ export class ControlDatabase {
|
|
|
4575
4987
|
// counts make that free today; if a long-lived cadre accumulates thousands and inbound
|
|
4576
4988
|
// upgrades slow down, add an expiry-ordered index or prune withdrawn/expired rows (which
|
|
4577
4989
|
// first needs every device they admitted re-vouched, so the chain no longer needs them).
|
|
4990
|
+
// NOTE: this scan reaches storage while queryCadreInvites serves the same rows from the
|
|
4991
|
+
// membership view; if the stranger path's reads ever near the gate's 2 s deadline, walk
|
|
4992
|
+
// queryCadreInvites() here instead.
|
|
4578
4993
|
const metered: Array<{ key: string; totalUses: number }> = [];
|
|
4579
4994
|
let unlimitedLive = false;
|
|
4580
4995
|
for (const row of await this.readRows(
|
|
@@ -4612,16 +5027,21 @@ export class ControlDatabase {
|
|
|
4612
5027
|
|
|
4613
5028
|
/**
|
|
4614
5029
|
* Is this one cadre invitation still redeemable here — the conditions
|
|
4615
|
-
* {@link hasLiveCadreInvite} applies, for one invitation? The row judged is the one this
|
|
4616
|
-
* holds under `invite.key
|
|
4617
|
-
* yet), but only if its issuer signature
|
|
4618
|
-
* judged by whichever owner it names as
|
|
4619
|
-
* {@link countCadreInviteUsage}: {@link seatCadreInvite}
|
|
4620
|
-
* what enforce every condition.
|
|
4621
|
-
|
|
4622
|
-
|
|
5030
|
+
* {@link hasLiveCadreInvite} applies, for one invitation? The row judged is `held`, the one this
|
|
5031
|
+
* node holds under `invite.key` ({@link queryCadreInvite}, read by the caller); when it holds
|
|
5032
|
+
* none, `invite` itself (a bundle's copy, not seated yet), but only if its issuer signature
|
|
5033
|
+
* verifies, because an unsigned copy would otherwise be judged by whichever owner it names as
|
|
5034
|
+
* issuer. A permissive pre-check like {@link countCadreInviteUsage}: {@link seatCadreInvite}
|
|
5035
|
+
* and the redemption's own write are what enforce every condition.
|
|
5036
|
+
*
|
|
5037
|
+
* The use count read here is read again inside the redemption's write lock
|
|
5038
|
+
* ({@link assertCadreInviteSeatRemains}), on purpose: this one decides whether the device is
|
|
5039
|
+
* pushed the control store before the write, so the holder of a spent invitation is refused
|
|
5040
|
+
* without being sent anything; the in-lock one decides the seat, against a concurrent redemption.
|
|
5041
|
+
*/
|
|
5042
|
+
async isCadreInviteLive(invite: CadreInviteRow, held: CadreInviteRow | null, nowMs: number = Date.now()): Promise<boolean> {
|
|
4623
5043
|
this.ensureInitialized();
|
|
4624
|
-
const row =
|
|
5044
|
+
const row = held ?? (verifyCadreInviteRow(this.config.partyId, invite) ? invite : null);
|
|
4625
5045
|
return this.issuedRowLive('CadreInvite', row, nowMs, key => this.countCadreInviteUsage(key));
|
|
4626
5046
|
}
|
|
4627
5047
|
|
|
@@ -4794,11 +5214,27 @@ export class ControlDatabase {
|
|
|
4794
5214
|
*/
|
|
4795
5215
|
async redeemCadreInvite(params: {
|
|
4796
5216
|
inviteKey: string;
|
|
5217
|
+
/**
|
|
5218
|
+
* The row this node holds under `inviteKey`, read before the call ({@link queryCadreInvite});
|
|
5219
|
+
* omitted, it is read inside the locked body. Never a bundle's copy: the requester chooses a
|
|
5220
|
+
* copy's `TotalUses` and `IssuerKey`, and the seat check must judge the stored cap. The
|
|
5221
|
+
* schema refuses every update and delete of the row (`NoUpdate`, `NoDelete`), so the earlier
|
|
5222
|
+
* read is the row a locked read would return; withdrawal is a `Revocation` row, which the schema's
|
|
5223
|
+
* `NotRevoked` and `Authorized` checks still read at commit.
|
|
5224
|
+
*/
|
|
5225
|
+
held?: CadreInviteRow;
|
|
4797
5226
|
peerId: string;
|
|
4798
5227
|
/** The device's ed25519 public key (base64url); `peerId` is its identity multihash. */
|
|
4799
5228
|
peerKey: string;
|
|
4800
5229
|
/** The device's dialable addresses, if known; stored comma-joined as `authorizePeer` does. */
|
|
4801
5230
|
multiaddrs?: string[];
|
|
5231
|
+
/**
|
|
5232
|
+
* The rest of the device's own signed address record over `multiaddrs`, already verified by
|
|
5233
|
+
* the caller (`admittedRecord` in `cadre-invite-protocol.ts`): stored as the row's
|
|
5234
|
+
* `UpdatedAt`, `Sig` and `Profile`, so the row resolves to the device's addresses from its
|
|
5235
|
+
* admission. Omitted, the row is written unsigned, `UpdatedAt` at this machine's clock.
|
|
5236
|
+
*/
|
|
5237
|
+
record?: Pick<PeerAddressRecord, 'updatedAt' | 'profile' | 'sig'>;
|
|
4802
5238
|
/** Single-use nonce minted by the device; both signatures cover it. */
|
|
4803
5239
|
usageStampId: string;
|
|
4804
5240
|
/** The holder's signature with the invitation private key over the `'redeem'` digest. */
|
|
@@ -4808,13 +5244,13 @@ export class ControlDatabase {
|
|
|
4808
5244
|
nowMs?: number;
|
|
4809
5245
|
}): Promise<CadreInviteRedemptionResult> {
|
|
4810
5246
|
this.ensureInitialized();
|
|
4811
|
-
const { inviteKey, peerId, peerKey, usageStampId, inviteSig, peerSig } = params;
|
|
5247
|
+
const { inviteKey, peerId, peerKey, record, usageStampId, inviteSig, peerSig } = params;
|
|
4812
5248
|
const nowMs = params.nowMs ?? Date.now();
|
|
4813
5249
|
const multiaddr = params.multiaddrs?.length ? params.multiaddrs.join(',') : '';
|
|
4814
5250
|
log('Redeeming cadre invitation %s for %s', inviteKey, peerId);
|
|
4815
5251
|
|
|
4816
5252
|
return this.mutateCadrePeer('cadre-invite-redeem', async () => {
|
|
4817
|
-
const invite = await this.queryCadreInvite(inviteKey, false);
|
|
5253
|
+
const invite = params.held ?? await this.queryCadreInvite(inviteKey, false);
|
|
4818
5254
|
if (invite === null) {
|
|
4819
5255
|
throw new IssuedRowNotHereYetError('CadreInvite', inviteKey);
|
|
4820
5256
|
}
|
|
@@ -4834,10 +5270,11 @@ export class ControlDatabase {
|
|
|
4834
5270
|
const nowCanonical = await canonicalDatetime(this.db!, nowMs);
|
|
4835
5271
|
await this.inTransaction('cadre-invite-redeem', async () => {
|
|
4836
5272
|
await this.db!.exec(`
|
|
4837
|
-
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
5273
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, Profile, StampId, VouchOwner, VouchSig, VouchUsage)
|
|
4838
5274
|
with context OwnerKey = null, Signature = null
|
|
4839
|
-
values (?, ?, ?, ?,
|
|
4840
|
-
`, [peerId, peerKey, multiaddr, nowMs,
|
|
5275
|
+
values (?, ?, ?, ?, ?, ?, ?, ?, null, ?)
|
|
5276
|
+
`, [peerId, peerKey, multiaddr, record?.updatedAt ?? nowMs, record?.sig ?? null, record?.profile ?? null,
|
|
5277
|
+
peerStampId, invite.issuerKey, usageStampId]);
|
|
4841
5278
|
await this.db!.exec(`
|
|
4842
5279
|
insert into CadreControl.CadreInviteUsage (UsageStampId, InviteKey, PeerId, PeerKey, PeerStampId, InviteSig, PeerSig)
|
|
4843
5280
|
with context Now = ?
|
|
@@ -4947,7 +5384,9 @@ export class ControlDatabase {
|
|
|
4947
5384
|
/**
|
|
4948
5385
|
* Read one `OwnerGrant` row by key, or null when absent. Withdrawn rows are returned too,
|
|
4949
5386
|
* as {@link queryCadreInvite} returns them: the row is the holder's proof and never goes
|
|
4950
|
-
* away.
|
|
5387
|
+
* away. Read once per redemption and carried into {@link isOwnerGrantLive} and
|
|
5388
|
+
* {@link redeemOwnerGrant}; `retry: false` is passed by {@link redeemOwnerGrant} when it was
|
|
5389
|
+
* handed none and reads inside its locked body.
|
|
4951
5390
|
*/
|
|
4952
5391
|
async queryOwnerGrant(key: string, retry = true): Promise<OwnerGrantRow | null> {
|
|
4953
5392
|
this.ensureInitialized();
|
|
@@ -4963,19 +5402,12 @@ export class ControlDatabase {
|
|
|
4963
5402
|
* `retry: false` is forwarded from the membership-gate refresh for {@link queryCadrePeers}' reason.
|
|
4964
5403
|
*/
|
|
4965
5404
|
async queryOwnerGrants(retry = true): Promise<OwnerGrantRow[]> {
|
|
4966
|
-
this.
|
|
4967
|
-
const rows = await this.readRows(OWNER_GRANT_SELECT_SQL, undefined, 'owner-grants', retry);
|
|
4968
|
-
return rows.map(ownerGrantRowOf);
|
|
5405
|
+
return (await this.readMembershipRows(this.ownerGrantView, retry)).map(ownerGrantRowOf);
|
|
4969
5406
|
}
|
|
4970
5407
|
|
|
4971
|
-
/** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}. */
|
|
5408
|
+
/** Every `OwnerGrantUsage` row this node holds — the redemption records a grant-seated `OwnerKey` row is verified against. Same `retry` contract as {@link queryOwnerGrants}; both come from their membership views ({@link readMembershipRows}). */
|
|
4972
5409
|
async queryOwnerGrantUsages(retry = true): Promise<OwnerGrantUsageRow[]> {
|
|
4973
|
-
this.
|
|
4974
|
-
const rows = await this.readRows(
|
|
4975
|
-
'select UsageStampId, GrantKey, SeatedKey, SeatedStampId, GrantSig, OwnerSig from CadreControl.OwnerGrantUsage',
|
|
4976
|
-
undefined, 'owner-grant-usages', retry
|
|
4977
|
-
);
|
|
4978
|
-
return rows.map(ownerGrantUsageRowOf);
|
|
5410
|
+
return (await this.readMembershipRows(this.ownerGrantUsageView, retry)).map(ownerGrantUsageRowOf);
|
|
4979
5411
|
}
|
|
4980
5412
|
|
|
4981
5413
|
/**
|
|
@@ -4989,14 +5421,16 @@ export class ControlDatabase {
|
|
|
4989
5421
|
|
|
4990
5422
|
/**
|
|
4991
5423
|
* Is this one owner grant still redeemable here — not withdrawn, unexpired, uses left, issuer
|
|
4992
|
-
* still an owner — judged as {@link isCadreInviteLive} judges an invitation: by the row
|
|
4993
|
-
* node holds under `grant.key
|
|
4994
|
-
* when its issuer signature verifies. A
|
|
4995
|
-
* redemption's own write enforce every
|
|
5424
|
+
* still an owner — judged as {@link isCadreInviteLive} judges an invitation: by `held`, the row
|
|
5425
|
+
* this node holds under `grant.key` ({@link queryOwnerGrant}, read by the caller), else by
|
|
5426
|
+
* `grant` itself (a bundle's copy, not seated yet) when its issuer signature verifies. A
|
|
5427
|
+
* permissive pre-check; {@link seatOwnerGrant} and the redemption's own write enforce every
|
|
5428
|
+
* condition. Its use count is read again inside the write lock, for
|
|
5429
|
+
* {@link isCadreInviteLive}'s reason.
|
|
4996
5430
|
*/
|
|
4997
|
-
async isOwnerGrantLive(grant: OwnerGrantRow, nowMs: number = Date.now()): Promise<boolean> {
|
|
5431
|
+
async isOwnerGrantLive(grant: OwnerGrantRow, held: OwnerGrantRow | null, nowMs: number = Date.now()): Promise<boolean> {
|
|
4998
5432
|
this.ensureInitialized();
|
|
4999
|
-
const row =
|
|
5433
|
+
const row = held ?? (verifyOwnerGrantRow(this.config.partyId, grant) ? grant : null);
|
|
5000
5434
|
return this.issuedRowLive('OwnerGrant', row, nowMs, key => this.countOwnerGrantUsage(key));
|
|
5001
5435
|
}
|
|
5002
5436
|
|
|
@@ -5058,6 +5492,8 @@ export class ControlDatabase {
|
|
|
5058
5492
|
*/
|
|
5059
5493
|
async redeemOwnerGrant(params: {
|
|
5060
5494
|
grantKey: string;
|
|
5495
|
+
/** The row this node holds under `grantKey`, read before the call; omitted, it is read inside the locked body. Never a bundle's copy, for {@link redeemCadreInvite}'s reason. */
|
|
5496
|
+
held?: OwnerGrantRow;
|
|
5061
5497
|
/** The owner key to seat (base64url ed25519): the holder generated it, or already holds it. */
|
|
5062
5498
|
seatedKey: string;
|
|
5063
5499
|
/** Single-use nonce minted by the holder; both signatures cover it. */
|
|
@@ -5074,7 +5510,7 @@ export class ControlDatabase {
|
|
|
5074
5510
|
log('Redeeming owner grant %s for %s', grantKey, seatedKey);
|
|
5075
5511
|
|
|
5076
5512
|
return this.mutateCadrePeer('owner-grant-redeem', async () => {
|
|
5077
|
-
const grant = await this.queryOwnerGrant(grantKey, false);
|
|
5513
|
+
const grant = params.held ?? await this.queryOwnerGrant(grantKey, false);
|
|
5078
5514
|
if (grant === null) {
|
|
5079
5515
|
throw new IssuedRowNotHereYetError('OwnerGrant', grantKey);
|
|
5080
5516
|
}
|
|
@@ -5114,7 +5550,10 @@ export class ControlDatabase {
|
|
|
5114
5550
|
/**
|
|
5115
5551
|
* Close the database and cleanup resources.
|
|
5116
5552
|
*
|
|
5117
|
-
*
|
|
5553
|
+
* Disposes every {@link watchTables} subscription before anything else, so a commit that
|
|
5554
|
+
* lands while the write chain drains schedules no run, and again before dropping the handle.
|
|
5555
|
+
*
|
|
5556
|
+
* Then drains the local-write chain: {@link withWriteLock} can park a write behind
|
|
5118
5557
|
* others across an await, and a queued closure evaluates `this.db!` only when it
|
|
5119
5558
|
* finally runs — so nulling the handle out from under it would throw a TypeError on a
|
|
5120
5559
|
* null handle instead of committing. The tail never rejects (it swallows both
|
|
@@ -5125,11 +5564,17 @@ export class ControlDatabase {
|
|
|
5125
5564
|
* deadline) if a write can ever hang.
|
|
5126
5565
|
*/
|
|
5127
5566
|
async close(): Promise<void> {
|
|
5567
|
+
this.tableWatches.disposeAll();
|
|
5128
5568
|
await this.writeQueue;
|
|
5129
5569
|
if (this.collectionFactory) {
|
|
5130
5570
|
await this.collectionFactory.shutdown();
|
|
5131
5571
|
this.collectionFactory = null;
|
|
5132
5572
|
}
|
|
5573
|
+
this.txnBridge = null;
|
|
5574
|
+
// Again: a subscription taken during the awaits above would keep its timers past close.
|
|
5575
|
+
this.tableWatches.disposeAll();
|
|
5576
|
+
// Here, not first: a read started during the awaits above must not install either.
|
|
5577
|
+
this.invalidateMembershipViews();
|
|
5133
5578
|
if (this.db) {
|
|
5134
5579
|
void this.db.close();
|
|
5135
5580
|
this.db = null;
|