@serfab/cadre-core 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +255 -255
- package/dist/cadre-node.d.ts +203 -28
- package/dist/cadre-node.js +445 -84
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-cohort.d.ts +26 -0
- package/dist/control-cohort.js +26 -0
- package/dist/control-cohort.js.map +1 -1
- package/dist/control-database.d.ts +82 -117
- package/dist/control-database.js +217 -295
- package/dist/control-database.js.map +1 -1
- package/dist/control-formation-recorder.d.ts +8 -7
- package/dist/control-formation-recorder.js +8 -7
- package/dist/control-formation-recorder.js.map +1 -1
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +789 -774
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +15 -5
- package/dist/control-write-retry.js +19 -18
- package/dist/control-write-retry.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/peer-addr-book.d.ts +92 -0
- package/dist/peer-addr-book.js +220 -0
- package/dist/peer-addr-book.js.map +1 -0
- package/dist/seed-bootstrap.d.ts +7 -0
- package/dist/seed-bootstrap.js +15 -13
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/strand-cohort.d.ts +4 -37
- package/dist/strand-cohort.js +4 -15
- package/dist/strand-cohort.js.map +1 -1
- package/dist/strand-database.d.ts +11 -16
- package/dist/strand-database.js +19 -7
- package/dist/strand-database.js.map +1 -1
- package/dist/strand-formation-manager.d.ts +8 -8
- package/dist/strand-formation-manager.js +15 -18
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +52 -21
- package/dist/strand-instance-manager.js +100 -34
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +6 -4
- package/dist/strand-membership-writer.js +38 -36
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/types.d.ts +39 -31
- package/dist/types.js.map +1 -1
- package/package.json +8 -7
- package/src/arachnode-stub.ts +159 -159
- package/src/bootstrap-peer-store-file.ts +44 -44
- package/src/bootstrap-peer-store.ts +172 -172
- package/src/cadre-node.ts +5158 -4771
- package/src/canonical-json.ts +30 -30
- package/src/control-authorization.ts +114 -114
- package/src/control-cohort.ts +130 -103
- package/src/control-database.ts +2406 -2506
- package/src/control-formation-recorder.ts +305 -304
- package/src/control-schema.ts +801 -786
- package/src/control-stream.ts +205 -205
- package/src/control-write-retry.ts +483 -473
- package/src/delegate-admission.ts +302 -302
- package/src/device-token.ts +94 -94
- package/src/diagnostics/connection-path.ts +277 -277
- package/src/diagnostics/webrtc-turn-tracker.ts +222 -222
- package/src/ed25519-key.ts +134 -134
- package/src/enrollment.ts +199 -199
- package/src/file-durable-slot.ts +61 -61
- package/src/formation-approval.ts +604 -604
- package/src/fs-atomic.ts +107 -107
- package/src/hibernation-manager.ts +437 -437
- package/src/identity-key.ts +109 -109
- package/src/index.ts +1 -0
- package/src/key-store-file.ts +132 -132
- package/src/key-store.ts +99 -99
- package/src/membership-connection-gater.ts +204 -204
- package/src/node-local-snapshot.ts +260 -260
- package/src/peer-addr-book.ts +250 -0
- package/src/peer-authorization.ts +259 -259
- package/src/peer-record.ts +164 -164
- package/src/push-credentials.ts +84 -84
- package/src/push-node.ts +64 -64
- package/src/push-notifier-apns.ts +247 -247
- package/src/push-notifier-fcm.ts +220 -220
- package/src/push-notifier-shared.ts +28 -28
- package/src/push-notifier.ts +52 -52
- package/src/relay-addrs.ts +111 -111
- package/src/relay-reservation.ts +823 -823
- package/src/seed-bootstrap.ts +15 -15
- package/src/seed-trust-policy.ts +140 -140
- package/src/strand-addr-protocol.ts +441 -441
- package/src/strand-backfill.ts +366 -366
- package/src/strand-cohort.ts +38 -77
- package/src/strand-database.ts +202 -204
- package/src/strand-formation-manager.ts +491 -494
- package/src/strand-formation-protocol.ts +692 -692
- package/src/strand-instance-manager.ts +108 -50
- package/src/strand-member-key.ts +39 -39
- package/src/strand-member-registry.ts +182 -182
- package/src/strand-membership-writer.ts +1432 -1430
- package/src/strand-solicitation.ts +472 -472
- package/src/strand-transport-key.ts +58 -58
- package/src/strand-wake-payload.ts +32 -32
- package/src/strand-wake-protocol.ts +314 -314
- package/src/strand-watcher.ts +314 -314
- package/src/trusted-owner-store-file.ts +41 -41
- package/src/trusted-owner-store.ts +171 -171
- package/src/types.ts +1309 -1303
package/dist/control-database.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import debug from 'debug';
|
|
2
2
|
import { toString as uint8ArrayToString } from 'uint8arrays';
|
|
3
|
-
import { Database, registerPlugin
|
|
3
|
+
import { Database, registerPlugin } from '@quereus/quereus';
|
|
4
4
|
import cryptoPlugin from '@optimystic/quereus-plugin-crypto/plugin';
|
|
5
5
|
import optimysticPlugin from '@optimystic/quereus-plugin-optimystic/plugin';
|
|
6
6
|
import { digest, randomBytes } from '@optimystic/quereus-plugin-crypto';
|
|
@@ -73,94 +73,30 @@ export class FormationAbortedError extends Error {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
/**
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
76
|
+
* The invitation's seat budget is already spent: the count of recorded `FormationUsage` rows
|
|
77
|
+
* for the token has reached `FormationInvite.TotalUses`, so this redemption cannot be given a
|
|
78
|
+
* seat.
|
|
79
79
|
*
|
|
80
|
-
* Raised
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
80
|
+
* Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
|
|
81
|
+
* the write, inside {@link ControlDatabase.redeemInvitation} /
|
|
82
|
+
* {@link ControlDatabase.recordFormationUsage} — and off nothing else. It exists because
|
|
83
|
+
* without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
|
|
84
|
+
* schema's own count-based cap clause, which the manager reports as a retryable
|
|
85
|
+
* `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
|
|
86
|
+
* Two redemptions of one token racing on the SAME node serialize behind the local write
|
|
87
|
+
* queue, so the loser reads the winner's committed row and is refused here by name —
|
|
88
|
+
* `StrandFormationManager.validateToken`'s `isTokenUsed` check cannot catch that, since it
|
|
89
|
+
* ran before either redemption wrote anything.
|
|
88
90
|
*/
|
|
89
91
|
export class InvitationExhaustedError extends Error {
|
|
90
|
-
constructor(token,
|
|
91
|
-
super(`Invitation ${token} is exhausted:
|
|
92
|
+
constructor(token, usesRecorded, totalUses) {
|
|
93
|
+
super(`Invitation ${token} is exhausted: ${usesRecorded} of ${totalUses} use(s) already recorded`);
|
|
92
94
|
this.token = token;
|
|
93
|
-
this.
|
|
95
|
+
this.usesRecorded = usesRecorded;
|
|
94
96
|
this.totalUses = totalUses;
|
|
95
97
|
this.name = 'InvitationExhaustedError';
|
|
96
98
|
}
|
|
97
99
|
}
|
|
98
|
-
/**
|
|
99
|
-
* Attempts allowed per redemption: the first, plus two retries of a use number another
|
|
100
|
-
* writer took. Bounded so sustained contention terminates in a clean rejection rather than
|
|
101
|
-
* spinning; two retries is enough for the realistic case (a handful of nodes redeeming one
|
|
102
|
-
* multi-use token at once) without holding the write lock hostage.
|
|
103
|
-
*
|
|
104
|
-
* NOTE: the attempts fire back-to-back, with no backoff and no jitter — each one only takes
|
|
105
|
-
* and releases the write lock. Fine while cross-node contention on a single token is a
|
|
106
|
-
* handful of writers; if one hot multi-use token ever draws sustained concurrent redemption
|
|
107
|
-
* (a broadcast invite, a bulk onboarding run), all three attempts can burn against the same
|
|
108
|
-
* competitor and the caller gets the engine's error. Add a short randomised delay between
|
|
109
|
-
* attempts then — NOT a longer attempt budget, which would hold the write queue.
|
|
110
|
-
*/
|
|
111
|
-
const USE_NUMBER_ATTEMPTS = 3;
|
|
112
|
-
/**
|
|
113
|
-
* The two error messages that mean "another writer took the `UseNumber` we picked", and
|
|
114
|
-
* nothing else.
|
|
115
|
-
*
|
|
116
|
-
* - the `(Token, UseNumber)` primary-key collision, which the optimystic vtab reports on the
|
|
117
|
-
* insert itself (`uniqueConstraintMessage` renders the columns table-qualified, no schema
|
|
118
|
-
* prefix);
|
|
119
|
-
* - `CHECK constraint failed: Monotonic` — `FormationUsage.Monotonic` contains a subquery, so
|
|
120
|
-
* Quereus defers it to commit where it reads `committed.FormationUsage`. A concurrent row
|
|
121
|
-
* that our insert's key probe did not see still trips it there. Same meaning, different
|
|
122
|
-
* surface.
|
|
123
|
-
*
|
|
124
|
-
* Deliberately NOT matched: `FormationUsage.UsageStampId`'s `unique` (a repeated nonce is
|
|
125
|
-
* replay, never a race — retrying it would re-present a spent approval), and every named
|
|
126
|
-
* CHECK other than `Monotonic`. `Authorized` in particular renders with the same
|
|
127
|
-
* `CHECK constraint failed:` prefix, so the constraint NAME is anchored with `\b` exactly as
|
|
128
|
-
* `expectConstraintFailure` does rather than matched by prefix.
|
|
129
|
-
*
|
|
130
|
-
* NOTE: a CHECK failure names only the CONSTRAINT, never its table, and `Monotonic` is a
|
|
131
|
-
* generic enough name that a second table could plausibly claim it. Today it is unique across
|
|
132
|
-
* `schemas/control.qsql`, so the pattern cannot mis-fire; if another table ever declares a
|
|
133
|
-
* `Monotonic` constraint, a failure of THAT constraint would be read here as a lost use-number
|
|
134
|
-
* race and pointlessly retried up to {@link USE_NUMBER_ATTEMPTS} times. Rename the other
|
|
135
|
-
* constraint (cheapest) rather than trying to recover the table from the message.
|
|
136
|
-
*/
|
|
137
|
-
const LOST_USE_NUMBER_PATTERNS = [
|
|
138
|
-
/UNIQUE constraint failed: FormationUsage\.Token, FormationUsage\.UseNumber/i,
|
|
139
|
-
/CHECK constraint failed: Monotonic\b/,
|
|
140
|
-
];
|
|
141
|
-
/**
|
|
142
|
-
* Did this write fail purely because another writer took the `UseNumber` it picked — i.e. is
|
|
143
|
-
* re-presenting the SAME approval under the next free use number the right response?
|
|
144
|
-
*
|
|
145
|
-
* Classifies by MESSAGE, walking the `cause` chain with Quereus' own {@link unwrapError}:
|
|
146
|
-
* neither surface can be recognised by type. The deferred `Monotonic` failure is thrown as a
|
|
147
|
-
* bare `QuereusError`, not a `ConstraintError` (see
|
|
148
|
-
* `quereus/src/runtime/deferred-constraint-queue.ts`), so an `instanceof ConstraintError` gate
|
|
149
|
-
* would silently drop the branch this retry exists for. Messages also carry an
|
|
150
|
-
* ` (at line N, column M)` suffix and arrive wrapped when the write ran inside a transaction,
|
|
151
|
-
* so the match is a substring test rather than an equality one.
|
|
152
|
-
*
|
|
153
|
-
* NOTE: this is the one piece of the retry that depends on engine error TEXT. Its own test
|
|
154
|
-
* (`control-formation-use-number-retry.spec.ts`) produces all three messages from the real
|
|
155
|
-
* engine rather than from string literals, so a reworded storage-layer error fails that spec
|
|
156
|
-
* instead of silently disabling the retry.
|
|
157
|
-
*/
|
|
158
|
-
export function isLostUseNumberRace(error) {
|
|
159
|
-
if (!(error instanceof Error)) {
|
|
160
|
-
return false;
|
|
161
|
-
}
|
|
162
|
-
return unwrapError(error).some(({ message }) => LOST_USE_NUMBER_PATTERNS.some(pattern => pattern.test(message)));
|
|
163
|
-
}
|
|
164
100
|
/**
|
|
165
101
|
* Parse a stored Quereus `datetime` value into epoch milliseconds.
|
|
166
102
|
*
|
|
@@ -377,6 +313,20 @@ export class ControlDatabase {
|
|
|
377
313
|
timing('[controlDb] hydrate: %dms (tables=%d, indexes=%d)', Math.round(performance.now() - t0), hydrated.tables, hydrated.indexes);
|
|
378
314
|
log('Hydrated control catalog (tables=%d, indexes=%d)', hydrated.tables, hydrated.indexes);
|
|
379
315
|
// Load and execute the schema
|
|
316
|
+
// NOTE: this is where a slow launch is felt. Duration is (raw-storage operations
|
|
317
|
+
// issued) × per-operation storage latency: ~1ms/op on an idle machine, but
|
|
318
|
+
// 50-90ms/op on a loaded disk or a phone's flash under launch contention. A cold
|
|
319
|
+
// start now reaches the backend 172 times (8 tables + 1 index, 21 distinct blocks
|
|
320
|
+
// — dominated by its 130 genuine writes), a warm restart 52, because cadre-core
|
|
321
|
+
// wraps every embedder storage in `@optimystic/db-p2p`'s write-through cache
|
|
322
|
+
// (@serfab/quereus-plugin-sereus's cached-storage.ts). Uncached the same start issued ~2000 operations — the
|
|
323
|
+
// upstream re-read amplification measured in
|
|
324
|
+
// tickets/complete/optimystic-block-read-amplification-on-control-start.md (and
|
|
325
|
+
// its follow-up ...-schema-catalog-reread-per-write-blows-storage-budgets.md),
|
|
326
|
+
// which also rules out the retry policy and cluster deadlines. The counts are
|
|
327
|
+
// pinned by packages/cadre-core/test/control-start-storage-op-budget.spec.ts; if
|
|
328
|
+
// they grow — e.g. the cache leaves this path — that spec fails before anyone
|
|
329
|
+
// has to debug a launch.
|
|
380
330
|
t0 = performance.now();
|
|
381
331
|
await this.loadSchema();
|
|
382
332
|
timing('[controlDb] loadSchema: %dms', Math.round(performance.now() - t0));
|
|
@@ -443,7 +393,7 @@ export class ControlDatabase {
|
|
|
443
393
|
// also what lets schema init absorb optimystic's self-coordination grace refusal, which
|
|
444
394
|
// is unsafe for control writes in general. Why, on
|
|
445
395
|
// `RETRIABLE_SCHEMA_INIT_MATCHERS` in `control-write-retry.ts`.
|
|
446
|
-
await this.lockedWithRetry(() => this.db.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY);
|
|
396
|
+
await this.lockedWithRetry(() => this.db.exec(schemaContent), SCHEMA_INIT_RETRY_POLICY, 'schema-init');
|
|
447
397
|
log('Schema loaded and executed');
|
|
448
398
|
}
|
|
449
399
|
/**
|
|
@@ -564,6 +514,14 @@ export class ControlDatabase {
|
|
|
564
514
|
* membership-gate refresh path; control tables are a handful of rows today, so this
|
|
565
515
|
* is cheap — if they ever grow, fold the exclusion into one statement or cache the
|
|
566
516
|
* retired-stamp set.
|
|
517
|
+
*
|
|
518
|
+
* NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
|
|
519
|
+
* (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
|
|
520
|
+
* which feed the node rows directly. Nothing enforces that the copies stay true, so a
|
|
521
|
+
* change to the filter's SHAPE here (what it keys on, which table it consults) must be
|
|
522
|
+
* carried into both, or those suites keep passing against a contract the database no
|
|
523
|
+
* longer honours. Fine while the filter is one `Set.has` on `StampId`; if it grows a
|
|
524
|
+
* second dimension, give the fakes a shared fixture or a contract test instead.
|
|
567
525
|
*/
|
|
568
526
|
async queryCadrePeers() {
|
|
569
527
|
this.ensureInitialized();
|
|
@@ -757,12 +715,12 @@ export class ControlDatabase {
|
|
|
757
715
|
async updateSelfPeerRecord(record) {
|
|
758
716
|
this.ensureInitialized();
|
|
759
717
|
const multiaddr = record.addrs.join(',');
|
|
760
|
-
await this.execWrite(`
|
|
761
|
-
update CadreControl.CadrePeer
|
|
762
|
-
with context OwnerKey = null, Signature = ?
|
|
763
|
-
set Multiaddr = ?, UpdatedAt = ?, Sig = ?
|
|
764
|
-
where PeerId = ?
|
|
765
|
-
`, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId]);
|
|
718
|
+
await this.execWrite(`
|
|
719
|
+
update CadreControl.CadrePeer
|
|
720
|
+
with context OwnerKey = null, Signature = ?
|
|
721
|
+
set Multiaddr = ?, UpdatedAt = ?, Sig = ?
|
|
722
|
+
where PeerId = ?
|
|
723
|
+
`, [record.sig, multiaddr, record.updatedAt, record.sig, record.peerId], 'self-record-update');
|
|
766
724
|
log('Self peer record updated: %s (updatedAt=%d)', record.peerId, record.updatedAt);
|
|
767
725
|
}
|
|
768
726
|
/**
|
|
@@ -805,12 +763,12 @@ export class ControlDatabase {
|
|
|
805
763
|
*/
|
|
806
764
|
async updateSelfDeviceToken(record) {
|
|
807
765
|
this.ensureInitialized();
|
|
808
|
-
await this.execWrite(`
|
|
809
|
-
update CadreControl.DeviceToken
|
|
810
|
-
with context OwnerKey = null, Signature = ?
|
|
811
|
-
set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
|
|
812
|
-
where PeerId = ?
|
|
813
|
-
`, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId]);
|
|
766
|
+
await this.execWrite(`
|
|
767
|
+
update CadreControl.DeviceToken
|
|
768
|
+
with context OwnerKey = null, Signature = ?
|
|
769
|
+
set Platform = ?, Token = ?, UpdatedAt = ?, Sig = ?
|
|
770
|
+
where PeerId = ?
|
|
771
|
+
`, [record.sig, record.platform, record.token, record.updatedAt, record.sig, record.peerId], 'device-token-update');
|
|
814
772
|
log('Self device token updated: %s (platform=%s, updatedAt=%d)', record.peerId, record.platform, record.updatedAt);
|
|
815
773
|
}
|
|
816
774
|
/**
|
|
@@ -828,11 +786,11 @@ export class ControlDatabase {
|
|
|
828
786
|
// StampId in the row's own column to satisfy the not-null/unique anti-replay constraint —
|
|
829
787
|
// the StampId is a real column value, not the optimystic `StampId()` SQL function.
|
|
830
788
|
const stampId = generateStampId(this.config.libp2pNode.peerId.toString());
|
|
831
|
-
await this.execWrite(`
|
|
832
|
-
insert into CadreControl.OwnerKey (Key, StampId)
|
|
833
|
-
with context OwnerKey = null, Signature = null
|
|
834
|
-
values (?, ?)
|
|
835
|
-
`, [key, stampId]);
|
|
789
|
+
await this.execWrite(`
|
|
790
|
+
insert into CadreControl.OwnerKey (Key, StampId)
|
|
791
|
+
with context OwnerKey = null, Signature = null
|
|
792
|
+
values (?, ?)
|
|
793
|
+
`, [key, stampId], 'owner-key-insert');
|
|
836
794
|
log('Owner key inserted');
|
|
837
795
|
}
|
|
838
796
|
/**
|
|
@@ -861,11 +819,11 @@ export class ControlDatabase {
|
|
|
861
819
|
const message = buildAuthorizationMessage('CadreControl.Strand', 'add', [strandId, type, memberPrivateKey ?? '', stampId]);
|
|
862
820
|
const signature = signMessage(message);
|
|
863
821
|
// StampId is a real, unique column (single-use anti-replay), no longer a context value.
|
|
864
|
-
await this.execWrite(`
|
|
865
|
-
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
866
|
-
with context OwnerKey = ?, Signature = ?
|
|
867
|
-
values (?, ?, ?, ?)
|
|
868
|
-
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId]);
|
|
822
|
+
await this.execWrite(`
|
|
823
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
824
|
+
with context OwnerKey = ?, Signature = ?
|
|
825
|
+
values (?, ?, ?, ?)
|
|
826
|
+
`, [ownerKey, signature, strandId, type, memberPrivateKey ?? null, stampId], 'strand-insert');
|
|
869
827
|
log('Strand inserted: %s', strandId);
|
|
870
828
|
}
|
|
871
829
|
/**
|
|
@@ -887,7 +845,7 @@ export class ControlDatabase {
|
|
|
887
845
|
* when a row was actually removed.
|
|
888
846
|
*/
|
|
889
847
|
deleteStrand(strandId, ownerKey, signMessage) {
|
|
890
|
-
return this.lockedWithRetry(() => this.deleteGuardedRow('Strand', strandId, ownerKey, signMessage));
|
|
848
|
+
return this.lockedWithRetry(() => this.deleteGuardedRow('Strand', strandId, ownerKey, signMessage), {}, 'strand-delete');
|
|
891
849
|
}
|
|
892
850
|
/**
|
|
893
851
|
* Insert a validation key into the control database using an owner signature.
|
|
@@ -910,11 +868,11 @@ export class ControlDatabase {
|
|
|
910
868
|
// Field order MUST match the schema's ValidationKey `AuthorizedInsert` verify: Key, StampId.
|
|
911
869
|
const message = buildAuthorizationMessage('CadreControl.ValidationKey', 'add', [key, stampId]);
|
|
912
870
|
const signature = signMessage(message);
|
|
913
|
-
await this.execWrite(`
|
|
914
|
-
insert into CadreControl.ValidationKey (Key, StampId)
|
|
915
|
-
with context OwnerKey = ?, Signature = ?
|
|
916
|
-
values (?, ?)
|
|
917
|
-
`, [ownerKey, signature, key, stampId]);
|
|
871
|
+
await this.execWrite(`
|
|
872
|
+
insert into CadreControl.ValidationKey (Key, StampId)
|
|
873
|
+
with context OwnerKey = ?, Signature = ?
|
|
874
|
+
values (?, ?)
|
|
875
|
+
`, [ownerKey, signature, key, stampId], 'validation-key-insert');
|
|
918
876
|
log('Validation key inserted: %s', key);
|
|
919
877
|
}
|
|
920
878
|
/**
|
|
@@ -934,7 +892,7 @@ export class ControlDatabase {
|
|
|
934
892
|
* when a row was actually removed.
|
|
935
893
|
*/
|
|
936
894
|
deleteValidationKey(key, ownerKey, signMessage) {
|
|
937
|
-
return this.lockedWithRetry(() => this.deleteGuardedRow('ValidationKey', key, ownerKey, signMessage));
|
|
895
|
+
return this.lockedWithRetry(() => this.deleteGuardedRow('ValidationKey', key, ownerKey, signMessage), {}, 'validation-key-delete');
|
|
938
896
|
}
|
|
939
897
|
/**
|
|
940
898
|
* Owner-vouched INSERT of one `CadrePeer` membership row, notifying the membership
|
|
@@ -990,10 +948,10 @@ export class ControlDatabase {
|
|
|
990
948
|
// Persist the vouching (owner, signature) onto the row (VouchOwner/VouchSig) —
|
|
991
949
|
// identical to the context pair, which the AuthorizedInsert constraint binds — so a
|
|
992
950
|
// reader can later re-check the voucher against its node-local trusted-owner anchor.
|
|
993
|
-
await this.db.exec(`
|
|
994
|
-
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
|
|
995
|
-
with context OwnerKey = ?, Signature = ?
|
|
996
|
-
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
951
|
+
await this.db.exec(`
|
|
952
|
+
insert into CadreControl.CadrePeer (PeerId, PublicKey, Multiaddr, UpdatedAt, Sig, StampId, VouchOwner, VouchSig)
|
|
953
|
+
with context OwnerKey = ?, Signature = ?
|
|
954
|
+
values (?, ?, ?, ?, ?, ?, ?, ?)
|
|
997
955
|
`, [ownerKey, signature, row.peerId, row.publicKey, row.multiaddr, row.updatedAt, row.sig, stampId, ownerKey, signature]);
|
|
998
956
|
return true;
|
|
999
957
|
});
|
|
@@ -1056,11 +1014,11 @@ export class ControlDatabase {
|
|
|
1056
1014
|
const signature = signMessage(buildAuthorizationMessage('CadreControl.CadrePeer', 'vouch', [peerId, stampId]));
|
|
1057
1015
|
await this.mutateCadrePeer('peer-reauthorize', async () => {
|
|
1058
1016
|
// Bare `exec` — inside the non-re-entrant write lock (see insertCadrePeer).
|
|
1059
|
-
await this.db.exec(`
|
|
1060
|
-
update CadreControl.CadrePeer
|
|
1061
|
-
with context OwnerKey = ?, Signature = ?
|
|
1062
|
-
set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
|
|
1063
|
-
where PeerId = ?
|
|
1017
|
+
await this.db.exec(`
|
|
1018
|
+
update CadreControl.CadrePeer
|
|
1019
|
+
with context OwnerKey = ?, Signature = ?
|
|
1020
|
+
set UpdatedAt = ?, VouchOwner = ?, VouchSig = ?
|
|
1021
|
+
where PeerId = ?
|
|
1064
1022
|
`, [ownerKey, signature, updatedAt, ownerKey, signature, peerId]);
|
|
1065
1023
|
});
|
|
1066
1024
|
return true;
|
|
@@ -1102,7 +1060,7 @@ export class ControlDatabase {
|
|
|
1102
1060
|
* when a row was actually removed.
|
|
1103
1061
|
*/
|
|
1104
1062
|
deleteDeviceToken(peerId, ownerKey, signMessage) {
|
|
1105
|
-
return this.lockedWithRetry(() => this.deleteGuardedRow('DeviceToken', peerId, ownerKey, signMessage));
|
|
1063
|
+
return this.lockedWithRetry(() => this.deleteGuardedRow('DeviceToken', peerId, ownerKey, signMessage), {}, 'device-token-delete');
|
|
1106
1064
|
}
|
|
1107
1065
|
/**
|
|
1108
1066
|
* Owner-signed delete of one guarded row plus the `Revocation` tombstone retiring its
|
|
@@ -1151,18 +1109,18 @@ export class ControlDatabase {
|
|
|
1151
1109
|
// cover it — the digests are domain-separated and neither replays as the other.
|
|
1152
1110
|
const revocationSignature = signMessage(buildAuthorizationMessage('CadreControl.Revocation', 'remove', [table, keyValue, stampId]));
|
|
1153
1111
|
await this.inTransaction(`delete ${table}`, async () => {
|
|
1154
|
-
await this.db.exec(`
|
|
1155
|
-
delete from CadreControl.${table}
|
|
1156
|
-
with context OwnerKey = ?, Signature = ?
|
|
1157
|
-
where ${GUARDED_KEY_COLUMN[table]} = ?
|
|
1112
|
+
await this.db.exec(`
|
|
1113
|
+
delete from CadreControl.${table}
|
|
1114
|
+
with context OwnerKey = ?, Signature = ?
|
|
1115
|
+
where ${GUARDED_KEY_COLUMN[table]} = ?
|
|
1158
1116
|
`, [ownerKey, signature, keyValue]);
|
|
1159
1117
|
// ReissuedAt named explicitly at 0 (the only value FreshTombstone accepts) rather
|
|
1160
1118
|
// than leaning on the column default — the seat-at-zero rule is load-bearing for
|
|
1161
1119
|
// reissueRevocations' monotonic bump, so state it at the write site.
|
|
1162
|
-
await this.db.exec(`
|
|
1163
|
-
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1164
|
-
with context OwnerKey = ?, Signature = ?
|
|
1165
|
-
values (?, ?, ?, 0)
|
|
1120
|
+
await this.db.exec(`
|
|
1121
|
+
insert into CadreControl.Revocation (TableName, RowKey, StampId, ReissuedAt)
|
|
1122
|
+
with context OwnerKey = ?, Signature = ?
|
|
1123
|
+
values (?, ?, ?, 0)
|
|
1166
1124
|
`, [ownerKey, revocationSignature, table, keyValue, stampId]);
|
|
1167
1125
|
});
|
|
1168
1126
|
log('%s deleted: %s (stamp retired)', table, keyValue);
|
|
@@ -1239,10 +1197,10 @@ export class ControlDatabase {
|
|
|
1239
1197
|
log('reap %s: %s holds a fresh incarnation (live stamp differs from the tombstoned one); leaving it', table, rowKey);
|
|
1240
1198
|
return false;
|
|
1241
1199
|
}
|
|
1242
|
-
const sql = `
|
|
1243
|
-
delete from CadreControl.${table}
|
|
1244
|
-
with context OwnerKey = null, Signature = null
|
|
1245
|
-
where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
|
|
1200
|
+
const sql = `
|
|
1201
|
+
delete from CadreControl.${table}
|
|
1202
|
+
with context OwnerKey = null, Signature = null
|
|
1203
|
+
where ${GUARDED_KEY_COLUMN[table]} = ? and StampId = ?
|
|
1246
1204
|
`;
|
|
1247
1205
|
if (table === 'CadrePeer') {
|
|
1248
1206
|
// Through mutateCadrePeer so the "EVERY CadrePeer writer goes through here"
|
|
@@ -1259,7 +1217,7 @@ export class ControlDatabase {
|
|
|
1259
1217
|
await this.mutateCadrePeer('peer-reap', () => this.db.exec(sql, [rowKey, stampId]));
|
|
1260
1218
|
}
|
|
1261
1219
|
else {
|
|
1262
|
-
await this.execWrite(sql, [rowKey, stampId]);
|
|
1220
|
+
await this.execWrite(sql, [rowKey, stampId], `reap-${table}`);
|
|
1263
1221
|
}
|
|
1264
1222
|
// A row leaving the control plane without an owner signature at the write site
|
|
1265
1223
|
// should be visible in a log.
|
|
@@ -1400,18 +1358,18 @@ export class ControlDatabase {
|
|
|
1400
1358
|
// output minted per row incarnation (generateStampId), so it identifies one
|
|
1401
1359
|
// row across all TableName values. Do NOT "fix" this by adding TableName to
|
|
1402
1360
|
// the where clause.
|
|
1403
|
-
await this.db.exec(`
|
|
1404
|
-
update CadreControl.Revocation
|
|
1405
|
-
with context OwnerKey = ?, Signature = ?
|
|
1406
|
-
set ReissuedAt = ?
|
|
1407
|
-
where StampId = ?
|
|
1361
|
+
await this.db.exec(`
|
|
1362
|
+
update CadreControl.Revocation
|
|
1363
|
+
with context OwnerKey = ?, Signature = ?
|
|
1364
|
+
set ReissuedAt = ?
|
|
1365
|
+
where StampId = ?
|
|
1408
1366
|
`, [ownerKey, signature, reissuedAt, row.stampId]);
|
|
1409
1367
|
executed++;
|
|
1410
1368
|
}
|
|
1411
1369
|
});
|
|
1412
1370
|
log('Reissued %d revocation tombstone(s) at %d', executed, reissuedAt);
|
|
1413
1371
|
return executed;
|
|
1414
|
-
});
|
|
1372
|
+
}, {}, 'revocation-reissue');
|
|
1415
1373
|
}
|
|
1416
1374
|
/**
|
|
1417
1375
|
* Run `body` between `beginTransaction` and `commit`, rolling back on failure.
|
|
@@ -1514,7 +1472,7 @@ export class ControlDatabase {
|
|
|
1514
1472
|
this.assertCommitBoundary(reason, 'on return from');
|
|
1515
1473
|
await this.notifyMembershipChanged(reason);
|
|
1516
1474
|
return result;
|
|
1517
|
-
});
|
|
1475
|
+
}, {}, reason);
|
|
1518
1476
|
}
|
|
1519
1477
|
/**
|
|
1520
1478
|
* Run one local write under the database-wide write lock, serializing it against
|
|
@@ -1569,10 +1527,8 @@ export class ControlDatabase {
|
|
|
1569
1527
|
*
|
|
1570
1528
|
* The retry wraps the LOCK, it does not live inside it: each attempt takes and
|
|
1571
1529
|
* releases the lock, and the backoff sleeps with NO lock held, so a write parked in
|
|
1572
|
-
* backoff never stalls the other local writers queued behind it.
|
|
1573
|
-
*
|
|
1574
|
-
* `USE_NUMBER_ATTEMPTS`. Safe because every locked body is atomic and re-runnable
|
|
1575
|
-
* (the contract on {@link withWriteLock}).
|
|
1530
|
+
* backoff never stalls the other local writers queued behind it. Safe because every
|
|
1531
|
+
* locked body is atomic and re-runnable (the contract on {@link withWriteLock}).
|
|
1576
1532
|
*
|
|
1577
1533
|
* Also carries {@link loadSchema}'s distributed DDL, the one caller that runs BEFORE
|
|
1578
1534
|
* `initialized` is set — so it reaches this method directly rather than through
|
|
@@ -1581,9 +1537,16 @@ export class ControlDatabase {
|
|
|
1581
1537
|
* rather than carving out a lockless path. It is also the ONLY caller that passes a
|
|
1582
1538
|
* `policy` — everything else takes the default, whose classifier and attempt count are
|
|
1583
1539
|
* unchanged by that call site existing.
|
|
1540
|
+
*
|
|
1541
|
+
* `label` names the operation in this loop's debug lines and nothing else
|
|
1542
|
+
* ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
|
|
1543
|
+
* several writes retry CONCURRENTLY in a real party, so an unlabelled line cannot be
|
|
1544
|
+
* attributed to a write — keep new call sites labelled.
|
|
1584
1545
|
*/
|
|
1585
|
-
lockedWithRetry(fn, policy = {}) {
|
|
1586
|
-
|
|
1546
|
+
lockedWithRetry(fn, policy = {}, label) {
|
|
1547
|
+
// The label lands LAST so a call site's own name survives both the policy and the
|
|
1548
|
+
// spec-injected pacing; it changes log attribution only, never behaviour.
|
|
1549
|
+
return retryControlWrite(() => this.withWriteLock(fn), { ...policy, ...this.controlWriteRetryPacing, ...(label !== undefined ? { label } : {}) });
|
|
1587
1550
|
}
|
|
1588
1551
|
/**
|
|
1589
1552
|
* One-statement local write, serialized by {@link withWriteLock} and retried on a
|
|
@@ -1594,10 +1557,13 @@ export class ControlDatabase {
|
|
|
1594
1557
|
* Public so `SeedBootstrapService`'s direct `CadrePeer`/`DeviceToken` SQL writes go
|
|
1595
1558
|
* through the same seam. Must NOT be called from inside an already-locked body (the
|
|
1596
1559
|
* lock is not re-entrant); such a caller uses a bare `getDatabase().exec` instead.
|
|
1560
|
+
*
|
|
1561
|
+
* `label` names the write in the retry loop's debug lines (see {@link lockedWithRetry});
|
|
1562
|
+
* optional only because it is log-only, but every caller in this repo passes one.
|
|
1597
1563
|
*/
|
|
1598
|
-
execWrite(sql, params) {
|
|
1564
|
+
execWrite(sql, params, label) {
|
|
1599
1565
|
this.ensureInitialized();
|
|
1600
|
-
return this.lockedWithRetry(() => this.db.exec(sql, params));
|
|
1566
|
+
return this.lockedWithRetry(() => this.db.exec(sql, params), {}, label);
|
|
1601
1567
|
}
|
|
1602
1568
|
/**
|
|
1603
1569
|
* Throw unless the database sits at a committed boundary — `getAutocommit()` is false
|
|
@@ -1711,10 +1677,10 @@ export class ControlDatabase {
|
|
|
1711
1677
|
// Persist the canonical ExpiresAt string (datetime parse is idempotent on it) so the
|
|
1712
1678
|
// signed source-of-truth and the stored value are produced once. StampId is a real,
|
|
1713
1679
|
// unique column (single-use anti-replay), no longer a context value.
|
|
1714
|
-
await this.execWrite(`
|
|
1715
|
-
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
1716
|
-
with context OwnerKey = ?, Signature = ?
|
|
1717
|
-
values (?, ?, ?, ?, ?, ?, ?)
|
|
1680
|
+
await this.execWrite(`
|
|
1681
|
+
insert into CadreControl.FormationInvite (Token, sAppId, ExpiresAt, TotalUses, ValidationUrl, StrandId, StampId)
|
|
1682
|
+
with context OwnerKey = ?, Signature = ?
|
|
1683
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
1718
1684
|
`, [
|
|
1719
1685
|
ownerKey, signature,
|
|
1720
1686
|
token, sAppId,
|
|
@@ -1723,7 +1689,7 @@ export class ControlDatabase {
|
|
|
1723
1689
|
options.validationUrl ?? null,
|
|
1724
1690
|
options.strandId ?? null,
|
|
1725
1691
|
stampId,
|
|
1726
|
-
]);
|
|
1692
|
+
], 'formation-invite-insert');
|
|
1727
1693
|
log('Formation invite inserted: %s', token);
|
|
1728
1694
|
}
|
|
1729
1695
|
/**
|
|
@@ -1751,50 +1717,60 @@ export class ControlDatabase {
|
|
|
1751
1717
|
* takes an owner re-seat ({@link insertStrand}) plus a bound invite, never another
|
|
1752
1718
|
* redemption.
|
|
1753
1719
|
*
|
|
1754
|
-
*
|
|
1755
|
-
*
|
|
1756
|
-
*
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
1759
|
-
*
|
|
1760
|
-
* cannot drift, and no second trip through the approval hook is possible from in here.
|
|
1720
|
+
* The usage row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so
|
|
1721
|
+
* concurrent redemptions of one token never contend for a shared row key — there is no
|
|
1722
|
+
* lost race to retry and no second trip through the approval hook. The invite's seat
|
|
1723
|
+
* budget is checked by COUNT ({@link assertSeatRemains}) inside the write lock, ahead of
|
|
1724
|
+
* the write, so a spent invite is refused by name (`InvitationExhaustedError`) instead of
|
|
1725
|
+
* as the schema cap clause's generic `Authorized` CHECK failure.
|
|
1761
1726
|
*/
|
|
1762
1727
|
async redeemInvitation(params) {
|
|
1763
1728
|
this.ensureInitialized();
|
|
1764
1729
|
const { token, strandId, disclosure = '', peerKey, peerSignature, usageStampId, nowMs, validationKey, validationSignature, signal, totalUses, } = params;
|
|
1765
1730
|
log('Redeeming invitation %s -> strand %s', token, strandId);
|
|
1766
1731
|
const localPeerId = this.config.libp2pNode.peerId.toString();
|
|
1767
|
-
//
|
|
1768
|
-
//
|
|
1769
|
-
// approver's signed digest. Re-minting per attempt would only churn.
|
|
1732
|
+
// A failed attempt rolls its whole transaction back, so nothing was ever persisted under
|
|
1733
|
+
// this stamp, and it is deliberately not part of the approver's signed digest.
|
|
1770
1734
|
const strandStampId = generateStampId(localPeerId);
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1735
|
+
// Ordering inside the locked body is contractual: the abort check, then the seat check,
|
|
1736
|
+
// then the write — FormationAbortedError may only ever be thrown before the write is
|
|
1737
|
+
// issued, and a transient-failure retry (lockedWithRetry re-runs the whole body) must
|
|
1738
|
+
// re-check both before re-presenting the write.
|
|
1739
|
+
await this.lockedWithRetry(async () => {
|
|
1740
|
+
// Inside the lock: a write still parked behind another writer when the caller's
|
|
1741
|
+
// budget expired is abandoned rather than executed.
|
|
1742
|
+
if (signal?.aborted) {
|
|
1743
|
+
throw new FormationAbortedError(token, 'redemption');
|
|
1744
|
+
}
|
|
1745
|
+
await this.assertSeatRemains(token, totalUses);
|
|
1746
|
+
await this.inTransaction('redemption', async () => {
|
|
1747
|
+
// 1. Strand row — authorised by the FormationUsage branch (no owner sig),
|
|
1748
|
+
// still carrying a fresh unique StampId for the anti-replay column.
|
|
1749
|
+
// Hard-coded open + keyless: the consent branch admits no other shape.
|
|
1750
|
+
await this.db.exec(`
|
|
1751
|
+
insert into CadreControl.Strand (Id, Type, MemberPrivateKey, StampId)
|
|
1752
|
+
with context OwnerKey = null, Signature = null
|
|
1753
|
+
values (?, 'o', null, ?)
|
|
1779
1754
|
`, [strandId, strandStampId]);
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1755
|
+
// 2. FormationUsage row — authorised by the matching FormationInvite, and
|
|
1756
|
+
// carrying the strand's stamp so it authorizes THIS row and no other.
|
|
1757
|
+
await this.execFormationUsageInsert({
|
|
1758
|
+
token, usageStampId, disclosure, strandId, strandStampId,
|
|
1759
|
+
peerKey, peerSignature, nowMs: nowMs ?? Date.now(),
|
|
1760
|
+
validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
|
|
1761
|
+
});
|
|
1786
1762
|
});
|
|
1787
|
-
})
|
|
1788
|
-
log('Redeemed invitation %s -> strand %s (
|
|
1789
|
-
return {
|
|
1763
|
+
}, {}, 'formation-usage');
|
|
1764
|
+
log('Redeemed invitation %s -> strand %s (stamp %s)', token, strandId, usageStampId);
|
|
1765
|
+
return { usageStampId };
|
|
1790
1766
|
}
|
|
1791
1767
|
/**
|
|
1792
1768
|
* Record a `FormationUsage` against an **already-existing** `Strand` (no strand
|
|
1793
1769
|
* insert). This is the redemption path when the strand was provisioned
|
|
1794
1770
|
* separately (e.g. owner-signed) and the consent record is added after the
|
|
1795
1771
|
* fact: the single insert auto-commits, and the deferred `StrandExists` CHECK
|
|
1796
|
-
* is satisfied by the pre-existing committed strand row.
|
|
1797
|
-
* `
|
|
1772
|
+
* is satisfied by the pre-existing committed strand row. Echoes back the
|
|
1773
|
+
* redemption's `usageStampId` (the row's primary key).
|
|
1798
1774
|
*
|
|
1799
1775
|
* Use {@link redeemInvitation} instead when the strand must be created by
|
|
1800
1776
|
* consent atomically with the usage.
|
|
@@ -1808,108 +1784,47 @@ export class ControlDatabase {
|
|
|
1808
1784
|
* by {@link ControlFormationUsageRecorder.resolveStrand}, so an absent row at this
|
|
1809
1785
|
* point is a genuine race and deserves a named error, not a silent rollback.
|
|
1810
1786
|
*
|
|
1811
|
-
*
|
|
1812
|
-
*
|
|
1813
|
-
*
|
|
1814
|
-
*
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
1787
|
+
* The row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so this
|
|
1788
|
+
* path — the one production actually races on (a bound invite published by `cadre-web` /
|
|
1789
|
+
* `cadre-phone` is always record-only) — never contends with another redemption for a
|
|
1790
|
+
* shared row key: there is no lost race to retry and no second trip through the approval
|
|
1791
|
+
* hook. The invite's seat budget is checked by COUNT ({@link assertSeatRemains}) inside
|
|
1792
|
+
* the write lock, ahead of the write.
|
|
1817
1793
|
*/
|
|
1818
1794
|
async recordFormationUsage(params) {
|
|
1819
1795
|
this.ensureInitialized();
|
|
1820
1796
|
const { token, strandId, disclosure = '', peerKey, peerSignature, usageStampId, nowMs, validationKey, validationSignature, signal, totalUses, } = params;
|
|
1821
|
-
//
|
|
1822
|
-
//
|
|
1797
|
+
// The host strand is pre-existing and owner-signed; its stamp names the strand ROW the
|
|
1798
|
+
// consent record authorizes.
|
|
1823
1799
|
const strandStampId = await this.queryStrandStampId(strandId);
|
|
1824
1800
|
if (strandStampId === null) {
|
|
1825
1801
|
throw new MissingHostStrandError(strandId, token);
|
|
1826
1802
|
}
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
log('Recorded formation usage: token=%s strand=%s (use #%d)', token, strandId, useNumber);
|
|
1833
|
-
return { useNumber, usageStampId };
|
|
1834
|
-
}
|
|
1835
|
-
/**
|
|
1836
|
-
* Assign a `UseNumber` and run `write` under it, re-picking and retrying when another
|
|
1837
|
-
* writer took that number first.
|
|
1838
|
-
*
|
|
1839
|
-
* Each attempt takes {@link withWriteLock} and, inside it, reads {@link nextUseNumber}
|
|
1840
|
-
* afresh. Reading under the lock is the whole fix for the common case: two redemptions of
|
|
1841
|
-
* one token on the SAME node are serialized by the local write queue, so each computes a
|
|
1842
|
-
* number the other has already committed and neither collides. The retry covers the writers
|
|
1843
|
-
* this lock does not reach — another node of the cadre, another `Database` handle over the
|
|
1844
|
-
* same store — which can still take the number between our read and our commit.
|
|
1845
|
-
*
|
|
1846
|
-
* `write` receives ONLY the use number; every other parameter of the redemption (the nonce,
|
|
1847
|
-
* the approver's signature, the strand, the joining peer key, the disclosure) is closed over
|
|
1848
|
-
* by the caller and passed through untouched. So by construction a retry re-presents the
|
|
1849
|
-
* IDENTICAL approval and the four non-nonce fields inside the approver's signed digest
|
|
1850
|
-
* cannot drift. No second call to the approval hook is reachable from in here either:
|
|
1851
|
-
* `obtainApproval` lives a layer up in `ControlFormationUsageRecorder` and is never
|
|
1852
|
-
* re-entered.
|
|
1853
|
-
*
|
|
1854
|
-
* `write` must be a BARE private body ({@link inTransaction} /
|
|
1855
|
-
* {@link execFormationUsageInsert}) — {@link withWriteLock} is not re-entrant and a `write`
|
|
1856
|
-
* that called another locked public method would hang silently and strand the write queue.
|
|
1857
|
-
*
|
|
1858
|
-
* A failed attempt has written nothing (a bare insert never landed; a transactional one
|
|
1859
|
-
* rolled back), so the abort check at the top of each attempt stays inside
|
|
1860
|
-
* {@link FormationAbortedError}'s contract — it is only ever thrown before this attempt's
|
|
1861
|
-
* write is issued.
|
|
1862
|
-
*
|
|
1863
|
-
* @param operation - `'redemption'` | `'usage recording'`, for the abort error's text.
|
|
1864
|
-
* @param knownTotalUses - The invite's seat budget when the caller already has it in hand
|
|
1865
|
-
* (`undefined` to fall back to a fresh {@link queryFormationInvite} read; `null` for "no
|
|
1866
|
-
* invite row" / unlimited). Passed through to {@link assertSeatRemains} on every attempt.
|
|
1867
|
-
* @returns The `UseNumber` the successful attempt wrote under.
|
|
1868
|
-
*/
|
|
1869
|
-
async withUseNumberRetry(token, operation, signal, knownTotalUses, write) {
|
|
1870
|
-
let lastError;
|
|
1871
|
-
for (let attempt = 1; attempt <= USE_NUMBER_ATTEMPTS; attempt++) {
|
|
1872
|
-
try {
|
|
1873
|
-
// lockedWithRetry carries the transient-cluster retry INSIDE this use-number
|
|
1874
|
-
// retry. The loops cannot multiply: a lost use number is a constraint failure,
|
|
1875
|
-
// which `isRetriableControlWriteFailure` never matches, and a transient cluster
|
|
1876
|
-
// failure is not a lost use number, so exactly one loop claims any given error.
|
|
1877
|
-
return await this.lockedWithRetry(async () => {
|
|
1878
|
-
// Inside the lock: a write still parked behind another writer when the caller's
|
|
1879
|
-
// budget expired is abandoned rather than executed.
|
|
1880
|
-
if (signal?.aborted) {
|
|
1881
|
-
throw new FormationAbortedError(token, operation);
|
|
1882
|
-
}
|
|
1883
|
-
const useNumber = await this.nextUseNumber(token);
|
|
1884
|
-
await this.assertSeatRemains(token, useNumber, knownTotalUses);
|
|
1885
|
-
await write(useNumber);
|
|
1886
|
-
return useNumber;
|
|
1887
|
-
});
|
|
1888
|
-
}
|
|
1889
|
-
catch (error) {
|
|
1890
|
-
if (!isLostUseNumberRace(error)) {
|
|
1891
|
-
throw error;
|
|
1892
|
-
}
|
|
1893
|
-
lastError = error;
|
|
1894
|
-
log('Use number for token %s was taken by another writer (attempt %d/%d): %s', token, attempt, USE_NUMBER_ATTEMPTS, error);
|
|
1803
|
+
// Ordering inside the locked body is contractual — abort check, seat check, write — see
|
|
1804
|
+
// the matching note in redeemInvitation.
|
|
1805
|
+
await this.lockedWithRetry(async () => {
|
|
1806
|
+
if (signal?.aborted) {
|
|
1807
|
+
throw new FormationAbortedError(token, 'usage recording');
|
|
1895
1808
|
}
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1809
|
+
await this.assertSeatRemains(token, totalUses);
|
|
1810
|
+
await this.execFormationUsageInsert({
|
|
1811
|
+
token, usageStampId, disclosure, strandId, strandStampId,
|
|
1812
|
+
peerKey, peerSignature, nowMs: nowMs ?? Date.now(),
|
|
1813
|
+
validationKey: validationKey ?? null, validationSignature: validationSignature ?? null,
|
|
1814
|
+
});
|
|
1815
|
+
}, {}, 'formation-usage');
|
|
1816
|
+
log('Recorded formation usage: token=%s strand=%s (stamp %s)', token, strandId, usageStampId);
|
|
1817
|
+
return { usageStampId };
|
|
1901
1818
|
}
|
|
1902
1819
|
/**
|
|
1903
|
-
* Refuse
|
|
1820
|
+
* Refuse a redemption that would consume a seat the invite does not have.
|
|
1904
1821
|
*
|
|
1905
|
-
* `FormationUsage.Authorized`
|
|
1906
|
-
*
|
|
1907
|
-
*
|
|
1908
|
-
*
|
|
1909
|
-
*
|
|
1910
|
-
*
|
|
1911
|
-
* local write queue, so the loser's first attempt is already over budget, with no retry
|
|
1912
|
-
* involved.
|
|
1822
|
+
* The schema's count-based cap clause (`FormationUsage.Authorized`) refuses an over-limit
|
|
1823
|
+
* write at the database anyway — but it fails as a generic `CHECK constraint failed:
|
|
1824
|
+
* Authorized`, which the manager reports as a retryable conflict. Catching it here as
|
|
1825
|
+
* {@link InvitationExhaustedError} lets the joiner be told the invitation is spent instead
|
|
1826
|
+
* of being sent around a loop that can never close. Runs inside the write lock, so on a
|
|
1827
|
+
* same-node race the loser reads the winner's committed row and is refused by name.
|
|
1913
1828
|
*
|
|
1914
1829
|
* `knownTotalUses` lets a caller that already read the `FormationInvite` row (both
|
|
1915
1830
|
* production callers in `ControlFormationUsageRecorder` do) skip a second read on the common,
|
|
@@ -1919,9 +1834,9 @@ export class ControlDatabase {
|
|
|
1919
1834
|
* invite, before an outbound approval call). Safe today because `FormationInvite` is
|
|
1920
1835
|
* insert/delete only (its `Immutable` constraint), so the only way to stale it is an owner
|
|
1921
1836
|
* revoking the token and re-issuing it with MORE seats mid-redemption; if invites ever gain
|
|
1922
|
-
* an update path, drop the parameter and read here
|
|
1837
|
+
* an update path, drop the parameter and read here instead.
|
|
1923
1838
|
*/
|
|
1924
|
-
async assertSeatRemains(token,
|
|
1839
|
+
async assertSeatRemains(token, knownTotalUses) {
|
|
1925
1840
|
// A missing invite is left to `Authorized` — it is not an exhaustion, and the CHECK's
|
|
1926
1841
|
// rejection is already the right, non-retryable answer for it. That fallback is also what
|
|
1927
1842
|
// makes this read's failure mode safe: it is a full-primary-key point lookup
|
|
@@ -1932,8 +1847,12 @@ export class ControlDatabase {
|
|
|
1932
1847
|
const totalUses = knownTotalUses !== undefined
|
|
1933
1848
|
? knownTotalUses
|
|
1934
1849
|
: (await this.queryFormationInvite(token))?.totalUses ?? null;
|
|
1935
|
-
if (totalUses
|
|
1936
|
-
|
|
1850
|
+
if (totalUses == null) {
|
|
1851
|
+
return;
|
|
1852
|
+
}
|
|
1853
|
+
const used = await this.countFormationUsage(token);
|
|
1854
|
+
if (used >= totalUses) {
|
|
1855
|
+
throw new InvitationExhaustedError(token, used, totalUses);
|
|
1937
1856
|
}
|
|
1938
1857
|
}
|
|
1939
1858
|
/** Parameterised `FormationUsage` insert shared by redeem + record paths. */
|
|
@@ -1947,13 +1866,13 @@ export class ControlDatabase {
|
|
|
1947
1866
|
// robustness/consistency change, matching the strand layer's `consumeInvite`,
|
|
1948
1867
|
// not a fix for an observable mis-ordering.
|
|
1949
1868
|
const nowCanonical = await canonicalDatetime(this.db, opts.nowMs);
|
|
1950
|
-
await this.db.exec(`
|
|
1951
|
-
insert into CadreControl.FormationUsage (Token,
|
|
1952
|
-
with context Now = ?, ValidationKey = ?, ValidationSignature = ?
|
|
1953
|
-
values (?, ?, ?, ?, ?, ?,
|
|
1869
|
+
await this.db.exec(`
|
|
1870
|
+
insert into CadreControl.FormationUsage (Token, UsageStampId, PeerKey, PeerSig, Disclosure, StrandId, StrandStampId)
|
|
1871
|
+
with context Now = ?, ValidationKey = ?, ValidationSignature = ?
|
|
1872
|
+
values (?, ?, ?, ?, ?, ?, ?)
|
|
1954
1873
|
`, [
|
|
1955
1874
|
nowCanonical, opts.validationKey, opts.validationSignature,
|
|
1956
|
-
opts.token, opts.
|
|
1875
|
+
opts.token, opts.usageStampId, opts.peerKey,
|
|
1957
1876
|
opts.peerSignature, opts.disclosure, opts.strandId, opts.strandStampId,
|
|
1958
1877
|
]);
|
|
1959
1878
|
}
|
|
@@ -1978,6 +1897,22 @@ export class ControlDatabase {
|
|
|
1978
1897
|
}
|
|
1979
1898
|
/**
|
|
1980
1899
|
* Count `FormationUsage` rows recorded against a token (uses consumed so far).
|
|
1900
|
+
*
|
|
1901
|
+
* NOTE: `Token` left the primary key in `formation-unique-token-redesign` and gained the
|
|
1902
|
+
* `FormationUsageByToken` secondary index, so this filter is now served by an index SEEK
|
|
1903
|
+
* (a descent through the index collection) where it used to be a leading-key partial match
|
|
1904
|
+
* the optimystic vtab declined and the engine served by scan. That matters because the seat
|
|
1905
|
+
* cap is this count: a descent that under-reports admits a seat the invite does not have,
|
|
1906
|
+
* where a scan could not. Descent misses have been observed on a NETWORKED store (tracked
|
|
1907
|
+
* by `debt-composite-pk-point-lookup-unreliable-untracked`, for full-primary-key lookups);
|
|
1908
|
+
* no miss has been observed on this index on a SINGLE node. Across nodes the index is
|
|
1909
|
+
* currently blind: a sibling never learns index entries for rows written elsewhere (the
|
|
1910
|
+
* distributed commit drops the index update — measured 2026-08-12, tracked by
|
|
1911
|
+
* `blocked/secondary-index-seek-blind-to-sibling-rows`), so on a multi-machine party this
|
|
1912
|
+
* count under-reports and the cap over-admits until the upstream fix lands. If
|
|
1913
|
+
* over-admission ever shows up on a converged single node — a token with more usage rows
|
|
1914
|
+
* than its `TotalUses` and no concurrent redeemers to explain it — suspect this seek first
|
|
1915
|
+
* and drop the index to fall back to the scan.
|
|
1981
1916
|
*/
|
|
1982
1917
|
async countFormationUsage(token) {
|
|
1983
1918
|
this.ensureInitialized();
|
|
@@ -2035,19 +1970,6 @@ export class ControlDatabase {
|
|
|
2035
1970
|
}
|
|
2036
1971
|
return false;
|
|
2037
1972
|
}
|
|
2038
|
-
/**
|
|
2039
|
-
* Next `UseNumber` for a token = max(existing)+1, per the `Monotonic` constraint.
|
|
2040
|
-
*
|
|
2041
|
-
* Called only from inside {@link withUseNumberRetry}'s locked body — a read takes no
|
|
2042
|
-
* transaction of its own, so it is safe there, and reading it under the lock is what stops
|
|
2043
|
-
* two local redemptions of one token from picking the same number.
|
|
2044
|
-
*/
|
|
2045
|
-
async nextUseNumber(token) {
|
|
2046
|
-
for await (const row of this.db.eval('select coalesce(max(UseNumber), 0) as MaxUse from CadreControl.FormationUsage where Token = ?', [token])) {
|
|
2047
|
-
return row.MaxUse + 1;
|
|
2048
|
-
}
|
|
2049
|
-
return 1;
|
|
2050
|
-
}
|
|
2051
1973
|
/**
|
|
2052
1974
|
* Close the database and cleanup resources.
|
|
2053
1975
|
*
|