@optimystic/db-p2p 0.22.0 → 0.24.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 (177) hide show
  1. package/dist/src/cluster/client.d.ts +10 -0
  2. package/dist/src/cluster/client.d.ts.map +1 -1
  3. package/dist/src/cluster/client.js +30 -1
  4. package/dist/src/cluster/client.js.map +1 -1
  5. package/dist/src/cluster/cluster-repo.d.ts +39 -14
  6. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-repo.js +226 -119
  8. package/dist/src/cluster/cluster-repo.js.map +1 -1
  9. package/dist/src/cluster/service.d.ts +32 -1
  10. package/dist/src/cluster/service.d.ts.map +1 -1
  11. package/dist/src/cluster/service.js +43 -2
  12. package/dist/src/cluster/service.js.map +1 -1
  13. package/dist/src/cohort-topic/stream-util.d.ts +22 -6
  14. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  15. package/dist/src/cohort-topic/stream-util.js +56 -10
  16. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  17. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  18. package/dist/src/dispute/dispute-service.js +9 -3
  19. package/dist/src/dispute/dispute-service.js.map +1 -1
  20. package/dist/src/index.d.ts +3 -0
  21. package/dist/src/index.d.ts.map +1 -1
  22. package/dist/src/index.js +3 -0
  23. package/dist/src/index.js.map +1 -1
  24. package/dist/src/libp2p-key-network.d.ts +88 -2
  25. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  26. package/dist/src/libp2p-key-network.js +134 -28
  27. package/dist/src/libp2p-key-network.js.map +1 -1
  28. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  29. package/dist/src/libp2p-node-base.js +25 -1
  30. package/dist/src/libp2p-node-base.js.map +1 -1
  31. package/dist/src/logger.d.ts +17 -1
  32. package/dist/src/logger.d.ts.map +1 -1
  33. package/dist/src/logger.js +19 -2
  34. package/dist/src/logger.js.map +1 -1
  35. package/dist/src/owned-block-seed.d.ts +6 -3
  36. package/dist/src/owned-block-seed.d.ts.map +1 -1
  37. package/dist/src/owned-block-seed.js +16 -3
  38. package/dist/src/owned-block-seed.js.map +1 -1
  39. package/dist/src/peer-address-book.d.ts +72 -0
  40. package/dist/src/peer-address-book.d.ts.map +1 -0
  41. package/dist/src/peer-address-book.js +123 -0
  42. package/dist/src/peer-address-book.js.map +1 -0
  43. package/dist/src/repo/client.d.ts.map +1 -1
  44. package/dist/src/repo/client.js +11 -2
  45. package/dist/src/repo/client.js.map +1 -1
  46. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  47. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  48. package/dist/src/repo/cluster-coordinator.js +95 -3
  49. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  50. package/dist/src/repo/coordinator-repo.d.ts +62 -9
  51. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  52. package/dist/src/repo/coordinator-repo.js +242 -73
  53. package/dist/src/repo/coordinator-repo.js.map +1 -1
  54. package/dist/src/rn.d.ts +3 -0
  55. package/dist/src/rn.d.ts.map +1 -1
  56. package/dist/src/rn.js +3 -0
  57. package/dist/src/rn.js.map +1 -1
  58. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  59. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  60. package/dist/src/storage/cached-raw-storage.js +152 -0
  61. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  62. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  63. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  64. package/dist/src/storage/cached-store-driver.js +775 -0
  65. package/dist/src/storage/cached-store-driver.js.map +1 -0
  66. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  67. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  68. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  69. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  70. package/dist/src/storage/shared-cache-pool.js +354 -0
  71. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  72. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  73. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  74. package/dist/src/testing/raw-storage-conformance.js +35 -2
  75. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  76. package/package.json +3 -3
  77. package/readme.md +668 -668
  78. package/src/cluster/block-transfer.ts +424 -424
  79. package/src/cluster/client.ts +119 -88
  80. package/src/cluster/cluster-error.ts +64 -64
  81. package/src/cluster/cluster-policy.ts +203 -203
  82. package/src/cluster/cluster-repo.ts +242 -122
  83. package/src/cluster/cluster-size-coupling.ts +45 -45
  84. package/src/cluster/commit-cert.ts +139 -139
  85. package/src/cluster/i-transaction-state-store.ts +43 -43
  86. package/src/cluster/memory-transaction-state-store.ts +56 -56
  87. package/src/cluster/peer-key-binding.ts +37 -37
  88. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  89. package/src/cluster/quorum-restore.ts +223 -223
  90. package/src/cluster/reconcile-block.ts +203 -203
  91. package/src/cluster/service.ts +293 -241
  92. package/src/cluster/supermajority-coupling.ts +37 -37
  93. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  94. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  95. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  96. package/src/cohort-topic/change-bridge.ts +109 -109
  97. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  98. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  99. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  100. package/src/cohort-topic/host.ts +2901 -2901
  101. package/src/cohort-topic/index.ts +13 -13
  102. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  103. package/src/cohort-topic/membership-source.ts +68 -68
  104. package/src/cohort-topic/peer-codec.ts +31 -31
  105. package/src/cohort-topic/peer-sig.ts +86 -86
  106. package/src/cohort-topic/protocols.ts +71 -71
  107. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  108. package/src/cohort-topic/size-estimator.ts +16 -16
  109. package/src/cohort-topic/stream-util.ts +135 -87
  110. package/src/cohort-topic/threshold-crypto.ts +239 -239
  111. package/src/cohort-topic/topic-router.ts +77 -77
  112. package/src/dispute/arbitrator-selection.ts +138 -138
  113. package/src/dispute/cascade.ts +524 -524
  114. package/src/dispute/dispute-service.ts +11 -5
  115. package/src/dispute/invalidation.ts +625 -625
  116. package/src/inbound-authorization.ts +190 -190
  117. package/src/index.ts +52 -49
  118. package/src/libp2p-key-network.ts +1120 -990
  119. package/src/libp2p-node-base.ts +1675 -1651
  120. package/src/libp2p-node-rn.ts +30 -30
  121. package/src/libp2p-node.ts +36 -36
  122. package/src/logger.ts +19 -2
  123. package/src/matchmaking/aggregate-counts.ts +104 -104
  124. package/src/matchmaking/index.ts +20 -20
  125. package/src/matchmaking/module.ts +363 -363
  126. package/src/matchmaking/protocols.ts +51 -51
  127. package/src/matchmaking/provider-manager.ts +95 -95
  128. package/src/matchmaking/query-handler.ts +88 -88
  129. package/src/matchmaking/query-transport.ts +492 -492
  130. package/src/matchmaking/seeker-manager.ts +64 -64
  131. package/src/matchmaking/seeker-walk-client.ts +293 -293
  132. package/src/matchmaking/traffic-validation.ts +195 -195
  133. package/src/optimystic-node.ts +36 -36
  134. package/src/owned-block-seed.ts +53 -40
  135. package/src/peer-address-book.ts +149 -0
  136. package/src/protocol-limits.ts +33 -33
  137. package/src/reactivity/forwarder-host.ts +438 -438
  138. package/src/reactivity/index.ts +19 -19
  139. package/src/reactivity/notify-transport.ts +144 -144
  140. package/src/reactivity/origination-manager.ts +192 -192
  141. package/src/reactivity/protocols.ts +61 -61
  142. package/src/reactivity/push-state-gossip.ts +291 -291
  143. package/src/reactivity/recover-transport.ts +408 -408
  144. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  145. package/src/reactivity/subscriber-registry.ts +96 -96
  146. package/src/reactivity/subscription-manager.ts +450 -450
  147. package/src/reactivity/topic-bytes.ts +37 -37
  148. package/src/repo/client.ts +12 -2
  149. package/src/repo/cluster-coordinator.ts +99 -3
  150. package/src/repo/coordinator-repo.ts +281 -74
  151. package/src/repo/types.ts +7 -7
  152. package/src/rn.ts +39 -36
  153. package/src/rpc-deadline.ts +45 -45
  154. package/src/storage/arachnode-partition.ts +74 -74
  155. package/src/storage/cached-raw-storage.ts +180 -0
  156. package/src/storage/cached-store-driver.ts +859 -0
  157. package/src/storage/i-kv-store.ts +8 -8
  158. package/src/storage/i-raw-storage.ts +12 -5
  159. package/src/storage/kv-raw-storage.ts +135 -135
  160. package/src/storage/memory-kv-store.ts +28 -28
  161. package/src/storage/memory-storage.ts +25 -25
  162. package/src/storage/memory-store-driver.ts +157 -157
  163. package/src/storage/raw-store-codec.ts +42 -42
  164. package/src/storage/raw-store-driver.ts +80 -80
  165. package/src/storage/ring-selector.ts +317 -317
  166. package/src/storage/ring-shift-coordinator.ts +271 -271
  167. package/src/storage/shared-cache-pool.ts +452 -0
  168. package/src/storage/storage-repo.ts +1014 -1014
  169. package/src/testing/cohort-topic-mesh-harness.ts +663 -663
  170. package/src/testing/index.ts +8 -8
  171. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  172. package/src/testing/raw-storage-conformance.ts +453 -417
  173. package/src/testing/reactivity-mesh-harness.ts +922 -922
  174. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  175. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  176. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  177. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -1,524 +1,524 @@
