@optimystic/db-p2p 0.17.0 → 0.19.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 (66) hide show
  1. package/dist/src/cluster/block-transfer-service.d.ts +10 -0
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +2 -1
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/cluster-policy.d.ts +112 -0
  6. package/dist/src/cluster/cluster-policy.d.ts.map +1 -0
  7. package/dist/src/cluster/cluster-policy.js +88 -0
  8. package/dist/src/cluster/cluster-policy.js.map +1 -0
  9. package/dist/src/cluster/cluster-repo.d.ts +35 -11
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +95 -19
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/quorum-restore.d.ts +25 -3
  14. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  15. package/dist/src/cluster/quorum-restore.js +27 -3
  16. package/dist/src/cluster/quorum-restore.js.map +1 -1
  17. package/dist/src/cluster/reconcile-block.d.ts +10 -2
  18. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  19. package/dist/src/cluster/reconcile-block.js +38 -18
  20. package/dist/src/cluster/reconcile-block.js.map +1 -1
  21. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  22. package/dist/src/cluster/spread-on-churn.js +8 -0
  23. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  24. package/dist/src/inbound-authorization.d.ts +6 -0
  25. package/dist/src/inbound-authorization.d.ts.map +1 -1
  26. package/dist/src/inbound-authorization.js +6 -0
  27. package/dist/src/inbound-authorization.js.map +1 -1
  28. package/dist/src/libp2p-key-network.d.ts +66 -4
  29. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  30. package/dist/src/libp2p-key-network.js +130 -17
  31. package/dist/src/libp2p-key-network.js.map +1 -1
  32. package/dist/src/libp2p-node-base.d.ts +22 -23
  33. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  34. package/dist/src/libp2p-node-base.js +45 -34
  35. package/dist/src/libp2p-node-base.js.map +1 -1
  36. package/dist/src/repo/cluster-coordinator.d.ts +21 -3
  37. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  38. package/dist/src/repo/cluster-coordinator.js +27 -5
  39. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  40. package/dist/src/repo/coordinator-repo.d.ts +88 -28
  41. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  42. package/dist/src/repo/coordinator-repo.js +287 -81
  43. package/dist/src/repo/coordinator-repo.js.map +1 -1
  44. package/dist/src/storage/storage-repo.d.ts +9 -0
  45. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  46. package/dist/src/storage/storage-repo.js +77 -7
  47. package/dist/src/storage/storage-repo.js.map +1 -1
  48. package/dist/src/testing/mesh-harness.d.ts +17 -0
  49. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  50. package/dist/src/testing/mesh-harness.js +27 -4
  51. package/dist/src/testing/mesh-harness.js.map +1 -1
  52. package/package.json +2 -2
  53. package/readme.md +20 -0
  54. package/src/cluster/block-transfer-service.ts +9 -1
  55. package/src/cluster/cluster-policy.ts +152 -0
  56. package/src/cluster/cluster-repo.ts +100 -22
  57. package/src/cluster/quorum-restore.ts +28 -3
  58. package/src/cluster/reconcile-block.ts +52 -19
  59. package/src/cluster/spread-on-churn.ts +8 -0
  60. package/src/inbound-authorization.ts +6 -0
  61. package/src/libp2p-key-network.ts +958 -807
  62. package/src/libp2p-node-base.ts +65 -57
  63. package/src/repo/cluster-coordinator.ts +30 -6
  64. package/src/repo/coordinator-repo.ts +329 -91
  65. package/src/storage/storage-repo.ts +81 -10
  66. package/src/testing/mesh-harness.ts +34 -4
@@ -21,6 +21,7 @@ import type { IRawStorage } from './storage/i-raw-storage.js';
21
21
  import { seedOwnedBlocksFromStorage } from './owned-block-seed.js';
22
22
  import { clusterMember, type ReconcileBlockCallback, type CommitCertificateSink, type DeriveExpectedClusterCallback } from './cluster/cluster-repo.js';
23
23
  import { createReconcileBlock } from './cluster/reconcile-block.js';
24
+ import { resolveClusterPolicy, type ClusterPolicyOptions } from './cluster/cluster-policy.js';
24
25
  import { createCommitCertStore, makeClusterCommitCertExtractor, type CommitCertStore } from './cluster/commit-cert.js';
25
26
  import { coordinatorRepo } from './repo/coordinator-repo.js';
