@peerbit/shared-log 13.2.24 → 13.2.25

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 (81) hide show
  1. package/dist/src/checked-prune.d.ts +12 -0
  2. package/dist/src/checked-prune.d.ts.map +1 -1
  3. package/dist/src/checked-prune.js +33 -0
  4. package/dist/src/checked-prune.js.map +1 -1
  5. package/dist/src/coordinate-persistence.d.ts +211 -0
  6. package/dist/src/coordinate-persistence.d.ts.map +1 -0
  7. package/dist/src/coordinate-persistence.js +1133 -0
  8. package/dist/src/coordinate-persistence.js.map +1 -0
  9. package/dist/src/errors.d.ts +1 -0
  10. package/dist/src/errors.d.ts.map +1 -1
  11. package/dist/src/errors.js +19 -0
  12. package/dist/src/errors.js.map +1 -1
  13. package/dist/src/index.d.ts +127 -133
  14. package/dist/src/index.d.ts.map +1 -1
  15. package/dist/src/index.js +1400 -4269
  16. package/dist/src/index.js.map +1 -1
  17. package/dist/src/instance-lifecycle.d.ts +117 -0
  18. package/dist/src/instance-lifecycle.d.ts.map +1 -0
  19. package/dist/src/instance-lifecycle.js +254 -0
  20. package/dist/src/instance-lifecycle.js.map +1 -0
  21. package/dist/src/join-warmup.d.ts +86 -0
  22. package/dist/src/join-warmup.d.ts.map +1 -0
  23. package/dist/src/join-warmup.js +351 -0
  24. package/dist/src/join-warmup.js.map +1 -0
  25. package/dist/src/native-write-through-block-store.d.ts +123 -0
  26. package/dist/src/native-write-through-block-store.d.ts.map +1 -0
  27. package/dist/src/native-write-through-block-store.js +989 -0
  28. package/dist/src/native-write-through-block-store.js.map +1 -0
  29. package/dist/src/peer-session.d.ts +117 -0
  30. package/dist/src/peer-session.d.ts.map +1 -0
  31. package/dist/src/peer-session.js +259 -0
  32. package/dist/src/peer-session.js.map +1 -0
  33. package/dist/src/replication-announcement.d.ts +116 -0
  34. package/dist/src/replication-announcement.d.ts.map +1 -0
  35. package/dist/src/replication-announcement.js +515 -0
  36. package/dist/src/replication-announcement.js.map +1 -0
  37. package/dist/src/replicator-liveness.d.ts +59 -0
  38. package/dist/src/replicator-liveness.d.ts.map +1 -0
  39. package/dist/src/replicator-liveness.js +304 -0
  40. package/dist/src/replicator-liveness.js.map +1 -0
  41. package/dist/src/sync/dispatch-lifecycle.d.ts +48 -0
  42. package/dist/src/sync/dispatch-lifecycle.d.ts.map +1 -0
  43. package/dist/src/sync/dispatch-lifecycle.js +141 -0
  44. package/dist/src/sync/dispatch-lifecycle.js.map +1 -0
  45. package/dist/src/sync/factory.d.ts +29 -0
  46. package/dist/src/sync/factory.d.ts.map +1 -0
  47. package/dist/src/sync/factory.js +87 -0
  48. package/dist/src/sync/factory.js.map +1 -0
  49. package/dist/src/sync/pending-sync-store.d.ts +105 -0
  50. package/dist/src/sync/pending-sync-store.d.ts.map +1 -0
  51. package/dist/src/sync/pending-sync-store.js +877 -0
  52. package/dist/src/sync/pending-sync-store.js.map +1 -0
  53. package/dist/src/sync/rateless-iblt.d.ts +4 -1
  54. package/dist/src/sync/rateless-iblt.d.ts.map +1 -1
  55. package/dist/src/sync/rateless-iblt.js +102 -106
  56. package/dist/src/sync/rateless-iblt.js.map +1 -1
  57. package/dist/src/sync/simple.d.ts +10 -41
  58. package/dist/src/sync/simple.d.ts.map +1 -1
  59. package/dist/src/sync/simple.js +259 -952
  60. package/dist/src/sync/simple.js.map +1 -1
  61. package/dist/src/sync/sync-peer-state.d.ts +16 -0
  62. package/dist/src/sync/sync-peer-state.d.ts.map +1 -0
  63. package/dist/src/sync/sync-peer-state.js +82 -0
  64. package/dist/src/sync/sync-peer-state.js.map +1 -0
  65. package/package.json +9 -9
  66. package/src/checked-prune.ts +35 -0
  67. package/src/coordinate-persistence.ts +1796 -0
  68. package/src/errors.ts +21 -0
  69. package/src/index.ts +4014 -7858
  70. package/src/instance-lifecycle.ts +351 -0
  71. package/src/join-warmup.ts +507 -0
  72. package/src/native-write-through-block-store.ts +1170 -0
  73. package/src/peer-session.ts +336 -0
  74. package/src/replication-announcement.ts +744 -0
  75. package/src/replicator-liveness.ts +439 -0
  76. package/src/sync/dispatch-lifecycle.ts +230 -0
  77. package/src/sync/factory.ts +154 -0
  78. package/src/sync/pending-sync-store.ts +1085 -0
  79. package/src/sync/rateless-iblt.ts +122 -132
  80. package/src/sync/simple.ts +334 -1144
  81. package/src/sync/sync-peer-state.ts +107 -0
