@serfab/cadre-core 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cadre-node.d.ts +38 -15
- package/dist/cadre-node.js +77 -29
- package/dist/cadre-node.js.map +1 -1
- package/dist/control-database.d.ts +24 -10
- package/dist/control-database.js +48 -16
- package/dist/control-database.js.map +1 -1
- package/dist/control-read-retry.d.ts +12 -0
- package/dist/control-read-retry.js +12 -0
- package/dist/control-read-retry.js.map +1 -1
- package/dist/control-write-retry.d.ts +5 -4
- package/dist/control-write-retry.js +24 -7
- package/dist/control-write-retry.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/link-budget.d.ts +182 -0
- package/dist/link-budget.js +195 -0
- package/dist/link-budget.js.map +1 -0
- package/dist/peer-dial.d.ts +36 -17
- package/dist/peer-dial.js +37 -17
- package/dist/peer-dial.js.map +1 -1
- package/dist/peer-join-backfill.d.ts +58 -4
- package/dist/peer-join-backfill.js +165 -20
- package/dist/peer-join-backfill.js.map +1 -1
- package/dist/relay-reservation.d.ts +59 -11
- package/dist/relay-reservation.js +169 -43
- package/dist/relay-reservation.js.map +1 -1
- package/dist/seed-bootstrap.js +2 -1
- package/dist/seed-bootstrap.js.map +1 -1
- package/dist/strand-first-sync-gate.d.ts +43 -5
- package/dist/strand-first-sync-gate.js +55 -7
- package/dist/strand-first-sync-gate.js.map +1 -1
- package/dist/strand-instance-manager.d.ts +27 -2
- package/dist/strand-instance-manager.js +56 -13
- package/dist/strand-instance-manager.js.map +1 -1
- package/dist/strand-membership-reconciler.d.ts +200 -45
- package/dist/strand-membership-reconciler.js +302 -86
- package/dist/strand-membership-reconciler.js.map +1 -1
- package/dist/strand-membership-writer.d.ts +3 -2
- package/dist/strand-membership-writer.js +67 -90
- package/dist/strand-membership-writer.js.map +1 -1
- package/dist/strand-network-config.d.ts +2 -2
- package/dist/strand-network-config.js +2 -2
- package/dist/types.d.ts +96 -9
- package/dist/types.js +14 -4
- package/dist/types.js.map +1 -1
- package/package.json +8 -8
- package/src/cadre-node.ts +7445 -7397
- package/src/control-database.ts +48 -16
- package/src/control-read-retry.ts +214 -202
- package/src/control-write-retry.ts +24 -7
- package/src/index.ts +581 -563
- package/src/link-budget.ts +217 -0
- package/src/peer-dial.ts +39 -17
- package/src/peer-join-backfill.ts +192 -23
- package/src/relay-reservation.ts +1119 -951
- package/src/seed-bootstrap.ts +2 -1
- package/src/strand-first-sync-gate.ts +55 -7
- package/src/strand-instance-manager.ts +1516 -1458
- package/src/strand-membership-reconciler.ts +334 -92
- package/src/strand-membership-writer.ts +1962 -1987
- package/src/strand-network-config.ts +2 -2
- package/src/types.ts +95 -8
package/dist/cadre-node.d.ts
CHANGED
|
@@ -308,8 +308,10 @@ export declare class CadreNode implements SAppIdLookup {
|
|
|
308
308
|
* (`strand-membership-reconciler.ts`, wired via `StartStrandConfig.pendingMembershipInvite`
|
|
309
309
|
* in {@link launchStrand}): it deletes the entry once the invitation is redeemed, burned
|
|
310
310
|
* against an already-seated member, or found dead (expired / cancelled / consumed
|
|
311
|
-
* elsewhere / the strand sealed)
|
|
312
|
-
*
|
|
311
|
+
* elsewhere / the strand sealed) — naming the invitation it settled, so a re-formation
|
|
312
|
+
* that replaced the entry meanwhile keeps its fresh one
|
|
313
|
+
* ({@link unstageMembershipInvite}). Until the strand is actually launched here the entry
|
|
314
|
+
* just waits; once launched, staging notifies the manager, which re-arms a finished loop.
|
|
313
315
|
*
|
|
314
316
|
* NOTE: entries live for the node's lifetime (one small pair per formed closed
|
|
315
317
|
* strand), the same unbounded-keys tripwire {@link crossPartyStrandAddrs} documents —
|
|
@@ -1357,9 +1359,14 @@ export declare class CadreNode implements SAppIdLookup {
|
|
|
1357
1359
|
* {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials and
|
|
1358
1360
|
* `dialInvite`.
|
|
1359
1361
|
*
|
|
1360
|
-
* See
|
|
1361
|
-
* bounded as a whole, and
|
|
1362
|
-
*
|
|
1362
|
+
* See `peer-dial.ts`'s `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` for why a peer's dial is
|
|
1363
|
+
* bounded as a whole, and `DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS` for why each
|
|
1364
|
+
* address is bounded as well.
|
|
1365
|
+
*
|
|
1366
|
+
* Both are DERIVED from `network.linkRoundTripMs` rather than fixed, because the slowest
|
|
1367
|
+
* address either has to cover is a relayed dial and that costs a fixed number of exchanges —
|
|
1368
|
+
* `link-budget.ts` has the counts. A host's explicit `controlCohort` values still win, so a
|
|
1369
|
+
* test that drives dead addresses on purpose keeps the duration it chose.
|
|
1363
1370
|
*/
|
|
1364
1371
|
private controlDialBudget;
|
|
1365
1372
|
/**
|
|
@@ -2381,8 +2388,10 @@ export declare class CadreNode implements SAppIdLookup {
|
|
|
2381
2388
|
* caller runs the drive regardless).
|
|
2382
2389
|
*
|
|
2383
2390
|
* NOTE: against a relay that is DOWN this hook costs up to two strand-addr
|
|
2384
|
-
* timeouts (10 s each: dial by peer id, then by addr) before the
|
|
2385
|
-
* starts, so one failed re-drive
|
|
2391
|
+
* timeouts (10 s each: dial by peer id, then by addr) before the reservation drive even
|
|
2392
|
+
* starts, so one failed re-drive holds the supervisor `driving` for those 20 s plus the
|
|
2393
|
+
* drive's own deadline — 28 s at the default declared link round trip, and longer on a host
|
|
2394
|
+
* that declared a slower one (`link-budget.ts`).
|
|
2386
2395
|
* Bounded and harmless while the relay is unreachable anyway; if recovery
|
|
2387
2396
|
* latency after a relay comes back ever matters, skip the announce when the
|
|
2388
2397
|
* control node holds no connection to the relay (the drive's own dial fails
|
|
@@ -2602,13 +2611,15 @@ export declare class CadreNode implements SAppIdLookup {
|
|
|
2602
2611
|
* local), and only then does it reach out. Everything above in {@link start}
|
|
2603
2612
|
* has completed by the time this runs.
|
|
2604
2613
|
*
|
|
2605
|
-
* BUDGET: the supervisor's first attempt
|
|
2606
|
-
*
|
|
2607
|
-
*
|
|
2608
|
-
*
|
|
2609
|
-
*
|
|
2610
|
-
* on links that were merely slow.
|
|
2611
|
-
*
|
|
2614
|
+
* BUDGET: the supervisor's first attempt is what `start()` waits on — deliberately the drive's
|
|
2615
|
+
* ordinary deadline rather than a boot-specific one. That deadline is now COUNTED, four link
|
|
2616
|
+
* round trips at the declared `network.linkRoundTripMs` (8 s at its default, where it was a
|
|
2617
|
+
* fixed 10 s): a healthy dial-plus-reserve is sub-second even over a WAN, so this is slack for
|
|
2618
|
+
* a slow link, while going much longer would make a dead relay indistinguishable from a hung
|
|
2619
|
+
* start and much shorter would fail nodes on links that were merely slow. A host that declares
|
|
2620
|
+
* a slower link lengthens it without touching this path — `link-budget.ts`. The retries carry
|
|
2621
|
+
* on in the background after this resolves, exactly as they do for a {@link reserveRelays}
|
|
2622
|
+
* caller.
|
|
2612
2623
|
*
|
|
2613
2624
|
* `network.requireRelay === false` softens only the outcome below: a first
|
|
2614
2625
|
* attempt that lands no `/p2p-circuit` address is logged instead of thrown, and
|
|
@@ -3057,7 +3068,11 @@ export declare class CadreNode implements SAppIdLookup {
|
|
|
3057
3068
|
* an intact control DB) and mints + persists a fresh one otherwise. The invitation
|
|
3058
3069
|
* will admit THIS key's public half as the `Strand.Member`.
|
|
3059
3070
|
* 2. Stage the invitation in {@link pendingMembershipInvites} for the strand
|
|
3060
|
-
* bring-up flow (`strand-node-binds-member-peer`) to redeem via `consumeInvite
|
|
3071
|
+
* bring-up flow (`strand-node-binds-member-peer`) to redeem via `consumeInvite`,
|
|
3072
|
+
* and tell the instance manager, which re-arms an already-finished membership
|
|
3073
|
+
* reconciler so a RE-formation against a launched strand is attempted at once — the
|
|
3074
|
+
* removed-party case, where the loop finished long before the removal. A strand not
|
|
3075
|
+
* launched here yet has no loop to re-arm, and its bring-up finds the entry.
|
|
3061
3076
|
*
|
|
3062
3077
|
* Throws — failing the whole {@link formStrand} — when the identity cannot be
|
|
3063
3078
|
* persisted: a joiner "joined" without a persistable identity could never become a
|
|
@@ -3067,6 +3082,14 @@ export declare class CadreNode implements SAppIdLookup {
|
|
|
3067
3082
|
* rejected) and redeeming a FRESH invitation.
|
|
3068
3083
|
*/
|
|
3069
3084
|
private adoptFormationMembershipInvite;
|
|
3085
|
+
/**
|
|
3086
|
+
* The reconciler's half of the {@link pendingMembershipInvites} seam: drop `settled`
|
|
3087
|
+
* (spent, burned, or dead) only while it is still the staged entry. A re-formation
|
|
3088
|
+
* replaces the entry between a pass's read and its settle, and the fresh invitation it
|
|
3089
|
+
* staged is the one the re-armed loop is about to redeem — deleting it here would lose
|
|
3090
|
+
* it silently, the very outcome the re-arm exists to prevent.
|
|
3091
|
+
*/
|
|
3092
|
+
private unstageMembershipInvite;
|
|
3070
3093
|
/**
|
|
3071
3094
|
* The pending single-use membership invitation a closed-strand formation carried back
|
|
3072
3095
|
* for `strandId`, or `undefined` when none is staged — the seam the strand bring-up
|
package/dist/cadre-node.js
CHANGED
|
@@ -27,7 +27,8 @@ import { StrandInstanceManager, liveStrandStatus } from './strand-instance-manag
|
|
|
27
27
|
import { PeerJoinBackfill } from './peer-join-backfill.js';
|
|
28
28
|
import { deriveCohortMembers } from './strand-cohort.js';
|
|
29
29
|
import { selectControlCohortDials, DEFAULT_CONTROL_COHORT_RECONCILE_MS, DEFAULT_CONTROL_COHORT_TARGET_DEGREE } from './control-cohort.js';
|
|
30
|
-
import { dialPeerAddrs,
|
|
30
|
+
import { dialPeerAddrs, CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS } from './peer-dial.js';
|
|
31
|
+
import { peerJoinPushBudget, relayReservationBudgetMs, relayedDialBudgetMs, resolveLinkRoundTripMs } from './link-budget.js';
|
|
31
32
|
import { EnrollmentService } from './enrollment.js';
|
|
32
33
|
import { HibernationManager } from './hibernation-manager.js';
|
|
33
34
|
import { ControlDatabase, isStrandIdConflict } from './control-database.js';
|
|
@@ -423,8 +424,10 @@ export class CadreNode {
|
|
|
423
424
|
* (`strand-membership-reconciler.ts`, wired via `StartStrandConfig.pendingMembershipInvite`
|
|
424
425
|
* in {@link launchStrand}): it deletes the entry once the invitation is redeemed, burned
|
|
425
426
|
* against an already-seated member, or found dead (expired / cancelled / consumed
|
|
426
|
-
* elsewhere / the strand sealed)
|
|
427
|
-
*
|
|
427
|
+
* elsewhere / the strand sealed) — naming the invitation it settled, so a re-formation
|
|
428
|
+
* that replaced the entry meanwhile keeps its fresh one
|
|
429
|
+
* ({@link unstageMembershipInvite}). Until the strand is actually launched here the entry
|
|
430
|
+
* just waits; once launched, staging notifies the manager, which re-arms a finished loop.
|
|
428
431
|
*
|
|
429
432
|
* NOTE: entries live for the node's lifetime (one small pair per formed closed
|
|
430
433
|
* strand), the same unbounded-keys tripwire {@link crossPartyStrandAddrs} documents —
|
|
@@ -762,6 +765,11 @@ export class CadreNode {
|
|
|
762
765
|
// throws on a malformed `relayAddrs` entry, and that failure belongs on the same
|
|
763
766
|
// logged-and-cleaned-up path as every other one in start().
|
|
764
767
|
this.warnIfAnnounceAddrsDiscardRelay();
|
|
768
|
+
// Refuse a bad `linkRoundTripMs` HERE rather than wherever a budget is first derived
|
|
769
|
+
// from it: every consumer of it is conditional (no control storage, no relays, no dial
|
|
770
|
+
// yet), so a node with a zero or NaN declaration would otherwise boot and fail later
|
|
771
|
+
// inside a best-effort path that logs and carries on. `link-budget.ts`.
|
|
772
|
+
resolveLinkRoundTripMs(this.config.network?.linkRoundTripMs);
|
|
765
773
|
// Install the WebRTC TURN-relay tracker BEFORE any libp2p bring-up, so it
|
|
766
774
|
// wraps globalThis.RTCPeerConnection before the control node can create one.
|
|
767
775
|
// Inert on Node.js (no RTCPeerConnection); disposed in cleanup().
|
|
@@ -1267,6 +1275,11 @@ export class CadreNode {
|
|
|
1267
1275
|
// before looking anywhere else; if catch-up pushes start showing up as
|
|
1268
1276
|
// connection-churn noise, raise it.
|
|
1269
1277
|
debounceMs: 250,
|
|
1278
|
+
// Dial and response deadlines counted in link round trips, not fixed milliseconds — a
|
|
1279
|
+
// relayed dial that cannot finish inside the budget fails identically on every retry.
|
|
1280
|
+
// See `link-budget.ts`. Spread BEFORE the host's own config so an explicit
|
|
1281
|
+
// `controlBackfill.dialTimeoutMs` still wins.
|
|
1282
|
+
...peerJoinPushBudget(this.config.network?.linkRoundTripMs),
|
|
1270
1283
|
...this.config.controlBackfill
|
|
1271
1284
|
});
|
|
1272
1285
|
this.controlBackfill.start();
|
|
@@ -1396,7 +1409,13 @@ export class CadreNode {
|
|
|
1396
1409
|
// itself, so the unknown case is provably byte-for-byte the old behaviour.
|
|
1397
1410
|
// The yardstick moves ALONE: `assumedClusterSize` stays pinned at 2, because a
|
|
1398
1411
|
// party of phones cannot promise three quarters of its machines are awake.
|
|
1399
|
-
|
|
1412
|
+
// With neither declared this is still the frozen constant by identity, which is the
|
|
1413
|
+
// production path. A degenerate deadline is Optimystic's to refuse, in
|
|
1414
|
+
// createControlNode below.
|
|
1415
|
+
clusterPolicy: controlClusterPolicy({
|
|
1416
|
+
enrolledMachines: this.declaredEnrolledMachines,
|
|
1417
|
+
cohortQueryTimeoutMs: network?.cohortQueryTimeoutMs
|
|
1418
|
+
}),
|
|
1400
1419
|
arachnode: { enableRingZulu: profile === 'storage' },
|
|
1401
1420
|
...(identityKey && { privateKey: identityKey }),
|
|
1402
1421
|
...(network?.transports && { transports: network.transports }),
|
|
@@ -2753,15 +2772,22 @@ export class CadreNode {
|
|
|
2753
2772
|
* {@link SeedBootstrapService} this node builds — `applySeed`'s owner dials and
|
|
2754
2773
|
* `dialInvite`.
|
|
2755
2774
|
*
|
|
2756
|
-
* See
|
|
2757
|
-
* bounded as a whole, and
|
|
2758
|
-
*
|
|
2775
|
+
* See `peer-dial.ts`'s `DEFAULT_CONTROL_COHORT_DIAL_TIMEOUT_MS` for why a peer's dial is
|
|
2776
|
+
* bounded as a whole, and `DEFAULT_CONTROL_COHORT_PER_ADDRESS_DIAL_TIMEOUT_MS` for why each
|
|
2777
|
+
* address is bounded as well.
|
|
2778
|
+
*
|
|
2779
|
+
* Both are DERIVED from `network.linkRoundTripMs` rather than fixed, because the slowest
|
|
2780
|
+
* address either has to cover is a relayed dial and that costs a fixed number of exchanges —
|
|
2781
|
+
* `link-budget.ts` has the counts. A host's explicit `controlCohort` values still win, so a
|
|
2782
|
+
* test that drives dead addresses on purpose keeps the duration it chose.
|
|
2759
2783
|
*/
|
|
2760
2784
|
controlDialBudget() {
|
|
2761
2785
|
const cohort = this.config.network?.controlCohort;
|
|
2786
|
+
const perAddressMs = cohort?.perAddressDialTimeoutMs
|
|
2787
|
+
?? relayedDialBudgetMs(this.config.network?.linkRoundTripMs);
|
|
2762
2788
|
return {
|
|
2763
|
-
perAddressMs
|
|
2764
|
-
totalMs: cohort?.dialTimeoutMs ??
|
|
2789
|
+
perAddressMs,
|
|
2790
|
+
totalMs: cohort?.dialTimeoutMs ?? CONTROL_COHORT_DIAL_ADDRESS_ATTEMPTS * perAddressMs,
|
|
2765
2791
|
};
|
|
2766
2792
|
}
|
|
2767
2793
|
/**
|
|
@@ -4906,6 +4932,7 @@ export class CadreNode {
|
|
|
4906
4932
|
revocationEnforcement: this.config.strandRevocationEnforcement,
|
|
4907
4933
|
membershipReconciliation: this.config.strandMembershipReconciliation,
|
|
4908
4934
|
onSelfRevoked: (revokedStrandId) => this.emit('strand:revoked', { strandId: revokedStrandId }),
|
|
4935
|
+
onRejoinBlocked: (blockedStrandId) => this.emit('strand:rejoin-blocked', { strandId: blockedStrandId }),
|
|
4909
4936
|
// The joiner's first-sync write gate (strand-first-sync-gate.ts): a launch that
|
|
4910
4937
|
// comes up `'syncing'` announces the moment its database is published.
|
|
4911
4938
|
firstSync: this.config.strandFirstSync,
|
|
@@ -4919,7 +4946,7 @@ export class CadreNode {
|
|
|
4919
4946
|
// cleared once spent, burned, or dead — see pendingMembershipInvites.
|
|
4920
4947
|
pendingMembershipInvite: {
|
|
4921
4948
|
get: () => this.pendingMembershipInvites.get(strand.Id),
|
|
4922
|
-
clear: () =>
|
|
4949
|
+
clear: (settled) => this.unstageMembershipInvite(strand.Id, settled)
|
|
4923
4950
|
},
|
|
4924
4951
|
// The RESOLVED flag, never the raw argument — see the doc comment above.
|
|
4925
4952
|
founder: resolvedFounder,
|
|
@@ -5147,8 +5174,10 @@ export class CadreNode {
|
|
|
5147
5174
|
* caller runs the drive regardless).
|
|
5148
5175
|
*
|
|
5149
5176
|
* NOTE: against a relay that is DOWN this hook costs up to two strand-addr
|
|
5150
|
-
* timeouts (10 s each: dial by peer id, then by addr) before the
|
|
5151
|
-
* starts, so one failed re-drive
|
|
5177
|
+
* timeouts (10 s each: dial by peer id, then by addr) before the reservation drive even
|
|
5178
|
+
* starts, so one failed re-drive holds the supervisor `driving` for those 20 s plus the
|
|
5179
|
+
* drive's own deadline — 28 s at the default declared link round trip, and longer on a host
|
|
5180
|
+
* that declared a slower one (`link-budget.ts`).
|
|
5152
5181
|
* Bounded and harmless while the relay is unreachable anyway; if recovery
|
|
5153
5182
|
* latency after a relay comes back ever matters, skip the announce when the
|
|
5154
5183
|
* control node holds no connection to the relay (the drive's own dial fails
|
|
@@ -5646,13 +5675,15 @@ export class CadreNode {
|
|
|
5646
5675
|
* local), and only then does it reach out. Everything above in {@link start}
|
|
5647
5676
|
* has completed by the time this runs.
|
|
5648
5677
|
*
|
|
5649
|
-
* BUDGET: the supervisor's first attempt
|
|
5650
|
-
*
|
|
5651
|
-
*
|
|
5652
|
-
*
|
|
5653
|
-
*
|
|
5654
|
-
* on links that were merely slow.
|
|
5655
|
-
*
|
|
5678
|
+
* BUDGET: the supervisor's first attempt is what `start()` waits on — deliberately the drive's
|
|
5679
|
+
* ordinary deadline rather than a boot-specific one. That deadline is now COUNTED, four link
|
|
5680
|
+
* round trips at the declared `network.linkRoundTripMs` (8 s at its default, where it was a
|
|
5681
|
+
* fixed 10 s): a healthy dial-plus-reserve is sub-second even over a WAN, so this is slack for
|
|
5682
|
+
* a slow link, while going much longer would make a dead relay indistinguishable from a hung
|
|
5683
|
+
* start and much shorter would fail nodes on links that were merely slow. A host that declares
|
|
5684
|
+
* a slower link lengthens it without touching this path — `link-budget.ts`. The retries carry
|
|
5685
|
+
* on in the background after this resolves, exactly as they do for a {@link reserveRelays}
|
|
5686
|
+
* caller.
|
|
5656
5687
|
*
|
|
5657
5688
|
* `network.requireRelay === false` softens only the outcome below: a first
|
|
5658
5689
|
* attempt that lands no `/p2p-circuit` address is logged instead of thrown, and
|
|
@@ -5714,7 +5745,13 @@ export class CadreNode {
|
|
|
5714
5745
|
this.relayReserveError = 'control node unavailable';
|
|
5715
5746
|
return this.getRelayReservationState();
|
|
5716
5747
|
}
|
|
5717
|
-
|
|
5748
|
+
// The drive's deadline is counted in link round trips (`link-budget.ts`), so a host that
|
|
5749
|
+
// declared a slower link gets a longer drive without naming a number here. A caller's own
|
|
5750
|
+
// `timeoutMs` still wins.
|
|
5751
|
+
const supervisor = superviseRelayReservation(this.controlNode, addrs, {
|
|
5752
|
+
timeoutMs: relayReservationBudgetMs(this.config.network?.linkRoundTripMs),
|
|
5753
|
+
...opts
|
|
5754
|
+
});
|
|
5718
5755
|
this.relayReserveSupervisor = supervisor;
|
|
5719
5756
|
await supervisor.firstAttempt;
|
|
5720
5757
|
// Read through `this`, not `supervisor`: an overlapping call may have replaced
|
|
@@ -6558,7 +6595,11 @@ export class CadreNode {
|
|
|
6558
6595
|
* an intact control DB) and mints + persists a fresh one otherwise. The invitation
|
|
6559
6596
|
* will admit THIS key's public half as the `Strand.Member`.
|
|
6560
6597
|
* 2. Stage the invitation in {@link pendingMembershipInvites} for the strand
|
|
6561
|
-
* bring-up flow (`strand-node-binds-member-peer`) to redeem via `consumeInvite
|
|
6598
|
+
* bring-up flow (`strand-node-binds-member-peer`) to redeem via `consumeInvite`,
|
|
6599
|
+
* and tell the instance manager, which re-arms an already-finished membership
|
|
6600
|
+
* reconciler so a RE-formation against a launched strand is attempted at once — the
|
|
6601
|
+
* removed-party case, where the loop finished long before the removal. A strand not
|
|
6602
|
+
* launched here yet has no loop to re-arm, and its bring-up finds the entry.
|
|
6562
6603
|
*
|
|
6563
6604
|
* Throws — failing the whole {@link formStrand} — when the identity cannot be
|
|
6564
6605
|
* persisted: a joiner "joined" without a persistable identity could never become a
|
|
@@ -6577,16 +6618,23 @@ export class CadreNode {
|
|
|
6577
6618
|
'cannot become a member without it. Fix the underlying cause, then redeem a fresh ' +
|
|
6578
6619
|
'invitation (the delivered one dies with this error).', { cause: error });
|
|
6579
6620
|
}
|
|
6580
|
-
// NOTE: staging alone is not enough on a node whose reconciler for this strand has
|
|
6581
|
-
// already finished. `StrandMembershipReconciler` latches a terminal stopped state on
|
|
6582
|
-
// its `done` path and is rebuilt only by a strand relaunch, so a RE-formation against
|
|
6583
|
-
// an already-launched strand stages an invitation no loop will ever look at. Harmless
|
|
6584
|
-
// for a party that is still a member (the invitation would only be burned), and the
|
|
6585
|
-
// live defect for one that has been REMOVED — which is exactly when an app re-forms.
|
|
6586
|
-
// Tracked as `backlog/bug-removed-party-cannot-redeem-its-way-back`; pinned by
|
|
6587
|
-
// `strand-party-removal-via-formation-e2e.integration.ts` (test 2).
|
|
6588
6621
|
this.pendingMembershipInvites.set(strandId, invite);
|
|
6589
6622
|
log('formStrand: staged membership invitation for strand %s (party key persisted)', strandId);
|
|
6623
|
+
this.strandManager.notifyMembershipInviteStaged(strandId);
|
|
6624
|
+
}
|
|
6625
|
+
/**
|
|
6626
|
+
* The reconciler's half of the {@link pendingMembershipInvites} seam: drop `settled`
|
|
6627
|
+
* (spent, burned, or dead) only while it is still the staged entry. A re-formation
|
|
6628
|
+
* replaces the entry between a pass's read and its settle, and the fresh invitation it
|
|
6629
|
+
* staged is the one the re-armed loop is about to redeem — deleting it here would lose
|
|
6630
|
+
* it silently, the very outcome the re-arm exists to prevent.
|
|
6631
|
+
*/
|
|
6632
|
+
unstageMembershipInvite(strandId, settled) {
|
|
6633
|
+
if (this.pendingMembershipInvites.get(strandId)?.inviteKey !== settled.inviteKey) {
|
|
6634
|
+
log('strand %s: a fresh membership invitation replaced the one just settled — keeping it staged', strandId);
|
|
6635
|
+
return;
|
|
6636
|
+
}
|
|
6637
|
+
this.pendingMembershipInvites.delete(strandId);
|
|
6590
6638
|
}
|
|
6591
6639
|
/**
|
|
6592
6640
|
* The pending single-use membership invitation a closed-strand formation carried back
|