1
- import type {
2
- ActionId, BlockId, CollectionId, Log,
3
- DisputeResolutionProof, RevertedBlock, ReadDependency,
4
- } from '@optimystic/db-core';
5
- import type { IBlockStorage } from '../storage/i-block-storage.js';
6
- import { applyInvalidation, hashBlockContent, DELETED_BLOCK_RESTORE, type CertificateTarget } from './invalidation.js';
7
- import { createLogger } from '../logger.js';
8
-
9
- const log = createLogger('cascade');
10
-
11
- // ─── Configuration ───
12
-
13
- export type CascadeConfig = {
14
- /**
15
- * Maximum number of re-evaluation rounds. Each round rescans the in-scope collections for fresh
16
- * read-dependents of everything invalidated so far; a linear chain processed in revision order
17
- * collapses into a single round, so this caps pathological out-of-order dependency graphs.
18
- */
19
- readonly maxCascadeDepth: number;
20
- /** Maximum transactions the cascade may invalidate, counting the root. */
21
- readonly maxCascadeTransactions: number;
22
- };
23
-
24
- export const DEFAULT_CASCADE_CONFIG: CascadeConfig = {
25
- maxCascadeDepth: 32,
26
- maxCascadeTransactions: 1000,
27
- };
28
-
29
- // ─── Collection environment ───
30
-
31
- /**
32
- * Everything the cascade needs from one collection: its append-only log (where child invalidation
33
- * entries land and where read-dependents are discovered) and a resolver for the per-block storage
34
- * its actions wrote. The caller supplies one per collection in the cascade's universe — the root's
35
- * collections plus every collection reachable via a cross-collection read edge. (Discovering that
36
- * universe requires a block→collection read index; the engine consumes the universe rather than
37
- * computing it — see the handoff's cross-collection note.)
38
- */
39
- export type CollectionEnv = {
40
- readonly collectionId: CollectionId;
41
- readonly log: Log<unknown>;
42
- readonly createBlockStorage: (blockId: BlockId) => IBlockStorage;
43
- /**
44
- * Optional per-block commit-latch runner threaded into each cascade child's {@link applyInvalidation}
45
- * so the child's compensating write serializes against a concurrent commit on the same block — the
46
- * same mutual-exclusion the root apply gets (see `InvalidationContext.withBlockCommitLatch`). Omitted
47
- * → child writes run unlatched (today's behavior).
48
- */
49
- readonly withBlockCommitLatch?: <T>(blockId: BlockId, fn: () => Promise<T>) => Promise<T>;
50
- };
51
-
52
- // ─── Invalidated (blockId, revision) pairs ───
53
-
54
- /**
55
- * A block revision proven invalid: a committed action wrote `blockId` at `rev`, and that action has
56
- * been invalidated (the root, or a cascade child). Any committed transaction whose read set contains
57
- * `(blockId, rev)` observed this now-invalid revision and is a read-dependent.
58
- *
59
- * `restoredContentHash` is the content hash of the as-if-absent (reverted) value — the hash the
60
- * invalidation recorded. The default re-evaluator compares it against the content the dependent
61
- * actually observed: equal ⇒ the revert did not change what was read ⇒ retain.
62
- */
63
- export type InvalidatedPair = {
64
- readonly blockId: BlockId;
65
- readonly rev: number;
66
- readonly restoredContentHash: string;
67
- /** The collection whose invalidation produced this pair (so same-collection rev ordering is checkable). */
68
- readonly collectionId: CollectionId;
69
- /** Resolves storage for this block, from its owning collection — the dependent may live elsewhere. */
70
- readonly createBlockStorage: (blockId: BlockId) => IBlockStorage;
71
- };
72
-
73
- function pairKey(blockId: BlockId, rev: number): string {
74
- return `${blockId}\0${rev}`;
75
- }
76
-
77
- /**
78
- * Dedup identity for a reverted log entry: the (collectionId, actionId) pair, not the actionId
79
- * alone. A transaction spanning N collections has one entry per collection (same actionId,
80
- * different collection/blockIds/rev) — each must be reverted independently, tracked separately.
81
- * Uses a NUL separator, mirroring {@link pairKey}, so ids containing spaces cannot collide.
82
- */
83
- function entryKey(collectionId: CollectionId, actionId: ActionId): string {
84
- return `${collectionId}\0${actionId}`;
85
- }
86
-
87
- // ─── Re-evaluation ───
88
-
89
- /** A candidate read-dependent under re-evaluation against post-invalidation state. */
90
- export type CascadeCandidate = {
91
- readonly env: CollectionEnv;
92
- readonly collectionId: CollectionId;
93
- readonly actionId: ActionId;
94
- readonly rev: number;
95
- /** Blocks this action wrote (its entry's blockIds) — what gets reverted if it is invalidated. */
96
- readonly blockIds: ReadonlyArray<BlockId>;
97
- /** This action's persisted read set, or `undefined` for a legacy (pre-cascade) entry. */
98
- readonly reads: ReadonlyArray<ReadDependency> | undefined;
99
- /** The subset of `reads` intersecting an invalidated `(blockId, rev)`. Empty for a legacy candidate. */
100
- readonly matched: ReadonlyArray<InvalidatedPair>;
101
- };
102
-
103
- /**
104
- * Verdict for a candidate re-evaluated against the reverted state:
105
- * - `retain` — the read still holds; the transaction stands untouched (the re-evaluation prune).
106
- * - `invalidate` — the read no longer holds; revert this transaction and recurse into its dependents.
107
- * - `unevaluable` — cannot decide from available data (e.g. a legacy entry with no persisted reads,
108
- * and no engine to re-execute). Escalated rather than guessed.
109
- */
110
- export type CascadeVerdict = 'retain' | 'invalidate' | 'unevaluable';
111
-
112
- /** Decides whether a candidate still holds against the reverted state. */
113
- export type Reevaluate = (candidate: CascadeCandidate) => Promise<CascadeVerdict>;
114
-
115
- /**
116
- * The default, engine-free re-evaluator: deterministic from stored revisions alone (no engine replay),
117
- * matching the compensating-state philosophy of the single-collection core.
118
- *
119
- * For each of the candidate's intersecting reads `(blockId, rev)`, it compares the content the
120
- * candidate *observed* (the immutable historical revision `blockId@rev`) against the `restoredContentHash`
121
- * the invalidation recorded (the as-if-absent value):
122
- * - any read whose observed content differs from the restored content ⇒ the read no longer holds ⇒ **invalidate**;
123
- * - a writer that *created* the block (deleted-block sentinel) ⇒ the observed content no longer exists ⇒ **invalidate**;
124
- * - all intersecting reads unchanged ⇒ **retain** (e.g. a structural-block false dependent whose content the
125
- * revert did not actually alter, or a redundant write that reverted to the same bytes).
126
- * - a legacy entry (no persisted reads) ⇒ **unevaluable** (escalate, never guess independent).
127
- *
128
- * This is *sound but conservative* at block granularity: it never wrongly retains, but because reads are
129
- * block-granular it may invalidate a dependent that read an unchanged *field* of a changed block. That is
130
- * safe (over-invalidation just resubmits the transaction). Field/operation-granular pruning — re-executing
131
- * the transaction and checking its operations still reproduce — requires an engine and is provided by
132
- * injecting a custom {@link Reevaluate} instead (see the handoff).
133
- */
134
- export function contentEqualityReevaluator(): Reevaluate {
135
- return async (candidate: CascadeCandidate): Promise<CascadeVerdict> => {
136
- if (candidate.reads === undefined) {
137
- return 'unevaluable';
138
- }
139
- // Defensive: a candidate with no intersecting reads is not actually a dependent.
140
- if (candidate.matched.length === 0) {
141
- return 'retain';
142
- }
143
- for (const pair of candidate.matched) {
144
- if (pair.restoredContentHash === DELETED_BLOCK_RESTORE) {
145
- return 'invalidate';
146
- }
147
- const observed = await pair.createBlockStorage(pair.blockId).getBlock(pair.rev);
148
- if (!observed) {
149
- // Cannot confirm the observed revision still materializes → conservative invalidate.
150
- return 'invalidate';
151
- }
152
- if (await hashBlockContent(observed.block) !== pair.restoredContentHash) {
153
- return 'invalidate';
154
- }
155
- }
156
- return 'retain';
157
- };
158
- }
159
-
160
- // ─── Cascade input / output ───
161
-
162
- /** A `(blockId, rev)` the root invalidation proved invalid in a given collection, with its restored hash. */
163
- export type CascadeSeed = {
164
- readonly collectionId: CollectionId;
165
- readonly blockId: BlockId;
166
- readonly rev: number;
167
- readonly restoredContentHash: string;
168
- };
169
-
170
- export type CascadeInput = {
171
- /** actionId of the root invalidation — recorded as `cascadeRoot` on every child entry. */
172
- readonly rootActionId: ActionId;
173
- /** The root's invalidation certificate; reused to authorize each child invalidation. */
174
- readonly proof: DisputeResolutionProof;
175
- /** The `(blockId, rev)` pairs the root reversal produced — seeds the dependency frontier. */
176
- readonly seed: ReadonlyArray<CascadeSeed>;
177
- /**
178
- * The target the root proof's votes are bound to — `(rootActionId, root blockIds)`. Every child
179
- * reuses the root proof, whose votes were signed over the *root's* targetHash, so each child's
180
- * certificate verification must be against this target, not the child's own. Defaults to the root
181
- * action id plus the distinct block ids across {@link seed} (the root's reverted blocks). Pass it
182
- * explicitly when the seed is not a faithful 1:1 image of the root's commit blockIds.
183
- */
184
- readonly certificateTarget?: CertificateTarget;
185
- /** Every collection the cascade may walk: the root's, plus any reachable via cross-collection reads. */
186
- readonly envs: ReadonlyArray<CollectionEnv>;
187
- readonly config?: CascadeConfig;
188
- /** Re-evaluation strategy; defaults to {@link contentEqualityReevaluator}. */
189
- readonly reevaluate?: Reevaluate;
190
- /**
191
- * Operator-escalation health signal: invoked once when the cascade stops at a hard horizon (or hits
192
- * an unevaluable candidate) and the affected collection(s) need a full re-sync. Never throws past here.
193
- */
194
- readonly onEscalation?: (escalation: CascadeEscalation) => void;
195
- };
196
-
197
- export type CascadeChild = {
198
- readonly collectionId: CollectionId;
199
- readonly actionId: ActionId;
200
- readonly rev: number;
201
- readonly reverted: ReadonlyArray<RevertedBlock>;
202
- };
203
-
204
- export type CascadeStanding = {
205
- readonly collectionId: CollectionId;
206
- readonly actionId: ActionId;
207
- readonly rev: number;
208
- };
209
-
210
- export type CascadeEscalation = {
211
- readonly reason: 'max-depth' | 'max-transactions' | 'unevaluable';
212
- /** Collections to flag for operator-escalated full re-sync. */
213
- readonly collections: ReadonlyArray<CollectionId>;
214
- /** Read-dependents left un-cascaded at the horizon — surfaced, never silently dropped. */
215
- readonly remainder: ReadonlyArray<CascadeStanding>;
216
- /** Candidates that could not be re-evaluated (e.g. legacy entries with no engine to re-execute them). */
217
- readonly unevaluable: ReadonlyArray<CascadeStanding>;
218
- };
219
-
220
- export type CascadeResult = {
221
- readonly rootActionId: ActionId;
222
- /** Read-dependents invalidated by the cascade (children only — the root is the caller's to apply). */
223
- readonly invalidated: ReadonlyArray<CascadeChild>;
224
- /** Read-dependents re-evaluated and retained (appeared in the chain but did not actually depend). */
225
- readonly retained: ReadonlyArray<CascadeStanding>;
226
- /** Re-evaluation rounds run (dependency-graph depth proxy). */
227
- readonly rounds: number;
228
- /** Present iff the cascade stopped at a hard horizon or hit an unevaluable candidate. */
229
- readonly escalation?: CascadeEscalation;
230
- };
231
-
232
- // ─── Engine ───
233
-
234
- /**
235
- * Detects and re-evaluates the transitive read-dependents of an already-applied root invalidation,
236
- * invalidating only those that no longer hold against the reverted state and leaving the rest in place.
237
- *
238
- * Preconditions: the caller has already applied the *root* reversal (driven through cluster consensus)
239
- * and supplies its proven-invalid `(blockId, rev)` pairs as `seed`. This engine owns only the *cascade*:
240
- * each child invalidation is appended via {@link applyInvalidation} carrying `cascadeRoot`, exactly the
241
- * deterministic primitive every member runs — so a member replaying the same seed + logs converges on
242
- * the same children (no per-child consensus round is required while the re-evaluator is deterministic;
243
- * a non-deterministic/engine-based re-evaluator would need each child driven through consensus instead).
244
- *
245
- * Algorithm (fixpoint):
246
- * 1. Seed the invalidated `(blockId, rev)` frontier from the root.
247
- * 2. Each round, walk every in-scope collection log forward, collecting unprocessed actions whose read
248
- * set intersects the frontier (legacy reads-less entries are always candidates — unknown dependency).
249
- * 3. Process candidates in `(rev, collectionId, actionId)` order, deduped by actionId. Re-evaluate each
250
- * against the reverted state; **retain** leaves it (revisited next round in case a later ancestor
251
- * reverts), **invalidate** appends its child entry and feeds its reverted blocks back into the frontier.
252
- * 4. Repeat until a round makes no progress (fixpoint) or a horizon trips (escalate, applying what it did).
253
- *
254
- * Diamonds: a dependent of two invalidated ancestors is evaluated once (dedup), after both are reverted
255
- * (revision order within a collection; cross-collection retains are re-examined every round until fixpoint).
256
- * Cycle-freedom: a read observes a strictly earlier write, so within a collection a child's rev exceeds the
257
- * pair it depends on — a back-edge is corruption and throws.
258
- */
259
- export async function cascadeInvalidate(input: CascadeInput): Promise<CascadeResult> {
260
- const config = input.config ?? DEFAULT_CASCADE_CONFIG;
261
- const reevaluate = input.reevaluate ?? contentEqualityReevaluator();
262
- const envByCollection = new Map(input.envs.map(e => [e.collectionId, e] as const));
263
-
264
- // Invalidated frontier, seeded from the root's reverted blocks.
265
- const pairs = new Map<string, InvalidatedPair>();
266
- for (const s of input.seed) {
267
- const env = envByCollection.get(s.collectionId);
268
- if (!env) {
269
- throw new Error(`cascade: seed references collection ${s.collectionId} not present in envs`);
270
- }
271
- pairs.set(pairKey(s.blockId, s.rev), {
272
- blockId: s.blockId, rev: s.rev, restoredContentHash: s.restoredContentHash,
273
- collectionId: s.collectionId, createBlockStorage: env.createBlockStorage,
274
- });
275
- }
276
-
277
- const rootActionId = input.rootActionId;
278
- // The root proof's votes are bound to the ROOT's target; every child reuses that proof, so each
279
- // child's applyInvalidation must verify against the root's target rather than the child's own. The
280
- // child-specific justification is THIS deterministic read-dependency derivation, replayed identically
281
- // by every member — not the certificate (which only attests the root is invalid).
282
- const rootCertificateTarget: CertificateTarget = input.certificateTarget
283
- ?? { invalidatedActionId: rootActionId, blockIds: [...new Set(input.seed.map(s => s.blockId))] };
284
- // Dedup identity is per collection-entry (collectionId, actionId), not actionId alone: a
285
- // multi-collection transaction has one entry per collection and each must be reverted separately.
286
- const processedEntries = new Set<string>(); // entryKey() of collection-entries reverted this cascade
287
- // The horizon counts distinct transactions (the root counts once, a multi-collection dependent once),
288
- // so a transaction is never split across the budget — reverted in some collections, escalated in others.
289
- const invalidatedTxns = new Set<ActionId>([rootActionId]);
290
- const children: CascadeChild[] = [];
291
- const affectedCollections = new Set<CollectionId>(input.seed.map(s => s.collectionId));
292
- const unevaluable: CascadeStanding[] = [];
293
- let retained: CascadeStanding[] = [];
294
- let rounds = 0;
295
- let escalation: CascadeEscalation | undefined;
296
- // Set once a new transaction is refused at the transaction horizon. We do NOT break the cascade
297
- // on that refusal: already-counted transactions must still finish every remaining collection-entry
298
- // (all-or-nothing), so we skip only the over-budget newcomer and let the round complete. The
299
- // max-transactions escalation is built once at the end, after the protected transactions drain.
300
- let horizonReached = false;
301
-
302
- while (true) {
303
- // Depth horizon: stop before another rescan, surfacing the un-cascaded frontier.
304
- if (rounds >= config.maxCascadeDepth) {
305
- const remainder = await collectCandidates(input.envs, pairs, rootActionId, processedEntries);
306
- escalation = makeEscalation('max-depth', affectedCollections, remainder, unevaluable);
307
- break;
308
- }
309
- rounds++;
310
-
311
- const candidates = await collectCandidates(input.envs, pairs, rootActionId, processedEntries);
312
- if (candidates.length === 0) {
313
- break; // fixpoint: no remaining read-dependents
314
- }
315
-
316
- retained = [];
317
- let progressed = false;
318
-
319
- for (const cand of candidates) {
320
- if (processedEntries.has(entryKey(cand.collectionId, cand.actionId))) {
321
- continue; // diamond: an ancestor pass already reverted this collection-entry
322
- }
323
- // Recompute matches against the live frontier (picks up same-round ancestors, ordered by rev).
324
- const matched = matchReads(cand.reads, pairs);
325
- const isLegacy = cand.reads === undefined;
326
- if (matched.length === 0 && !isLegacy) {
327
- continue; // no longer a dependent this round — revisit next round
328
- }
329
-
330
- assertForwardOnly(cand, matched);
331
-
332
- const verdict = await reevaluate({ ...cand, matched });
333
- if (verdict === 'retain') {
334
- retained.push(standing(cand));
335
- continue;
336
- }
337
- if (verdict === 'unevaluable') {
338
- if (!unevaluable.some(u => u.collectionId === cand.collectionId && u.actionId === cand.actionId)) {
339
- unevaluable.push(standing(cand));
340
- }
341
- affectedCollections.add(cand.collectionId);
342
- continue;
343
- }
344
-
345
- // invalidate — but never START a new transaction past the horizon. An already-counted
346
- // transaction's remaining collection-entries pass freely (the `has` short-circuit), so a
347
- // multi-collection dependent is reverted all-or-nothing and is never split across the budget.
348
- // Crucially we only SKIP the over-budget newcomer here — we must not break the round, or a
349
- // counted transaction's later-sorted sibling entry would be abandoned (silent partial revert).
350
- if (!invalidatedTxns.has(cand.actionId) && invalidatedTxns.size + 1 > config.maxCascadeTransactions) {
351
- horizonReached = true;
352
- continue;
353
- }
354
-
355
- const env = cand.env;
356
- const result = await applyInvalidation(
357
- { log: env.log, createBlockStorage: env.createBlockStorage, withBlockCommitLatch: env.withBlockCommitLatch },
358
- {
359
- invalidatedActionId: cand.actionId,
360
- invalidatedRev: cand.rev,
361
- blockIds: cand.blockIds,
362
- proof: input.proof,
363
- cascadeRoot: input.rootActionId,
364
- // Verify the reused root proof against the root's target, not this child's own.
365
- certificateTarget: rootCertificateTarget,
366
- }
367
- );
368
-
369
- // `applied:false, already-applied` is the idempotent re-run path: the child entry already
370
- // exists (prior cascade run / restart). Treat it as invalidated and reuse its reverted blocks
371
- // so the frontier still grows and the cascade reconverges without a second entry.
372
- if (!result.applied && result.reason !== 'already-applied') {
373
- // invalid-certificate should be impossible: the child verifies the root proof against the
374
- // root's target (rootCertificateTarget), and the root proof is a valid challenger-wins cert.
375
- log('child-apply-rejected actionId=%s reason=%s', cand.actionId, result.reason);
376
- continue;
377
- }
378
-
379
- processedEntries.add(entryKey(cand.collectionId, cand.actionId));
380
- invalidatedTxns.add(cand.actionId);
381
- progressed = true;
382
- affectedCollections.add(cand.collectionId);
383
- children.push({ collectionId: cand.collectionId, actionId: cand.actionId, rev: cand.rev, reverted: result.reverted });
384
-
385
- for (const rb of result.reverted) {
386
- pairs.set(pairKey(rb.blockId, cand.rev), {
387
- blockId: rb.blockId, rev: cand.rev, restoredContentHash: rb.restoredContentHash,
388
- collectionId: cand.collectionId, createBlockStorage: env.createBlockStorage,
389
- });
390
- }
391
- }
392
-
393
- if (!progressed) {
394
- break; // only retains / unevaluable / over-budget newcomers remain — fixpoint
395
- }
396
- }
397
-
398
- // Transaction-horizon escalation, built after the protected transactions have fully drained so the
399
- // remainder reflects only what was genuinely left un-cascaded (never a half-reverted transaction).
400
- if (horizonReached && !escalation) {
401
- const remainder = await collectCandidates(input.envs, pairs, rootActionId, processedEntries);
402
- escalation = makeEscalation('max-transactions', affectedCollections, remainder, unevaluable);
403
- }
404
-
405
- if (!escalation && unevaluable.length > 0) {
406
- escalation = makeEscalation('unevaluable', affectedCollections, [], unevaluable);
407
- }
408
-
409
- if (escalation) {
410
- log('escalate reason=%s collections=%d remainder=%d unevaluable=%d',
411
- escalation.reason, escalation.collections.length, escalation.remainder.length, escalation.unevaluable.length);
412
- try {
413
- input.onEscalation?.(escalation);
414
- } catch (err) {
415
- log('escalation-sink-error error=%s', (err as Error).message);
416
- }
417
- }
418
-
419
- return {
420
- rootActionId: input.rootActionId,
421
- invalidated: children,
422
- retained,
423
- rounds,
424
- ...(escalation ? { escalation } : {}),
425
- };
426
- }
427
-
428
- // ─── Walk / match helpers ───
429
-
430
- /** Walk every in-scope collection log forward, collecting unprocessed actions that are read-dependents. */
431
- async function collectCandidates(
432
- envs: ReadonlyArray<CollectionEnv>,
433
- pairs: Map<string, InvalidatedPair>,
434
- rootActionId: ActionId,
435
- processedEntries: Set<string>
436
- ): Promise<CascadeCandidate[]> {
437
- const candidates: CascadeCandidate[] = [];
438
- for (const env of envs) {
439
- for await (const entry of env.log.select()) {
440
- const action = entry.action;
441
- // Exclude the root (invalidated by the caller across all its collections) and any
442
- // collection-entry already reverted this cascade — but a not-yet-reverted entry of the
443
- // same transaction in a different collection is still a live candidate.
444
- if (!action || action.actionId === rootActionId || processedEntries.has(entryKey(env.collectionId, action.actionId))) {
445
- continue;
446
- }
447
- const reads = action.reads;
448
- const isLegacy = reads === undefined;
449
- const matched = matchReads(reads, pairs);
450
- if (matched.length === 0 && !isLegacy) {
451
- continue;
452
- }
453
- candidates.push({
454
- env,
455
- collectionId: env.collectionId,
456
- actionId: action.actionId,
457
- rev: entry.rev,
458
- blockIds: action.blockIds,
459
- reads,
460
- matched,
461
- });
462
- }
463
- }
464
- // Deterministic processing order: revision, then collection, then actionId.
465
- candidates.sort((a, b) =>
466
- a.rev - b.rev
467
- || (a.collectionId < b.collectionId ? -1 : a.collectionId > b.collectionId ? 1 : 0)
468
- || (a.actionId < b.actionId ? -1 : a.actionId > b.actionId ? 1 : 0)
469
- );
470
- return candidates;
471
- }
472
-
473
- /** The subset of `reads` that intersect the invalidated frontier; `[]` for a legacy (reads-less) entry. */
474
- function matchReads(reads: ReadonlyArray<ReadDependency> | undefined, pairs: Map<string, InvalidatedPair>): InvalidatedPair[] {
475
- if (!reads) {
476
- return [];
477
- }
478
- const matched: InvalidatedPair[] = [];
479
- for (const r of reads) {
480
- const pair = pairs.get(pairKey(r.blockId, r.revision));
481
- if (pair) {
482
- matched.push(pair);
483
- }
484
- }
485
- return matched;
486
- }
487
-
488
- /**
489
- * Cycle-freedom guard. A read observes a strictly earlier write, so within a single collection a
490
- * dependent's revision must exceed the pair it depends on. A same-collection back-edge means the log
491
- * is corrupt — throw rather than risk a non-terminating walk. Cross-collection pairs live in
492
- * independent revision sequences and are not comparable, so they are skipped.
493
- */
494
- function assertForwardOnly(cand: CascadeCandidate, matched: ReadonlyArray<InvalidatedPair>): void {
495
- for (const pair of matched) {
496
- if (pair.collectionId === cand.collectionId && cand.rev <= pair.rev) {
497
- throw new Error(
498
- `cascade: back-edge detected — action ${cand.actionId} at rev ${cand.rev} reads ${pair.blockId}@${pair.rev} in the same collection (dependency graph must be a forward DAG)`
499
- );
500
- }
501
- }
502
- }
503
-
504
- function standing(cand: CascadeCandidate): CascadeStanding {
505
- return { collectionId: cand.collectionId, actionId: cand.actionId, rev: cand.rev };
506
- }
507
-
508
- function makeEscalation(
509
- reason: CascadeEscalation['reason'],
510
- collections: Set<CollectionId>,
511
- remainder: ReadonlyArray<CascadeCandidate>,
512
- unevaluable: ReadonlyArray<CascadeStanding>
513
- ): CascadeEscalation {
514
- const affected = new Set(collections);
515
- for (const r of remainder) {
516
- affected.add(r.collectionId);
517
- }
518
- return {
519
- reason,
520
- collections: [...affected],
521
- remainder: remainder.map(standing),
522
- unevaluable: [...unevaluable],
523
- };
524
- }
1
+ import type {
2
+ ActionId, BlockId, CollectionId, Log,
3
+ DisputeResolutionProof, RevertedBlock, ReadDependency,
4
+ } from '@optimystic/db-core';
5
+ import type { IBlockStorage } from '../storage/i-block-storage.js';
6
+ import { applyInvalidation, hashBlockContent, DELETED_BLOCK_RESTORE, type CertificateTarget } from './invalidation.js';
7
+ import { createLogger } from '../logger.js';
8
+
9
+ const log = createLogger('cascade');
10
+
11
+ // ─── Configuration ───
12
+
13
+ export type CascadeConfig = {
14
+ /**
15
+ * Maximum number of re-evaluation rounds. Each round rescans the in-scope collections for fresh
16
+ * read-dependents of everything invalidated so far; a linear chain processed in revision order
17
+ * collapses into a single round, so this caps pathological out-of-order dependency graphs.
18
+ */
19
+ readonly maxCascadeDepth: number;
20
+ /** Maximum transactions the cascade may invalidate, counting the root. */
21
+ readonly maxCascadeTransactions: number;
22
+ };
23
+
24
+ export const DEFAULT_CASCADE_CONFIG: CascadeConfig = {
25
+ maxCascadeDepth: 32,
26
+ maxCascadeTransactions: 1000,
27
+ };
28
+
29
+ // ─── Collection environment ───
30
+
31
+ /**
32
+ * Everything the cascade needs from one collection: its append-only log (where child invalidation
33
+ * entries land and where read-dependents are discovered) and a resolver for the per-block storage
34
+ * its actions wrote. The caller supplies one per collection in the cascade's universe — the root's
35
+ * collections plus every collection reachable via a cross-collection read edge. (Discovering that
36
+ * universe requires a block→collection read index; the engine consumes the universe rather than
37
+ * computing it — see the handoff's cross-collection note.)
38
+ */
39
+ export type CollectionEnv = {
40
+ readonly collectionId: CollectionId;
41
+ readonly log: Log<unknown>;
42
+ readonly createBlockStorage: (blockId: BlockId) => IBlockStorage;
43
+ /**
44
+ * Optional per-block commit-latch runner threaded into each cascade child's {@link applyInvalidation}
45
+ * so the child's compensating write serializes against a concurrent commit on the same block — the
46
+ * same mutual-exclusion the root apply gets (see `InvalidationContext.withBlockCommitLatch`). Omitted
47
+ * → child writes run unlatched (today's behavior).
48
+ */
49
+ readonly withBlockCommitLatch?: <T>(blockId: BlockId, fn: () => Promise<T>) => Promise<T>;
50
+ };
51
+
52
+ // ─── Invalidated (blockId, revision) pairs ───
53
+
54
+ /**
55
+ * A block revision proven invalid: a committed action wrote `blockId` at `rev`, and that action has
56
+ * been invalidated (the root, or a cascade child). Any committed transaction whose read set contains
57
+ * `(blockId, rev)` observed this now-invalid revision and is a read-dependent.
58
+ *
59
+ * `restoredContentHash` is the content hash of the as-if-absent (reverted) value — the hash the
60
+ * invalidation recorded. The default re-evaluator compares it against the content the dependent
61
+ * actually observed: equal ⇒ the revert did not change what was read ⇒ retain.
62
+ */
63
+ export type InvalidatedPair = {
64
+ readonly blockId: BlockId;
65
+ readonly rev: number;
66
+ readonly restoredContentHash: string;
67
+ /** The collection whose invalidation produced this pair (so same-collection rev ordering is checkable). */
68
+ readonly collectionId: CollectionId;
69
+ /** Resolves storage for this block, from its owning collection — the dependent may live elsewhere. */
70
+ readonly createBlockStorage: (blockId: BlockId) => IBlockStorage;
71
+ };
72
+
73
+ function pairKey(blockId: BlockId, rev: number): string {
74
+ return `${blockId}\0${rev}`;
75
+ }
76
+
77
+ /**
78
+ * Dedup identity for a reverted log entry: the (collectionId, actionId) pair, not the actionId
79
+ * alone. A transaction spanning N collections has one entry per collection (same actionId,
80
+ * different collection/blockIds/rev) — each must be reverted independently, tracked separately.
81
+ * Uses a NUL separator, mirroring {@link pairKey}, so ids containing spaces cannot collide.
82
+ */
83
+ function entryKey(collectionId: CollectionId, actionId: ActionId): string {
84
+ return `${collectionId}\0${actionId}`;
85
+ }
86
+
87
+ // ─── Re-evaluation ───
88
+
89
+ /** A candidate read-dependent under re-evaluation against post-invalidation state. */
90
+ export type CascadeCandidate = {
91
+ readonly env: CollectionEnv;
92
+ readonly collectionId: CollectionId;
93
+ readonly actionId: ActionId;
94
+ readonly rev: number;
95
+ /** Blocks this action wrote (its entry's blockIds) — what gets reverted if it is invalidated. */
96
+ readonly blockIds: ReadonlyArray<BlockId>;
97
+ /** This action's persisted read set, or `undefined` for a legacy (pre-cascade) entry. */
98
+ readonly reads: ReadonlyArray<ReadDependency> | undefined;
99
+ /** The subset of `reads` intersecting an invalidated `(blockId, rev)`. Empty for a legacy candidate. */
100
+ readonly matched: ReadonlyArray<InvalidatedPair>;
101
+ };
102
+
103
+ /**
104
+ * Verdict for a candidate re-evaluated against the reverted state:
105
+ * - `retain` — the read still holds; the transaction stands untouched (the re-evaluation prune).
106
+ * - `invalidate` — the read no longer holds; revert this transaction and recurse into its dependents.
107
+ * - `unevaluable` — cannot decide from available data (e.g. a legacy entry with no persisted reads,
108
+ * and no engine to re-execute). Escalated rather than guessed.
109
+ */
110
+ export type CascadeVerdict = 'retain' | 'invalidate' | 'unevaluable';
111
+
112
+ /** Decides whether a candidate still holds against the reverted state. */
113
+ export type Reevaluate = (candidate: CascadeCandidate) => Promise<CascadeVerdict>;
114
+
115
+ /**
116
+ * The default, engine-free re-evaluator: deterministic from stored revisions alone (no engine replay),
117
+ * matching the compensating-state philosophy of the single-collection core.
118
+ *
119
+ * For each of the candidate's intersecting reads `(blockId, rev)`, it compares the content the
120
+ * candidate *observed* (the immutable historical revision `blockId@rev`) against the `restoredContentHash`
121
+ * the invalidation recorded (the as-if-absent value):
122
+ * - any read whose observed content differs from the restored content ⇒ the read no longer holds ⇒ **invalidate**;
123
+ * - a writer that *created* the block (deleted-block sentinel) ⇒ the observed content no longer exists ⇒ **invalidate**;
124
+ * - all intersecting reads unchanged ⇒ **retain** (e.g. a structural-block false dependent whose content the
125
+ * revert did not actually alter, or a redundant write that reverted to the same bytes).
126
+ * - a legacy entry (no persisted reads) ⇒ **unevaluable** (escalate, never guess independent).
127
+ *
128
+ * This is *sound but conservative* at block granularity: it never wrongly retains, but because reads are
129
+ * block-granular it may invalidate a dependent that read an unchanged *field* of a changed block. That is
130
+ * safe (over-invalidation just resubmits the transaction). Field/operation-granular pruning — re-executing
131
+ * the transaction and checking its operations still reproduce — requires an engine and is provided by
132
+ * injecting a custom {@link Reevaluate} instead (see the handoff).
133
+ */
134
+ export function contentEqualityReevaluator(): Reevaluate {
135
+ return async (candidate: CascadeCandidate): Promise<CascadeVerdict> => {
136
+ if (candidate.reads === undefined) {
137
+ return 'unevaluable';
138
+ }
139
+ // Defensive: a candidate with no intersecting reads is not actually a dependent.
140
+ if (candidate.matched.length === 0) {
141
+ return 'retain';
142
+ }
143
+ for (const pair of candidate.matched) {
144
+ if (pair.restoredContentHash === DELETED_BLOCK_RESTORE) {
145
+ return 'invalidate';
146
+ }
147
+ const observed = await pair.createBlockStorage(pair.blockId).getBlock(pair.rev);
148
+ if (!observed) {
149
+ // Cannot confirm the observed revision still materializes → conservative invalidate.
150
+ return 'invalidate';
151
+ }
152
+ if (await hashBlockContent(observed.block) !== pair.restoredContentHash) {
153
+ return 'invalidate';
154
+ }
155
+ }
156
+ return 'retain';
157
+ };
158
+ }
159
+
160
+ // ─── Cascade input / output ───
161
+
162
+ /** A `(blockId, rev)` the root invalidation proved invalid in a given collection, with its restored hash. */
163
+ export type CascadeSeed = {
164
+ readonly collectionId: CollectionId;
165
+ readonly blockId: BlockId;
166
+ readonly rev: number;
167
+ readonly restoredContentHash: string;
168
+ };
169
+
170
+ export type CascadeInput = {
171
+ /** actionId of the root invalidation — recorded as `cascadeRoot` on every child entry. */
172
+ readonly rootActionId: ActionId;
173
+ /** The root's invalidation certificate; reused to authorize each child invalidation. */
174
+ readonly proof: DisputeResolutionProof;
175
+ /** The `(blockId, rev)` pairs the root reversal produced — seeds the dependency frontier. */
176
+ readonly seed: ReadonlyArray<CascadeSeed>;
177
+ /**
178
+ * The target the root proof's votes are bound to — `(rootActionId, root blockIds)`. Every child
179
+ * reuses the root proof, whose votes were signed over the *root's* targetHash, so each child's
180
+ * certificate verification must be against this target, not the child's own. Defaults to the root
181
+ * action id plus the distinct block ids across {@link seed} (the root's reverted blocks). Pass it
182
+ * explicitly when the seed is not a faithful 1:1 image of the root's commit blockIds.
183
+ */
184
+ readonly certificateTarget?: CertificateTarget;
185
+ /** Every collection the cascade may walk: the root's, plus any reachable via cross-collection reads. */
186
+ readonly envs: ReadonlyArray<CollectionEnv>;
187
+ readonly config?: CascadeConfig;
188
+ /** Re-evaluation strategy; defaults to {@link contentEqualityReevaluator}. */
189
+ readonly reevaluate?: Reevaluate;
190
+ /**
191
+ * Operator-escalation health signal: invoked once when the cascade stops at a hard horizon (or hits
192
+ * an unevaluable candidate) and the affected collection(s) need a full re-sync. Never throws past here.
193
+ */
194
+ readonly onEscalation?: (escalation: CascadeEscalation) => void;
195
+ };
196
+
197
+ export type CascadeChild = {
198
+ readonly collectionId: CollectionId;
199
+ readonly actionId: ActionId;
200
+ readonly rev: number;
201
+ readonly reverted: ReadonlyArray<RevertedBlock>;
202
+ };
203
+
204
+ export type CascadeStanding = {
205
+ readonly collectionId: CollectionId;
206
+ readonly actionId: ActionId;
207
+ readonly rev: number;
208
+ };
209
+
210
+ export type CascadeEscalation = {
211
+ readonly reason: 'max-depth' | 'max-transactions' | 'unevaluable';
212
+ /** Collections to flag for operator-escalated full re-sync. */
213
+ readonly collections: ReadonlyArray<CollectionId>;
214
+ /** Read-dependents left un-cascaded at the horizon — surfaced, never silently dropped. */
215
+ readonly remainder: ReadonlyArray<CascadeStanding>;
216
+ /** Candidates that could not be re-evaluated (e.g. legacy entries with no engine to re-execute them). */
217
+ readonly unevaluable: ReadonlyArray<CascadeStanding>;
218
+ };
219
+
220
+ export type CascadeResult = {
221
+ readonly rootActionId: ActionId;
222
+ /** Read-dependents invalidated by the cascade (children only — the root is the caller's to apply). */
223
+ readonly invalidated: ReadonlyArray<CascadeChild>;
224
+ /** Read-dependents re-evaluated and retained (appeared in the chain but did not actually depend). */
225
+ readonly retained: ReadonlyArray<CascadeStanding>;
226
+ /** Re-evaluation rounds run (dependency-graph depth proxy). */
227
+ readonly rounds: number;
228
+ /** Present iff the cascade stopped at a hard horizon or hit an unevaluable candidate. */
229
+ readonly escalation?: CascadeEscalation;
230
+ };
231
+
232
+ // ─── Engine ───
233
+
234
+ /**
235
+ * Detects and re-evaluates the transitive read-dependents of an already-applied root invalidation,
236
+ * invalidating only those that no longer hold against the reverted state and leaving the rest in place.
237
+ *
238
+ * Preconditions: the caller has already applied the *root* reversal (driven through cluster consensus)
239
+ * and supplies its proven-invalid `(blockId, rev)` pairs as `seed`. This engine owns only the *cascade*:
240
+ * each child invalidation is appended via {@link applyInvalidation} carrying `cascadeRoot`, exactly the
241
+ * deterministic primitive every member runs — so a member replaying the same seed + logs converges on
242
+ * the same children (no per-child consensus round is required while the re-evaluator is deterministic;
243
+ * a non-deterministic/engine-based re-evaluator would need each child driven through consensus instead).
244
+ *
245
+ * Algorithm (fixpoint):
246
+ * 1. Seed the invalidated `(blockId, rev)` frontier from the root.
247
+ * 2. Each round, walk every in-scope collection log forward, collecting unprocessed actions whose read
248
+ * set intersects the frontier (legacy reads-less entries are always candidates — unknown dependency).
249
+ * 3. Process candidates in `(rev, collectionId, actionId)` order, deduped by actionId. Re-evaluate each
250
+ * against the reverted state; **retain** leaves it (revisited next round in case a later ancestor
251
+ * reverts), **invalidate** appends its child entry and feeds its reverted blocks back into the frontier.
252
+ * 4. Repeat until a round makes no progress (fixpoint) or a horizon trips (escalate, applying what it did).
253
+ *
254
+ * Diamonds: a dependent of two invalidated ancestors is evaluated once (dedup), after both are reverted
255
+ * (revision order within a collection; cross-collection retains are re-examined every round until fixpoint).
256
+ * Cycle-freedom: a read observes a strictly earlier write, so within a collection a child's rev exceeds the
257
+ * pair it depends on — a back-edge is corruption and throws.
258
+ */
259
+ export async function cascadeInvalidate(input: CascadeInput): Promise<CascadeResult> {
260
+ const config = input.config ?? DEFAULT_CASCADE_CONFIG;
261
+ const reevaluate = input.reevaluate ?? contentEqualityReevaluator();
262
+ const envByCollection = new Map(input.envs.map(e => [e.collectionId, e] as const));
263
+
264
+ // Invalidated frontier, seeded from the root's reverted blocks.
265
+ const pairs = new Map<string, InvalidatedPair>();
266
+ for (const s of input.seed) {
267
+ const env = envByCollection.get(s.collectionId);
268
+ if (!env) {
269
+ throw new Error(`cascade: seed references collection ${s.collectionId} not present in envs`);
270
+ }
271
+ pairs.set(pairKey(s.blockId, s.rev), {
272
+ blockId: s.blockId, rev: s.rev, restoredContentHash: s.restoredContentHash,
273
+ collectionId: s.collectionId, createBlockStorage: env.createBlockStorage,
274
+ });
275
+ }
276
+
277
+ const rootActionId = input.rootActionId;
278
+ // The root proof's votes are bound to the ROOT's target; every child reuses that proof, so each
279
+ // child's applyInvalidation must verify against the root's target rather than the child's own. The
280
+ // child-specific justification is THIS deterministic read-dependency derivation, replayed identically
281
+ // by every member — not the certificate (which only attests the root is invalid).
282
+ const rootCertificateTarget: CertificateTarget = input.certificateTarget
283
+ ?? { invalidatedActionId: rootActionId, blockIds: [...new Set(input.seed.map(s => s.blockId))] };
284
+ // Dedup identity is per collection-entry (collectionId, actionId), not actionId alone: a
285
+ // multi-collection transaction has one entry per collection and each must be reverted separately.
286
+ const processedEntries = new Set<string>(); // entryKey() of collection-entries reverted this cascade
287
+ // The horizon counts distinct transactions (the root counts once, a multi-collection dependent once),
288
+ // so a transaction is never split across the budget — reverted in some collections, escalated in others.
289
+ const invalidatedTxns = new Set<ActionId>([rootActionId]);
290
+ const children: CascadeChild[] = [];
291
+ const affectedCollections = new Set<CollectionId>(input.seed.map(s => s.collectionId));
292
+ const unevaluable: CascadeStanding[] = [];
293
+ let retained: CascadeStanding[] = [];
294
+ let rounds = 0;
295
+ let escalation: CascadeEscalation | undefined;
296
+ // Set once a new transaction is refused at the transaction horizon. We do NOT break the cascade
297
+ // on that refusal: already-counted transactions must still finish every remaining collection-entry
298
+ // (all-or-nothing), so we skip only the over-budget newcomer and let the round complete. The
299
+ // max-transactions escalation is built once at the end, after the protected transactions drain.
300
+ let horizonReached = false;
301
+
302
+ while (true) {
303
+ // Depth horizon: stop before another rescan, surfacing the un-cascaded frontier.
304
+ if (rounds >= config.maxCascadeDepth) {
305
+ const remainder = await collectCandidates(input.envs, pairs, rootActionId, processedEntries);
306
+ escalation = makeEscalation('max-depth', affectedCollections, remainder, unevaluable);
307
+ break;
308
+ }
309
+ rounds++;
310
+
311
+ const candidates = await collectCandidates(input.envs, pairs, rootActionId, processedEntries);
312
+ if (candidates.length === 0) {
313
+ break; // fixpoint: no remaining read-dependents
314
+ }
315
+
316
+ retained = [];
317
+ let progressed = false;
318
+
319
+ for (const cand of candidates) {
320
+ if (processedEntries.has(entryKey(cand.collectionId, cand.actionId))) {
321
+ continue; // diamond: an ancestor pass already reverted this collection-entry
322
+ }
323
+ // Recompute matches against the live frontier (picks up same-round ancestors, ordered by rev).
324
+ const matched = matchReads(cand.reads, pairs);
325
+ const isLegacy = cand.reads === undefined;
326
+ if (matched.length === 0 && !isLegacy) {
327
+ continue; // no longer a dependent this round — revisit next round
328
+ }
329
+
330
+ assertForwardOnly(cand, matched);
331
+
332
+ const verdict = await reevaluate({ ...cand, matched });
333
+ if (verdict === 'retain') {
334
+ retained.push(standing(cand));
335
+ continue;
336
+ }
337
+ if (verdict === 'unevaluable') {
338
+ if (!unevaluable.some(u => u.collectionId === cand.collectionId && u.actionId === cand.actionId)) {
339
+ unevaluable.push(standing(cand));
340
+ }
341
+ affectedCollections.add(cand.collectionId);
342
+ continue;
343
+ }
344
+
345
+ // invalidate — but never START a new transaction past the horizon. An already-counted
346
+ // transaction's remaining collection-entries pass freely (the `has` short-circuit), so a
347
+ // multi-collection dependent is reverted all-or-nothing and is never split across the budget.
348
+ // Crucially we only SKIP the over-budget newcomer here — we must not break the round, or a
349
+ // counted transaction's later-sorted sibling entry would be abandoned (silent partial revert).
350
+ if (!invalidatedTxns.has(cand.actionId) && invalidatedTxns.size + 1 > config.maxCascadeTransactions) {
351
+ horizonReached = true;
352
+ continue;
353
+ }
354
+
355
+ const env = cand.env;
356
+ const result = await applyInvalidation(
357
+ { log: env.log, createBlockStorage: env.createBlockStorage, withBlockCommitLatch: env.withBlockCommitLatch },
358
+ {
359
+ invalidatedActionId: cand.actionId,
360
+ invalidatedRev: cand.rev,
361
+ blockIds: cand.blockIds,
362
+ proof: input.proof,
363
+ cascadeRoot: input.rootActionId,
364
+ // Verify the reused root proof against the root's target, not this child's own.
365
+ certificateTarget: rootCertificateTarget,
366
+ }
367
+ );
368
+
369
+ // `applied:false, already-applied` is the idempotent re-run path: the child entry already
370
+ // exists (prior cascade run / restart). Treat it as invalidated and reuse its reverted blocks
371
+ // so the frontier still grows and the cascade reconverges without a second entry.
372
+ if (!result.applied && result.reason !== 'already-applied') {
373
+ // invalid-certificate should be impossible: the child verifies the root proof against the
374
+ // root's target (rootCertificateTarget), and the root proof is a valid challenger-wins cert.
375
+ log('child-apply-rejected actionId=%s reason=%s', cand.actionId, result.reason);
376
+ continue;
377
+ }
378
+
379
+ processedEntries.add(entryKey(cand.collectionId, cand.actionId));
380
+ invalidatedTxns.add(cand.actionId);
381
+ progressed = true;
382
+ affectedCollections.add(cand.collectionId);
383
+ children.push({ collectionId: cand.collectionId, actionId: cand.actionId, rev: cand.rev, reverted: result.reverted });
384
+
385
+ for (const rb of result.reverted) {
386
+ pairs.set(pairKey(rb.blockId, cand.rev), {
387
+ blockId: rb.blockId, rev: cand.rev, restoredContentHash: rb.restoredContentHash,
388
+ collectionId: cand.collectionId, createBlockStorage: env.createBlockStorage,
389
+ });
390
+ }
391
+ }
392
+
393
+ if (!progressed) {
394
+ break; // only retains / unevaluable / over-budget newcomers remain — fixpoint
395
+ }
396
+ }
397
+
398
+ // Transaction-horizon escalation, built after the protected transactions have fully drained so the
399
+ // remainder reflects only what was genuinely left un-cascaded (never a half-reverted transaction).
400
+ if (horizonReached && !escalation) {
401
+ const remainder = await collectCandidates(input.envs, pairs, rootActionId, processedEntries);
402
+ escalation = makeEscalation('max-transactions', affectedCollections, remainder, unevaluable);
403
+ }
404
+
405
+ if (!escalation && unevaluable.length > 0) {
406
+ escalation = makeEscalation('unevaluable', affectedCollections, [], unevaluable);
407
+ }
408
+
409
+ if (escalation) {
410
+ log('escalate reason=%s collections=%d remainder=%d unevaluable=%d',
411
+ escalation.reason, escalation.collections.length, escalation.remainder.length, escalation.unevaluable.length);
412
+ try {
413
+ input.onEscalation?.(escalation);
414
+ } catch (err) {
415
+ log('escalation-sink-error error=%s', (err as Error).message);
416
+ }
417
+ }
418
+
419
+ return {
420
+ rootActionId: input.rootActionId,
421
+ invalidated: children,
422
+ retained,
423
+ rounds,
424
+ ...(escalation ? { escalation } : {}),
425
+ };
426
+ }
427
+
428
+ // ─── Walk / match helpers ───
429
+
430
+ /** Walk every in-scope collection log forward, collecting unprocessed actions that are read-dependents. */
431
+ async function collectCandidates(
432
+ envs: ReadonlyArray<CollectionEnv>,
433
+ pairs: Map<string, InvalidatedPair>,
434
+ rootActionId: ActionId,
435
+ processedEntries: Set<string>
436
+ ): Promise<CascadeCandidate[]> {
437
+ const candidates: CascadeCandidate[] = [];
438
+ for (const env of envs) {
439
+ for await (const entry of env.log.select()) {
440
+ const action = entry.action;
441
+ // Exclude the root (invalidated by the caller across all its collections) and any
442
+ // collection-entry already reverted this cascade — but a not-yet-reverted entry of the
443
+ // same transaction in a different collection is still a live candidate.
444
+ if (!action || action.actionId === rootActionId || processedEntries.has(entryKey(env.collectionId, action.actionId))) {
445
+ continue;
446
+ }
447
+ const reads = action.reads;
448
+ const isLegacy = reads === undefined;
449
+ const matched = matchReads(reads, pairs);
450
+ if (matched.length === 0 && !isLegacy) {
451
+ continue;
452
+ }
453
+ candidates.push({
454
+ env,
455
+ collectionId: env.collectionId,
456
+ actionId: action.actionId,
457
+ rev: entry.rev,
458
+ blockIds: action.blockIds,
459
+ reads,
460
+ matched,
461
+ });
462
+ }
463
+ }
464
+ // Deterministic processing order: revision, then collection, then actionId.
465
+ candidates.sort((a, b) =>
466
+ a.rev - b.rev
467
+ || (a.collectionId < b.collectionId ? -1 : a.collectionId > b.collectionId ? 1 : 0)
468
+ || (a.actionId < b.actionId ? -1 : a.actionId > b.actionId ? 1 : 0)
469
+ );
470
+ return candidates;
471
+ }
472
+
473
+ /** The subset of `reads` that intersect the invalidated frontier; `[]` for a legacy (reads-less) entry. */
474
+ function matchReads(reads: ReadonlyArray<ReadDependency> | undefined, pairs: Map<string, InvalidatedPair>): InvalidatedPair[] {
475
+ if (!reads) {
476
+ return [];
477
+ }
478
+ const matched: InvalidatedPair[] = [];
479
+ for (const r of reads) {
480
+ const pair = pairs.get(pairKey(r.blockId, r.revision));
481
+ if (pair) {
482
+ matched.push(pair);
483
+ }
484
+ }
485
+ return matched;
486
+ }
487
+
488
+ /**
489
+ * Cycle-freedom guard. A read observes a strictly earlier write, so within a single collection a
490
+ * dependent's revision must exceed the pair it depends on. A same-collection back-edge means the log
491
+ * is corrupt — throw rather than risk a non-terminating walk. Cross-collection pairs live in
492
+ * independent revision sequences and are not comparable, so they are skipped.
493
+ */
494
+ function assertForwardOnly(cand: CascadeCandidate, matched: ReadonlyArray<InvalidatedPair>): void {
495
+ for (const pair of matched) {
496
+ if (pair.collectionId === cand.collectionId && cand.rev <= pair.rev) {
497
+ throw new Error(
498
+ `cascade: back-edge detected — action ${cand.actionId} at rev ${cand.rev} reads ${pair.blockId}@${pair.rev} in the same collection (dependency graph must be a forward DAG)`
499
+ );
500
+ }
501
+ }
502
+ }
503
+
504
+ function standing(cand: CascadeCandidate): CascadeStanding {
505
+ return { collectionId: cand.collectionId, actionId: cand.actionId, rev: cand.rev };
506
+ }
507
+
508
+ function makeEscalation(
509
+ reason: CascadeEscalation['reason'],
510
+ collections: Set<CollectionId>,
511
+ remainder: ReadonlyArray<CascadeCandidate>,
512
+ unevaluable: ReadonlyArray<CascadeStanding>
513
+ ): CascadeEscalation {
514
+ const affected = new Set(collections);
515
+ for (const r of remainder) {
516
+ affected.add(r.collectionId);
517
+ }
518
+ return {
519
+ reason,
520
+ collections: [...affected],
521
+ remainder: remainder.map(standing),
522
+ unevaluable: [...unevaluable],
523
+ };
524
+ }