@serfab/cadre-core 1.10.0 → 1.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.
Files changed (61) hide show
  1. package/dist/cadre-node.d.ts +0 -27
  2. package/dist/cadre-node.js +2 -94
  3. package/dist/cadre-node.js.map +1 -1
  4. package/dist/control-schema.js +1066 -1066
  5. package/dist/diagnostics/connection-path.d.ts +1 -18
  6. package/dist/diagnostics/connection-path.js +1 -20
  7. package/dist/diagnostics/connection-path.js.map +1 -1
  8. package/dist/identity-key.d.ts +0 -30
  9. package/dist/identity-key.js +7 -40
  10. package/dist/identity-key.js.map +1 -1
  11. package/dist/index.d.ts +3 -2
  12. package/dist/index.js +6 -4
  13. package/dist/index.js.map +1 -1
  14. package/dist/relay-stun.d.ts +24 -0
  15. package/dist/relay-stun.js +51 -0
  16. package/dist/relay-stun.js.map +1 -0
  17. package/dist/seed-bootstrap.js +4 -4
  18. package/dist/strand-membership-writer.js +32 -32
  19. package/package.json +2 -2
  20. package/src/cadre-node.ts +8654 -8753
  21. package/src/control-authorization.ts +117 -117
  22. package/src/control-cohort.ts +128 -128
  23. package/src/control-formation-recorder.ts +353 -353
  24. package/src/control-read-retry.ts +216 -216
  25. package/src/control-schema.ts +1078 -1078
  26. package/src/control-write-retry.ts +567 -567
  27. package/src/diagnostics/connection-path.ts +1 -29
  28. package/src/formation-approval.ts +610 -610
  29. package/src/hibernation-manager.ts +638 -638
  30. package/src/identity-key.ts +7 -62
  31. package/src/index.ts +644 -647
  32. package/src/invitation-bootstrap.ts +40 -40
  33. package/src/joined-strand-store.ts +498 -498
  34. package/src/link-budget.ts +684 -684
  35. package/src/membership-connection-gater.ts +570 -570
  36. package/src/node-local-snapshot.ts +298 -298
  37. package/src/peer-dial.ts +315 -315
  38. package/src/peer-join-backfill.ts +710 -710
  39. package/src/pending-join-runner.ts +735 -735
  40. package/src/relay-reservation.ts +1121 -1121
  41. package/src/relay-server.ts +98 -98
  42. package/src/relay-stun.ts +58 -0
  43. package/src/seed-bootstrap.ts +1448 -1448
  44. package/src/strand-addr-protocol.ts +547 -547
  45. package/src/strand-database.ts +287 -287
  46. package/src/strand-first-sync-gate.ts +391 -391
  47. package/src/strand-formation-deadlines.ts +227 -227
  48. package/src/strand-formation-manager.ts +725 -725
  49. package/src/strand-fret-addrs.ts +145 -145
  50. package/src/strand-instance-manager.ts +1789 -1789
  51. package/src/strand-membership-writer.ts +1974 -1974
  52. package/src/strand-network-state-file.ts +40 -40
  53. package/src/strand-network-state.ts +243 -243
  54. package/src/strand-revocation-enforcer.ts +654 -654
  55. package/src/strand-solicitation.ts +532 -532
  56. package/src/strand-wake-protocol.ts +401 -401
  57. package/src/types.ts +2222 -2222
  58. package/dist/diagnostics/webrtc-turn-tracker.d.ts +0 -50
  59. package/dist/diagnostics/webrtc-turn-tracker.js +0 -196
  60. package/dist/diagnostics/webrtc-turn-tracker.js.map +0 -1
  61. package/src/diagnostics/webrtc-turn-tracker.ts +0 -222
