@optimystic/db-core 1.0.0-beta.1 → 1.0.0-beta.3

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 (37) hide show
  1. package/dist/src/cluster/structs.d.ts +45 -15
  2. package/dist/src/cluster/structs.d.ts.map +1 -1
  3. package/dist/src/cluster/structs.js.map +1 -1
  4. package/dist/src/collection/collection.d.ts +29 -2
  5. package/dist/src/collection/collection.d.ts.map +1 -1
  6. package/dist/src/collection/collection.js +37 -2
  7. package/dist/src/collection/collection.js.map +1 -1
  8. package/dist/src/collections/tree/struct.d.ts +81 -2
  9. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  10. package/dist/src/collections/tree/struct.js +59 -0
  11. package/dist/src/collections/tree/struct.js.map +1 -1
  12. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  13. package/dist/src/collections/tree/tree.js +39 -2
  14. package/dist/src/collections/tree/tree.js.map +1 -1
  15. package/dist/src/index.d.ts +1 -0
  16. package/dist/src/index.d.ts.map +1 -1
  17. package/dist/src/index.js +1 -0
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/logger-registry.d.ts +57 -0
  20. package/dist/src/logger-registry.d.ts.map +1 -0
  21. package/dist/src/logger-registry.js +168 -0
  22. package/dist/src/logger-registry.js.map +1 -0
  23. package/dist/src/logger.d.ts.map +1 -1
  24. package/dist/src/logger.js +3 -0
  25. package/dist/src/logger.js.map +1 -1
  26. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  27. package/dist/src/transactor/network-transactor.js +8 -3
  28. package/dist/src/transactor/network-transactor.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/cluster/structs.ts +277 -247
  31. package/src/collection/collection.ts +37 -2
  32. package/src/collections/tree/struct.ts +116 -26
  33. package/src/collections/tree/tree.ts +36 -2
  34. package/src/index.ts +1 -0
  35. package/src/logger-registry.ts +224 -0
  36. package/src/logger.ts +4 -0
  37. package/src/transactor/network-transactor.ts +8 -3
