@bitsocial/pubsub-voting 0.0.8 → 0.0.10

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 CHANGED
@@ -38,7 +38,7 @@ The library never starts a node and never takes a host SDK (there is no `pkc` ar
38
38
  | `chains` | `ChainClientFactory` | yes | builds a viem `PublicClient` per chain; rules read through it for the gate and weight |
39
39
  | `signer` | `VoteSigner` | no | the voting wallet's address + EIP-712 ballot signing; omit for a read-only voter |
40
40
  | `nameResolvers` | `NameResolver[]` | no | community-name resolvers (same interface and instances as pkc-js's `nameResolvers`, e.g. `@bitsocial/bso-resolver` for `name.bso`); each vote's `community.name` claim is verified through them — inline at the forward-gate for live votes, in the background verifier for cold-join admits — and a bundle whose name resolves to a different `publicKey` than claimed is dropped/evicted |
41
- | `dataPath` | `string \| false` | no | directory for the voter's persistent caches (gate results + name resolutions), the pkc-js `dataPath` equivalent. Node default: `{cwd}/.bitsocial-pubsub-voting` (better-sqlite3 under `{dataPath}/lru-storage/`); in the browser the path is ignored and the caches live in IndexedDB. Pass `false` for in-memory-only (the pkc-js `noData` equivalent). A restart re-serves settled gate reads and fresh name resolutions from the store instead of the RPC |
41
+ | `dataPath` | `string \| false` | no | directory for the voter's persistent state (gate-result + name-resolution caches, and each joined contest's **checkpoint snapshot** — its last fully-verified winner-set, reloaded at join so a restart with no other peer online keeps the tally), the pkc-js `dataPath` equivalent. Node default: `{cwd}/.bitsocial-pubsub-voting` (better-sqlite3 under `{dataPath}/lru-storage/` + `{dataPath}/checkpoints.db`); in the browser the path is ignored and everything lives in IndexedDB. Pass `false` for in-memory-only (the pkc-js `noData` equivalent). A restart re-serves settled gate reads and fresh name resolutions from the store instead of the RPC, and restores each contest's checkpoint before the cold-start pull. A seeder should always set a stable path |
42
42
  | `httpRouterUrls` | `string[]` | no | Delegated Routing V1 router base URLs to **announce provider records to** (one unsigned `PUT /routing/v1/providers` per router; `Keys` batches every joined contest's criteria CID + current checkpoint root + chunk CIDs — hourly, debounced on root changes, and on address changes). **Seeders only**: absent/empty means never announce (the default — plain clients are not dialable), and the browser build never announces regardless. The node must be publicly **reachable** (its listening port open/forwarded/published), but it does not need to know its own public IP: private, loopback, and link-local addrs are filtered client-side, and when nothing survives — the normal zero-config case behind NAT or a Docker bridge, and even on public-IP hosts, since libp2p withholds unconfirmed public addrs pending AutoNAT — the announcer sends the wildcard sentinels (`/ip4/0.0.0.0/...`, `/ip6/::/...`) that the router rewrites to the PUT's observed source IP, exactly as kubo announces work. Configured `addresses.announce` values (concrete public addrs, DNS/AutoTLS, or a kubo-style wildcard) are used as-is. Only a loopback-only node announces nothing. *Querying* needs no URLs here — cold-join discovery uses the injected node's `libp2p.contentRouting`, which the host wires its routers into |
43
43
 
44
44
  A contest is addressed by its **full criteria document**, passed to `createContest` / `createContestVote`. The document is strictly validated there (`CriteriaSchema` + the rule registry), and its canonical bytes derive the topic — so the exact document every participant shares is the only contest configuration that exists.
@@ -53,7 +53,7 @@ const voter = new PubsubVoter({
53
53
  chains: viemChainFactory(), // ({ chain, config }) => viem PublicClient
54
54
  signer: mySigner, // optional; omit → read-only voter
55
55
  nameResolvers: [bsoResolver], // optional; verifies community-name claims (e.g. @bitsocial/bso-resolver)
56
- dataPath: "/path/to/data", // optional; persistent-cache directory (default {cwd}/.bitsocial-pubsub-voting; false → in-memory)
56
+ dataPath: "/path/to/data", // optional; persistent state: caches + checkpoint snapshots (default {cwd}/.bitsocial-pubsub-voting; false → in-memory)
57
57
  httpRouterUrls: [ // optional, SEEDERS ONLY (publicly reachable node): announce provider
58
58
  "https://routing.example" // records (criteria CID + checkpoint root + chunks) so cold joiners
59
59
  ] // can discover this node via the routers; clients omit this
@@ -38,6 +38,12 @@ export interface EncodedCheckpoint {
38
38
  }
39
39
  /** Default chunk ceiling (bytes of inlined bundles per chunk), just under a 1 MiB block. */
40
40
  export declare const DEFAULT_MAX_CHUNK_BYTES: number;
41
+ /**
42
+ * The content address of one checkpoint block's bytes (dag-cbor + sha256). Exported for the
43
+ * snapshot restore path (see client/voter.ts): re-deriving each persisted block's CID is what
44
+ * lets the restore self-verify the blob against its root instead of trusting stored CIDs.
45
+ */
46
+ export declare function blockForBytes(bytes: Uint8Array): Promise<CheckpointBlock>;
41
47
  /**
42
48
  * Encode a winner set into checkpoint blocks. `winners` should be the CRDT's current (non-expired)
43
49
  * LWW winners; order does not matter (they are sorted here). Returns the root CID and every block to
@@ -5,7 +5,12 @@ import { encodeCanonical, dagCborCode } from "../encoding/canonical.js";
5
5
  import { encodeBundle, toWireBundle, fromWireBundle } from "../crdt/codec.js";
6
6
  /** Default chunk ceiling (bytes of inlined bundles per chunk), just under a 1 MiB block. */
7
7
  export const DEFAULT_MAX_CHUNK_BYTES = 1 << 20;
8
- async function blockFor(bytes) {
8
+ /**
9
+ * The content address of one checkpoint block's bytes (dag-cbor + sha256). Exported for the
10
+ * snapshot restore path (see client/voter.ts): re-deriving each persisted block's CID is what
11
+ * lets the restore self-verify the blob against its root instead of trusting stored CIDs.
12
+ */
13
+ export async function blockForBytes(bytes) {
9
14
  const digest = await sha256.digest(bytes);
10
15
  return { cid: CID.createV1(dagCborCode, digest), bytes };
11
16
  }
@@ -17,7 +22,7 @@ async function blockFor(bytes) {
17
22
  */
18
23
  async function checkpointRootBlock(chunks) {
19
24
  const root = { chunks };
20
- return blockFor(encodeCanonical(root));
25
+ return blockForBytes(encodeCanonical(root));
21
26
  }
22
27
  /**
23
28
  * Encode a winner set into checkpoint blocks. `winners` should be the CRDT's current (non-expired)
@@ -53,7 +58,7 @@ export async function encodeCheckpoint(winners, maxChunkBytes = DEFAULT_MAX_CHUN
53
58
  for (const chunk of chunks) {
54
59
  // Chunks inline the same binary wire objects the bundle block uses (see crdt/codec.ts),
55
60
  // so the binary-field byte saving multiplies across every inlined winner.
56
- const block = await blockFor(encodeCanonical(chunk.map(toWireBundle)));
61
+ const block = await blockForBytes(encodeCanonical(chunk.map(toWireBundle)));
57
62
  blocks.push(block);
58
63
  chunkCids.push(block.cid);
59
64
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Checkpoint-snapshot container — the blob a voter persists per topic so its own last
3
+ * fully-verified checkpoint survives a restart (see DESIGN.md "Persistent caches", checkpoint
4
+ * snapshots). It wraps the two existing codecs rather than defining new state: `record` is the
5
+ * fetch-protocol root record's exact wire bytes (`encodeRootRecord` — root CID, chunk index,
6
+ * counts), and `blocks` are the checkpoint blocks those CIDs address (chunks + root manifest,
7
+ * bytes only — CIDs are re-derived on load, so a corrupted block self-invalidates instead of
8
+ * being trusted).
9
+ *
10
+ * This is a LOCAL format, not wire: nothing remote ever sees a snapshot blob, so a version bump
11
+ * only invalidates on-disk snapshots (discarded gracefully at load) and needs no pinned vector.
12
+ * Pure and network-free, like the rest of `src/checkpoint/`.
13
+ */
14
+ /** Bumped on any layout change; a mismatched blob is discarded at load, never migrated. */
15
+ export declare const SNAPSHOT_VERSION = 1;
16
+ /** The decoded container: root-record wire bytes plus the checkpoint block bytes it references. */
17
+ export interface CheckpointSnapshot {
18
+ record: Uint8Array;
19
+ blocks: Uint8Array[];
20
+ }
21
+ /** Encode a snapshot blob (canonical dag-cbor, like every other byte layout in the library). */
22
+ export declare function encodeSnapshot(snapshot: CheckpointSnapshot): Uint8Array;
23
+ /** Decode and shape-check a snapshot blob; throws on garbage or a version mismatch. */
24
+ export declare function decodeSnapshot(bytes: Uint8Array): CheckpointSnapshot;
@@ -0,0 +1,36 @@
1
+ import * as dagCbor from "@ipld/dag-cbor";
2
+ import { encodeCanonical } from "../encoding/canonical.js";
3
+ /**
4
+ * Checkpoint-snapshot container — the blob a voter persists per topic so its own last
5
+ * fully-verified checkpoint survives a restart (see DESIGN.md "Persistent caches", checkpoint
6
+ * snapshots). It wraps the two existing codecs rather than defining new state: `record` is the
7
+ * fetch-protocol root record's exact wire bytes (`encodeRootRecord` — root CID, chunk index,
8
+ * counts), and `blocks` are the checkpoint blocks those CIDs address (chunks + root manifest,
9
+ * bytes only — CIDs are re-derived on load, so a corrupted block self-invalidates instead of
10
+ * being trusted).
11
+ *
12
+ * This is a LOCAL format, not wire: nothing remote ever sees a snapshot blob, so a version bump
13
+ * only invalidates on-disk snapshots (discarded gracefully at load) and needs no pinned vector.
14
+ * Pure and network-free, like the rest of `src/checkpoint/`.
15
+ */
16
+ /** Bumped on any layout change; a mismatched blob is discarded at load, never migrated. */
17
+ export const SNAPSHOT_VERSION = 1;
18
+ /** Encode a snapshot blob (canonical dag-cbor, like every other byte layout in the library). */
19
+ export function encodeSnapshot(snapshot) {
20
+ return encodeCanonical({ v: SNAPSHOT_VERSION, record: snapshot.record, blocks: snapshot.blocks });
21
+ }
22
+ /** Decode and shape-check a snapshot blob; throws on garbage or a version mismatch. */
23
+ export function decodeSnapshot(bytes) {
24
+ const decoded = dagCbor.decode(bytes);
25
+ if (typeof decoded !== "object" || decoded === null)
26
+ throw new Error("snapshot is not a map");
27
+ const { v, record, blocks } = decoded;
28
+ if (v !== SNAPSHOT_VERSION)
29
+ throw new Error(`unsupported snapshot version ${String(v)}`);
30
+ if (!(record instanceof Uint8Array))
31
+ throw new Error("snapshot record is not bytes");
32
+ if (!Array.isArray(blocks) || blocks.some((block) => !(block instanceof Uint8Array))) {
33
+ throw new Error("snapshot blocks are not a bytes array");
34
+ }
35
+ return { record, blocks };
36
+ }
@@ -205,12 +205,16 @@ export interface PubsubVoterOptions {
205
205
  */
206
206
  httpRouterUrls?: string[];
207
207
  /**
208
- * Directory for the voter's persistent caches (gate results, name resolutions), the
209
- * pkc-js `dataPath` equivalent. On Node the caches are better-sqlite3 databases under
210
- * `{dataPath}/lru-storage/`; in the browser the path is ignored and the caches live in
211
- * IndexedDB (via localforage) either way. Defaults to `{cwd}/.bitsocial-pubsub-voting`
212
- * on Node. Pass `false` for in-memory-only caches (no disk, no IndexedDB — the pkc-js
213
- * `noData` equivalent): nothing survives the process, but nothing is written either.
208
+ * Directory for the voter's persistent state, the pkc-js `dataPath` equivalent: the
209
+ * gate-result and name-resolution caches, plus each joined contest's **checkpoint
210
+ * snapshot** the node's own last fully-verified winner-set, written debounced on
211
+ * changes and reloaded at join, so a seeder restarting with no other peer online does
212
+ * not lose the tally. On Node this is better-sqlite3 databases under
213
+ * `{dataPath}/lru-storage/` plus `{dataPath}/checkpoints.db`; in the browser the path
214
+ * is ignored and everything lives in IndexedDB (via localforage) either way. Defaults
215
+ * to `{cwd}/.bitsocial-pubsub-voting` on Node. Pass `false` for in-memory-only (no
216
+ * disk, no IndexedDB — the pkc-js `noData` equivalent): nothing survives the process,
217
+ * but nothing is written either. A seeder should ALWAYS set a stable path.
214
218
  */
215
219
  dataPath?: string | false;
216
220
  }
@@ -24,7 +24,8 @@ import { encode as encodeDagCbor } from "@ipld/dag-cbor";
24
24
  import { sha256 } from "viem";
25
25
  import { makeBackgroundVerifier } from "../verify/background.js";
26
26
  import { makeAcceptedDedup } from "../transport/accepted-dedup.js";
27
- import { encodeCheckpoint } from "../checkpoint/codec.js";
27
+ import { blockForBytes, decodeCheckpoint, encodeCheckpoint } from "../checkpoint/codec.js";
28
+ import { decodeSnapshot, encodeSnapshot } from "../checkpoint/snapshot.js";
28
29
  import { CID } from "multiformats/cid";
29
30
  import { makeTally } from "../tally/tally.js";
30
31
  import { ballotTypedData } from "../signer/eip712.js";
@@ -120,6 +121,20 @@ const COLD_START_FETCH_BACKOFF_CAP_MS = 4_000;
120
121
  * others idle.
121
122
  */
122
123
  const COLD_START_PEER_FETCH_LIMIT = 24;
124
+ /**
125
+ * How long after `join()` the cold-start pull stays armed on gossipsub's `subscription-change`
126
+ * (see `#armSubscriptionRepull`). One heartbeat interval: the window exists to close the
127
+ * join-races-subscription-gossip gap (issue #15), and past the first heartbeat the passive
128
+ * root-record heartbeat covers divergence detection anyway — an unbounded listener would
129
+ * instead pay one root fetch per churning subscriber for the node's whole lifetime.
130
+ */
131
+ const COLD_START_REPULL_WINDOW_MS = HEARTBEAT_INTERVAL_MS;
132
+ /**
133
+ * Debounce (ms) for persisting the checkpoint snapshot after a winner-set change, mirroring the
134
+ * announcer's cadence on the same transition: a gossip burst costs one write per window, and
135
+ * `leave()` flushes whatever is still pending so a clean shutdown never loses the tail.
136
+ */
137
+ const SNAPSHOT_DEBOUNCE_MS = 10_000;
123
138
  /** Per-root chase deadline (ms): a multi-block directed-bitswap pull, coarser than one message. */
124
139
  const CHASE_TIMEOUT_MS = 30_000;
125
140
  /** Concurrent root chases; a spray of divergent roots queues, never floods. */
@@ -232,8 +247,10 @@ class ContestEngine {
232
247
  */
233
248
  #currentBucketCache = 0;
234
249
  /**
235
- * The on-demand checkpoint cache: the last encoded root record and the bucket it was encoded
236
- * at. Invalidated by {@link #markStateChanged} (any merge/publish/chase admit) and by a bucket
250
+ * The on-demand checkpoint cache: the last encoded root record, the bucket it was encoded
251
+ * at, and the encoded blocks themselves (also in the blockstore; kept here so the snapshot
252
+ * writer never has to read them back through a seam that can fall back to a network want).
253
+ * Invalidated by {@link #markStateChanged} (any merge/publish/chase admit) and by a bucket
237
254
  * advance (expiry changes the winner set without any message). See DESIGN.md "Checkpoints".
238
255
  */
239
256
  #rootRecordCache = undefined;
@@ -250,6 +267,13 @@ class ContestEngine {
250
267
  #peerRoots = new Map();
251
268
  /** The armed heartbeat timer (jittered; see {@link #armHeartbeat}), cleared by `leave()`. */
252
269
  #heartbeatTimer;
270
+ /** The armed (debounced) snapshot-write timer; flushed by `leave()`. See {@link #writeSnapshot}. */
271
+ #snapshotTimer;
272
+ /**
273
+ * Tears down the per-join `subscription-change` re-pull (listener + window timer); set by
274
+ * {@link #armSubscriptionRepull}, cleared by `leave()` or by the window expiring.
275
+ */
276
+ #subscriptionRepullDisarm;
253
277
  /** True when a heartbeat matching our own root was heard this interval (suppression). */
254
278
  #heardMatchingRoot = false;
255
279
  /** True once we published our record this interval (heartbeat OR divergence response). */
@@ -649,13 +673,21 @@ class ContestEngine {
649
673
  // heartbeat it broadcasts there.
650
674
  this.#deps.onTopicJoined();
651
675
  this.#armHeartbeat();
676
+ // Reload our own persisted checkpoint BEFORE any network activity, and awaited — so a
677
+ // seeder restarting with no other peer online serves its restored state immediately
678
+ // (the initial tally after `update()` already carries it), and the cold-start pull
679
+ // below compares peers' roots against the restored root instead of an empty one.
680
+ // No snapshot ⇒ no cost (one store miss); a non-empty one costs one head read (the
681
+ // bucket-admissibility check), consistent with the state-present rule below.
682
+ await this.#restoreSnapshot();
652
683
  // Cold-start / reconnect pull: ask connected topic peers for their root records and
653
684
  // chase any divergence. Fire-and-forget — joining must not block on slow peers, and
654
685
  // live gossip plus the heartbeat converge regardless; this only shortens the gap.
655
686
  void this.#coldStart().catch(() => { });
656
- // If a re-join left state behind, refresh the bucket and prune the decayed nodes. Gated
657
- // on non-empty state so an empty join stays network-free (no getBlockNumber read),
658
- // preserving the "zero chain reads for a constant-weight tally" property.
687
+ // If a re-join left state behind (or the snapshot restore just reloaded some), refresh
688
+ // the bucket and prune the decayed nodes. Gated on non-empty state so an empty join
689
+ // stays network-free (no getBlockNumber read), preserving the "zero chain reads for a
690
+ // constant-weight tally" property.
659
691
  if (this.#crdt.nodeCount() > 0) {
660
692
  await this.#refreshBucket();
661
693
  await this.#crdt.prune(this.#currentBucketCache);
@@ -669,6 +701,14 @@ class ContestEngine {
669
701
  * the providers of the criteria CID from the host's HTTP content router. Roots are **unioned,
670
702
  * never quorum'd** — a record served by a single peer is still chased, so a colluding majority
671
703
  * cannot hide a vote.
704
+ *
705
+ * The `getSubscribers` source is an instantaneous snapshot, and a joiner that dials a seeder
706
+ * and joins immediately (the normal browser boot order) races subscription gossip: at the
707
+ * instant of `join()` it sees zero subscribers, pulls nothing, and would idle until the topic
708
+ * heartbeat (issue #15 — measured 90+ s vs ~4 s). So the pull closure (and its `seen` dedup)
709
+ * stays armed on gossipsub's `subscription-change` for {@link COLD_START_REPULL_WINDOW_MS}:
710
+ * each peer whose subscription to this topic becomes visible inside the window is asked once,
711
+ * closing the race for router-less clients too. See {@link #armSubscriptionRepull}.
672
712
  */
673
713
  async #coldStart() {
674
714
  const seen = new Set();
@@ -700,12 +740,45 @@ class ContestEngine {
700
740
  // live gossip still converge.
701
741
  }
702
742
  };
743
+ // Arm the re-pull BEFORE the initial fan-out so no subscriber can land in the gap
744
+ // between the snapshot below and the listener; `seen` dedups any overlap.
745
+ this.#armSubscriptionRepull(pull);
703
746
  // Shuffle before slicing: a deterministic first-N pick would funnel a whole directory
704
747
  // join through the same peers' stream caps while other subscribers idle; a random N
705
748
  // spreads contests across the topic's serving peers (see COLD_START_PEER_FETCH_LIMIT).
706
749
  const fromSubscribers = shuffled(this.#deps.pubsub.getSubscribers(this.topic)).slice(0, COLD_START_PEERS).map(pull);
707
750
  await Promise.allSettled([...fromSubscribers, this.#discoverProviders(pull)]);
708
751
  }
752
+ /**
753
+ * Keep the cold-start pull live on gossipsub's `subscription-change` for one re-pull window:
754
+ * a peer whose subscription to this topic becomes visible after `join()`'s instantaneous
755
+ * `getSubscribers` snapshot is pulled the moment it appears (once — the shared `seen` set
756
+ * dedups), instead of waiting for the heartbeat. Bounded by {@link COLD_START_REPULL_WINDOW_MS}:
757
+ * past the first heartbeat interval the passive heartbeat already covers divergence detection,
758
+ * so a long-lived node does not pay one fetch per churning subscriber forever. Disarmed by
759
+ * `leave()`; a re-join arms a fresh window.
760
+ */
761
+ #armSubscriptionRepull(pull) {
762
+ this.#disarmSubscriptionRepull(); // a stale listener from a prior join must not leak
763
+ const pubsub = this.#deps.pubsub;
764
+ const listener = (evt) => {
765
+ if (!evt.detail.subscriptions.some((s) => s.topic === this.topic && s.subscribe))
766
+ return;
767
+ void pull(evt.detail.peerId).catch(() => { });
768
+ };
769
+ pubsub.addEventListener("subscription-change", listener);
770
+ const timer = setTimeout(() => this.#disarmSubscriptionRepull(), COLD_START_REPULL_WINDOW_MS);
771
+ // Don't hold a Node process open; no-op in the browser.
772
+ timer.unref?.();
773
+ this.#subscriptionRepullDisarm = () => {
774
+ clearTimeout(timer);
775
+ pubsub.removeEventListener("subscription-change", listener);
776
+ };
777
+ }
778
+ #disarmSubscriptionRepull() {
779
+ this.#subscriptionRepullDisarm?.();
780
+ this.#subscriptionRepullDisarm = undefined;
781
+ }
709
782
  /**
710
783
  * Pull one peer's root record over the fetch protocol, retrying a THROWN fetch with full-jittered
711
784
  * exponential backoff until {@link COLD_START_FETCH_DEADLINE_MS} (see the constant's note for the
@@ -793,8 +866,16 @@ class ContestEngine {
793
866
  if (this.#heartbeatTimer !== undefined)
794
867
  clearTimeout(this.#heartbeatTimer);
795
868
  this.#heartbeatTimer = undefined;
869
+ // Flush the debounced snapshot write so a clean shutdown persists the latest state
870
+ // (still skipped if checks are pending — the stale-but-good snapshot stays put).
871
+ if (this.#snapshotTimer !== undefined) {
872
+ clearTimeout(this.#snapshotTimer);
873
+ this.#snapshotTimer = undefined;
874
+ await this.#writeSnapshot();
875
+ }
796
876
  // Pause the background verifier's retry timer; pending state survives for a re-join.
797
877
  this.#background.stop();
878
+ this.#disarmSubscriptionRepull();
798
879
  this.#heardMatchingRoot = false;
799
880
  this.#publishedRootThisInterval = false;
800
881
  this.#chaser = undefined;
@@ -847,8 +928,126 @@ class ContestEngine {
847
928
  #markStateChanged() {
848
929
  this.#checkpointDirty = true;
849
930
  // The same transition the encode cache invalidates on is what makes router provider
850
- // records stale, so the announcer's debounced re-announce rides it (see ResolvedDeps).
931
+ // records stale, so the announcer's debounced re-announce rides it (see ResolvedDeps),
932
+ // and what makes the persisted snapshot stale, so the debounced write rides it too.
851
933
  this.#deps.onCheckpointChanged();
934
+ this.#scheduleSnapshotWrite();
935
+ }
936
+ /**
937
+ * Arm the debounced snapshot write (announcer-style: the first change in a window arms the
938
+ * timer, the rest coalesce into it). Only while joined — a never-joined engine (a ballot
939
+ * created but never published) holds no view worth persisting, and `leave()` flushes the
940
+ * armed timer so nothing fires after teardown.
941
+ */
942
+ #scheduleSnapshotWrite() {
943
+ if (!this.#joined || this.#snapshotTimer !== undefined)
944
+ return;
945
+ const timer = setTimeout(() => {
946
+ this.#snapshotTimer = undefined;
947
+ void this.#writeSnapshot();
948
+ }, SNAPSHOT_DEBOUNCE_MS);
949
+ // Don't hold a Node process open; no-op in the browser.
950
+ timer.unref?.();
951
+ this.#snapshotTimer = timer;
952
+ }
953
+ /** Any admitted bundle with a deferred check still pending (same predicate as {@link #isPending}). */
954
+ #hasUnsettledChecks() {
955
+ for (const checks of this.#checks.values()) {
956
+ if (!checks.chainVerified || checks.nameResolved === false)
957
+ return true;
958
+ }
959
+ return false;
960
+ }
961
+ /**
962
+ * Persist this contest's current checkpoint under the voter's `dataPath`: the root record's
963
+ * wire bytes plus the blocks it references (just re-put by the encode, read back from the
964
+ * blockstore so no second copy is held in memory). Best-effort like every persistent-cache
965
+ * write — a broken store degrades to the pre-persistence behavior (the cold-start pull),
966
+ * never an error.
967
+ *
968
+ * Skipped while ANY admitted bundle has a deferred check pending: the encoder serves only
969
+ * fully verified bundles, so writing mid-settlement would persist a snapshot that OMITS the
970
+ * pending ones — right after a restore (where every reloaded bundle is provisional) that
971
+ * would clobber a good snapshot with a near-empty one, and a crash in that window would lose
972
+ * the very votes persistence exists to keep. Every settlement and eviction re-marks the
973
+ * state changed, so the write that was skipped here is re-armed by the last one to land.
974
+ */
975
+ async #writeSnapshot() {
976
+ if (this.#hasUnsettledChecks())
977
+ return;
978
+ try {
979
+ await this.rootRecord(); // (re-)encode so the cache reflects the current winner-set
980
+ // Read record + blocks from the cache as one unit: a state change racing the encode
981
+ // above can only leave a NEWER consistent pair here, never a torn one.
982
+ const cached = this.#rootRecordCache;
983
+ if (cached === undefined)
984
+ return;
985
+ await this.#deps.snapshots.set(this.topic, encodeSnapshot({ record: encodeRootRecord(cached.record), blocks: cached.blocks.map((block) => block.bytes) }));
986
+ }
987
+ catch {
988
+ // Best-effort: a failed write leaves the previous snapshot in place; the next
989
+ // state change re-arms the debounce and retries.
990
+ }
991
+ }
992
+ /**
993
+ * Reload this node's own persisted checkpoint at `join()`, before any network activity: the
994
+ * fix for the seeder-restart vote loss (issue #14). The blob is decoded through the SAME
995
+ * pipeline as a chased remote checkpoint — block CIDs re-derived (a corrupted block
996
+ * self-invalidates), `decodeCheckpoint` re-derives the chunk index against the root, each
997
+ * bundle re-passes the offline signature/constraint checks, and the deferred gate read +
998
+ * name resolution ride the background verifier (mostly persisted-gate-cache hits on a
999
+ * restart) — so the trust model is unchanged: this is the node's own previously-validated
1000
+ * state, re-validated on load. A corrupt or version-mismatched blob is removed and the join
1001
+ * proceeds empty, exactly as before persistence; the cold-start pull then still runs, so a
1002
+ * stale snapshot self-heals by union with the live topic.
1003
+ */
1004
+ async #restoreSnapshot() {
1005
+ let blob;
1006
+ try {
1007
+ blob = await this.#deps.snapshots.get(this.topic);
1008
+ }
1009
+ catch {
1010
+ return; // unreadable store — degrade to a plain cold join
1011
+ }
1012
+ if (blob === undefined)
1013
+ return;
1014
+ try {
1015
+ const snapshot = decodeSnapshot(blob);
1016
+ const record = decodeRootRecord(snapshot.record);
1017
+ const byCid = new Map();
1018
+ for (const bytes of snapshot.blocks)
1019
+ byCid.set((await blockForBytes(bytes)).cid.toString(), bytes);
1020
+ const winners = await decodeCheckpoint(record.root, async (cid) => byCid.get(cid.toString()), record.chunks);
1021
+ const pending = [];
1022
+ for (const bundle of winners) {
1023
+ // Same two-stage admit as the chase (see transport/chase.ts): offline checks
1024
+ // synchronously before admit, chain/name checks deferred and batched. Admission
1025
+ // goes through `crdt.add` (which stores the block AND registers the bundle) — a
1026
+ // merge-by-CID would re-read through the blockstore, which the restore must not
1027
+ // depend on: it may be fresh (the incident's in-memory case) or hold the block
1028
+ // already (a persistent one), neither of which says the CRDT knows the bundle.
1029
+ const cid = await bundleCidForBytes(encodeBundle(bundle));
1030
+ if (this.#checks.has(cid.toString()))
1031
+ continue; // already admitted (a re-join)
1032
+ if (!(await this.#isEvaluableNow(bundle)))
1033
+ continue;
1034
+ const offline = await this.#verifier.verifyOffline(bundle);
1035
+ if (!offline.valid)
1036
+ continue;
1037
+ await this.#crdt.add(bundle);
1038
+ this.#recordChecks(cid, bundle, false);
1039
+ pending.push({ cid, bundle });
1040
+ }
1041
+ if (pending.length > 0) {
1042
+ this.#background.enqueue(pending);
1043
+ this.#onStateChanged();
1044
+ }
1045
+ }
1046
+ catch {
1047
+ // Corrupt, truncated, or version-mismatched blob: discard it and join empty — the
1048
+ // pre-persistence behavior. Never let a bad snapshot block the join.
1049
+ void this.#deps.snapshots.remove(this.topic).catch(() => { });
1050
+ }
852
1051
  }
853
1052
  /**
854
1053
  * The contest's current root record, encoded **on demand** and cached until the winner-set
@@ -886,7 +1085,7 @@ class ContestEngine {
886
1085
  count: winners.length,
887
1086
  sizeBytes: blocks.reduce((total, block) => total + block.bytes.length, 0)
888
1087
  };
889
- this.#rootRecordCache = { record, bucket };
1088
+ this.#rootRecordCache = { record, bucket, blocks };
890
1089
  return record;
891
1090
  }
892
1091
  /**
@@ -1176,7 +1375,8 @@ export class PubsubVoter {
1176
1375
  onCheckpointChanged: () => this.#announcer?.notifyChange(),
1177
1376
  fetchBudget: makePerPeerBudget(COLD_START_PEER_FETCH_LIMIT),
1178
1377
  gateStore: this.#storage.openLru({ cacheName: "gate-results", maxItems: GATE_RESULTS_MAX_ITEMS }),
1179
- nameResolutionCache: makeNameResolutionCache(this.#storage.openLru({ cacheName: "name-resolutions", maxItems: NAME_RESOLUTIONS_MAX_ITEMS }))
1378
+ nameResolutionCache: makeNameResolutionCache(this.#storage.openLru({ cacheName: "name-resolutions", maxItems: NAME_RESOLUTIONS_MAX_ITEMS })),
1379
+ snapshots: this.#storage.openSnapshots()
1180
1380
  };
1181
1381
  // The announcer touches libp2p (peer id, addresses, address events) only when routers are
1182
1382
  // configured, so a host that never announces pays nothing and injects nothing extra.
@@ -88,11 +88,38 @@ class LocalForageLruStorage {
88
88
  await Promise.all([db.active().clear(), db.inactive().clear()]);
89
89
  }
90
90
  }
91
+ /**
92
+ * The browser checkpoint-snapshot store: a single localforage (IndexedDB) database, no
93
+ * dual-instance eviction — a snapshot must never be evicted (see types.ts). localforage
94
+ * round-trips typed arrays natively under its IndexedDB driver; an `ArrayBuffer` coming
95
+ * back from an older driver is re-wrapped.
96
+ */
97
+ class LocalForageSnapshotStorage {
98
+ #db;
99
+ #init() {
100
+ return (this.#db ??= localForage.createInstance({ name: "pubsub-voting-checkpoints" }));
101
+ }
102
+ async get(key) {
103
+ const value = await this.#init().getItem(key);
104
+ if (value instanceof Uint8Array)
105
+ return value;
106
+ if (value instanceof ArrayBuffer)
107
+ return new Uint8Array(value);
108
+ return undefined;
109
+ }
110
+ async set(key, bytes) {
111
+ await this.#init().setItem(key, bytes);
112
+ }
113
+ async remove(key) {
114
+ await this.#init().removeItem(key);
115
+ }
116
+ }
91
117
  /** Build the browser {@link VoteStorage}: IndexedDB via localforage, or in-memory for `false`. */
92
118
  export function makeStorage(options) {
93
119
  if (options.dataPath === false)
94
120
  return makeMemoryStorage();
95
121
  const stores = new Map();
122
+ let snapshots;
96
123
  return {
97
124
  openLru({ cacheName, maxItems }) {
98
125
  let store = stores.get(cacheName);
@@ -102,9 +129,13 @@ export function makeStorage(options) {
102
129
  }
103
130
  return store;
104
131
  },
132
+ openSnapshots() {
133
+ return (snapshots ??= new LocalForageSnapshotStorage());
134
+ },
105
135
  // localforage holds no closeable handles; dropping the references is the whole teardown.
106
136
  async destroy() {
107
137
  stores.clear();
138
+ snapshots = undefined;
108
139
  }
109
140
  };
110
141
  }
@@ -1,4 +1,4 @@
1
- import type { LruStorage, VoteStorage } from "./types.js";
1
+ import type { LruStorage, SnapshotStorage, VoteStorage } from "./types.js";
2
2
  /**
3
3
  * The `dataPath: false` backend on both platforms (pkc-js's `noData` equivalent): a true-LRU
4
4
  * `Map` — a `get` hit re-inserts the key so insertion order IS recency order, and eviction
@@ -6,5 +6,7 @@ import type { LruStorage, VoteStorage } from "./types.js";
6
6
  * match the persistent backends so tests and no-disk hosts exercise the same code paths.
7
7
  */
8
8
  export declare function makeMemoryLruStorage(maxItems: number): LruStorage;
9
+ /** The `dataPath: false` snapshot store: a plain `Map`, no eviction (see types.ts). */
10
+ export declare function makeMemorySnapshotStorage(): SnapshotStorage;
9
11
  /** An all-in-memory {@link VoteStorage}; `destroy()` only drops the references. */
10
12
  export declare function makeMemoryStorage(): VoteStorage;
@@ -37,9 +37,25 @@ export function makeMemoryLruStorage(maxItems) {
37
37
  }
38
38
  };
39
39
  }
40
+ /** The `dataPath: false` snapshot store: a plain `Map`, no eviction (see types.ts). */
41
+ export function makeMemorySnapshotStorage() {
42
+ const blobs = new Map();
43
+ return {
44
+ async get(key) {
45
+ return blobs.get(key);
46
+ },
47
+ async set(key, bytes) {
48
+ blobs.set(key, bytes);
49
+ },
50
+ async remove(key) {
51
+ blobs.delete(key);
52
+ }
53
+ };
54
+ }
40
55
  /** An all-in-memory {@link VoteStorage}; `destroy()` only drops the references. */
41
56
  export function makeMemoryStorage() {
42
57
  const stores = new Map();
58
+ let snapshots;
43
59
  return {
44
60
  openLru({ cacheName, maxItems }) {
45
61
  let store = stores.get(cacheName);
@@ -49,8 +65,12 @@ export function makeMemoryStorage() {
49
65
  }
50
66
  return store;
51
67
  },
68
+ openSnapshots() {
69
+ return (snapshots ??= makeMemorySnapshotStorage());
70
+ },
52
71
  async destroy() {
53
72
  stores.clear();
73
+ snapshots = undefined;
54
74
  }
55
75
  };
56
76
  }
@@ -37,21 +37,31 @@ class SqliteLruStorage {
37
37
  if (this.#closed)
38
38
  throw new Error("storage is closed");
39
39
  mkdirSync(this.#dir, { recursive: true });
40
+ // A corrupt file fails on the first statement, not the constructor (SQLite reads the
41
+ // header lazily), so close the never-assigned handle or every caller retry leaks an fd.
40
42
  const db = new Database(this.#file);
41
- db.pragma("journal_mode = WAL");
42
- db.prepare(`CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, lastAccess INT)`).run();
43
- db.prepare(`CREATE INDEX IF NOT EXISTS lastAccess ON cache (lastAccess)`).run();
44
- this.#db = db;
45
- this.#statements = {
46
- get: db.prepare(`UPDATE OR IGNORE cache SET lastAccess = @now WHERE key = @key RETURNING value`),
47
- set: db.prepare(`INSERT OR REPLACE INTO cache (key, value, lastAccess) VALUES (@key, @value, @now)`),
48
- remove: db.prepare(`DELETE FROM cache WHERE key = @key`),
49
- keys: db.prepare(`SELECT key FROM cache`),
50
- clear: db.prepare(`DELETE FROM cache`),
51
- evict: db.prepare(`WITH lru AS (SELECT key FROM cache ORDER BY lastAccess DESC LIMIT -1 OFFSET @maxItems)
52
- DELETE FROM cache WHERE key IN lru`)
53
- };
54
- return this.#statements;
43
+ try {
44
+ db.pragma("journal_mode = WAL");
45
+ db.prepare(`CREATE TABLE IF NOT EXISTS cache (key TEXT PRIMARY KEY, value TEXT, lastAccess INT)`).run();
46
+ db.prepare(`CREATE INDEX IF NOT EXISTS lastAccess ON cache (lastAccess)`).run();
47
+ this.#db = db;
48
+ this.#statements = {
49
+ get: db.prepare(`UPDATE OR IGNORE cache SET lastAccess = @now WHERE key = @key RETURNING value`),
50
+ set: db.prepare(`INSERT OR REPLACE INTO cache (key, value, lastAccess) VALUES (@key, @value, @now)`),
51
+ remove: db.prepare(`DELETE FROM cache WHERE key = @key`),
52
+ keys: db.prepare(`SELECT key FROM cache`),
53
+ clear: db.prepare(`DELETE FROM cache`),
54
+ evict: db.prepare(`WITH lru AS (SELECT key FROM cache ORDER BY lastAccess DESC LIMIT -1 OFFSET @maxItems)
55
+ DELETE FROM cache WHERE key IN lru`)
56
+ };
57
+ return this.#statements;
58
+ }
59
+ catch (error) {
60
+ this.#db = undefined;
61
+ this.#statements = undefined;
62
+ db.close();
63
+ throw error;
64
+ }
55
65
  }
56
66
  async getItem(key) {
57
67
  const row = this.#open().get.get({ key, now: Date.now() });
@@ -78,6 +88,67 @@ class SqliteLruStorage {
78
88
  this.#statements = undefined;
79
89
  }
80
90
  }
91
+ /**
92
+ * The Node checkpoint-snapshot store: one better-sqlite3 database at
93
+ * `{dataPath}/checkpoints.db`, one BLOB row per topic. Same lazy open / WAL / closed-flag
94
+ * discipline as {@link SqliteLruStorage}, but no LRU machinery: a snapshot must never be
95
+ * evicted (see types.ts). A single `INSERT OR REPLACE` per `set` makes each write atomic —
96
+ * a crash mid-write leaves the previous blob, never a torn one.
97
+ */
98
+ class SqliteSnapshotStorage {
99
+ #db;
100
+ #file;
101
+ #dir;
102
+ #closed = false;
103
+ #statements;
104
+ constructor(opts) {
105
+ this.#dir = opts.dir;
106
+ this.#file = join(opts.dir, "checkpoints.db");
107
+ }
108
+ #open() {
109
+ if (this.#statements)
110
+ return this.#statements;
111
+ if (this.#closed)
112
+ throw new Error("storage is closed");
113
+ mkdirSync(this.#dir, { recursive: true });
114
+ // Same handle-leak guard as SqliteLruStorage.#open: the debounced snapshot write
115
+ // retries on every state change, so an unclosed handle per attempt is an EMFILE.
116
+ const db = new Database(this.#file);
117
+ try {
118
+ db.pragma("journal_mode = WAL");
119
+ db.prepare(`CREATE TABLE IF NOT EXISTS snapshot (key TEXT PRIMARY KEY, value BLOB)`).run();
120
+ this.#db = db;
121
+ this.#statements = {
122
+ get: db.prepare(`SELECT value FROM snapshot WHERE key = @key`),
123
+ set: db.prepare(`INSERT OR REPLACE INTO snapshot (key, value) VALUES (@key, @value)`),
124
+ remove: db.prepare(`DELETE FROM snapshot WHERE key = @key`)
125
+ };
126
+ return this.#statements;
127
+ }
128
+ catch (error) {
129
+ this.#db = undefined;
130
+ this.#statements = undefined;
131
+ db.close();
132
+ throw error;
133
+ }
134
+ }
135
+ async get(key) {
136
+ const row = this.#open().get.get({ key });
137
+ return row === undefined ? undefined : new Uint8Array(row.value);
138
+ }
139
+ async set(key, bytes) {
140
+ this.#open().set.run({ key, value: Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength) });
141
+ }
142
+ async remove(key) {
143
+ this.#open().remove.run({ key });
144
+ }
145
+ close() {
146
+ this.#closed = true;
147
+ this.#db?.close();
148
+ this.#db = undefined;
149
+ this.#statements = undefined;
150
+ }
151
+ }
81
152
  /** Node's default data path when the host passes none (see {@link StorageOptions.dataPath}). */
82
153
  export function defaultDataPath() {
83
154
  return join(process.cwd(), ".bitsocial-pubsub-voting");
@@ -86,8 +157,10 @@ export function defaultDataPath() {
86
157
  export function makeStorage(options) {
87
158
  if (options.dataPath === false)
88
159
  return makeMemoryStorage();
89
- const dir = join(options.dataPath ?? defaultDataPath(), "lru-storage");
160
+ const dataPath = options.dataPath ?? defaultDataPath();
161
+ const dir = join(dataPath, "lru-storage");
90
162
  const stores = new Map();
163
+ let snapshots;
91
164
  return {
92
165
  openLru({ cacheName, maxItems }) {
93
166
  let store = stores.get(cacheName);
@@ -97,10 +170,15 @@ export function makeStorage(options) {
97
170
  }
98
171
  return store;
99
172
  },
173
+ openSnapshots() {
174
+ return (snapshots ??= new SqliteSnapshotStorage({ dir: dataPath }));
175
+ },
100
176
  async destroy() {
101
177
  for (const store of stores.values())
102
178
  store.close();
103
179
  stores.clear();
180
+ snapshots?.close();
181
+ snapshots = undefined;
104
182
  }
105
183
  };
106
184
  }
@@ -22,6 +22,21 @@ export interface LruStorage {
22
22
  keys(): Promise<string[]>;
23
23
  clear(): Promise<void>;
24
24
  }
25
+ /**
26
+ * The checkpoint-snapshot store: one binary blob per topic, holding the node's own last
27
+ * fully-verified checkpoint so a restart with no other peer online does not lose the tally
28
+ * (see DESIGN.md "Persistent caches", checkpoint snapshots). Deliberately NOT an
29
+ * {@link LruStorage}: values are binary (no JSON round-trip for ~half-MB block sets) and
30
+ * eviction would silently corrupt a snapshot — a topic's blob lives until overwritten or
31
+ * removed. Each `set` replaces the whole blob in one write, so a snapshot is never torn.
32
+ */
33
+ export interface SnapshotStorage {
34
+ /** The stored blob, or `undefined` on a miss. */
35
+ get(key: string): Promise<Uint8Array | undefined>;
36
+ /** Insert or replace the blob atomically. */
37
+ set(key: string, bytes: Uint8Array): Promise<void>;
38
+ remove(key: string): Promise<void>;
39
+ }
25
40
  /** One voter's persistent-cache root: opens named LRU stores, closed as a unit on `destroy()`. */
26
41
  export interface VoteStorage {
27
42
  /**
@@ -32,6 +47,8 @@ export interface VoteStorage {
32
47
  cacheName: string;
33
48
  maxItems: number;
34
49
  }): LruStorage;
50
+ /** Open (or return the already-open) checkpoint-snapshot store (one per voter). */
51
+ openSnapshots(): SnapshotStorage;
35
52
  /** Close every open store (Node: close the sqlite handles). Terminal, like `PubsubVoter.destroy`. */
36
53
  destroy(): Promise<void>;
37
54
  }
@@ -75,6 +75,15 @@ export interface PubsubService {
75
75
  from?: PeerId;
76
76
  };
77
77
  }) => void): void;
78
+ /**
79
+ * gossipsub's "a peer's subscription set changed" notification. The cold-start pull
80
+ * re-runs off it (see client/voter.ts `#armSubscriptionRepull`): a joiner that dials a
81
+ * seeder and joins immediately sees zero subscribers at the instant of `join()` — the
82
+ * seeder only appears here once subscription gossip lands — so without this trigger it
83
+ * would idle until the topic heartbeat.
84
+ */
85
+ addEventListener(type: "subscription-change", listener: SubscriptionChangeListener): void;
86
+ removeEventListener(type: "subscription-change", listener: SubscriptionChangeListener): void;
78
87
  /**
79
88
  * gossipsub's per-topic validator map. The transport installs the async forward-gate
80
89
  * here (`topicValidators.set(topic, gate)`); gossipsub awaits the returned promise
@@ -85,6 +94,16 @@ export interface PubsubService {
85
94
  */
86
95
  topicValidators?: Map<string, GossipTopicValidator>;
87
96
  }
97
+ /** Listener for gossipsub's `subscription-change` (the libp2p `SubscriptionChangeData` shape). */
98
+ export type SubscriptionChangeListener = (evt: {
99
+ detail: {
100
+ peerId: PeerId;
101
+ subscriptions: Array<{
102
+ topic: string;
103
+ subscribe: boolean;
104
+ }>;
105
+ };
106
+ }) => void;
88
107
  /** A received pubsub message, as passed to a gossipsub topic validator. */
89
108
  export interface GossipMessage {
90
109
  topic: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bitsocial/pubsub-voting",
3
- "version": "0.0.8",
3
+ "version": "0.0.10",
4
4
  "description": "Trustless pubsub voting over a shared libp2p/Helia node.",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0-or-later",
@@ -31,6 +31,7 @@
31
31
  "build:bench": "tsc -p benchmark/tsconfig.json",
32
32
  "bench:cold-join": "node benchmark/run.mjs",
33
33
  "bench:directory-load": "node benchmark/run-directory.mjs",
34
+ "bench:warm-restart": "npm run build && npm run build:bench && node benchmark/warm-restart.js",
34
35
  "release": "HUSKY=0 release-it --config config/.release-it.json",
35
36
  "prepare": "husky",
36
37
  "prepublishOnly": "npm run build",