@serfab/cadre-core 0.10.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +255 -255
- package/dist/announce-addrs.d.ts +49 -0
- package/dist/announce-addrs.js +78 -0
- package/dist/announce-addrs.js.map +1 -0
- package/dist/cadre-node.d.ts +484 -58
- package/dist/cadre-node.js +953 -182
- 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 +222 -125
- package/dist/control-database.js +356 -266
- 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-read-retry.d.ts +103 -0
- package/dist/control-read-retry.js +145 -0
- package/dist/control-read-retry.js.map +1 -0
- package/dist/control-retry.d.ts +101 -0
- package/dist/control-retry.js +108 -0
- package/dist/control-retry.js.map +1 -0
- package/dist/control-schema.d.ts +1 -1
- package/dist/control-schema.js +805 -774
- package/dist/control-schema.js.map +1 -1
- package/dist/control-write-retry.d.ts +65 -31
- package/dist/control-write-retry.js +33 -103
- package/dist/control-write-retry.js.map +1 -1
- package/dist/delegate-admission.d.ts +17 -15
- package/dist/delegate-admission.js +17 -15
- package/dist/delegate-admission.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +18 -9
- package/dist/index.js.map +1 -1
- package/dist/membership-connection-gater.d.ts +211 -25
- package/dist/membership-connection-gater.js +309 -29
- package/dist/membership-connection-gater.js.map +1 -1
- package/dist/peer-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/peer-record.d.ts +26 -0
- package/dist/peer-record.js +32 -0
- package/dist/peer-record.js.map +1 -1
- package/dist/relay-addrs.d.ts +76 -26
- package/dist/relay-addrs.js +115 -27
- package/dist/relay-addrs.js.map +1 -1
- package/dist/relay-reservation.d.ts +20 -15
- package/dist/relay-reservation.js +18 -14
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.d.ts +7 -0
- package/dist/seed-bootstrap.js +15 -13
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/strand-addr-protocol.js +16 -0
- package/dist/strand-addr-protocol.js.map +1 -1
- package/dist/strand-backfill.js +13 -4
- package/dist/strand-backfill.js.map +1 -1
- package/dist/strand-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 +18 -17
- package/dist/strand-formation-manager.js +25 -27
- package/dist/strand-formation-manager.js.map +1 -1
- package/dist/strand-formation-protocol.d.ts +7 -7
- package/dist/strand-formation-protocol.js +8 -8
- package/dist/strand-formation-protocol.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +53 -21
- package/dist/strand-instance-manager.js +128 -37
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +113 -19
- package/dist/strand-membership-writer.js +219 -51
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-solicitation.d.ts +3 -3
- package/dist/strand-solicitation.js +4 -5
- package/dist/strand-solicitation.js.map +1 -1
- package/dist/strand-wake-protocol.d.ts +47 -2
- package/dist/strand-wake-protocol.js +76 -6
- package/dist/strand-wake-protocol.js.map +1 -1
- package/dist/types.d.ts +107 -54
- package/dist/types.js.map +1 -1
- package/package.json +8 -7
- package/src/announce-addrs.ts +93 -0
- 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 +1026 -194
- 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 +376 -298
- package/src/control-formation-recorder.ts +305 -304
- package/src/control-read-retry.ts +167 -0
- package/src/control-retry.ts +190 -0
- package/src/control-schema.ts +817 -786
- package/src/control-stream.ts +205 -205
- package/src/control-write-retry.ts +383 -473
- package/src/delegate-admission.ts +304 -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 +435 -406
- package/src/key-store-file.ts +132 -132
- package/src/key-store.ts +99 -99
- package/src/membership-connection-gater.ts +368 -39
- 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 +197 -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 +134 -27
- package/src/relay-reservation.ts +22 -17
- package/src/seed-bootstrap.ts +15 -15
- package/src/seed-trust-policy.ts +140 -140
- package/src/strand-addr-protocol.ts +458 -441
- package/src/strand-backfill.ts +28 -5
- package/src/strand-cohort.ts +38 -77
- package/src/strand-database.ts +202 -204
- package/src/strand-formation-manager.ts +26 -28
- package/src/strand-formation-protocol.ts +692 -692
- package/src/strand-instance-manager.ts +636 -553
- package/src/strand-member-key.ts +39 -39
- package/src/strand-member-registry.ts +182 -182
- package/src/strand-membership-writer.ts +1627 -1430
- package/src/strand-solicitation.ts +471 -472
- package/src/strand-transport-key.ts +58 -58
- package/src/strand-wake-payload.ts +32 -32
- package/src/strand-wake-protocol.ts +400 -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 +1354 -1303
|
@@ -49,43 +49,27 @@ export declare class FormationAbortedError extends Error {
|
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
52
|
+
* The invitation's seat budget is already spent: the count of recorded `FormationUsage` rows
|
|
53
|
+
* for the token has reached `FormationInvite.TotalUses`, so this redemption cannot be given a
|
|
54
|
+
* seat.
|
|
55
55
|
*
|
|
56
|
-
* Raised
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
56
|
+
* Raised off the committed-count check ({@link ControlDatabase.assertSeatRemains}) ahead of
|
|
57
|
+
* the write, inside {@link ControlDatabase.redeemInvitation} /
|
|
58
|
+
* {@link ControlDatabase.recordFormationUsage} — and off nothing else. It exists because
|
|
59
|
+
* without it the refusal surfaces as a generic `CHECK constraint failed: Authorized` from the
|
|
60
|
+
* schema's own count-based cap clause, which the manager reports as a retryable
|
|
61
|
+
* `Formation conflict, retry` — telling the joiner to retry something that can never succeed.
|
|
62
|
+
* Two redemptions of one token racing on the SAME node serialize behind the local write
|
|
63
|
+
* queue, so the loser reads the winner's committed row and is refused here by name —
|
|
64
|
+
* `StrandFormationManager.validateToken`'s `isTokenUsed` check cannot catch that, since it
|
|
65
|
+
* ran before either redemption wrote anything.
|
|
64
66
|
*/
|
|
65
67
|
export declare class InvitationExhaustedError extends Error {
|
|
66
68
|
readonly token: string;
|
|
67
|
-
readonly
|
|
69
|
+
readonly usesRecorded: number;
|
|
68
70
|
readonly totalUses: number;
|
|
69
|
-
constructor(token: string,
|
|
71
|
+
constructor(token: string, usesRecorded: number, totalUses: number);
|
|
70
72
|
}
|
|
71
|
-
/**
|
|
72
|
-
* Did this write fail purely because another writer took the `UseNumber` it picked — i.e. is
|
|
73
|
-
* re-presenting the SAME approval under the next free use number the right response?
|
|
74
|
-
*
|
|
75
|
-
* Classifies by MESSAGE, walking the `cause` chain with Quereus' own {@link unwrapError}:
|
|
76
|
-
* neither surface can be recognised by type. The deferred `Monotonic` failure is thrown as a
|
|
77
|
-
* bare `QuereusError`, not a `ConstraintError` (see
|
|
78
|
-
* `quereus/src/runtime/deferred-constraint-queue.ts`), so an `instanceof ConstraintError` gate
|
|
79
|
-
* would silently drop the branch this retry exists for. Messages also carry an
|
|
80
|
-
* ` (at line N, column M)` suffix and arrive wrapped when the write ran inside a transaction,
|
|
81
|
-
* so the match is a substring test rather than an equality one.
|
|
82
|
-
*
|
|
83
|
-
* NOTE: this is the one piece of the retry that depends on engine error TEXT. Its own test
|
|
84
|
-
* (`control-formation-use-number-retry.spec.ts`) produces all three messages from the real
|
|
85
|
-
* engine rather than from string literals, so a reworded storage-layer error fails that spec
|
|
86
|
-
* instead of silently disabling the retry.
|
|
87
|
-
*/
|
|
88
|
-
export declare function isLostUseNumberRace(error: unknown): boolean;
|
|
89
73
|
/**
|
|
90
74
|
* What a recorded `FormationUsage` row came out as.
|
|
91
75
|
*
|
|
@@ -94,9 +78,7 @@ export declare function isLostUseNumberRace(error: unknown): boolean;
|
|
|
94
78
|
* `FormationUsage.Authorized` rejects the row (see {@link formationVouchMessage}).
|
|
95
79
|
*/
|
|
96
80
|
export interface FormationUsageResult {
|
|
97
|
-
/** The `
|
|
98
|
-
useNumber: number;
|
|
99
|
-
/** The single-use nonce written to `UsageStampId` — the caller's, or a freshly minted one. */
|
|
81
|
+
/** The single-use nonce written to `UsageStampId` (the row's primary key) — the caller's, or a freshly minted one. */
|
|
100
82
|
usageStampId: string;
|
|
101
83
|
}
|
|
102
84
|
/**
|
|
@@ -245,12 +227,111 @@ export declare class ControlDatabase {
|
|
|
245
227
|
* having to restate the policy's attempts or classifier.
|
|
246
228
|
*/
|
|
247
229
|
private controlWriteRetryPacing;
|
|
230
|
+
/**
|
|
231
|
+
* Pacing seams for {@link readRows}'s retry — the read twin of
|
|
232
|
+
* {@link controlWriteRetryPacing}, with the same contract: production leaves this
|
|
233
|
+
* empty, specs inject a recorded `sleep` / fake `now` so no test waits out a real
|
|
234
|
+
* backoff. Applied OVER the read policy's defaults but UNDER the call site's `label`.
|
|
235
|
+
*/
|
|
236
|
+
private controlReadRetryPacing;
|
|
248
237
|
constructor(config: ControlDatabaseConfig);
|
|
249
238
|
/**
|
|
250
239
|
* Initialize the database - load schema and register plugins
|
|
251
240
|
*/
|
|
252
241
|
initialize(): Promise<void>;
|
|
253
242
|
private loadSchema;
|
|
243
|
+
/**
|
|
244
|
+
* One UNRETRIED drain of a control read to rows — every read in this class bottoms out
|
|
245
|
+
* here (almost all via {@link readRows}, which adds the transient-failure retry), and
|
|
246
|
+
* this is the ONE place the committed-read opt-in is spelled.
|
|
247
|
+
*
|
|
248
|
+
* Reached only through {@link readRows}, which drops straight here for the call sites
|
|
249
|
+
* that pass `retry: false` — reads issued from INSIDE a locked write body, where a
|
|
250
|
+
* backoff sleep would hold the write lock and the write funnel re-runs the read anyway
|
|
251
|
+
* (see the NOTE on {@link readRows}).
|
|
252
|
+
*
|
|
253
|
+
* A control read normally queues on the database's exec mutex, so it does not answer
|
|
254
|
+
* until whatever statement holds that mutex finishes. When a control WRITE is parked
|
|
255
|
+
* against an unresponsive cohort member that is minutes, and a read that only wants
|
|
256
|
+
* the last committed state waits the whole time for no reason
|
|
257
|
+
* (`complete/control-reads-blocked-by-stalled-write`).
|
|
258
|
+
*
|
|
259
|
+
* Quereus's remedy is `readConcurrency: 'committed'` — a PER-CALL opt-in
|
|
260
|
+
* (`Statement.tryRouteConcurrent` refuses anything that did not ask for it by name)
|
|
261
|
+
* that runs an eligible read off the mutex against each table's last committed state.
|
|
262
|
+
* Routing is best-effort and never an error: an ineligible statement falls back to
|
|
263
|
+
* the serialized path silently — any side-effecting node, any table-valued function
|
|
264
|
+
* call (pure ones included), any table whose module does not declare
|
|
265
|
+
* `readCommittedSnapshot`, or an EXPLICIT transaction being open on this database.
|
|
266
|
+
*
|
|
267
|
+
* **It is asked for only while a write is in flight, and that condition is the whole
|
|
268
|
+
* point.** The opt-in does not merely move the read off the mutex: it connects the
|
|
269
|
+
* table with `_readCommitted`, and the optimystic vtab serves such a connection from
|
|
270
|
+
* a pinned pre-transaction snapshot that NEVER refreshes from the network, where an
|
|
271
|
+
* ordinary read calls `update()` on the collection first. Taking that path
|
|
272
|
+
* unconditionally therefore trades "reads block behind a stalled write" for "reads
|
|
273
|
+
* stop seeing what other machines wrote" — measured, not theorised: with every read
|
|
274
|
+
* opted in, `control-write-degraded-cohort-member` could no longer observe a sibling
|
|
275
|
+
* publishing its own `CadrePeer` row and failed at suite setup on two runs out of two.
|
|
276
|
+
*
|
|
277
|
+
* `getAutocommit()` reports the whole `Database` rather than this call (the same
|
|
278
|
+
* property {@link assertCommitBoundary} leans on), which is exactly what is wanted:
|
|
279
|
+
* false means some writer's transaction is open, which is the only situation where a
|
|
280
|
+
* read would have blocked. The check races that writer harmlessly in both directions
|
|
281
|
+
* — losing it either reads normally (today's behaviour) or reads committed state.
|
|
282
|
+
*
|
|
283
|
+
* What a committed read gives up is observing a write that has not finished
|
|
284
|
+
* committing, and that is safe for every caller here: an AWAITED write is committed by
|
|
285
|
+
* the time it returns, and a read inside {@link inTransaction} is disqualified by the
|
|
286
|
+
* open explicit transaction and stays serialized, so it still sees its own
|
|
287
|
+
* transaction's rows. A read after an UNAWAITED write on this database would be wrong
|
|
288
|
+
* — no such caller exists, and {@link withWriteLock} is what keeps it that way.
|
|
289
|
+
*/
|
|
290
|
+
private readRowsOnce;
|
|
291
|
+
/**
|
|
292
|
+
* {@link readRowsOnce} plus the bounded transient-failure retry
|
|
293
|
+
* ({@link retryControlRead}) — the funnel every UNLOCKED control read goes through, so a
|
|
294
|
+
* read that failed because the cluster could not be asked properly (a stream reset
|
|
295
|
+
* mid-scan, a partially unreachable cohort) is re-presented a moment later instead of
|
|
296
|
+
* surfacing to every reader.
|
|
297
|
+
*
|
|
298
|
+
* MATERIALIZES the read rather than returning a lazy iterator, and that shape is what
|
|
299
|
+
* makes the retry possible at all: the failure happens during ITERATION, not at call
|
|
300
|
+
* time, and a half-consumed iterator cannot be retried without re-yielding rows the
|
|
301
|
+
* caller already saw. Materializing is safe here — every caller either drains to an
|
|
302
|
+
* array anyway or takes the first row of a statement that yields at most one (a
|
|
303
|
+
* primary-key lookup or a `count(1)`); nothing streams.
|
|
304
|
+
*
|
|
305
|
+
* Each ATTEMPT is a fresh {@link readRowsOnce} call, so the committed-read opt-in
|
|
306
|
+
* (`getAutocommit()`) is re-evaluated per attempt — a write can finish between attempts
|
|
307
|
+
* and change the right answer.
|
|
308
|
+
*
|
|
309
|
+
* The retry's budget ({@link CONTROL_READ_RETRY_BUDGET_MS}, 1.5 s) is deliberately far
|
|
310
|
+
* under the write funnel's: the tightest caller deadline over a control read is the
|
|
311
|
+
* inbound admission gate's 2 s FAIL-OPEN timeout, and a read that outlives it spends its
|
|
312
|
+
* retries after the gate has already admitted (see `control-read-retry.ts`).
|
|
313
|
+
*
|
|
314
|
+
* `label` names the read in the retry loop's `Control read [<label>] …` debug lines and
|
|
315
|
+
* nothing else; several reads are in flight concurrently in a real party, so an
|
|
316
|
+
* unlabelled line cannot be attributed — keep new call sites labelled.
|
|
317
|
+
*
|
|
318
|
+
* `retry: false` drops straight to {@link readRowsOnce}: reads issued from INSIDE a
|
|
319
|
+
* locked write body must not retry on their own. `retryControlWrite`'s contract is that
|
|
320
|
+
* backoff sleeps happen with NO lock held; a read retrying its own backoff inside a
|
|
321
|
+
* locked body would sleep holding the write lock (stalling every other local writer),
|
|
322
|
+
* and the write funnel already re-runs the body's reads when it re-runs the body. See
|
|
323
|
+
* {@link queryStampId} / {@link assertSeatRemains} / {@link queryCadrePeers} for the
|
|
324
|
+
* per-call opt-outs.
|
|
325
|
+
*
|
|
326
|
+
* NOTE: nothing structural enforces that opt-out — it is per-call because an unlocked
|
|
327
|
+
* read runs CONCURRENTLY with a locked body, so `this`-state cannot tell the two apart,
|
|
328
|
+
* and there is no async-context primitive available on every target platform (browser,
|
|
329
|
+
* React Native) to carry the answer. Reads reached through a CALLBACK invoked under the
|
|
330
|
+
* lock are the easy miss: `notifyMembershipChanged` runs its listener with the lock
|
|
331
|
+
* held, and its membership read is opted out at {@link queryCadrePeers}. If another
|
|
332
|
+
* under-lock callback seam is ever added, audit its read graph the same way.
|
|
333
|
+
*/
|
|
334
|
+
private readRows;
|
|
254
335
|
/**
|
|
255
336
|
* Query all strands from the control database
|
|
256
337
|
*/
|
|
@@ -314,8 +395,25 @@ export declare class ControlDatabase {
|
|
|
314
395
|
* membership-gate refresh path; control tables are a handful of rows today, so this
|
|
315
396
|
* is cheap — if they ever grow, fold the exclusion into one statement or cache the
|
|
316
397
|
* retired-stamp set.
|
|
398
|
+
*
|
|
399
|
+
* NOTE: this filter is HAND-MIRRORED by the gate suites' fake control databases
|
|
400
|
+
* (`test/membership-gate-helpers.ts` and `test/cadre-node-authorized-surface.spec.ts`),
|
|
401
|
+
* which feed the node rows directly. Nothing enforces that the copies stay true, so a
|
|
402
|
+
* change to the filter's SHAPE here (what it keys on, which table it consults) must be
|
|
403
|
+
* carried into both, or those suites keep passing against a contract the database no
|
|
404
|
+
* longer honours. Fine while the filter is one `Set.has` on `StampId`; if it grows a
|
|
405
|
+
* second dimension, give the fakes a shared fixture or a contract test instead.
|
|
406
|
+
*
|
|
407
|
+
* `retry: false` is passed by exactly one caller —
|
|
408
|
+
* `CadreNode.refreshAuthorizedControlPeers`, the membership-gate snapshot refresh —
|
|
409
|
+
* because {@link notifyMembershipChanged} invokes that listener with the write lock
|
|
410
|
+
* HELD, and a retrying read there would sleep its backoff holding the lock (see the
|
|
411
|
+
* NOTE on {@link readRows}). It costs nothing: that refresh is best-effort, keeps the
|
|
412
|
+
* previous snapshot on failure, and is re-driven by the next write and by the timed
|
|
413
|
+
* reconcile. BOTH of this method's reads take the flag — retrying either one under the
|
|
414
|
+
* lock is the same defect.
|
|
317
415
|
*/
|
|
318
|
-
queryCadrePeers(): Promise<CadrePeerRow[]>;
|
|
416
|
+
queryCadrePeers(retry?: boolean): Promise<CadrePeerRow[]>;
|
|
319
417
|
/**
|
|
320
418
|
* Every approver public key currently enrolled in `CadreControl.ValidationKey` — the
|
|
321
419
|
* set a `ValidationUrl` redemption's approval signature must be signed by (see
|
|
@@ -340,6 +438,16 @@ export declare class ControlDatabase {
|
|
|
340
438
|
* `table` and its {@link GUARDED_KEY_COLUMN} column are interpolated into the SQL; both
|
|
341
439
|
* come from closed literal unions — no caller-supplied string can reach the statement
|
|
342
440
|
* (same injection-surface discipline as {@link countRows}'s `CONTROL_TABLE_SET` guard).
|
|
441
|
+
*
|
|
442
|
+
* NOTE: `retry: false` is passed by exactly the call sites that run INSIDE a locked
|
|
443
|
+
* write body — {@link deleteGuardedRow} and {@link insertCadrePeer}'s insert-if-absent
|
|
444
|
+
* guard. A read retrying there would sleep its backoff HOLDING the write lock
|
|
445
|
+
* (`retryControlWrite`'s contract is that backoff sleeps happen with no lock held), and
|
|
446
|
+
* the write funnel already re-runs the body's reads when it re-runs the body. The
|
|
447
|
+
* unlocked callers — the public stamp readers, {@link reauthorizeCadrePeer} and
|
|
448
|
+
* {@link reapRevokedRow}, whose stamp reads run BEFORE their locks are taken — keep the
|
|
449
|
+
* default retried path. Explicit per call rather than an ambient flag: unlocked reads
|
|
450
|
+
* run concurrently with a locked body, so `this`-state cannot tell the two apart.
|
|
343
451
|
*/
|
|
344
452
|
private queryStampId;
|
|
345
453
|
/**
|
|
@@ -374,8 +482,11 @@ export declare class ControlDatabase {
|
|
|
374
482
|
* NOTE: re-reads the whole retired set on every call, and the caller runs per inbound
|
|
375
483
|
* gate request while the table only ever grows. Cheap today (a cadre removes peers
|
|
376
484
|
* rarely); if removals ever become routine, cache the set and invalidate it on write.
|
|
485
|
+
*
|
|
486
|
+
* `retry: false` is forwarded by {@link queryCadrePeers} for the one caller that reads
|
|
487
|
+
* under the write lock — see that method's note.
|
|
377
488
|
*/
|
|
378
|
-
queryRevokedStamps(tableName: RevocableTable): Promise<Set<string>>;
|
|
489
|
+
queryRevokedStamps(tableName: RevocableTable, retry?: boolean): Promise<Set<string>>;
|
|
379
490
|
/**
|
|
380
491
|
* Every locally-held `CadreControl.Revocation` tombstone — identity triple plus its
|
|
381
492
|
* `ReissuedAt` counter. Consumed by the cohort-growth re-issue sweep, which
|
|
@@ -396,7 +507,7 @@ export declare class ControlDatabase {
|
|
|
396
507
|
* {@link queryRevokedStamps} rather than inlining the SQL, so tests can interpose on
|
|
397
508
|
* that seam (see {@link queryCadrePeers}).
|
|
398
509
|
*
|
|
399
|
-
* Missing/
|
|
510
|
+
* Missing/null column values are coalesced
|
|
400
511
|
* to their empty form (`''` key/sig, `[]` addrs, `0` stamp) so the caller's
|
|
401
512
|
* verify/freshness gates uniformly reject an unpublished or malformed row.
|
|
402
513
|
* The split `addrs` re-join to the exact stored `Multiaddr` (split-on-`,` is
|
|
@@ -431,7 +542,7 @@ export declare class ControlDatabase {
|
|
|
431
542
|
/**
|
|
432
543
|
* Read a single peer's device push token (the full `DeviceToken` row) by PeerId.
|
|
433
544
|
*
|
|
434
|
-
* Returns null when no row exists. Missing/
|
|
545
|
+
* Returns null when no row exists. Missing/null column values are coalesced to
|
|
435
546
|
* their empty form (`''` token/sig, `0` stamp) so the caller's verify/freshness
|
|
436
547
|
* gates uniformly reject an unpublished or malformed row. `platform` is returned
|
|
437
548
|
* verbatim (the resolver validates it against {@link PushPlatform} and re-verifies
|
|
@@ -900,7 +1011,8 @@ export declare class ControlDatabase {
|
|
|
900
1011
|
* {@link assertCommitBoundary} (any `CadrePeer` path) or silently join each other's
|
|
901
1012
|
* open transaction — a torn commit if either side rolls back. This lock closes that
|
|
902
1013
|
* class: every public write method of this class runs its statement(s) under it, and
|
|
903
|
-
* `SeedBootstrapService`
|
|
1014
|
+
* `SeedBootstrapService` reaches it through {@link execWrite} rather than touching the
|
|
1015
|
+
* `Database` directly, so it is covered too. The race is
|
|
904
1016
|
* real, not theoretical: the background self-record publish on a control connection
|
|
905
1017
|
* opening (`CadreNode.drainPendingControlReplication` → `registerSelf`) collided with
|
|
906
1018
|
* a foreground `authorizePeer` — both `CadrePeer` inserts — and tripped the boundary
|
|
@@ -937,10 +1049,8 @@ export declare class ControlDatabase {
|
|
|
937
1049
|
*
|
|
938
1050
|
* The retry wraps the LOCK, it does not live inside it: each attempt takes and
|
|
939
1051
|
* releases the lock, and the backoff sleeps with NO lock held, so a write parked in
|
|
940
|
-
* backoff never stalls the other local writers queued behind it.
|
|
941
|
-
*
|
|
942
|
-
* `USE_NUMBER_ATTEMPTS`. Safe because every locked body is atomic and re-runnable
|
|
943
|
-
* (the contract on {@link withWriteLock}).
|
|
1052
|
+
* backoff never stalls the other local writers queued behind it. Safe because every
|
|
1053
|
+
* locked body is atomic and re-runnable (the contract on {@link withWriteLock}).
|
|
944
1054
|
*
|
|
945
1055
|
* Also carries {@link loadSchema}'s distributed DDL, the one caller that runs BEFORE
|
|
946
1056
|
* `initialized` is set — so it reaches this method directly rather than through
|
|
@@ -949,6 +1059,11 @@ export declare class ControlDatabase {
|
|
|
949
1059
|
* rather than carving out a lockless path. It is also the ONLY caller that passes a
|
|
950
1060
|
* `policy` — everything else takes the default, whose classifier and attempt count are
|
|
951
1061
|
* unchanged by that call site existing.
|
|
1062
|
+
*
|
|
1063
|
+
* `label` names the operation in this loop's debug lines and nothing else
|
|
1064
|
+
* ({@link ControlWriteRetryOptions.label}). Every call site in this class supplies one:
|
|
1065
|
+
* several writes retry CONCURRENTLY in a real party, so an unlabelled line cannot be
|
|
1066
|
+
* attributed to a write — keep new call sites labelled.
|
|
952
1067
|
*/
|
|
953
1068
|
private lockedWithRetry;
|
|
954
1069
|
/**
|
|
@@ -960,8 +1075,11 @@ export declare class ControlDatabase {
|
|
|
960
1075
|
* Public so `SeedBootstrapService`'s direct `CadrePeer`/`DeviceToken` SQL writes go
|
|
961
1076
|
* through the same seam. Must NOT be called from inside an already-locked body (the
|
|
962
1077
|
* lock is not re-entrant); such a caller uses a bare `getDatabase().exec` instead.
|
|
1078
|
+
*
|
|
1079
|
+
* `label` names the write in the retry loop's debug lines (see {@link lockedWithRetry});
|
|
1080
|
+
* optional only because it is log-only, but every caller in this repo passes one.
|
|
963
1081
|
*/
|
|
964
|
-
execWrite(sql: string, params?: SqlParameters): Promise<void>;
|
|
1082
|
+
execWrite(sql: string, params?: SqlParameters, label?: string): Promise<void>;
|
|
965
1083
|
/**
|
|
966
1084
|
* Throw unless the database sits at a committed boundary — `getAutocommit()` is false
|
|
967
1085
|
* exactly while a transaction is open.
|
|
@@ -1012,7 +1130,8 @@ export declare class ControlDatabase {
|
|
|
1012
1130
|
* `StrandId` binds the invite to a pre-existing host strand (provision-then-record):
|
|
1013
1131
|
* when set, a responder redeeming this token records a `FormationUsage` against that
|
|
1014
1132
|
* strand and returns it (see {@link ControlFormationUsageRecorder.resolveStrand}); a
|
|
1015
|
-
* null `StrandId` (the default)
|
|
1133
|
+
* null `StrandId` (the default) takes the unbound responder-provisions path: the
|
|
1134
|
+
* responder provisions a fresh open strand and atomically records its one consent row.
|
|
1016
1135
|
* Like ValidationUrl it is a nullable bound field, signed as `''` when absent.
|
|
1017
1136
|
*
|
|
1018
1137
|
* The ExpiresAt and TotalUses message fields must byte-match what the (auto-deferred,
|
|
@@ -1062,13 +1181,12 @@ export declare class ControlDatabase {
|
|
|
1062
1181
|
* takes an owner re-seat ({@link insertStrand}) plus a bound invite, never another
|
|
1063
1182
|
* redemption.
|
|
1064
1183
|
*
|
|
1065
|
-
*
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
* cannot drift, and no second trip through the approval hook is possible from in here.
|
|
1184
|
+
* The usage row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so
|
|
1185
|
+
* concurrent redemptions of one token never contend for a shared row key — there is no
|
|
1186
|
+
* lost race to retry and no second trip through the approval hook. The invite's seat
|
|
1187
|
+
* budget is checked by COUNT ({@link assertSeatRemains}) inside the write lock, ahead of
|
|
1188
|
+
* the write, so a spent invite is refused by name (`InvitationExhaustedError`) instead of
|
|
1189
|
+
* as the schema cap clause's generic `Authorized` CHECK failure.
|
|
1072
1190
|
*/
|
|
1073
1191
|
redeemInvitation(params: {
|
|
1074
1192
|
token: string;
|
|
@@ -1091,9 +1209,9 @@ export declare class ControlDatabase {
|
|
|
1091
1209
|
signal?: AbortSignal;
|
|
1092
1210
|
/**
|
|
1093
1211
|
* The invite's seat budget, when the caller already has it in hand (e.g. it already read
|
|
1094
|
-
* the `FormationInvite` row to get here). Passed through to {@link
|
|
1095
|
-
*
|
|
1096
|
-
*
|
|
1212
|
+
* the `FormationInvite` row to get here). Passed through to {@link assertSeatRemains} so
|
|
1213
|
+
* the seat check needs no extra read. `undefined` falls back to a fresh
|
|
1214
|
+
* {@link queryFormationInvite} read; `null` means "no invite row" / unlimited.
|
|
1097
1215
|
*/
|
|
1098
1216
|
totalUses?: number | null;
|
|
1099
1217
|
}): Promise<FormationUsageResult>;
|
|
@@ -1102,8 +1220,8 @@ export declare class ControlDatabase {
|
|
|
1102
1220
|
* insert). This is the redemption path when the strand was provisioned
|
|
1103
1221
|
* separately (e.g. owner-signed) and the consent record is added after the
|
|
1104
1222
|
* fact: the single insert auto-commits, and the deferred `StrandExists` CHECK
|
|
1105
|
-
* is satisfied by the pre-existing committed strand row.
|
|
1106
|
-
* `
|
|
1223
|
+
* is satisfied by the pre-existing committed strand row. Echoes back the
|
|
1224
|
+
* redemption's `usageStampId` (the row's primary key).
|
|
1107
1225
|
*
|
|
1108
1226
|
* Use {@link redeemInvitation} instead when the strand must be created by
|
|
1109
1227
|
* consent atomically with the usage.
|
|
@@ -1117,12 +1235,12 @@ export declare class ControlDatabase {
|
|
|
1117
1235
|
* by {@link ControlFormationUsageRecorder.resolveStrand}, so an absent row at this
|
|
1118
1236
|
* point is a genuine race and deserves a named error, not a silent rollback.
|
|
1119
1237
|
*
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
*
|
|
1123
|
-
*
|
|
1124
|
-
*
|
|
1125
|
-
*
|
|
1238
|
+
* The row is keyed by the joiner's own nonce (`UsageStampId` is the primary key), so this
|
|
1239
|
+
* path — the one production actually races on (a bound invite published by `cadre-web` /
|
|
1240
|
+
* `cadre-phone` is always record-only) — never contends with another redemption for a
|
|
1241
|
+
* shared row key: there is no lost race to retry and no second trip through the approval
|
|
1242
|
+
* hook. The invite's seat budget is checked by COUNT ({@link assertSeatRemains}) inside
|
|
1243
|
+
* the write lock, ahead of the write.
|
|
1126
1244
|
*/
|
|
1127
1245
|
recordFormationUsage(params: {
|
|
1128
1246
|
token: string;
|
|
@@ -1138,66 +1256,28 @@ export declare class ControlDatabase {
|
|
|
1138
1256
|
validationKey?: string;
|
|
1139
1257
|
validationSignature?: string;
|
|
1140
1258
|
/**
|
|
1141
|
-
* Aborted when the caller has given up. Checked
|
|
1142
|
-
*
|
|
1143
|
-
*
|
|
1144
|
-
* once an insert has been issued.
|
|
1259
|
+
* Aborted when the caller has given up. Checked inside the write lock, before the insert
|
|
1260
|
+
* is issued, throwing {@link FormationAbortedError} with the invite unspent. Never
|
|
1261
|
+
* checked once the insert has been issued.
|
|
1145
1262
|
*/
|
|
1146
1263
|
signal?: AbortSignal;
|
|
1147
1264
|
/**
|
|
1148
1265
|
* The invite's seat budget, when the caller already has it in hand (e.g. it already read
|
|
1149
|
-
* the `FormationInvite` row to get here). Passed through to {@link
|
|
1150
|
-
*
|
|
1151
|
-
*
|
|
1266
|
+
* the `FormationInvite` row to get here). Passed through to {@link assertSeatRemains} so
|
|
1267
|
+
* the seat check needs no extra read. `undefined` falls back to a fresh
|
|
1268
|
+
* {@link queryFormationInvite} read; `null` means "no invite row" / unlimited.
|
|
1152
1269
|
*/
|
|
1153
1270
|
totalUses?: number | null;
|
|
1154
1271
|
}): Promise<FormationUsageResult>;
|
|
1155
1272
|
/**
|
|
1156
|
-
*
|
|
1157
|
-
* writer took that number first.
|
|
1158
|
-
*
|
|
1159
|
-
* Each attempt takes {@link withWriteLock} and, inside it, reads {@link nextUseNumber}
|
|
1160
|
-
* afresh. Reading under the lock is the whole fix for the common case: two redemptions of
|
|
1161
|
-
* one token on the SAME node are serialized by the local write queue, so each computes a
|
|
1162
|
-
* number the other has already committed and neither collides. The retry covers the writers
|
|
1163
|
-
* this lock does not reach — another node of the cadre, another `Database` handle over the
|
|
1164
|
-
* same store — which can still take the number between our read and our commit.
|
|
1165
|
-
*
|
|
1166
|
-
* `write` receives ONLY the use number; every other parameter of the redemption (the nonce,
|
|
1167
|
-
* the approver's signature, the strand, the joining peer key, the disclosure) is closed over
|
|
1168
|
-
* by the caller and passed through untouched. So by construction a retry re-presents the
|
|
1169
|
-
* IDENTICAL approval and the four non-nonce fields inside the approver's signed digest
|
|
1170
|
-
* cannot drift. No second call to the approval hook is reachable from in here either:
|
|
1171
|
-
* `obtainApproval` lives a layer up in `ControlFormationUsageRecorder` and is never
|
|
1172
|
-
* re-entered.
|
|
1173
|
-
*
|
|
1174
|
-
* `write` must be a BARE private body ({@link inTransaction} /
|
|
1175
|
-
* {@link execFormationUsageInsert}) — {@link withWriteLock} is not re-entrant and a `write`
|
|
1176
|
-
* that called another locked public method would hang silently and strand the write queue.
|
|
1177
|
-
*
|
|
1178
|
-
* A failed attempt has written nothing (a bare insert never landed; a transactional one
|
|
1179
|
-
* rolled back), so the abort check at the top of each attempt stays inside
|
|
1180
|
-
* {@link FormationAbortedError}'s contract — it is only ever thrown before this attempt's
|
|
1181
|
-
* write is issued.
|
|
1182
|
-
*
|
|
1183
|
-
* @param operation - `'redemption'` | `'usage recording'`, for the abort error's text.
|
|
1184
|
-
* @param knownTotalUses - The invite's seat budget when the caller already has it in hand
|
|
1185
|
-
* (`undefined` to fall back to a fresh {@link queryFormationInvite} read; `null` for "no
|
|
1186
|
-
* invite row" / unlimited). Passed through to {@link assertSeatRemains} on every attempt.
|
|
1187
|
-
* @returns The `UseNumber` the successful attempt wrote under.
|
|
1188
|
-
*/
|
|
1189
|
-
private withUseNumberRetry;
|
|
1190
|
-
/**
|
|
1191
|
-
* Refuse an attempt that would consume a seat the invite does not have.
|
|
1273
|
+
* Refuse a redemption that would consume a seat the invite does not have.
|
|
1192
1274
|
*
|
|
1193
|
-
* `FormationUsage.Authorized`
|
|
1194
|
-
*
|
|
1195
|
-
*
|
|
1196
|
-
*
|
|
1197
|
-
*
|
|
1198
|
-
*
|
|
1199
|
-
* local write queue, so the loser's first attempt is already over budget, with no retry
|
|
1200
|
-
* involved.
|
|
1275
|
+
* The schema's count-based cap clause (`FormationUsage.Authorized`) refuses an over-limit
|
|
1276
|
+
* write at the database anyway — but it fails as a generic `CHECK constraint failed:
|
|
1277
|
+
* Authorized`, which the manager reports as a retryable conflict. Catching it here as
|
|
1278
|
+
* {@link InvitationExhaustedError} lets the joiner be told the invitation is spent instead
|
|
1279
|
+
* of being sent around a loop that can never close. Runs inside the write lock, so on a
|
|
1280
|
+
* same-node race the loser reads the winner's committed row and is refused by name.
|
|
1201
1281
|
*
|
|
1202
1282
|
* `knownTotalUses` lets a caller that already read the `FormationInvite` row (both
|
|
1203
1283
|
* production callers in `ControlFormationUsageRecorder` do) skip a second read on the common,
|
|
@@ -1207,7 +1287,7 @@ export declare class ControlDatabase {
|
|
|
1207
1287
|
* invite, before an outbound approval call). Safe today because `FormationInvite` is
|
|
1208
1288
|
* insert/delete only (its `Immutable` constraint), so the only way to stale it is an owner
|
|
1209
1289
|
* revoking the token and re-issuing it with MORE seats mid-redemption; if invites ever gain
|
|
1210
|
-
* an update path, drop the parameter and read here
|
|
1290
|
+
* an update path, drop the parameter and read here instead.
|
|
1211
1291
|
*/
|
|
1212
1292
|
private assertSeatRemains;
|
|
1213
1293
|
/** Parameterised `FormationUsage` insert shared by redeem + record paths. */
|
|
@@ -1216,8 +1296,12 @@ export declare class ControlDatabase {
|
|
|
1216
1296
|
* Read a `FormationInvite` row by token, or null when absent. `expiresAtMs` is
|
|
1217
1297
|
* the parsed epoch-ms of the stored `datetime` (null when the invite never
|
|
1218
1298
|
* expires); the caller compares it against the wall clock for freshness.
|
|
1299
|
+
*
|
|
1300
|
+
* `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
|
|
1301
|
+
* locked write body — same per-call opt-out, and for the same reason, as
|
|
1302
|
+
* {@link queryStampId}'s. Every unlocked caller keeps the default retried path.
|
|
1219
1303
|
*/
|
|
1220
|
-
queryFormationInvite(token: string): Promise<{
|
|
1304
|
+
queryFormationInvite(token: string, retry?: boolean): Promise<{
|
|
1221
1305
|
token: string;
|
|
1222
1306
|
sAppId: string;
|
|
1223
1307
|
expiresAtMs: number | null;
|
|
@@ -1227,8 +1311,29 @@ export declare class ControlDatabase {
|
|
|
1227
1311
|
} | null>;
|
|
1228
1312
|
/**
|
|
1229
1313
|
* Count `FormationUsage` rows recorded against a token (uses consumed so far).
|
|
1314
|
+
*
|
|
1315
|
+
* This count IS the seat cap (`enforceFormationUseCap`), so an under-report admits a seat
|
|
1316
|
+
* the invitation never paid for. It is served by a table SCAN, deliberately: a bare
|
|
1317
|
+
* equality on a non-key column is a shape the optimystic vtab declines to claim, so the
|
|
1318
|
+
* engine applies the filter itself over a full read of the table.
|
|
1319
|
+
*
|
|
1320
|
+
* NOTE: it was briefly an index seek, and that is what the scan is protecting against.
|
|
1321
|
+
* `formation-unique-token-redesign` took `Token` out of the primary key and declared
|
|
1322
|
+
* `FormationUsageByToken` purely to spare this scan, recording as a tripwire that an
|
|
1323
|
+
* under-reporting descent would admit an unpaid-for seat — "a failure mode a scan could
|
|
1324
|
+
* not produce". The tripwire fired. Across machines a secondary-index descent returns only
|
|
1325
|
+
* the rows the READING machine wrote, so this count under-reported without bound on a
|
|
1326
|
+
* multi-machine party and the cap over-admitted. The index was removed 2026-08-25; the
|
|
1327
|
+
* measurement is recorded in the block comment at the `FormationUsage` declaration in
|
|
1328
|
+
* `schemas/control.qsql`, and the engine defect in
|
|
1329
|
+
* `blocked/secondary-index-seek-blind-to-sibling-rows`. Do NOT re-declare the index to
|
|
1330
|
+
* speed this up while that ticket is open.
|
|
1331
|
+
*
|
|
1332
|
+
* `retry: false` is passed only by {@link assertSeatRemains}, which runs INSIDE a
|
|
1333
|
+
* locked write body — same per-call opt-out, and for the same reason, as
|
|
1334
|
+
* {@link queryStampId}'s. Every unlocked caller keeps the default retried path.
|
|
1230
1335
|
*/
|
|
1231
|
-
countFormationUsage(token: string): Promise<number>;
|
|
1336
|
+
countFormationUsage(token: string, retry?: boolean): Promise<number>;
|
|
1232
1337
|
/**
|
|
1233
1338
|
* Is any `FormationInvite` row still redeemable — unexpired AND with usage
|
|
1234
1339
|
* below its `TotalUses`? A null `ExpiresAt` never expires and a null
|
|
@@ -1247,14 +1352,6 @@ export declare class ControlDatabase {
|
|
|
1247
1352
|
* unlimited-use invite anywhere in the scan short-circuits all of them.
|
|
1248
1353
|
*/
|
|
1249
1354
|
hasOutstandingFormationInvite(nowMs?: number): Promise<boolean>;
|
|
1250
|
-
/**
|
|
1251
|
-
* Next `UseNumber` for a token = max(existing)+1, per the `Monotonic` constraint.
|
|
1252
|
-
*
|
|
1253
|
-
* Called only from inside {@link withUseNumberRetry}'s locked body — a read takes no
|
|
1254
|
-
* transaction of its own, so it is safe there, and reading it under the lock is what stops
|
|
1255
|
-
* two local redemptions of one token from picking the same number.
|
|
1256
|
-
*/
|
|
1257
|
-
private nextUseNumber;
|
|
1258
1355
|
/**
|
|
1259
1356
|
* Close the database and cleanup resources.
|
|
1260
1357
|
*
|