@@ -0,0 +1,336 @@
1
+ // One PeerSession per peer connection-epoch. The session instance IS the
2
+ // opaque subscription-epoch token formerly stored by SharedLog: every `===`
3
+ // epoch comparison keeps working because tokens were always compared by
4
+ // identity and never inspected. Compound predicates now live on
5
+ // isCurrent()/isActive()/isReceiveAdmissionOpen().
6
+
7
+ export type PeerSessionKind = "opening" | "departing";
8
+
9
+ export type PeerSessionPhase =
10
+ | "opening" // subscribe transition; reconnect barrier not yet completed
11
+ | "open" // reconnect barrier completed (replication-info unblocked)
12
+ | "departing" // created by an unsubscribe epoch-advance, fencing the old connection
13
+ | "superseded"; // a newer session replaced this one (terminal)
14
+
15
+ export type PeerSessionDeps = {
16
+ // Late-bound readers into state that stays physically on SharedLog in
17
+ // stage 2. Delegating closures — NOT bound method refs — so sinon spies
18
+ // installed on the SharedLog instance keep intercepting (same constraint
19
+ // as the stage-1 coordinators).
20
+ isReplicationLifecycleActive: (
21
+ controller: AbortController | undefined,
22
+ ) => boolean;
23
+ getReplicationLifecycleController: () => AbortController | undefined;
24
+ };
25
+
26
+ export type PeerReceiveAdmissionOptions = {
27
+ allowReplicationInfoBlocked?: boolean;
28
+ allowCleanupGate?: boolean;
29
+ };
30
+
31
+ export class PeerSession {
32
+ readonly peerHash: string;
33
+ readonly kind: PeerSessionKind;
34
+ // The lifecycle controller live at rotation. All current seams pair the
35
+ // epoch check with a lifecycle check against a controller captured in the
36
+ // same synchronous window as the epoch advance; capturing it here
37
+ // reproduces that pairing exactly.
38
+ readonly replicationLifecycleController: AbortController | undefined;
39
+ phase: PeerSessionPhase;
40
+ // Diagnostics only in stage 2: the destructive removal funnel observed a
41
+ // committed replicator removal under this connection-epoch.
42
+ replicatorRemoved = false;
43
+ // Reconnect-barrier WINDOW sub-state (stage-3 home of the legacy
44
+ // _subscriptionOpeningEpochByPeer map entry, whose value was always this
45
+ // session). True exactly while the barrier window is open: set when
46
+ // handleSubscriptionChange starts the opening barrier (same synchronous
47
+ // window as the rotation that made this session current), cleared in the
48
+ // barrier's `finally` — including the barrier-throw path — where the map
49
+ // entry used to be deleted. Deliberately NOT derived from
50
+ // `phase === "opening"`: phase is "opening" from rotate() — BEFORE the
51
+ // barrier starts — and stays "opening" forever if the barrier throws
52
+ // (markOpen never runs), which would wrongly keep granting the opening
53
+ // lease bypasses that today's window drops.
54
+ openingBarrierActive = false;
55
+ readonly createdAt = Date.now(); // diagnostics only
56
+
57
+ constructor(
58
+ private readonly registry: PeerSessionRegistry,
59
+ peerHash: string,
60
+ kind: PeerSessionKind,
61
+ replicationLifecycleController: AbortController | undefined,
62
+ ) {
63
+ this.peerHash = peerHash;
64
+ this.kind = kind;
65
+ this.replicationLifecycleController = replicationLifecycleController;
66
+ this.phase = kind;
67
+ }
68
+
69
+ /** ≡ former SharedLog.isCurrentSubscriptionEpoch(this.peerHash, this). */
70
+ isCurrent(): boolean {
71
+ return this.registry.current(this.peerHash) === this;
72
+ }
73
+
74
+ /** Barrier window opens — ≡ the legacy
75
+ * `_subscriptionOpeningEpochByPeer.set(peerHash, thisSession)`. */
76
+ beginOpeningBarrier(): void {
77
+ this.openingBarrierActive = true;
78
+ }
79
+
80
+ /** Barrier window closes — ≡ the legacy identity-guarded
81
+ * `_subscriptionOpeningEpochByPeer.delete(peerHash)`. Unconditional:
82
+ * the map guard only avoided clobbering a NEWER peer-keyed entry, and
83
+ * per-session flags cannot collide (a newer barrier flags its own,
84
+ * already-rotated session). */
85
+ finishOpeningBarrier(): void {
86
+ this.openingBarrierActive = false;
87
+ }
88
+
89
+ /** ≡ ownsReplicationLifecycle() && ownsSubscriptionEpoch() with both
90
+ * captured at the epoch-advance. */
91
+ isActive(): boolean {
92
+ return (
93
+ this.isCurrent() &&
94
+ this.registry.deps.isReplicationLifecycleActive(
95
+ this.replicationLifecycleController,
96
+ )
97
+ );
98
+ }
99
+
100
+ /** ≡ former SharedLog.isPeerReceiveAdmissionOpen(peerHash,
101
+ * this.replicationLifecycleController, this, options). */
102
+ isReceiveAdmissionOpen(options?: PeerReceiveAdmissionOptions): boolean {
103
+ return this.registry.isReceiveAdmissionOpen(
104
+ this.peerHash,
105
+ this,
106
+ this.replicationLifecycleController,
107
+ options,
108
+ );
109
+ }
110
+ }
111
+
112
+ export class PeerSessionRegistry {
113
+ // The per-peer session map (formerly _subscriptionEpochByPeer; renamed
114
+ // once the last raw-token comparison in index.ts was gone — the values
115
+ // ARE the legacy epoch tokens, compared by identity and never inspected).
116
+ // The rename retires the map's fence-ratchet baseline entry: session
117
+ // identity is the mechanism the ratchet drains fences INTO, not a fence.
118
+ sessions!: Map<string, PeerSession>;
119
+ // Moved from SharedLog (fence B2, same name — the sanctioned file-to-file
120
+ // ratchet move). Local receive generations fence replication-info handlers
121
+ // that were admitted before a liveness eviction but reach the per-peer
122
+ // apply lane after it. Per-PEER, not per-session, on purpose: the token
123
+ // advances at removeReplicator, where the session does NOT rotate (the
124
+ // peer stays subscribed), and it must also fence a peer that never had a
125
+ // session. Unlike sessions this map IS cleared at _close (see
126
+ // clearReceiveEpochsForClose) and replaced at open.
127
+ _replicationInfoReceiveEpochByPeer!: Map<string, object>;
128
+ // Moved from SharedLog (fence B6, same name — the sanctioned file-to-file
129
+ // ratchet move). Refcount of in-flight destructive peer cleanups: while
130
+ // non-zero, receive admission for the peer is closed and prune final
131
+ // confirmations ignore the peer. Per-PEER, not per-session, on purpose:
132
+ // the gate is held across removeReplicator's awaited lanes while a
133
+ // reconnect may rotate the session; a fresh session with a zero gate
134
+ // would reopen receive admission mid-drain. The map instance is replaced
135
+ // only at open (resetForOpen) and cleared in place at _close.
136
+ _receiveCleanupGateByPeer!: Map<string, number>;
137
+ // Moved from SharedLog (fence B5, same name). Peers whose replication-info
138
+ // is fenced: added when a departure/unsubscribe rotation or a reconnect
139
+ // barrier starts, removed only when an opening barrier commits. Per-PEER,
140
+ // not per-session, on purpose (same reasoning as B2/B6 above): the block
141
+ // is set under one session (the departing rotation) and cleared under a
142
+ // LATER one (the opening barrier), so its lifetime deliberately spans
143
+ // session identities; readers (prune final-confirmation filter,
144
+ // announcement repair targeting, pruneOfflineReplicators) ask about peers
145
+ // that may have no session at all; and the `??`-fallback rotation in
146
+ // handleSubscriptionChange rotates WITHOUT touching blocked state — a
147
+ // per-session flag would silently reset it there. Replaced only at open
148
+ // (resetForOpen); deliberately NOT cleared at _close, matching the legacy
149
+ // host field site-for-site. Kept as a public field so existing tests can
150
+ // keep instrumenting the raw Set instance (events.spec.ts spies on its
151
+ // `delete`).
152
+ _replicationInfoBlockedPeers!: Set<string>;
153
+
154
+ constructor(readonly deps: PeerSessionDeps) {
155
+ this.resetForOpen();
156
+ }
157
+
158
+ /** open()-time re-init: REPLACE the map instances, matching today's
159
+ * `= new Map()` re-inits (index.ts ctor / open).
160
+ * The session map is intentionally NOT cleared at _close — tokens must
161
+ * stay current across close so a late continuation's epoch check resolves
162
+ * exactly as it does today (close-safety comes from the paired
163
+ * lifecycle-controller check, not the epoch). There is NO clearForClose()
164
+ * for sessions; the receive-epoch map, by contrast, IS cleared at close. */
165
+ resetForOpen(): void {
166
+ this.sessions = new Map();
167
+ this._replicationInfoReceiveEpochByPeer = new Map();
168
+ this._receiveCleanupGateByPeer = new Map();
169
+ this._replicationInfoBlockedPeers = new Set();
170
+ }
171
+
172
+ /** ≡ the legacy `this._replicationInfoBlockedPeers.add(peerHash)` (host
173
+ * Set, fence B5). */
174
+ blockReplicationInfo(peerHash: string): void {
175
+ this._replicationInfoBlockedPeers.add(peerHash);
176
+ }
177
+
178
+ /** ≡ the legacy `this._replicationInfoBlockedPeers.delete(peerHash)`. */
179
+ unblockReplicationInfo(peerHash: string): void {
180
+ this._replicationInfoBlockedPeers.delete(peerHash);
181
+ }
182
+
183
+ /** ≡ the legacy `this._replicationInfoBlockedPeers.has(peerHash)`. */
184
+ isReplicationInfoBlocked(peerHash: string): boolean {
185
+ return this._replicationInfoBlockedPeers.has(peerHash);
186
+ }
187
+
188
+ /** _close counterpart of the legacy
189
+ * `this._replicationInfoReceiveEpochByPeer?.clear()`: a receive-epoch
190
+ * capture held across close must compare against null, never against a
191
+ * surviving token. Sessions deliberately survive close (see
192
+ * resetForOpen); the paired membership-lifecycle term at every
193
+ * receive-epoch check site makes the ordering unobservable either way. */
194
+ clearReceiveEpochsForClose(): void {
195
+ this._replicationInfoReceiveEpochByPeer.clear();
196
+ }
197
+
198
+ /** _close counterpart of the legacy
199
+ * `this._receiveCleanupGateByPeer?.clear()`: an in-place clear (NOT a
200
+ * map replacement) so an in-flight removal's captured release still
201
+ * drains the instance it incremented — see acquireReceiveCleanupGate. */
202
+ clearCleanupGatesForClose(): void {
203
+ this._receiveCleanupGateByPeer.clear();
204
+ }
205
+
206
+ /** ≡ removeReplicator's inline `blockPeerReceiveAdmission` +
207
+ * release-in-`finally` pair (fence B6). Acquire captures the CURRENT
208
+ * gate-map instance and increments once; the returned release is
209
+ * idempotent and decrements the captured map, deleting the entry at 0.
210
+ * The map-instance capture is load-bearing: close/reopen replaces the
211
+ * map (resetForOpen), and a late release must drain the exact map it
212
+ * incremented — a release against a fresh open's map would corrupt that
213
+ * open's refcounts. The `?? 1` mirrors the legacy release: an entry
214
+ * cleared at _close decrements to 0 and stays deleted. */
215
+ acquireReceiveCleanupGate(peerHash: string): () => void {
216
+ const gates = this._receiveCleanupGateByPeer;
217
+ gates.set(peerHash, (gates.get(peerHash) ?? 0) + 1);
218
+ let released = false;
219
+ return () => {
220
+ if (released) {
221
+ return;
222
+ }
223
+ released = true;
224
+ const remaining = (gates.get(peerHash) ?? 1) - 1;
225
+ if (remaining > 0) {
226
+ gates.set(peerHash, remaining);
227
+ } else {
228
+ gates.delete(peerHash);
229
+ }
230
+ };
231
+ }
232
+
233
+ /** ≡ the legacy `(this._receiveCleanupGateByPeer.get(peer) ?? 0) === 0`
234
+ * read (receive-admission term and prune final-confirmation filter). */
235
+ isReceiveCleanupGateOpen(peerHash: string): boolean {
236
+ return (this._receiveCleanupGateByPeer.get(peerHash) ?? 0) === 0;
237
+ }
238
+
239
+ /** ≡ legacy SharedLog.advanceReplicationInfoReceiveEpoch. Advances
240
+ * WITHOUT rotating the session — the recovery fence must survive
241
+ * removeReplicator, where the peer stays subscribed. */
242
+ advanceReceiveEpoch(peerHash: string): object {
243
+ const next = {};
244
+ this._replicationInfoReceiveEpochByPeer.set(peerHash, next);
245
+ return next;
246
+ }
247
+
248
+ /** ≡ legacy SharedLog.getReplicationInfoReceiveEpoch (`?? null`). */
249
+ receiveEpoch(peerHash: string): object | null {
250
+ return this._replicationInfoReceiveEpochByPeer.get(peerHash) ?? null;
251
+ }
252
+
253
+ /** ≡ legacy SharedLog.isCurrentReplicationInfoReceiveEpoch. */
254
+ isReceiveEpochCurrent(peerHash: string, epoch: object | null): boolean {
255
+ return this.receiveEpoch(peerHash) === epoch;
256
+ }
257
+
258
+ current(peerHash: string): PeerSession | null {
259
+ return this.sessions.get(peerHash) ?? null;
260
+ }
261
+
262
+ /** null is a VALID current value: a peer that never subscribed has no
263
+ * session, and the former host predicate admitted
264
+ * isCurrent(peer, null) === true. Preserved exactly. */
265
+ isCurrent(peerHash: string, session: object | null): boolean {
266
+ return this.current(peerHash) === session;
267
+ }
268
+
269
+ /** The only creation point. Supersedes (never deletes) the previous
270
+ * session — per-peer entries are never removed, matching today's map. */
271
+ rotate(peerHash: string, kind: PeerSessionKind): PeerSession {
272
+ const previous = this.sessions.get(peerHash);
273
+ if (previous) {
274
+ previous.phase = "superseded";
275
+ }
276
+ const next = new PeerSession(
277
+ this,
278
+ peerHash,
279
+ kind,
280
+ this.deps.getReplicationLifecycleController(),
281
+ );
282
+ this.sessions.set(peerHash, next);
283
+ return next;
284
+ }
285
+
286
+ /** Reconnect barrier completed. Identity-guarded phase mark; no-op when
287
+ * the expected token was superseded meanwhile. */
288
+ markOpen(peerHash: string, expectedSession: object): void {
289
+ const current = this.sessions.get(peerHash);
290
+ if (
291
+ current !== undefined &&
292
+ current === expectedSession &&
293
+ current.kind === "opening"
294
+ ) {
295
+ current.phase = "open";
296
+ }
297
+ }
298
+
299
+ /** Destructive removal committed for this peer. Mirrors the removal
300
+ * funnel's epoch scoping: an epoch-scoped removal only stamps the
301
+ * session it was scoped to — a reconnect during the removal's awaited
302
+ * lanes must not inherit the stamp. Undefined = unscoped removal,
303
+ * which stamps the current session as before; null scopes to "peer had
304
+ * no session at capture", so a session created meanwhile is never
305
+ * stamped. */
306
+ noteReplicatorRemoved(
307
+ peerHash: string,
308
+ expectedSession?: object | null,
309
+ ): void {
310
+ const current = this.sessions.get(peerHash);
311
+ if (
312
+ current &&
313
+ (expectedSession === undefined || current === expectedSession)
314
+ ) {
315
+ current.replicatorRemoved = true;
316
+ }
317
+ }
318
+
319
+ /** Preserves the former SharedLog.isPeerReceiveAdmissionOpen predicate term
320
+ * for term. `session` may be null (pre-session peer). */
321
+ isReceiveAdmissionOpen(
322
+ peerHash: string,
323
+ session: object | null,
324
+ replicationLifecycleController: AbortController | undefined,
325
+ options?: PeerReceiveAdmissionOptions,
326
+ ): boolean {
327
+ return (
328
+ this.deps.isReplicationLifecycleActive(replicationLifecycleController) &&
329
+ this.isCurrent(peerHash, session) &&
330
+ (options?.allowReplicationInfoBlocked === true ||
331
+ !this.isReplicationInfoBlocked(peerHash)) &&
332
+ (options?.allowCleanupGate === true ||
333
+ this.isReceiveCleanupGateOpen(peerHash))
334
+ );
335
+ }
336
+ }