@@ -1,40 +1,40 @@
1
- /**
2
- * Node-only file-backed {@link StrandNetworkStateStore}: one JSON file per party
3
- * under a configured directory (the node's state directory — `cadre-cli` passes
4
- * `ResolvedConfig.nodeStateDir`), holding the node-local, non-replicated network
5
- * state of every strand this node runs (each strand node's saved FRET routing
6
- * table, with its peers' signed address records).
7
- *
8
- * Nothing but the file itself lives here: the envelope, load policy, per-entry
9
- * check and the serialised snapshot writes are cross-platform in
10
- * `PersistentStrandNetworkStateStore` / `node-local-snapshot.ts`, and the file is a
11
- * `FileDurableSlot`. This module exists only so the `node:fs` edge stays out of the
12
- * package's cross-platform default entry (`./index.js`) — import it from the
13
- * dedicated subpath instead:
14
- *
15
- * ```ts
16
- * import { FileStrandNetworkStateStore } from '@serfab/cadre-core/strand-network-state-file';
17
- * ```
18
- *
19
- * Same isolation pattern as `bootstrap-peer-store-file.ts` / `key-store-file.ts`.
20
- */
21
- import { FileDurableSlot } from './file-durable-slot.js';
22
- import { PersistentStrandNetworkStateStore } from './strand-network-state.js';
23
- import type { StrandNetworkStateStore } from './strand-network-state.js';
24
-
25
- /** Base name of the store file: `<dir>/strand-network.<encoded partyId>.json`. */
26
- const SLOT_NAME = 'strand-network';
27
-
28
- /**
29
- * File-backed {@link StrandNetworkStateStore}. Open via {@link open}, which loads
30
- * the existing file and returns the cross-platform store over it; an absent,
31
- * corrupt, or wrong-party file is a cold start (no saved state) while a
32
- * present-but-unreadable file throws. Those rules and their reasoning live on
33
- * `NodeLocalSnapshot.open`.
34
- */
35
- export const FileStrandNetworkStateStore = {
36
- /** Load (or cold-start) the party's strand network state from a file in `dir`. */
37
- async open(dir: string, partyId: string): Promise<StrandNetworkStateStore> {
38
- return PersistentStrandNetworkStateStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId);
39
- }
40
- };
1
+ /**
2
+ * Node-only file-backed {@link StrandNetworkStateStore}: one JSON file per party
3
+ * under a configured directory (the node's state directory — `cadre-cli` passes
4
+ * `ResolvedConfig.nodeStateDir`), holding the node-local, non-replicated network
5
+ * state of every strand this node runs (each strand node's saved FRET routing
6
+ * table, with its peers' signed address records).
7
+ *
8
+ * Nothing but the file itself lives here: the envelope, load policy, per-entry
9
+ * check and the serialised snapshot writes are cross-platform in
10
+ * `PersistentStrandNetworkStateStore` / `node-local-snapshot.ts`, and the file is a
11
+ * `FileDurableSlot`. This module exists only so the `node:fs` edge stays out of the
12
+ * package's cross-platform default entry (`./index.js`) — import it from the
13
+ * dedicated subpath instead:
14
+ *
15
+ * ```ts
16
+ * import { FileStrandNetworkStateStore } from '@serfab/cadre-core/strand-network-state-file';
17
+ * ```
18
+ *
19
+ * Same isolation pattern as `bootstrap-peer-store-file.ts` / `key-store-file.ts`.
20
+ */
21
+ import { FileDurableSlot } from './file-durable-slot.js';
22
+ import { PersistentStrandNetworkStateStore } from './strand-network-state.js';
23
+ import type { StrandNetworkStateStore } from './strand-network-state.js';
24
+
25
+ /** Base name of the store file: `<dir>/strand-network.<encoded partyId>.json`. */
26
+ const SLOT_NAME = 'strand-network';
27
+
28
+ /**
29
+ * File-backed {@link StrandNetworkStateStore}. Open via {@link open}, which loads
30
+ * the existing file and returns the cross-platform store over it; an absent,
31
+ * corrupt, or wrong-party file is a cold start (no saved state) while a
32
+ * present-but-unreadable file throws. Those rules and their reasoning live on
33
+ * `NodeLocalSnapshot.open`.
34
+ */
35
+ export const FileStrandNetworkStateStore = {
36
+ /** Load (or cold-start) the party's strand network state from a file in `dir`. */
37
+ async open(dir: string, partyId: string): Promise<StrandNetworkStateStore> {
38
+ return PersistentStrandNetworkStateStore.open(new FileDurableSlot(dir, SLOT_NAME, partyId), partyId);
39
+ }
40
+ };
@@ -1,243 +1,243 @@
1
- /**
2
- * Node-local, NON-replicated **strand network state**: for each strand this node
3
- * runs, the last network state Optimystic's db-p2p saved for that strand's libp2p
4
- * node — its FRET routing table (each peer entry carrying the peer's signed libp2p
5
- * address record), the network-size high-water mark, and which of those peers were
6
- * seen serving the strand. db-p2p reads it back once when the strand node is built
7
- * and re-imports the table, which hands every record to the libp2p peerStore, so a
8
- * restarted strand node already has addresses for the peers it was talking to
9
- * (gotchoices/sereus#18).
10
- *
11
- * The payload is opaque here. db-p2p owns its version fence (a snapshot whose
12
- * `version` it does not write is discarded whole) and FRET owns table validation
13
- * (a structurally corrupt table is refused, a stale record dropped). This module
14
- * checks only that an entry is a plain object with a numeric `version`, and never
15
- * rewrites one.
16
- *
17
- * Three implementations mirroring `bootstrap-peer-store.ts` (same "must outlive the
18
- * process, storage differs per platform" problem; both persistent forms share
19
- * `node-local-snapshot.ts`):
20
- * - {@link MemoryStrandNetworkStateStore} (this module, cross-platform) — ephemeral;
21
- * the default when no store is injected via `CadreNodeConfig.strandNetworkState`.
22
- * A node using it restarts with an empty routing table.
23
- * - {@link PersistentStrandNetworkStateStore} (this module, cross-platform) —
24
- * durable over any `DurableSlot` the embedding app supplies.
25
- * - `FileStrandNetworkStateStore` — the above over a Node file in the node's state
26
- * directory; Node-only, behind the subpath
27
- * `@serfab/cadre-core/strand-network-state-file` so `node:fs` never lands in the
28
- * RN/browser graph.
29
- *
30
- * One store per NODE holding every strand, keyed by strand id, so the embedder
31
- * wires one slot, like the other node-local stores.
32
- *
33
- * **Nothing here is trust-bearing.** A routing-table entry grants no authority:
34
- * every address record is signed by the peer it names and verified by FRET at
35
- * import, and the dialed peer authenticates by peer id at the handshake.
36
- */
37
- import debug from 'debug';
38
- import type { NetworkStatePersistence, PersistedNetworkState } from '@optimystic/db-p2p';
39
- import { NodeLocalSnapshot, type DurableSlot, type NodeLocalSnapshotSpec } from './node-local-snapshot.js';
40
-
41
- const log = debug('sereus:cadre:strand-network-state');
42
-
43
- export interface StrandNetworkStateStore {
44
- /** Party this store is scoped to. */
45
- readonly partyId: string;
46
-
47
- /**
48
- * The last state saved for `strandId`, or undefined. Synchronous: served from the
49
- * in-memory half of the store. The returned object is the stored one — callers
50
- * must not mutate it.
51
- */
52
- load(strandId: string): PersistedNetworkState | undefined;
53
-
54
- /**
55
- * Replace the strand's state. Implementations MUST reflect it in {@link load}
56
- * SYNCHRONOUSLY; the returned promise tracks durability only. The store keeps
57
- * `state` itself, so the caller must not mutate it afterwards (db-p2p builds a
58
- * fresh object per save).
59
- */
60
- save(strandId: string, state: PersistedNetworkState): Promise<void>;
61
-
62
- /**
63
- * Drop the strand's state — for a strand this node unpublished or left, whose
64
- * peers must not be re-imported by a later launch. A no-op for the stored state
65
- * when nothing is held, but {@link forgetGeneration} advances either way. Same
66
- * contract as {@link save}: reflected synchronously, the promise tracks durability.
67
- */
68
- forget(strandId: string): Promise<void>;
69
-
70
- /**
71
- * How many times `strandId` has been forgotten through this store instance.
72
- * {@link strandNetworkStatePersistence} compares it against the value it captured
73
- * to drop a save that arrives after the forget.
74
- */
75
- forgetGeneration(strandId: string): number;
76
- }
77
-
78
- /** Per-strand forget counts; the in-process half of {@link StrandNetworkStateStore.forgetGeneration}. */
79
- class ForgetGenerations {
80
- private readonly counts = new Map<string, number>();
81
-
82
- of(strandId: string): number {
83
- return this.counts.get(strandId) ?? 0;
84
- }
85
-
86
- advance(strandId: string): void {
87
- this.counts.set(strandId, this.of(strandId) + 1);
88
- }
89
- }
90
-
91
- /**
92
- * Ephemeral in-memory store for nodes without durable storage (tests, and the
93
- * default when nothing is injected). Same contract, no disk: a strand relaunched in
94
- * the same process re-imports its table (a hibernation resume, a stop()→start()),
95
- * and a restarted process starts with none.
96
- */
97
- export class MemoryStrandNetworkStateStore implements StrandNetworkStateStore {
98
- private readonly strands = new Map<string, PersistedNetworkState>();
99
- private readonly generations = new ForgetGenerations();
100
-
101
- constructor(readonly partyId: string) {}
102
-
103
- load(strandId: string): PersistedNetworkState | undefined {
104
- return this.strands.get(strandId);
105
- }
106
-
107
- async save(strandId: string, state: PersistedNetworkState): Promise<void> {
108
- this.strands.set(strandId, state);
109
- }
110
-
111
- async forget(strandId: string): Promise<void> {
112
- this.generations.advance(strandId);
113
- if (this.strands.delete(strandId)) {
114
- log('strand %s: network state forgotten (party=%s)', strandId, this.partyId);
115
- }
116
- }
117
-
118
- forgetGeneration(strandId: string): number {
119
- return this.generations.of(strandId);
120
- }
121
- }
122
-
123
- /**
124
- * What the store persists: `strands` maps strandId -> the state db-p2p saved. An
125
- * entry that is not a plain object with a numeric `version` is dropped and its
126
- * siblings kept (`drop-entry`): nothing here is trust-bearing, and one damaged
127
- * strand must not cost every other strand its routing table. Everything past that
128
- * structural check is db-p2p's and FRET's to judge at import.
129
- */
130
- const STRAND_NETWORK_STATE_SNAPSHOT_SPEC: NodeLocalSnapshotSpec<PersistedNetworkState> = {
131
- label: 'strand network state',
132
- payloadKey: 'strands',
133
- unusableEntry: 'drop-entry',
134
- acceptEntry: (_strandId, raw) => {
135
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined;
136
- if (typeof (raw as { version?: unknown }).version !== 'number') return undefined;
137
- return raw as PersistedNetworkState;
138
- }
139
- };
140
-
141
- /**
142
- * Durable {@link StrandNetworkStateStore} over an app-supplied {@link DurableSlot} —
143
- * the cross-platform half of every persistent backend (the Node
144
- * `FileStrandNetworkStateStore` is this class over a file slot).
145
- *
146
- * Construct via {@link open}. Load and persist policy — what an absent, corrupt,
147
- * foreign-party or unreadable slot does (the last one THROWS), and what a failed
148
- * persist does — is documented once on `NodeLocalSnapshot`. Writes are serialised
149
- * in-process by the snapshot's write chain, so several strand nodes saving at once
150
- * cannot interleave partial snapshots.
151
- *
152
- * NOTE: an entry leaves only through {@link forget}, and nothing ages one out. A
153
- * strand this machine detached because the strand watcher saw its control row gone
154
- * (another machine of the party unpublished or left it), or one stopped and never
155
- * relaunched, keeps its entry and it is rewritten with every save. The watcher path
156
- * (`CadreNode.handleStrandRemoved`) does not forget because its removal is not proof
157
- * the strand is gone: its query answers an empty list while the control database is
158
- * absent. If snapshots grow on machines that host many short-lived strands, or a
159
- * re-published strand id is seen importing its predecessor's table, drop entries at
160
- * {@link open} that are older than FRET's own 14-day record age.
161
- */
162
- export class PersistentStrandNetworkStateStore implements StrandNetworkStateStore {
163
- private readonly generations = new ForgetGenerations();
164
-
165
- private constructor(private readonly snapshot: NodeLocalSnapshot<PersistedNetworkState>) {}
166
-
167
- /** Load (or cold-start) the party's strand network state from `slot`. */
168
- static async open(slot: DurableSlot, partyId: string): Promise<PersistentStrandNetworkStateStore> {
169
- return new PersistentStrandNetworkStateStore(
170
- await NodeLocalSnapshot.open(slot, partyId, STRAND_NETWORK_STATE_SNAPSHOT_SPEC)
171
- );
172
- }
173
-
174
- get partyId(): string {
175
- return this.snapshot.partyId;
176
- }
177
-
178
- load(strandId: string): PersistedNetworkState | undefined {
179
- return this.snapshot.get(strandId);
180
- }
181
-
182
- /**
183
- * Replace, visible via {@link load} synchronously, then the full snapshot is persisted.
184
- *
185
- * NOTE: every save rewrites the party's whole snapshot — every strand's table —
186
- * and db-p2p saves on every `connection:open`. Fine for a handful of strands; if
187
- * slot writes show up in profiles, move to one slot per strand or coalesce saves.
188
- */
189
- save(strandId: string, state: PersistedNetworkState): Promise<void> {
190
- return this.snapshot.put(strandId, state);
191
- }
192
-
193
- /** Forget, gone from {@link load} synchronously, then persisted — unless nothing was there. */
194
- forget(strandId: string): Promise<void> {
195
- this.generations.advance(strandId);
196
- if (this.snapshot.has(strandId)) {
197
- log('strand %s: network state forgotten (party=%s); persisting', strandId, this.partyId);
198
- }
199
- return this.snapshot.remove(strandId);
200
- }
201
-
202
- forgetGeneration(strandId: string): number {
203
- return this.generations.of(strandId);
204
- }
205
- }
206
-
207
- /**
208
- * Adapts one strand's slice of the store to db-p2p's `NodeOptions.persistence`.
209
- * Build one per strand runtime (`StrandInstanceManager.buildStrandRuntime` does).
210
- *
211
- * A save is dropped once the strand has been forgotten after this adapter was
212
- * built: db-p2p saves fire-and-forget on connection events, so a node being torn
213
- * down can still save after `forget(strandId)`, which would put the entry back.
214
- * Saving resumes with the next runtime built for the strand (a relaunch or a
215
- * hibernation resume), which builds a new adapter.
216
- *
217
- * NOTE: a strand that keeps running after its state was forgotten (self-revocation
218
- * forgets but stops nothing) saves nothing more until its runtime is rebuilt. If a
219
- * removed party is routinely re-admitted without a relaunch, re-arm saving at
220
- * re-admission.
221
- *
222
- * NOTE: db-p2p saves on `connection:open` and on a changed serving verdict, not when
223
- * a peer's addresses change. A peer whose address rotates during a long connection
224
- * (a relay reservation moving) is saved with its old record until the next save,
225
- * which matters only to a restart in between. If restarts are seen dialing rotated
226
- * addresses, the fix is upstream: db-p2p saving when FRET's table changes.
227
- */
228
- export function strandNetworkStatePersistence(
229
- store: StrandNetworkStateStore,
230
- strandId: string
231
- ): NetworkStatePersistence {
232
- const generation = store.forgetGeneration(strandId);
233
- return {
234
- load: async () => store.load(strandId),
235
- save: async (state) => {
236
- if (store.forgetGeneration(strandId) !== generation) {
237
- log('strand %s: dropping a network-state save that arrived after the strand was forgotten', strandId);
238
- return;
239
- }
240
- await store.save(strandId, state);
241
- }
242
- };
243
- }
1
+ /**
2
+ * Node-local, NON-replicated **strand network state**: for each strand this node
3
+ * runs, the last network state Optimystic's db-p2p saved for that strand's libp2p
4
+ * node — its FRET routing table (each peer entry carrying the peer's signed libp2p
5
+ * address record), the network-size high-water mark, and which of those peers were
6
+ * seen serving the strand. db-p2p reads it back once when the strand node is built
7
+ * and re-imports the table, which hands every record to the libp2p peerStore, so a
8
+ * restarted strand node already has addresses for the peers it was talking to
9
+ * (gotchoices/sereus#18).
10
+ *
11
+ * The payload is opaque here. db-p2p owns its version fence (a snapshot whose
12
+ * `version` it does not write is discarded whole) and FRET owns table validation
13
+ * (a structurally corrupt table is refused, a stale record dropped). This module
14
+ * checks only that an entry is a plain object with a numeric `version`, and never
15
+ * rewrites one.
16
+ *
17
+ * Three implementations mirroring `bootstrap-peer-store.ts` (same "must outlive the
18
+ * process, storage differs per platform" problem; both persistent forms share
19
+ * `node-local-snapshot.ts`):
20
+ * - {@link MemoryStrandNetworkStateStore} (this module, cross-platform) — ephemeral;
21
+ * the default when no store is injected via `CadreNodeConfig.strandNetworkState`.
22
+ * A node using it restarts with an empty routing table.
23
+ * - {@link PersistentStrandNetworkStateStore} (this module, cross-platform) —
24
+ * durable over any `DurableSlot` the embedding app supplies.
25
+ * - `FileStrandNetworkStateStore` — the above over a Node file in the node's state
26
+ * directory; Node-only, behind the subpath
27
+ * `@serfab/cadre-core/strand-network-state-file` so `node:fs` never lands in the
28
+ * RN/browser graph.
29
+ *
30
+ * One store per NODE holding every strand, keyed by strand id, so the embedder
31
+ * wires one slot, like the other node-local stores.
32
+ *
33
+ * **Nothing here is trust-bearing.** A routing-table entry grants no authority:
34
+ * every address record is signed by the peer it names and verified by FRET at
35
+ * import, and the dialed peer authenticates by peer id at the handshake.
36
+ */
37
+ import debug from 'debug';
38
+ import type { NetworkStatePersistence, PersistedNetworkState } from '@optimystic/db-p2p';
39
+ import { NodeLocalSnapshot, type DurableSlot, type NodeLocalSnapshotSpec } from './node-local-snapshot.js';
40
+
41
+ const log = debug('sereus:cadre:strand-network-state');
42
+
43
+ export interface StrandNetworkStateStore {
44
+ /** Party this store is scoped to. */
45
+ readonly partyId: string;
46
+
47
+ /**
48
+ * The last state saved for `strandId`, or undefined. Synchronous: served from the
49
+ * in-memory half of the store. The returned object is the stored one — callers
50
+ * must not mutate it.
51
+ */
52
+ load(strandId: string): PersistedNetworkState | undefined;
53
+
54
+ /**
55
+ * Replace the strand's state. Implementations MUST reflect it in {@link load}
56
+ * SYNCHRONOUSLY; the returned promise tracks durability only. The store keeps
57
+ * `state` itself, so the caller must not mutate it afterwards (db-p2p builds a
58
+ * fresh object per save).
59
+ */
60
+ save(strandId: string, state: PersistedNetworkState): Promise<void>;
61
+
62
+ /**
63
+ * Drop the strand's state — for a strand this node unpublished or left, whose
64
+ * peers must not be re-imported by a later launch. A no-op for the stored state
65
+ * when nothing is held, but {@link forgetGeneration} advances either way. Same
66
+ * contract as {@link save}: reflected synchronously, the promise tracks durability.
67
+ */
68
+ forget(strandId: string): Promise<void>;
69
+
70
+ /**
71
+ * How many times `strandId` has been forgotten through this store instance.
72
+ * {@link strandNetworkStatePersistence} compares it against the value it captured
73
+ * to drop a save that arrives after the forget.
74
+ */
75
+ forgetGeneration(strandId: string): number;
76
+ }
77
+
78
+ /** Per-strand forget counts; the in-process half of {@link StrandNetworkStateStore.forgetGeneration}. */
79
+ class ForgetGenerations {
80
+ private readonly counts = new Map<string, number>();
81
+
82
+ of(strandId: string): number {
83
+ return this.counts.get(strandId) ?? 0;
84
+ }
85
+
86
+ advance(strandId: string): void {
87
+ this.counts.set(strandId, this.of(strandId) + 1);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Ephemeral in-memory store for nodes without durable storage (tests, and the
93
+ * default when nothing is injected). Same contract, no disk: a strand relaunched in
94
+ * the same process re-imports its table (a hibernation resume, a stop()→start()),
95
+ * and a restarted process starts with none.
96
+ */
97
+ export class MemoryStrandNetworkStateStore implements StrandNetworkStateStore {
98
+ private readonly strands = new Map<string, PersistedNetworkState>();
99
+ private readonly generations = new ForgetGenerations();
100
+
101
+ constructor(readonly partyId: string) {}
102
+
103
+ load(strandId: string): PersistedNetworkState | undefined {
104
+ return this.strands.get(strandId);
105
+ }
106
+
107
+ async save(strandId: string, state: PersistedNetworkState): Promise<void> {
108
+ this.strands.set(strandId, state);
109
+ }
110
+
111
+ async forget(strandId: string): Promise<void> {
112
+ this.generations.advance(strandId);
113
+ if (this.strands.delete(strandId)) {
114
+ log('strand %s: network state forgotten (party=%s)', strandId, this.partyId);
115
+ }
116
+ }
117
+
118
+ forgetGeneration(strandId: string): number {
119
+ return this.generations.of(strandId);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * What the store persists: `strands` maps strandId -> the state db-p2p saved. An
125
+ * entry that is not a plain object with a numeric `version` is dropped and its
126
+ * siblings kept (`drop-entry`): nothing here is trust-bearing, and one damaged
127
+ * strand must not cost every other strand its routing table. Everything past that
128
+ * structural check is db-p2p's and FRET's to judge at import.
129
+ */
130
+ const STRAND_NETWORK_STATE_SNAPSHOT_SPEC: NodeLocalSnapshotSpec<PersistedNetworkState> = {
131
+ label: 'strand network state',
132
+ payloadKey: 'strands',
133
+ unusableEntry: 'drop-entry',
134
+ acceptEntry: (_strandId, raw) => {
135
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined;
136
+ if (typeof (raw as { version?: unknown }).version !== 'number') return undefined;
137
+ return raw as PersistedNetworkState;
138
+ }
139
+ };
140
+
141
+ /**
142
+ * Durable {@link StrandNetworkStateStore} over an app-supplied {@link DurableSlot} —
143
+ * the cross-platform half of every persistent backend (the Node
144
+ * `FileStrandNetworkStateStore` is this class over a file slot).
145
+ *
146
+ * Construct via {@link open}. Load and persist policy — what an absent, corrupt,
147
+ * foreign-party or unreadable slot does (the last one THROWS), and what a failed
148
+ * persist does — is documented once on `NodeLocalSnapshot`. Writes are serialised
149
+ * in-process by the snapshot's write chain, so several strand nodes saving at once
150
+ * cannot interleave partial snapshots.
151
+ *
152
+ * NOTE: an entry leaves only through {@link forget}, and nothing ages one out. A
153
+ * strand this machine detached because the strand watcher saw its control row gone
154
+ * (another machine of the party unpublished or left it), or one stopped and never
155
+ * relaunched, keeps its entry and it is rewritten with every save. The watcher path
156
+ * (`CadreNode.handleStrandRemoved`) does not forget because its removal is not proof
157
+ * the strand is gone: its query answers an empty list while the control database is
158
+ * absent. If snapshots grow on machines that host many short-lived strands, or a
159
+ * re-published strand id is seen importing its predecessor's table, drop entries at
160
+ * {@link open} that are older than FRET's own 14-day record age.
161
+ */
162
+ export class PersistentStrandNetworkStateStore implements StrandNetworkStateStore {
163
+ private readonly generations = new ForgetGenerations();
164
+
165
+ private constructor(private readonly snapshot: NodeLocalSnapshot<PersistedNetworkState>) {}
166
+
167
+ /** Load (or cold-start) the party's strand network state from `slot`. */
168
+ static async open(slot: DurableSlot, partyId: string): Promise<PersistentStrandNetworkStateStore> {
169
+ return new PersistentStrandNetworkStateStore(
170
+ await NodeLocalSnapshot.open(slot, partyId, STRAND_NETWORK_STATE_SNAPSHOT_SPEC)
171
+ );
172
+ }
173
+
174
+ get partyId(): string {
175
+ return this.snapshot.partyId;
176
+ }
177
+
178
+ load(strandId: string): PersistedNetworkState | undefined {
179
+ return this.snapshot.get(strandId);
180
+ }
181
+
182
+ /**
183
+ * Replace, visible via {@link load} synchronously, then the full snapshot is persisted.
184
+ *
185
+ * NOTE: every save rewrites the party's whole snapshot — every strand's table —
186
+ * and db-p2p saves on every `connection:open`. Fine for a handful of strands; if
187
+ * slot writes show up in profiles, move to one slot per strand or coalesce saves.
188
+ */
189
+ save(strandId: string, state: PersistedNetworkState): Promise<void> {
190
+ return this.snapshot.put(strandId, state);
191
+ }
192
+
193
+ /** Forget, gone from {@link load} synchronously, then persisted — unless nothing was there. */
194
+ forget(strandId: string): Promise<void> {
195
+ this.generations.advance(strandId);
196
+ if (this.snapshot.has(strandId)) {
197
+ log('strand %s: network state forgotten (party=%s); persisting', strandId, this.partyId);
198
+ }
199
+ return this.snapshot.remove(strandId);
200
+ }
201
+
202
+ forgetGeneration(strandId: string): number {
203
+ return this.generations.of(strandId);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Adapts one strand's slice of the store to db-p2p's `NodeOptions.persistence`.
209
+ * Build one per strand runtime (`StrandInstanceManager.buildStrandRuntime` does).
210
+ *
211
+ * A save is dropped once the strand has been forgotten after this adapter was
212
+ * built: db-p2p saves fire-and-forget on connection events, so a node being torn
213
+ * down can still save after `forget(strandId)`, which would put the entry back.
214
+ * Saving resumes with the next runtime built for the strand (a relaunch or a
215
+ * hibernation resume), which builds a new adapter.
216
+ *
217
+ * NOTE: a strand that keeps running after its state was forgotten (self-revocation
218
+ * forgets but stops nothing) saves nothing more until its runtime is rebuilt. If a
219
+ * removed party is routinely re-admitted without a relaunch, re-arm saving at
220
+ * re-admission.
221
+ *
222
+ * NOTE: db-p2p saves on `connection:open` and on a changed serving verdict, not when
223
+ * a peer's addresses change. A peer whose address rotates during a long connection
224
+ * (a relay reservation moving) is saved with its old record until the next save,
225
+ * which matters only to a restart in between. If restarts are seen dialing rotated
226
+ * addresses, the fix is upstream: db-p2p saving when FRET's table changes.
227
+ */
228
+ export function strandNetworkStatePersistence(
229
+ store: StrandNetworkStateStore,
230
+ strandId: string
231
+ ): NetworkStatePersistence {
232
+ const generation = store.forgetGeneration(strandId);
233
+ return {
234
+ load: async () => store.load(strandId),
235
+ save: async (state) => {
236
+ if (store.forgetGeneration(strandId) !== generation) {
237
+ log('strand %s: dropping a network-state save that arrived after the strand was forgotten', strandId);
238
+ return;
239
+ }
240
+ await store.save(strandId, state);
241
+ }
242
+ };
243
+ }