26
27
  import { Libp2pKeyPeerNetwork, type NetworkMode, type NetworkStatePersistence } from './libp2p-key-network.js';
@@ -70,7 +71,6 @@ import {
70
71
  reactivityNodePolicy,
71
72
  createTierAddressing,
72
73
  createRingHash,
73
- DEFAULT_SUPER_MAJORITY_THRESHOLD,
74
74
  Tier,
75
75
  b64urlToBytes,
76
76
  bytesToB64url,
@@ -135,7 +135,12 @@ const wiringLog = createLogger('node-wiring');
135
135
  /** Factory function or instance for creating raw storage */
136
136
  export type RawStorageProvider = IRawStorage | (() => IRawStorage);
137
137
 
138
- export type NodeOptions = {
138
+ /**
139
+ * `ClusterPolicyOptions` is intersected in, not restated: `resolveClusterPolicy` consumes those
140
+ * fields structurally, so a second copy of the shape here would let a newly added knob compile and
141
+ * be silently ignored. See `cluster/cluster-policy.ts` for what each one resolves to.
142
+ */
143
+ export type NodeOptions = ClusterPolicyOptions & {
139
144
  /**
140
145
  * Network port. Only used by the default `listenAddrs` fallback.
141
146
  * For non-TCP transports (e.g. WebSockets), set `listenAddrs` explicitly.
@@ -174,29 +179,20 @@ export type NodeOptions = {
174
179
  relayServerInit?: CircuitRelayServerInit;
175
180
  /** Storage provider - either an IRawStorage instance or a factory function. Defaults to MemoryRawStorage if not provided. */
176
181
  storage?: RawStorageProvider;
177
- /**
178
- * Desired cluster size per key (default 10). Beyond sizing the cohort, this is the
179
- * node's declaration of how many peers *should* exist to corroborate a claim: the
180
- * read-repair corroboration floor is measured against it, so a genuine two-node
181
- * deployment must set `clusterSize: 2` for its members to be able to repair each
182
- * other (see `CoordinatorRepo.corroboratorCapacity`).
183
- */
184
- clusterSize?: number;
185
- clusterPolicy?: {
186
- allowDownsize?: boolean;
187
- sizeTolerance?: number; // acceptable relative difference (e.g. 0.5 = +/-50%)
188
- superMajorityThreshold?: number; // fraction of peers needed for super-majority (default: DEFAULT_SUPER_MAJORITY_THRESHOLD = 0.75)
189
- /**
190
- * Opt in to transacting below the safe cluster-size floor when FRET has no confident
191
- * network-size estimate — the membership-admission and coordinator small-cluster gates
192
- * both fail closed without it. Default false. Turn on only for single-node / local dev
193
- * meshes that knowingly run undersized.
194
- */
195
- allowUnvalidatedSmallCluster?: boolean;
196
- };
197
-
198
182
  /** Override libp2p listen multiaddrs. */
199
183
  listenAddrs?: string[];
184
+ /**
185
+ * Multiaddrs to advertise INSTEAD OF the listen addrs. For a node behind a NAT / reverse proxy /
186
+ * DNS front that binds one address but is reachable at another. When non-empty these REPLACE the
187
+ * advertised set entirely — observed/relayed addresses and {@link NodeOptions.appendAnnounceAddrs}
188
+ * are all dropped from it. An empty array means "unset" (libp2p's own semantics).
189
+ */
190
+ announceAddrs?: string[];
191
+ /**
192
+ * Multiaddrs to advertise IN ADDITION TO the listen addrs. Ignored while
193
+ * {@link NodeOptions.announceAddrs} is non-empty.
194
+ */
195
+ appendAnnounceAddrs?: string[];
200
196
  /** Override libp2p transports. */
201
197
  transports?: Libp2pTransports;
202
198
 
@@ -305,9 +301,10 @@ export type NodeOptions = {
305
301
  * dialing peer's `PeerId.toString()`. See {@link AuthorizeInboundStream} and
306
302
  * `docs/internals.md` § Inbound Stream Authorization.
307
303
  *
308
- * NOTE: this covers the four database protocols only. The reactivity, matchmaking,
304
+ * NOTE: this covers the four database protocols only. The dispute, reactivity, matchmaking,
309
305
  * cohort-topic and libp2p built-in (identify/ping/…) protocols this node also registers are
310
- * NOT gated by it.
306
+ * NOT gated by it. To refuse a peer at the connection level instead — every protocol at once,
307
+ * including identify — use {@link NodeOptions.connectionGater}.
311
308
  */
312
309
  authorizeInboundStream?: AuthorizeInboundStream;
313
310
 
@@ -464,8 +461,13 @@ export async function createLibp2pNodeBase(
464
461
  const libp2pOptions: Libp2pInit = {
465
462
  start: false,
466
463
  privateKey: nodePrivateKey,
464
+ // NOTE: libp2p's `AddressManagerInit` also carries `noAnnounce` and `announceFilter`; neither is
465
+ // exposed on `NodeOptions`. Add them here the same way if a deployment ever needs to suppress a
466
+ // specific advertised address rather than replace the whole set.
467
467
  addresses: {
468
- listen: listenAddrs
468
+ listen: listenAddrs,
469
+ ...(options.announceAddrs ? { announce: options.announceAddrs } : {}),
470
+ ...(options.appendAnnounceAddrs ? { appendAnnounce: options.appendAnnounceAddrs } : {})
469
471
  },
470
472
  connectionManager: {
471
473
  // `autoDial`, `minConnections`, and `dialQueue` were stale libp2p option keys silently
@@ -603,7 +605,9 @@ export async function createLibp2pNodeBase(
603
605
  });
604
606
  return serviceFactory({
605
607
  registrar: components.registrar,
606
- repo: storageRepo
608
+ repo: storageRepo,
609
+ // So this service's authorization denials reach the same error sink as the other three.
610
+ logger: components.logger
607
611
  });
608
612
  },
609
613
 
@@ -696,21 +700,11 @@ export async function createLibp2pNodeBase(
696
700
  const partitionDetector = new PartitionDetector();
697
701
  const fretSvc = (node as any).services?.fret as FretService | undefined;
698
702
 
699
- const consensusConfig = {
700
- superMajorityThreshold: options.clusterPolicy?.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
701
- simpleMajorityThreshold: 0.51,
702
- minAbsoluteClusterSize: 2,
703
- allowClusterDownsize: options.clusterPolicy?.allowDownsize ?? true,
704
- clusterSizeTolerance: options.clusterPolicy?.sizeTolerance ?? 0.5,
705
- // Fail closed by default (an undersized cluster with no confident network-size estimate is
706
- // rejected); embedders running knowingly-small meshes opt in through clusterPolicy.
707
- allowUnvalidatedSmallCluster: options.clusterPolicy?.allowUnvalidatedSmallCluster ?? false,
708
- partitionDetectionWindow: 60000,
709
- // Configured full cluster size — the member's own reference for "full size" in the membership
710
- // admission gate (a below-full-size declared set under low FRET confidence is refused as a possible
711
- // self-shrink). Matches the size threaded into the coordinator below.
712
- clusterSize: options.clusterSize ?? 10
713
- };
703
+ // Every cluster-policy default lives in `cluster/cluster-policy.ts` — including WHY the admission
704
+ // gate and the repair corroboration floor resolve the one operator field
705
+ // (`clusterPolicy.assumedClusterSize`) to different values when it is absent. Extracted so those
706
+ // defaults can be asserted without booting a node.
707
+ const consensusConfig = resolveClusterPolicy(options);
714
708
 
715
709
  // Fetch a block archive from one cohort peer over the sync protocol, bounded by a
716
710
  // per-peer timeout so an unreachable peer can't stall reconciliation. Mirrors the
@@ -741,12 +735,17 @@ export async function createLibp2pNodeBase(
741
735
  // (cohort drift, or a refused `missing-base-revision` commit). See `reconcile-block.ts` for
742
736
  // the corroboration rules — in particular why both quorums are capped by how many peers
743
737
  // could answer at all, which is what lets a genuinely two-node cohort heal.
738
+ // NOTE: this and the CoordinatorRepo below must cap against the SAME
739
+ // repairCorroborationClusterSize, or the two restoration paths disagree about how much trust a
740
+ // lone peer gets. Safe today because both read the one `resolveClusterPolicy` result above; if
741
+ // either ever resolves its own value, add a fail-fast coupling check like
742
+ // `assertSuperMajorityCoupling` rather than relying on proximity.
744
743
  const reconcileBlock: ReconcileBlockCallback = createReconcileBlock({
745
744
  selfPeerId: node.peerId.toString(),
746
745
  fetchArchive: fetchArchiveFromPeer,
747
746
  saveReplicatedBlock: (blockId, block, source) => storageRepo.saveReplicatedBlock(blockId, block, source),
748
747
  simpleMajorityThreshold: consensusConfig.simpleMajorityThreshold,
749
- clusterSize: consensusConfig.clusterSize,
748
+ repairCorroborationClusterSize: consensusConfig.repairCorroborationClusterSize,
750
749
  reputation
751
750
  });
752
751
 
@@ -803,12 +802,20 @@ export async function createLibp2pNodeBase(
803
802
  options.transactionStateStore
804
803
  );
805
804
 
806
- // Create callback for querying cluster peers for their latest block revision
805
+ // Create callback for querying cluster peers for their latest block revision. Three-way
806
+ // contract (see ClusterLatestCallback): an ActionRev is the peer's claim, a resolved
807
+ // `undefined` is the peer answering "I hold nothing", and a REJECTION is silence — the
808
+ // coordinator counts it as "did not answer" and refuses to report an authoritative absent
809
+ // over it. Transport errors must therefore propagate, not collapse into `undefined` (that
810
+ // collapse let a slow two-node cohort report a missing block as authoritatively absent —
811
+ // ticket cluster-read-consult-cannot-report-unreachable).
807
812
  const clusterLatestCallback: ClusterLatestCallback = async (peerId, blockId, context?) => {
808
813
  // Self-read short-circuit: dialling self via SyncClient is a round trip
809
814
  // with no remote on the other end, and on nodes without listen addresses
810
815
  // (solo WebSocket-only, bare-RN, etc.) the self-dial can hang the dial
811
- // queue. Read directly from the local storage repo instead.
816
+ // queue. Read directly from the local storage repo instead. The catch stays:
817
+ // a local storage error is not a cohort peer being unreachable, and the
818
+ // coordinator ignores a self rejection anyway.
812
819
  if (peerId.equals(node.peerId)) {
813
820
  try {
814
821
  const result = await storageRepo.get({ blockIds: [blockId], context });
@@ -818,21 +825,22 @@ export async function createLibp2pNodeBase(
818
825
  }
819
826
  }
820
827
  const syncClient = new SyncClient(peerId, keyNetwork, protocolPrefix);
821
- try {
822
- const response = await syncClient.requestBlock({ blockId, rev: undefined });
823
- if (response.success && response.archive) {
824
- const revisions = Object.keys(response.archive.revisions).map(Number);
825
- if (revisions.length > 0) {
826
- const maxRev = Math.max(...revisions);
827
- const revisionData = response.archive.revisions[maxRev];
828
- if (revisionData?.action) {
829
- return { actionId: revisionData.action.actionId, rev: maxRev };
830
- }
828
+ // No try/catch: a dial or protocol failure rejects through to the coordinator, whose
829
+ // per-peer deadline also bounds a hung request — slowness needs no race here.
830
+ const response = await syncClient.requestBlock({ blockId, rev: undefined });
831
+ if (response.success && response.archive) {
832
+ const revisions = Object.keys(response.archive.revisions).map(Number);
833
+ if (revisions.length > 0) {
834
+ const maxRev = Math.max(...revisions);
835
+ const revisionData = response.archive.revisions[maxRev];
836
+ if (revisionData?.action) {
837
+ return { actionId: revisionData.action.actionId, rev: maxRev };
831
838
  }
832
839
  }
833
- } catch {
834
- // Peer may be unreachable - return undefined to skip this peer
835
840
  }
841
+ // The peer DID answer, without data: `success:false` is the sync service's "Block not
842
+ // found in local storage", and an archive with no usable revisions holds nothing either
843
+ // way. Both are absent claims, not silence.
836
844
  return undefined;
837
845
  };
838
846
 
@@ -1,7 +1,6 @@
1
1
  import { peerIdFromString } from "@libp2p/peer-id";
2
- import type { ClusterRecord, IKeyNetwork, RepoMessage, BlockId, ClusterPeers, MessageOptions, ClusterConsensusConfig } from "@optimystic/db-core";
2
+ import type { ClusterRecord, IKeyNetwork, RepoMessage, BlockId, ClusterPeers, MessageOptions, ClusterConsensusConfig, ICluster } from "@optimystic/db-core";
3
3
  import { CURRENT_MEMBERSHIP_VERSION, computeClusterMessageHash, membershipDigest } from "@optimystic/db-core";
4
- import { ClusterClient } from "../cluster/client.js";
5
4
  import { Pending } from "@optimystic/db-core";
6
5
  import type { PeerId } from "@libp2p/interface";
7
6
  import { createLogger, verbose } from '../logger.js'
@@ -13,6 +12,26 @@ import type { ITransactionStateStore } from "../cluster/i-transaction-state-stor
13
12
 
14
13
  const log = createLogger('cluster')
15
14
 
15
+ /**
16
+ * Consensus refused a transaction: enough members voted reject that super-majority became
17
+ * impossible. A typed error (rather than a bare `Error`) so the repo layer above can distinguish
18
+ * "the cluster voted this down" from transport/availability failures WITHOUT string-matching the
19
+ * rejection reasons — those are free-form text that is part of each member's signed vote payload
20
+ * (see cluster-repo's `computeSigningPayload`), so their wording must never become control flow.
21
+ * `CoordinatorRepo.pend` uses this to decide whether a rejection is a retryable stale-revision
22
+ * loss (confirmed against local storage) or a genuine validation fault.
23
+ */
24
+ export class ValidatorRejectionError extends Error {
25
+ constructor(
26
+ message: string,
27
+ /** Per-peer reject reasons, verbatim from the vote signatures (free-form, wire-visible). */
28
+ readonly rejectReasons: Record<string, string>
29
+ ) {
30
+ super(message);
31
+ this.name = 'ValidatorRejectionError';
32
+ }
33
+ }
34
+
16
35
  /** Cancel handle for an injected timer; cancels a not-yet-fired timer (safe no-op after fire/cancel). */
17
36
  export type TimerCancel = () => void;
18
37
 
@@ -76,7 +95,8 @@ export class ClusterCoordinator {
76
95
 
77
96
  constructor(
78
97
  private readonly keyNetwork: IKeyNetwork,
79
- private readonly createClusterClient: (peerId: PeerId) => ClusterClient,
98
+ /** Factory for a per-peer cluster RPC handle; only `update` is ever called, hence `ICluster`. */
99
+ private readonly createClusterClient: (peerId: PeerId) => ICluster,
80
100
  private readonly cfg: ClusterConsensusConfig & { clusterSize: number },
81
101
  private readonly localCluster?: {
82
102
  update: (record: ClusterRecord) => Promise<ClusterRecord>;
@@ -322,9 +342,11 @@ export class ClusterCoordinator {
322
342
  // If more than (peerCount - superMajority) nodes reject, we can never reach super-majority
323
343
  const maxAllowedRejections = peerCount - superMajority;
324
344
  if (rejectionCount > maxAllowedRejections) {
325
- const rejectReasons = Object.entries(promises)
345
+ const rejectReasonsByPeer = Object.fromEntries(Object.entries(promises)
326
346
  .filter(([_, sig]) => sig.type === 'reject')
327
- .map(([peerId, sig]) => `${peerId}: ${sig.rejectReason ?? 'unknown'}`)
347
+ .map(([peerId, sig]) => [peerId, sig.rejectReason ?? 'unknown']));
348
+ const rejectReasons = Object.entries(rejectReasonsByPeer)
349
+ .map(([peerId, reason]) => `${peerId}: ${reason}`)
328
350
  .join('; ');
329
351
  log('cluster-tx:rejected-by-validators', {
330
352
  messageHash: record.messageHash,
@@ -334,7 +356,9 @@ export class ClusterCoordinator {
334
356
  reasons: rejectReasons
335
357
  });
336
358
  this.updateTransactionRecord(promised.record, 'rejected-by-validators');
337
- throw new Error(`Transaction rejected by validators (${rejectionCount}/${peerCount} rejected): ${rejectReasons}`);
359
+ throw new ValidatorRejectionError(
360
+ `Transaction rejected by validators (${rejectionCount}/${peerCount} rejected): ${rejectReasons}`,
361
+ rejectReasonsByPeer);
338
362
  }
339
363
 
340
364
  if (peerCount > 1 && approvalCount < superMajority) {