@@ -1,247 +1,277 @@
1
- import type { RepoMessage } from "../network/repo-protocol.js";
2
- import type { PendResult } from "../network/struct.js";
3
-
4
- /**
5
- * One member's vote on a cluster transaction, in either the promise or the commit map.
6
- *
7
- * A discriminated union rather than one shape with optional fields, so each vote kind carries
8
- * exactly its own payload: a `conflict` without its `conflictWith` (or a stray `rejectReason` on an
9
- * `approve`) does not typecheck. Every variant's extra field is folded into the signed payload
10
- * ({@link clusterVoteSigningPayload}), so none of them can be altered in transit.
11
- */
12
- export type Signature =
13
- | { type: 'approve'; signature: string }
14
- | { type: 'reject'; signature: string; rejectReason?: string }
15
- /**
16
- * This member refuses the transaction *for now*: it holds a conflicting transaction that won
17
- * the deterministic race (`resolveRace`). Retryable — NOT a validity judgement, and never
18
- * counted toward the permanent-rejection threshold. `conflictWith` is the winning
19
- * transaction's messageHash: structured, signed, and readable without parsing prose.
20
- */
21
- | { type: 'conflict'; signature: string; conflictWith: string };
22
-
23
- /**
24
- * The exact bytes a vote signature covers: `<hash>:<type>[:<extra>]`, where `extra` is the variant's
25
- * own payload — a reject's `rejectReason`, a conflict's `conflictWith`, nothing for an approve.
26
- * Folding the extra in is what makes it integrity-protected in transit rather than free-floating
27
- * prose.
28
- *
29
- * Producers and verifiers must both build the preimage here. It lives beside {@link Signature}
30
- * rather than in either consumer because a second copy that forgets a variant does not fail loudly:
31
- * it reports an honest vote as an invalid signature. (The dispute path once carried such a copy.)
32
- *
33
- * "Cluster" in the name distinguishes these consensus votes from the dispute subsystem's
34
- * arbitration votes, which have their own unrelated preimage (`dispute/invalidation.ts`).
35
- */
36
- export function clusterVoteSigningPayload(hash: string, type: Signature['type'], extra?: string): Uint8Array {
37
- return new TextEncoder().encode(hash + ':' + type + (extra ? ':' + extra : ''));
38
- }
39
-
40
- /** Verifier-side {@link clusterVoteSigningPayload}: reads each variant's signed extra off the vote itself. */
41
- export function clusterVoteVerificationPayload(hash: string, signature: Signature): Uint8Array {
42
- switch (signature.type) {
43
- case 'reject': return clusterVoteSigningPayload(hash, 'reject', signature.rejectReason);
44
- case 'conflict': return clusterVoteSigningPayload(hash, 'conflict', signature.conflictWith);
45
- default: return clusterVoteSigningPayload(hash, signature.type);
46
- }
47
- }
48
-
49
- export type ClusterPeers = {
50
- [id: string]: {
51
- multiaddrs: string[];
52
- /** Base64url-encoded public key (serialization-safe) */
53
- publicKey: string;
54
- };
55
- };
56
-
57
- /**
58
- * One member's own report of what its storage did with this transaction at consensus-apply time; see
59
- * {@link ClusterRecord.applyOutcomes} for the trust rules. Only the pend arm exists today — a commit
60
- * refusal is not reported this way, because a commit that reached commit-consensus is authoritative
61
- * (the coordinator's own retained verdict plus the commit-promise guard cover that tier instead).
62
- */
63
- export type MemberApplyOutcome = {
64
- /** This member's storage refused the record's pend with a conflict-shaped result. */
65
- pend?: PendResult;
66
- };
67
-
68
- export type ClusterRecord = {
69
- messageHash: string; // Serves as a unique identifier for the clustered transaction record
70
- peers: ClusterPeers;
71
- /**
72
- * Membership-binding version. Absent or `1` = legacy *unbound* record: the peer set is NOT covered by
73
- * any hash (pre-binding history and its stored commit certs verify byte-identically to before). `2` =
74
- * the sorted peer-id set (as {@link ClusterRecord.membershipDigest}) is folded into `messageHash`,
75
- * `promiseHash`, and `commitHash`, so two different peer sets yield two different `messageHash`es.
76
- * New coordinators always emit `2`. See `packages/db-core/src/cluster/membership.ts`.
77
- */
78
- membershipVersion?: 1 | 2;
79
- /** Membership digest of {@link ClusterRecord.peers}; present iff `membershipVersion === 2`. base64url. */
80
- membershipDigest?: string;
81
- /**
82
- * The transaction's operations and the block the coordinator selected the cohort by
83
- * ({@link RepoMessage.coordinatingBlockIds}). There is deliberately NO top-level copy of the
84
- * coordinating block ids on the record: `messageHash` covers `message` only, so a duplicate at
85
- * this level would be outside every hash and any relaying peer could rewrite it — and the
86
- * membership admission gate derives its own cohort view from exactly that id
87
- * (`ClusterMember.deriveExpectedClusterView`). One source of truth, inside the hash.
88
- */
89
- message: RepoMessage;
90
- promises: { [peerId: string]: Signature };
91
- commits: { [peerId: string]: Signature };
92
- /** Sender's recommended cluster size: min(estimated network size, configured cluster size) */
93
- suggestedClusterSize?: number;
94
- minRequiredSize?: number;
95
- /** Sender's current network size estimate */
96
- networkSizeHint?: number;
97
- /** Confidence in the network size estimate (0-1) */
98
- networkSizeConfidence?: number;
99
- /**
100
- * What each member's OWN storage answered when it applied this record's operations at consensus,
101
- * keyed by peer id. Advisory and **unsigned** no hash covers it — and deliberately so: it is
102
- * written by a member *after* the votes are cast, on the response it hands back, so no signed
103
- * payload could carry it without a further round trip.
104
- *
105
- * Members set only their own entry, and only for a *conflict-shaped* pend refusal (one carrying
106
- * `pending` or `missing`, per `isConflictFailure`) — the optimistic-concurrency verdict that a
107
- * rival holds the blocks or already took the revision. Successes and bare-reason faults are
108
- * omitted: a bare fault stays tolerated local divergence, mirroring the coordinator's own
109
- * local-verdict arm.
110
- *
111
- * Why unsigned is acceptable: a hostile entry can only *downgrade* a reported pend success into a
112
- * retryable conflict, which the writer answers by rebasing and trying again. The same member
113
- * could already force strictly worse outcomes with a signed reject or conflict vote, so this adds
114
- * no attack surface beyond retry pressure — and failing toward retry is the correct direction for
115
- * optimistic concurrency. Never treat an entry here as evidence of anything but "retry".
116
- *
117
- * Old peers never set it and old coordinators ignore it, so it is wire-compatible in both
118
- * directions.
119
- */
120
- applyOutcomes?: { [peerId: string]: MemberApplyOutcome };
121
- /** Transaction proceeded despite minority rejections */
122
- disputed?: boolean;
123
- /** Evidence of the dispute: which peers rejected and why */
124
- disputeEvidence?: {
125
- rejectingPeers: string[];
126
- rejectReasons: { [peerId: string]: string };
127
- };
128
- }
129
-
130
- /**
131
- * Single source of truth for the default super-majority threshold the fraction of a cluster's peers
132
- * that must promise before a transaction may proceed. Every component that falls back to a default when
133
- * config is absent (cluster member, coordinator policy, node composition root) references THIS constant,
134
- * so a member cannot silently default to a different threshold than the coordinator that commits. Explicit
135
- * caller-supplied thresholds are unaffected; this only unifies the *absent-config* default.
136
- * 0.75 = 3/4: chosen because the coordinator (which actually commits) already used it and the type documents it.
137
- */
138
- export const DEFAULT_SUPER_MAJORITY_THRESHOLD = 0.75;
139
-
140
- export interface ClusterConsensusConfig {
141
- /** Super-majority threshold for promises (default {@link DEFAULT_SUPER_MAJORITY_THRESHOLD} = 0.75 = 3/4) */
142
- superMajorityThreshold: number;
143
- /** Simple majority threshold for commits (default 0.51 = >50%) */
144
- simpleMajorityThreshold: number;
145
- /** Minimum absolute cluster size (default 3) */
146
- minAbsoluteClusterSize: number;
147
- /** Allow cluster to operate below configured size (default false) */
148
- allowClusterDownsize: boolean;
149
- /** Tolerance for cluster size variance as fraction (default 0.5 = 50%) */
150
- clusterSizeTolerance: number;
151
- /**
152
- * Configured full cluster size — the replication factor / target cohort breadth. This is what the
153
- * coordinator aims for when selecting a cohort. It is NOT a statement about how many peers exist,
154
- * and nothing may use it as a security yardstick; see {@link ClusterConsensusConfig.assumedClusterSize}.
155
- * The coordinator supplies this as a required `clusterSize` via `ClusterConsensusConfig & { clusterSize: number }`.
156
- */
157
- clusterSize?: number;
158
- /**
159
- * The smallest cohort the operator asserts this deployment can genuinely field — typically
160
- * `min(clusterSize, number of nodes actually run)`. Read ONLY when a node cannot independently
161
- * measure a cohort (no derivation capability wired, or no confident network-size estimate), where
162
- * it stands in for the measured estimate in the membership admission floor.
163
- *
164
- * Undefined means "unknown": the admission gate then cannot tell a downsize from a legitimately
165
- * small cluster and preserves legacy approve behavior. Composition roots should supply a concrete
166
- * value; `libp2p-node-base` defaults it to `minAbsoluteClusterSize`.
167
- */
168
- assumedClusterSize?: number;
169
- /**
170
- * Fraction of the member's OWN confident cluster-size estimate a declared peer set must meet to be
171
- * admitted for voting (default 0.75). Below `⌈membershipAdmissionFraction · K_est⌉` a declared set is
172
- * treated as an unjustified self-shrink and the member declines to approve. Distinct from
173
- * {@link superMajorityThreshold} (the vote-counting threshold) this gates *which set* may be voted on.
174
- */
175
- membershipAdmissionFraction?: number;
176
- /** Window for detecting partition in milliseconds (default 60000 = 1 min) */
177
- partitionDetectionWindow: number;
178
- /** Enable dispute escalation protocol (default false) */
179
- disputeEnabled?: boolean;
180
- /** Timeout for dispute arbitration in milliseconds (default 60000) */
181
- disputeArbitrationTimeoutMs?: number;
182
- /**
183
- * Hard horizon on an invalidation cascade: maximum number of recursive re-evaluation rounds
184
- * (dependency-graph depth) before the cascade stops and escalates the affected collection(s)
185
- * for operator full re-sync (default 32). Bounds unbounded automatic reversal.
186
- */
187
- maxCascadeDepth?: number;
188
- /**
189
- * Hard horizon on an invalidation cascade: maximum number of transactions (including the root)
190
- * the cascade may invalidate before it stops and escalates for operator full re-sync
191
- * (default 1000). On overflow the already-applied invalidations stand; the remainder is flagged,
192
- * never silently dropped.
193
- */
194
- maxCascadeTransactions?: number;
195
- /** Initial scheduled-retry interval for failed commit broadcasts, ms (default 250) */
196
- commitBroadcastRetryInitialMs?: number;
197
- /** Backoff factor for commit-broadcast scheduled retries (default 2) */
198
- commitBroadcastRetryBackoffFactor?: number;
199
- /** Max scheduled-retry interval, ms (default 8000) */
200
- commitBroadcastRetryMaxIntervalMs?: number;
201
- /** Max scheduled retry attempts before giving up (default 5) */
202
- commitBroadcastRetryMaxAttempts?: number;
203
- /** Immediate in-line retries per failed peer inside the broadcast (default 1) */
204
- commitBroadcastImmediateRetries?: number;
205
- /**
206
- * Immediate in-line retries per peer while collecting promises (default 1).
207
- * The promise phase rides the same libp2p stream the commit broadcast does;
208
- * a circuit-relay ("limited") connection can reset that stream once a
209
- * per-circuit cap is hit, surfacing to the coordinator as a StreamResetError.
210
- * Unlike the commit broadcast there is no follow-up scheduled retry, so a
211
- * single reset here would otherwise drop the peer and sink super-majority.
212
- */
213
- promiseImmediateRetries?: number;
214
- /** Read-repair behavior: 'off' (only fetch on missing — legacy), 'lazy' (fetch when local age > window), 'paranoid' (always verify against cluster on read). Default 'lazy'. */
215
- readRepairMode?: 'off' | 'lazy' | 'paranoid';
216
- /** For 'lazy' mode: read-repair triggers when (now - localEntry.lastSeenCommitMs) > this. Default 10000. */
217
- readRepairWindowMs?: number;
218
- /** Per-read probability of triggering read-repair in 'lazy' mode even within the window (0..1). Default 0 (no random check). */
219
- readRepairSampleRate?: number;
220
- /**
221
- * When FRET has no confident network-size estimate, allow an undersized cluster
222
- * (peerCount < minAbsoluteClusterSize) to proceed anyway. Default false: with no
223
- * confident estimate an undersized cluster is REJECTED. Turn on only for
224
- * single-node / local dev where you knowingly run below the safe floor.
225
- */
226
- allowUnvalidatedSmallCluster?: boolean;
227
- /**
228
- * What a member WITH a transaction validator does with a pend that carries no `validation`
229
- * payload. See {@link UnvalidatablePendPolicy}; default 'accept'.
230
- */
231
- unvalidatablePendPolicy?: UnvalidatablePendPolicy;
232
- }
233
-
234
- /**
235
- * What a receiver WITH a transaction checker does with a pend that carries no
236
- * {@link PendRequest.validation} payload the single-collection (`Collection.sync`) shape, which
237
- * has no transaction to re-execute.
238
- *
239
- * - `'accept'` (default) preserves the historical behaviour: the pend is approved unchecked.
240
- * - `'reject'` is the fail-closed posture for a deployment that has decided every write must be
241
- * re-checkable; it REFUSES `Collection.sync` writes, which is the point, not a bug.
242
- *
243
- * Irrelevant on a receiver with no checker, which never re-checks anything. Named once here and
244
- * referenced by every tier that carries the knob (`ClusterConsensusConfig`, db-p2p's
245
- * `ClusterPolicyOptions` and `StorageRepoOptions`) so the three cannot drift apart.
246
- */
247
- export type UnvalidatablePendPolicy = 'accept' | 'reject';
1
+ import type { RepoMessage } from "../network/repo-protocol.js";
2
+ import type { CommitResult, PendResult } from "../network/struct.js";
3
+
4
+ /**
5
+ * One member's vote on a cluster transaction, in either the promise or the commit map.
6
+ *
7
+ * A discriminated union rather than one shape with optional fields, so each vote kind carries
8
+ * exactly its own payload: a `conflict` without its `conflictWith` (or a stray `rejectReason` on an
9
+ * `approve`) does not typecheck. Every variant's extra field is folded into the signed payload
10
+ * ({@link clusterVoteSigningPayload}), so none of them can be altered in transit.
11
+ */
12
+ export type Signature =
13
+ | { type: 'approve'; signature: string }
14
+ | { type: 'reject'; signature: string; rejectReason?: string }
15
+ /**
16
+ * This member refuses the transaction *for now*: it holds a conflicting transaction that won
17
+ * the deterministic race (`resolveRace`). Retryable — NOT a validity judgement, and never
18
+ * counted toward the permanent-rejection threshold. `conflictWith` is the winning
19
+ * transaction's messageHash: structured, signed, and readable without parsing prose.
20
+ */
21
+ | { type: 'conflict'; signature: string; conflictWith: string };
22
+
23
+ /**
24
+ * The exact bytes a vote signature covers: `<hash>:<type>[:<extra>]`, where `extra` is the variant's
25
+ * own payload — a reject's `rejectReason`, a conflict's `conflictWith`, nothing for an approve.
26
+ * Folding the extra in is what makes it integrity-protected in transit rather than free-floating
27
+ * prose.
28
+ *
29
+ * Producers and verifiers must both build the preimage here. It lives beside {@link Signature}
30
+ * rather than in either consumer because a second copy that forgets a variant does not fail loudly:
31
+ * it reports an honest vote as an invalid signature. (The dispute path once carried such a copy.)
32
+ *
33
+ * "Cluster" in the name distinguishes these consensus votes from the dispute subsystem's
34
+ * arbitration votes, which have their own unrelated preimage (`dispute/invalidation.ts`).
35
+ */
36
+ export function clusterVoteSigningPayload(hash: string, type: Signature['type'], extra?: string): Uint8Array {
37
+ return new TextEncoder().encode(hash + ':' + type + (extra ? ':' + extra : ''));
38
+ }
39
+
40
+ /** Verifier-side {@link clusterVoteSigningPayload}: reads each variant's signed extra off the vote itself. */
41
+ export function clusterVoteVerificationPayload(hash: string, signature: Signature): Uint8Array {
42
+ switch (signature.type) {
43
+ case 'reject': return clusterVoteSigningPayload(hash, 'reject', signature.rejectReason);
44
+ case 'conflict': return clusterVoteSigningPayload(hash, 'conflict', signature.conflictWith);
45
+ default: return clusterVoteSigningPayload(hash, signature.type);
46
+ }
47
+ }
48
+
49
+ export type ClusterPeers = {
50
+ [id: string]: {
51
+ multiaddrs: string[];
52
+ /** Base64url-encoded public key (serialization-safe) */
53
+ publicKey: string;
54
+ };
55
+ };
56
+
57
+ /**
58
+ * One member's own report of what its storage did with this transaction at consensus-apply time; see
59
+ * {@link ClusterRecord.applyOutcomes} for the trust rules. The two arms report differently, and
60
+ * deliberately so:
61
+ *
62
+ * - `pend` is set ONLY for a conflict-shaped refusal. The coordinator's rule for it is "an entry
63
+ * means retry", so a success must not exist there.
64
+ * - `commit` is ALWAYS set once a commit applied successes included — because the coordinator
65
+ * counts durable holders (`CoordinatorRepo.commit`'s durability gate), and a count needs the
66
+ * positives. Consensus votes say the cohort agreed to apply the commit; this arm says whether
67
+ * this member's storage actually holds the committed revision under the record's action
68
+ * afterwards, measured AFTER the member's own reconcile (a member that pulled the revision from a
69
+ * cohort peer reports success). A commit that reached consensus is authoritative for ordering,
70
+ * but it was never evidence of storage — which is what this arm supplies.
71
+ */
72
+ export type MemberApplyOutcome = {
73
+ /** This member's storage refused the record's pend with a conflict-shaped result. */
74
+ pend?: PendResult;
75
+ /**
76
+ * Whether this member's storage durably holds the record's committed revision, for every block
77
+ * the commit named, after applying and (if needed) reconciling. `success: true` is a durable
78
+ * holder; a failure carries storage's refusal (or a refusal built from the missing-pend throw)
79
+ * for the operator's benefit the coordinator reads only `success`.
80
+ */
81
+ commit?: CommitResult;
82
+ };
83
+
84
+ export type ClusterRecord = {
85
+ messageHash: string; // Serves as a unique identifier for the clustered transaction record
86
+ peers: ClusterPeers;
87
+ /**
88
+ * Membership-binding version. Absent or `1` = legacy *unbound* record: the peer set is NOT covered by
89
+ * any hash (pre-binding history and its stored commit certs verify byte-identically to before). `2` =
90
+ * the sorted peer-id set (as {@link ClusterRecord.membershipDigest}) is folded into `messageHash`,
91
+ * `promiseHash`, and `commitHash`, so two different peer sets yield two different `messageHash`es.
92
+ * New coordinators always emit `2`. See `packages/db-core/src/cluster/membership.ts`.
93
+ */
94
+ membershipVersion?: 1 | 2;
95
+ /** Membership digest of {@link ClusterRecord.peers}; present iff `membershipVersion === 2`. base64url. */
96
+ membershipDigest?: string;
97
+ /**
98
+ * The transaction's operations and the block the coordinator selected the cohort by
99
+ * ({@link RepoMessage.coordinatingBlockIds}). There is deliberately NO top-level copy of the
100
+ * coordinating block ids on the record: `messageHash` covers `message` only, so a duplicate at
101
+ * this level would be outside every hash and any relaying peer could rewrite it — and the
102
+ * membership admission gate derives its own cohort view from exactly that id
103
+ * (`ClusterMember.deriveExpectedClusterView`). One source of truth, inside the hash.
104
+ */
105
+ message: RepoMessage;
106
+ promises: { [peerId: string]: Signature };
107
+ commits: { [peerId: string]: Signature };
108
+ /** Sender's recommended cluster size: min(estimated network size, configured cluster size) */
109
+ suggestedClusterSize?: number;
110
+ minRequiredSize?: number;
111
+ /** Sender's current network size estimate */
112
+ networkSizeHint?: number;
113
+ /** Confidence in the network size estimate (0-1) */
114
+ networkSizeConfidence?: number;
115
+ /**
116
+ * What each member's OWN storage answered when it applied this record's operations at consensus,
117
+ * keyed by peer id. Advisory and **unsigned** no hash covers it — and deliberately so: it is
118
+ * written by a member *after* the votes are cast, on the response it hands back, so no signed
119
+ * payload could carry it without a further round trip.
120
+ *
121
+ * Members set only their own entry. The pend arm is set only for a *conflict-shaped* pend
122
+ * refusal (one carrying `pending` or `missing`, per `isConflictFailure`) — the
123
+ * optimistic-concurrency verdict that a rival holds the blocks or already took the revision.
124
+ * Pend successes and bare-reason faults are omitted: a bare fault stays tolerated local
125
+ * divergence, mirroring the coordinator's own local-verdict arm. The commit arm is set for every
126
+ * applied commit, success or not, because the coordinator counts the successes
127
+ * ({@link MemberApplyOutcome}).
128
+ *
129
+ * Why unsigned is acceptable: a hostile pend entry can only *downgrade* a reported pend success
130
+ * into a retryable conflict, which the writer answers by rebasing and trying again. A hostile
131
+ * commit entry is no worse: a false *success* counts one durable holder the member already
132
+ * counted for with its signed approve vote (the vote is what admits it to the majority the count
133
+ * is measured against), and a false *refusal* is again only retry pressure. The same member
134
+ * could already force strictly worse outcomes with a signed reject or conflict vote, so this adds
135
+ * no attack surface beyond retry pressure and failing toward retry is the correct direction for
136
+ * optimistic concurrency. Never treat an entry here as evidence of anything but "retry" (pend) or
137
+ * "one more holder in a count that a signed vote already bounded" (commit).
138
+ *
139
+ * Old peers never set it and old coordinators ignore it, so it is wire-compatible in both
140
+ * directions.
141
+ *
142
+ * NOTE: unlike a signed reject/conflict vote, an entry here is unattributable — nothing verifies
143
+ * it came from the peer it is keyed under beyond the coordinator taking each peer's entry from
144
+ * that peer's own response, and no reputation penalty can be pinned on a false one. Fine while the
145
+ * worst it buys is retry pressure a signed vote could already produce. If a member is ever
146
+ * observed reporting refusals it did not make — writes on a block failing their retry budget with
147
+ * `coordinator-repo:pend-remote-refusal` naming one peer over and over the fix is to make the
148
+ * entry attributable (sign it on a follow-up round) rather than to start trusting it less.
149
+ */
150
+ applyOutcomes?: { [peerId: string]: MemberApplyOutcome };
151
+ /** Transaction proceeded despite minority rejections */
152
+ disputed?: boolean;
153
+ /** Evidence of the dispute: which peers rejected and why */
154
+ disputeEvidence?: {
155
+ rejectingPeers: string[];
156
+ rejectReasons: { [peerId: string]: string };
157
+ };
158
+ }
159
+
160
+ /**
161
+ * Single source of truth for the default super-majority threshold the fraction of a cluster's peers
162
+ * that must promise before a transaction may proceed. Every component that falls back to a default when
163
+ * config is absent (cluster member, coordinator policy, node composition root) references THIS constant,
164
+ * so a member cannot silently default to a different threshold than the coordinator that commits. Explicit
165
+ * caller-supplied thresholds are unaffected; this only unifies the *absent-config* default.
166
+ * 0.75 = 3/4: chosen because the coordinator (which actually commits) already used it and the type documents it.
167
+ */
168
+ export const DEFAULT_SUPER_MAJORITY_THRESHOLD = 0.75;
169
+
170
+ export interface ClusterConsensusConfig {
171
+ /** Super-majority threshold for promises (default {@link DEFAULT_SUPER_MAJORITY_THRESHOLD} = 0.75 = 3/4) */
172
+ superMajorityThreshold: number;
173
+ /** Simple majority threshold for commits (default 0.51 = >50%) */
174
+ simpleMajorityThreshold: number;
175
+ /** Minimum absolute cluster size (default 3) */
176
+ minAbsoluteClusterSize: number;
177
+ /** Allow cluster to operate below configured size (default false) */
178
+ allowClusterDownsize: boolean;
179
+ /** Tolerance for cluster size variance as fraction (default 0.5 = 50%) */
180
+ clusterSizeTolerance: number;
181
+ /**
182
+ * Configured full cluster size — the replication factor / target cohort breadth. This is what the
183
+ * coordinator aims for when selecting a cohort. It is NOT a statement about how many peers exist,
184
+ * and nothing may use it as a security yardstick; see {@link ClusterConsensusConfig.assumedClusterSize}.
185
+ * The coordinator supplies this as a required `clusterSize` via `ClusterConsensusConfig & { clusterSize: number }`.
186
+ */
187
+ clusterSize?: number;
188
+ /**
189
+ * The smallest cohort the operator asserts this deployment can genuinely field typically
190
+ * `min(clusterSize, number of nodes actually run)`. Read ONLY when a node cannot independently
191
+ * measure a cohort (no derivation capability wired, or no confident network-size estimate), where
192
+ * it stands in for the measured estimate in the membership admission floor.
193
+ *
194
+ * Undefined means "unknown": the admission gate then cannot tell a downsize from a legitimately
195
+ * small cluster and preserves legacy approve behavior. Composition roots should supply a concrete
196
+ * value; `libp2p-node-base` defaults it to `minAbsoluteClusterSize`.
197
+ */
198
+ assumedClusterSize?: number;
199
+ /**
200
+ * Fraction of the member's OWN confident cluster-size estimate a declared peer set must meet to be
201
+ * admitted for voting (default 0.75). Below `⌈membershipAdmissionFraction · K_est⌉` a declared set is
202
+ * treated as an unjustified self-shrink and the member declines to approve. Distinct from
203
+ * {@link superMajorityThreshold} (the vote-counting threshold) this gates *which set* may be voted on.
204
+ */
205
+ membershipAdmissionFraction?: number;
206
+ /** Window for detecting partition in milliseconds (default 60000 = 1 min) */
207
+ partitionDetectionWindow: number;
208
+ /** Enable dispute escalation protocol (default false) */
209
+ disputeEnabled?: boolean;
210
+ /** Timeout for dispute arbitration in milliseconds (default 60000) */
211
+ disputeArbitrationTimeoutMs?: number;
212
+ /**
213
+ * Hard horizon on an invalidation cascade: maximum number of recursive re-evaluation rounds
214
+ * (dependency-graph depth) before the cascade stops and escalates the affected collection(s)
215
+ * for operator full re-sync (default 32). Bounds unbounded automatic reversal.
216
+ */
217
+ maxCascadeDepth?: number;
218
+ /**
219
+ * Hard horizon on an invalidation cascade: maximum number of transactions (including the root)
220
+ * the cascade may invalidate before it stops and escalates for operator full re-sync
221
+ * (default 1000). On overflow the already-applied invalidations stand; the remainder is flagged,
222
+ * never silently dropped.
223
+ */
224
+ maxCascadeTransactions?: number;
225
+ /** Initial scheduled-retry interval for failed commit broadcasts, ms (default 250) */
226
+ commitBroadcastRetryInitialMs?: number;
227
+ /** Backoff factor for commit-broadcast scheduled retries (default 2) */
228
+ commitBroadcastRetryBackoffFactor?: number;
229
+ /** Max scheduled-retry interval, ms (default 8000) */
230
+ commitBroadcastRetryMaxIntervalMs?: number;
231
+ /** Max scheduled retry attempts before giving up (default 5) */
232
+ commitBroadcastRetryMaxAttempts?: number;
233
+ /** Immediate in-line retries per failed peer inside the broadcast (default 1) */
234
+ commitBroadcastImmediateRetries?: number;
235
+ /**
236
+ * Immediate in-line retries per peer while collecting promises (default 1).
237
+ * The promise phase rides the same libp2p stream the commit broadcast does;
238
+ * a circuit-relay ("limited") connection can reset that stream once a
239
+ * per-circuit cap is hit, surfacing to the coordinator as a StreamResetError.
240
+ * Unlike the commit broadcast there is no follow-up scheduled retry, so a
241
+ * single reset here would otherwise drop the peer and sink super-majority.
242
+ */
243
+ promiseImmediateRetries?: number;
244
+ /** Read-repair behavior: 'off' (only fetch on missing legacy), 'lazy' (fetch when local age > window), 'paranoid' (always verify against cluster on read, missing blocks included). Default 'lazy'. In 'off' and 'lazy', a missing block whose cohort is this node alone is re-consulted at most once per `readRepairWindowMs`. */
245
+ readRepairMode?: 'off' | 'lazy' | 'paranoid';
246
+ /** For 'lazy' mode: read-repair triggers when (now - localEntry.lastSeenCommitMs) > this. In 'lazy' and 'off' it is also how long a settled absence is remembered. Default 10000. */
247
+ readRepairWindowMs?: number;
248
+ /** Per-read probability of a cohort check in 'lazy' mode even within the window — of a held block or a settled absence (0..1). Default 0 (no random check). */
249
+ readRepairSampleRate?: number;
250
+ /**
251
+ * When FRET has no confident network-size estimate, allow an undersized cluster
252
+ * (peerCount < minAbsoluteClusterSize) to proceed anyway. Default false: with no
253
+ * confident estimate an undersized cluster is REJECTED. Turn on only for
254
+ * single-node / local dev where you knowingly run below the safe floor.
255
+ */
256
+ allowUnvalidatedSmallCluster?: boolean;
257
+ /**
258
+ * What a member WITH a transaction validator does with a pend that carries no `validation`
259
+ * payload. See {@link UnvalidatablePendPolicy}; default 'accept'.
260
+ */
261
+ unvalidatablePendPolicy?: UnvalidatablePendPolicy;
262
+ }
263
+
264
+ /**
265
+ * What a receiver WITH a transaction checker does with a pend that carries no
266
+ * {@link PendRequest.validation} payload — the single-collection (`Collection.sync`) shape, which
267
+ * has no transaction to re-execute.
268
+ *
269
+ * - `'accept'` (default) preserves the historical behaviour: the pend is approved unchecked.
270
+ * - `'reject'` is the fail-closed posture for a deployment that has decided every write must be
271
+ * re-checkable; it REFUSES `Collection.sync` writes, which is the point, not a bug.
272
+ *
273
+ * Irrelevant on a receiver with no checker, which never re-checks anything. Named once here and
274
+ * referenced by every tier that carries the knob (`ClusterConsensusConfig`, db-p2p's
275
+ * `ClusterPolicyOptions` and `StorageRepoOptions`) so the three cannot drift apart.
276
+ */
277
+ export type UnvalidatablePendPolicy = 'accept' | 'reject';
@@ -646,8 +646,36 @@ export class Collection<TAction> implements ICollection<TAction> {
646
646
 
647
647
  /** Restore the staged state captured by {@link snapshotPending}, discarding any
648
648
  * mutations staged since. Reads through the collection then observe exactly the
649
- * snapshot state again; storage is untouched because nothing was ever synced. */
649
+ * snapshot state again; storage is untouched because nothing was ever synced.
650
+ *
651
+ * A snapshot is only restorable VERBATIM onto the committed boundary it was captured
652
+ * on. If this collection has since ADOPTED a newer committed revision — a rival's
653
+ * commit folded in by a refresh while the snapshot's transaction was in flight, e.g.
654
+ * the conflict replay that refused a guarded insert (TreeKeyTakenError) — the
655
+ * snapshot's transforms describe block state at the OLD boundary, and reinstalling
656
+ * them would shadow committed blocks with stale structure. The observed case: an
657
+ * INVENTED collection's pre-commit header/root transforms restored over the rival's
658
+ * now-committed collection make every later read descend an empty tree, silently
659
+ * hiding the committed rows. When the snapshot's pending queue is empty (the
660
+ * transaction-rollback shape: the capture predates the transaction's first stage),
661
+ * the correct restore target IS the committed state — reset the tracker empty and
662
+ * let reads flow through to the adopted revision.
663
+ *
664
+ * NOTE: a snapshot that carries PENDING actions across a moved boundary (a
665
+ * mid-transaction savepoint captured before a mid-transaction refresh adopted a
666
+ * rival's commit) still restores verbatim below — rebasing it would require an async
667
+ * replay this synchronous method cannot run. That shape predates this guard and
668
+ * keeps its old behaviour; if it is ever observed producing stale reads, the rebase
669
+ * belongs in an async caller that can replay the pending queue (see replayActions). */
650
670
  restorePending(snapshot: CollectionSnapshot<TAction>): void {
671
+ const capturedRev = snapshot.context?.rev;
672
+ const currentRev = this.source.actionContext?.rev;
673
+ const boundaryMoved = currentRev !== undefined && (capturedRev === undefined || currentRev > capturedRev);
674
+ if (boundaryMoved && snapshot.pending.length === 0) {
675
+ this.tracker.reset();
676
+ this.pending = [];
677
+ return;
678
+ }
651
679
  this.tracker.reset(copyTransforms(snapshot.transforms));
652
680
  this.pending = [...snapshot.pending];
653
681
  }
@@ -763,7 +791,14 @@ export class Collection<TAction> implements ICollection<TAction> {
763
791
  * process can be at different revisions at the same instant. That gap is invisible
764
792
  * from outside the class without this accessor, which is the whole reason it
765
793
  * exists: `docs/debugging.md` (§ "Which revision did a read descend?") explains
766
- * how an operator reads the difference. */
794
+ * how an operator reads the difference.
795
+ *
796
+ * The one exception is `undefined` itself, which is a STATE rather than a revision:
797
+ * "this instance invented the collection and has never adopted a committed revision".
798
+ * For the same reason (only this instance moves it), it stays true until this instance
799
+ * updates, syncs or records a commit, so a caller holding a freshly opened instance may
800
+ * branch on it — the Quereus adapter does, to leave an invented, never-written index tree
801
+ * unflushed exactly as an unwritten table tree is left. Never branch on the NUMBER. */
767
802
  committedRevision(): number | undefined {
768
803
  return this.source.actionContext?.rev;
769
804
  }