@optimystic/db-p2p 0.24.1 → 0.25.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 (257) hide show
  1. package/{readme.md → README.md} +688 -668
  2. package/dist/src/cluster/block-transfer-service.d.ts +133 -10
  3. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  4. package/dist/src/cluster/block-transfer-service.js +155 -9
  5. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  6. package/dist/src/cluster/block-transfer.d.ts +67 -7
  7. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  8. package/dist/src/cluster/block-transfer.js +127 -24
  9. package/dist/src/cluster/block-transfer.js.map +1 -1
  10. package/dist/src/cluster/certified-claims.d.ts +170 -0
  11. package/dist/src/cluster/certified-claims.d.ts.map +1 -0
  12. package/dist/src/cluster/certified-claims.js +200 -0
  13. package/dist/src/cluster/certified-claims.js.map +1 -0
  14. package/dist/src/cluster/client-signature-verifier.d.ts +23 -0
  15. package/dist/src/cluster/client-signature-verifier.d.ts.map +1 -0
  16. package/dist/src/cluster/client-signature-verifier.js +33 -0
  17. package/dist/src/cluster/client-signature-verifier.js.map +1 -0
  18. package/dist/src/cluster/cluster-policy.d.ts +9 -3
  19. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  20. package/dist/src/cluster/cluster-policy.js +111 -27
  21. package/dist/src/cluster/cluster-policy.js.map +1 -1
  22. package/dist/src/cluster/cluster-repo.d.ts +140 -11
  23. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  24. package/dist/src/cluster/cluster-repo.js +485 -29
  25. package/dist/src/cluster/cluster-repo.js.map +1 -1
  26. package/dist/src/cluster/commit-proof.d.ts +134 -0
  27. package/dist/src/cluster/commit-proof.d.ts.map +1 -0
  28. package/dist/src/cluster/commit-proof.js +263 -0
  29. package/dist/src/cluster/commit-proof.js.map +1 -0
  30. package/dist/src/cluster/quorum-restore.d.ts +95 -8
  31. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  32. package/dist/src/cluster/quorum-restore.js +113 -14
  33. package/dist/src/cluster/quorum-restore.js.map +1 -1
  34. package/dist/src/cluster/rebalance-monitor.d.ts +84 -0
  35. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  36. package/dist/src/cluster/rebalance-monitor.js +207 -5
  37. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  38. package/dist/src/cluster/reconcile-block.d.ts +27 -7
  39. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  40. package/dist/src/cluster/reconcile-block.js +179 -38
  41. package/dist/src/cluster/reconcile-block.js.map +1 -1
  42. package/dist/src/cluster/service.d.ts +21 -4
  43. package/dist/src/cluster/service.d.ts.map +1 -1
  44. package/dist/src/cluster/service.js +28 -17
  45. package/dist/src/cluster/service.js.map +1 -1
  46. package/dist/src/cluster/spread-on-churn.d.ts +8 -2
  47. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  48. package/dist/src/cluster/spread-on-churn.js +7 -7
  49. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  50. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  51. package/dist/src/cohort-topic/host.js +8 -7
  52. package/dist/src/cohort-topic/host.js.map +1 -1
  53. package/dist/src/cohort-topic/stream-util.d.ts +7 -6
  54. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  55. package/dist/src/cohort-topic/stream-util.js +14 -41
  56. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  57. package/dist/src/dispute/cascade.d.ts +7 -8
  58. package/dist/src/dispute/cascade.d.ts.map +1 -1
  59. package/dist/src/dispute/cascade.js +24 -7
  60. package/dist/src/dispute/cascade.js.map +1 -1
  61. package/dist/src/dispute/dispute-service.d.ts +5 -0
  62. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  63. package/dist/src/dispute/dispute-service.js +13 -3
  64. package/dist/src/dispute/dispute-service.js.map +1 -1
  65. package/dist/src/dispute/invalidation.d.ts +33 -15
  66. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  67. package/dist/src/dispute/invalidation.js +127 -34
  68. package/dist/src/dispute/invalidation.js.map +1 -1
  69. package/dist/src/dispute/service.d.ts.map +1 -1
  70. package/dist/src/dispute/service.js +2 -1
  71. package/dist/src/dispute/service.js.map +1 -1
  72. package/dist/src/index.d.ts +7 -0
  73. package/dist/src/index.d.ts.map +1 -1
  74. package/dist/src/index.js +7 -0
  75. package/dist/src/index.js.map +1 -1
  76. package/dist/src/libp2p-key-network.d.ts +78 -6
  77. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  78. package/dist/src/libp2p-key-network.js +136 -57
  79. package/dist/src/libp2p-key-network.js.map +1 -1
  80. package/dist/src/libp2p-node-base.d.ts +38 -7
  81. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  82. package/dist/src/libp2p-node-base.js +150 -62
  83. package/dist/src/libp2p-node-base.js.map +1 -1
  84. package/dist/src/logger.d.ts +2 -2
  85. package/dist/src/logger.js +2 -2
  86. package/dist/src/network/open-protocol-stream.d.ts +56 -0
  87. package/dist/src/network/open-protocol-stream.d.ts.map +1 -0
  88. package/dist/src/network/open-protocol-stream.js +60 -0
  89. package/dist/src/network/open-protocol-stream.js.map +1 -0
  90. package/dist/src/network/register-protocol-handler.d.ts +66 -0
  91. package/dist/src/network/register-protocol-handler.d.ts.map +1 -0
  92. package/dist/src/network/register-protocol-handler.js +51 -0
  93. package/dist/src/network/register-protocol-handler.js.map +1 -0
  94. package/dist/src/peer-address-book.d.ts +128 -1
  95. package/dist/src/peer-address-book.d.ts.map +1 -1
  96. package/dist/src/peer-address-book.js +198 -15
  97. package/dist/src/peer-address-book.js.map +1 -1
  98. package/dist/src/pend-validation.d.ts +65 -0
  99. package/dist/src/pend-validation.d.ts.map +1 -0
  100. package/dist/src/pend-validation.js +69 -0
  101. package/dist/src/pend-validation.js.map +1 -0
  102. package/dist/src/reactivity/notify-transport.d.ts.map +1 -1
  103. package/dist/src/reactivity/notify-transport.js +2 -1
  104. package/dist/src/reactivity/notify-transport.js.map +1 -1
  105. package/dist/src/reactivity/push-state-gossip.d.ts.map +1 -1
  106. package/dist/src/reactivity/push-state-gossip.js +2 -1
  107. package/dist/src/reactivity/push-state-gossip.js.map +1 -1
  108. package/dist/src/repo/cluster-coordinator.d.ts +32 -6
  109. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  110. package/dist/src/repo/cluster-coordinator.js +44 -10
  111. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  112. package/dist/src/repo/coordinator-repo.d.ts +181 -25
  113. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  114. package/dist/src/repo/coordinator-repo.js +650 -62
  115. package/dist/src/repo/coordinator-repo.js.map +1 -1
  116. package/dist/src/repo/served-repo-proxy.d.ts +30 -0
  117. package/dist/src/repo/served-repo-proxy.d.ts.map +1 -0
  118. package/dist/src/repo/served-repo-proxy.js +50 -0
  119. package/dist/src/repo/served-repo-proxy.js.map +1 -0
  120. package/dist/src/repo/service.d.ts +22 -1
  121. package/dist/src/repo/service.d.ts.map +1 -1
  122. package/dist/src/repo/service.js +25 -13
  123. package/dist/src/repo/service.js.map +1 -1
  124. package/dist/src/reputation/types.d.ts +3 -1
  125. package/dist/src/reputation/types.d.ts.map +1 -1
  126. package/dist/src/reputation/types.js +3 -1
  127. package/dist/src/reputation/types.js.map +1 -1
  128. package/dist/src/rn.d.ts +24 -2
  129. package/dist/src/rn.d.ts.map +1 -1
  130. package/dist/src/rn.js +33 -4
  131. package/dist/src/rn.js.map +1 -1
  132. package/dist/src/routing/libp2p-known-peers.d.ts.map +1 -1
  133. package/dist/src/routing/libp2p-known-peers.js +5 -0
  134. package/dist/src/routing/libp2p-known-peers.js.map +1 -1
  135. package/dist/src/storage/block-archive.d.ts +153 -0
  136. package/dist/src/storage/block-archive.d.ts.map +1 -0
  137. package/dist/src/storage/block-archive.js +194 -0
  138. package/dist/src/storage/block-archive.js.map +1 -0
  139. package/dist/src/storage/block-latch.d.ts +92 -0
  140. package/dist/src/storage/block-latch.d.ts.map +1 -0
  141. package/dist/src/storage/block-latch.js +132 -0
  142. package/dist/src/storage/block-latch.js.map +1 -0
  143. package/dist/src/storage/block-storage.d.ts +169 -12
  144. package/dist/src/storage/block-storage.d.ts.map +1 -1
  145. package/dist/src/storage/block-storage.js +415 -152
  146. package/dist/src/storage/block-storage.js.map +1 -1
  147. package/dist/src/storage/cached-raw-storage.d.ts +5 -1
  148. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -1
  149. package/dist/src/storage/cached-raw-storage.js +16 -1
  150. package/dist/src/storage/cached-raw-storage.js.map +1 -1
  151. package/dist/src/storage/cached-store-driver.d.ts +14 -4
  152. package/dist/src/storage/cached-store-driver.d.ts.map +1 -1
  153. package/dist/src/storage/cached-store-driver.js +39 -5
  154. package/dist/src/storage/cached-store-driver.js.map +1 -1
  155. package/dist/src/storage/i-block-storage.d.ts +70 -19
  156. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  157. package/dist/src/storage/i-block-storage.js +21 -1
  158. package/dist/src/storage/i-block-storage.js.map +1 -1
  159. package/dist/src/storage/i-raw-storage.d.ts +38 -0
  160. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  161. package/dist/src/storage/kv-raw-storage.d.ts +14 -8
  162. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -1
  163. package/dist/src/storage/kv-raw-storage.js +29 -9
  164. package/dist/src/storage/kv-raw-storage.js.map +1 -1
  165. package/dist/src/storage/memory-store-driver.d.ts +4 -1
  166. package/dist/src/storage/memory-store-driver.d.ts.map +1 -1
  167. package/dist/src/storage/memory-store-driver.js +22 -2
  168. package/dist/src/storage/memory-store-driver.js.map +1 -1
  169. package/dist/src/storage/raw-store-codec.js +2 -2
  170. package/dist/src/storage/raw-store-driver.d.ts +44 -3
  171. package/dist/src/storage/raw-store-driver.d.ts.map +1 -1
  172. package/dist/src/storage/shared-cache-pool.d.ts +60 -3
  173. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -1
  174. package/dist/src/storage/shared-cache-pool.js +68 -4
  175. package/dist/src/storage/shared-cache-pool.js.map +1 -1
  176. package/dist/src/storage/storage-repo.d.ts +204 -26
  177. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  178. package/dist/src/storage/storage-repo.js +422 -119
  179. package/dist/src/storage/storage-repo.js.map +1 -1
  180. package/dist/src/storage/store-identity.d.ts +21 -0
  181. package/dist/src/storage/store-identity.d.ts.map +1 -0
  182. package/dist/src/storage/store-identity.js +33 -0
  183. package/dist/src/storage/store-identity.js.map +1 -0
  184. package/dist/src/storage/struct.d.ts +15 -0
  185. package/dist/src/storage/struct.d.ts.map +1 -1
  186. package/dist/src/storage/with-read-cache.d.ts +92 -0
  187. package/dist/src/storage/with-read-cache.d.ts.map +1 -0
  188. package/dist/src/storage/with-read-cache.js +132 -0
  189. package/dist/src/storage/with-read-cache.js.map +1 -0
  190. package/dist/src/sync/service.d.ts +13 -3
  191. package/dist/src/sync/service.d.ts.map +1 -1
  192. package/dist/src/sync/service.js +7 -30
  193. package/dist/src/sync/service.js.map +1 -1
  194. package/dist/src/testing/mesh-harness.d.ts +75 -2
  195. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  196. package/dist/src/testing/mesh-harness.js +193 -81
  197. package/dist/src/testing/mesh-harness.js.map +1 -1
  198. package/dist/src/testing/raw-storage-conformance.d.ts +8 -0
  199. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  200. package/dist/src/testing/raw-storage-conformance.js +97 -2
  201. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  202. package/package.json +5 -6
  203. package/src/cluster/block-transfer-service.ts +528 -312
  204. package/src/cluster/block-transfer.ts +547 -424
  205. package/src/cluster/certified-claims.ts +319 -0
  206. package/src/cluster/client-signature-verifier.ts +34 -0
  207. package/src/cluster/cluster-policy.ts +294 -203
  208. package/src/cluster/cluster-repo.ts +2501 -2034
  209. package/src/cluster/commit-proof.ts +344 -0
  210. package/src/cluster/quorum-restore.ts +348 -223
  211. package/src/cluster/rebalance-monitor.ts +539 -254
  212. package/src/cluster/reconcile-block.ts +404 -203
  213. package/src/cluster/service.ts +308 -293
  214. package/src/cluster/spread-on-churn.ts +352 -346
  215. package/src/cohort-topic/host.ts +8 -7
  216. package/src/cohort-topic/stream-util.ts +14 -43
  217. package/src/dispute/cascade.ts +31 -15
  218. package/src/dispute/dispute-service.ts +706 -696
  219. package/src/dispute/invalidation.ts +162 -52
  220. package/src/dispute/service.ts +126 -125
  221. package/src/index.ts +7 -0
  222. package/src/libp2p-key-network.ts +142 -58
  223. package/src/libp2p-node-base.ts +1793 -1675
  224. package/src/logger.ts +27 -27
  225. package/src/network/open-protocol-stream.ts +87 -0
  226. package/src/network/register-protocol-handler.ts +85 -0
  227. package/src/peer-address-book.ts +360 -149
  228. package/src/pend-validation.ts +89 -0
  229. package/src/reactivity/notify-transport.ts +145 -144
  230. package/src/reactivity/push-state-gossip.ts +292 -291
  231. package/src/repo/cluster-coordinator.ts +65 -11
  232. package/src/repo/coordinator-repo.ts +729 -71
  233. package/src/repo/served-repo-proxy.ts +55 -0
  234. package/src/repo/service.ts +332 -313
  235. package/src/reputation/types.ts +3 -1
  236. package/src/rn.ts +34 -4
  237. package/src/routing/libp2p-known-peers.ts +31 -26
  238. package/src/storage/block-archive.ts +242 -0
  239. package/src/storage/block-latch.ts +144 -0
  240. package/src/storage/block-storage.ts +787 -498
  241. package/src/storage/cached-raw-storage.ts +21 -1
  242. package/src/storage/cached-store-driver.ts +898 -859
  243. package/src/storage/i-block-storage.ts +172 -116
  244. package/src/storage/i-raw-storage.ts +40 -0
  245. package/src/storage/kv-raw-storage.ts +34 -9
  246. package/src/storage/memory-store-driver.ts +26 -2
  247. package/src/storage/raw-store-codec.ts +42 -42
  248. package/src/storage/raw-store-driver.ts +45 -3
  249. package/src/storage/shared-cache-pool.ts +516 -452
  250. package/src/storage/storage-repo.ts +1421 -1014
  251. package/src/storage/store-identity.ts +41 -0
  252. package/src/storage/struct.ts +52 -34
  253. package/src/storage/with-read-cache.ts +182 -0
  254. package/src/sync/service.ts +178 -198
  255. package/src/testing/cohort-topic-mesh-harness.ts +673 -673
  256. package/src/testing/mesh-harness.ts +524 -344
  257. package/src/testing/raw-storage-conformance.ts +114 -2
@@ -1,498 +1,787 @@
1
- import type { BlockId, IBlock, Transform, ActionId, ActionRev, ActionTransform } from "@optimystic/db-core";
2
- import { Latches, applyTransform, hashString } from "@optimystic/db-core";
3
- import type { BlockArchive, BlockMetadata, RestoreCallback, RevisionRange } from "./struct.js";
4
- import type { IRawStorage } from "./i-raw-storage.js";
5
- import { mergeRanges } from "./helpers.js";
6
- import type { IBlockStorage } from "./i-block-storage.js";
7
- import { createLogger } from "../logger.js";
8
-
9
- const log = createLogger('block-storage');
10
-
11
- /**
12
- * Default checkpoint cadence: a full materialization is retained at every `CHECKPOINT_INTERVAL`th
13
- * revision (plus the tip and each range floor). This bounds the maximum replay depth for any read
14
- * to at most `CHECKPOINT_INTERVAL` forward transforms. See {@link BlockStorage.pruneSupersededMaterialization}.
15
- */
16
- const CHECKPOINT_INTERVAL = 32;
17
-
18
- export class BlockStorage implements IBlockStorage {
19
- constructor(
20
- private readonly blockId: BlockId,
21
- private readonly storage: IRawStorage,
22
- private readonly restoreCallback?: RestoreCallback,
23
- /**
24
- * Revisions where `rev % checkpointInterval === 0` retain a full materialization even after they
25
- * stop being the tip. Optional (default {@link CHECKPOINT_INTERVAL}); tests inject a small value
26
- * to exercise sweeping without committing 32+ revisions.
27
- */
28
- private readonly checkpointInterval: number = CHECKPOINT_INTERVAL
29
- ) { }
30
-
31
- async getLatest(): Promise<ActionRev | undefined> {
32
- const meta = await this.storage.getMetadata(this.blockId);
33
- return meta?.latest;
34
- }
35
-
36
- async getBlock(rev?: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined> {
37
- const meta = await this.storage.getMetadata(this.blockId);
38
- if (!meta) {
39
- // No metadata at all ⇒ this node has never seen the block, and reads report it absent
40
- // WITHOUT consulting `restoreCallback`. That is deliberate, not an oversight: `restoreCallback`
41
- // is reachable only from ensureRevision below, so a never-seen block is never fetched HERE.
42
- // Attempting a fetch at this layer would turn every read of a genuinely non-existent block —
43
- // the common case for an insert probing for a collision — into a network round trip, because
44
- // storage cannot tell "nobody has this" from "I don't have this".
45
- //
46
- // The layer that CAN tell them apart makes that call instead: `CoordinatorRepo` acquires the
47
- // block only once the cohort has corroborated a `(rev, actionId)` for it
48
- // (`restoreCorroborated` `acquireBlockFromCohort`), so an id no peer claims still costs
49
- // nothing beyond the latest-query it already performed. Keep this early return as-is.
50
- return undefined;
51
- }
52
-
53
- // Pending-only state: metadata was seeded by savePendingTransaction but no revision has been
54
- // committed yet. "No committed base here" is an ABSENCE, not a fault — nothing is being
55
- // FAILED to reconstruct — so both arms below answer `undefined` rather than throwing, whether
56
- // or not the caller named a revision. StorageRepo.get then applies any pending overlay over
57
- // that absent base; a throw here would instead be caught into `unavailable: 'unmaterializable'`
58
- // and a writer reading back its own not-yet-committed insert would be told it is unreadable.
59
- // `unmaterializable` must keep its one meaning: records prove the block exists and this node
60
- // cannot reconstruct it.
61
- if (meta.latest === undefined) {
62
- if (rev === undefined) {
63
- return undefined;
64
- }
65
- // A named rev still ATTEMPTS the restore: `restoreCallback` may be able to supply that
66
- // revision even though nothing is committed locally, and a successful restore serves real
67
- // content with `latest` still undefined. That capability is pinned by the 'getBlock for an
68
- // absent revision fires restoreCallback (restore not short-circuited)' test in
69
- // test/block-storage.spec.ts — do not short-circuit it away.
70
- //
71
- // Only ensureRevision's FAILURE is swallowed (no callback wired, or restore could not
72
- // supply the rev): that is precisely the "no committed base here" absence. materializeBlock
73
- // below is deliberately OUTSIDE the try — a throw from there means revision records exist
74
- // with no materialization anywhere under them, which is genuine corruption and must keep
75
- // reading as `unmaterializable`.
76
- //
77
- // NOTE: a contextful read of a pending-only block still attempts a network restore before
78
- // falling back to absent (same cost as the pre-fix throw path); if pending-only read-backs
79
- // ever show as hot, short-circuit when ranges are empty.
80
- try {
81
- await this.ensureRevision(meta, rev);
82
- } catch (err) {
83
- log('getBlock:no-committed-base blockId=%s rev=%d error=%s', this.blockId, rev,
84
- err instanceof Error ? err.message : String(err));
85
- return undefined;
86
- }
87
- return await this.materializeBlock(meta, rev);
88
- }
89
-
90
- const targetRev = rev ?? meta.latest.rev;
91
- await this.ensureRevision(meta, targetRev);
92
- return await this.materializeBlock(meta, targetRev);
93
- }
94
-
95
- async getTransaction(actionId: ActionId): Promise<Transform | undefined> {
96
- return await this.storage.getTransaction(this.blockId, actionId);
97
- }
98
-
99
- async getPendingTransaction(actionId: ActionId): Promise<Transform | undefined> {
100
- return await this.storage.getPendingTransaction(this.blockId, actionId);
101
- }
102
-
103
- async *listPendingTransactions(): AsyncIterable<ActionId> {
104
- yield* this.storage.listPendingTransactions(this.blockId);
105
- }
106
-
107
- async savePendingTransaction(actionId: ActionId, transform: Transform): Promise<void> {
108
- log('pend blockId=%s actionId=%s', this.blockId, actionId);
109
- let meta = await this.storage.getMetadata(this.blockId);
110
- if (!meta) {
111
- // A freshly-pended block holds NO committed revision, so it can reconstruct
112
- // nothing yet: seed empty ranges. The first commit anchors an OPEN-ENDED span at
113
- // the earliest held rev E ([E, +inf)); later commits/recover merge into it via
114
- // setLatest/recover. Seeding open-ended `[[0]]` would falsely claim coverage of the
115
- // un-held revs below E and disable ensureRevision's restore path.
116
- meta = { latest: undefined, ranges: [] };
117
- await this.storage.saveMetadata(this.blockId, meta);
118
- }
119
- await this.storage.savePendingTransaction(this.blockId, actionId, transform);
120
- }
121
-
122
- async deletePendingTransaction(actionId: ActionId): Promise<void> {
123
- log('cancel blockId=%s actionId=%s', this.blockId, actionId);
124
- await this.storage.deletePendingTransaction(this.blockId, actionId);
125
- }
126
-
127
- async *listRevisions(startRev: number, endRev: number): AsyncIterable<ActionRev> {
128
- yield* this.storage.listRevisions(this.blockId, startRev, endRev);
129
- }
130
-
131
- async saveMaterializedBlock(actionId: ActionId, block: IBlock | undefined): Promise<void> {
132
- await this.storage.saveMaterializedBlock(this.blockId, actionId, block);
133
- }
134
-
135
- async pruneSupersededMaterialization(prior: ActionRev): Promise<void> {
136
- const meta = await this.storage.getMetadata(this.blockId);
137
- // No metadata / no committed tip yet ⇒ nothing has superseded `prior`; leave it.
138
- if (!meta || meta.latest === undefined) {
139
- return;
140
- }
141
- // `prior` is the PRIOR latest, so it shares the (latest) range containing meta.latest.rev — its
142
- // floor is that span's start. Retain if it is the tip, that floor, or a checkpoint rev.
143
- const rangeFloor = this.rangeFloorOf(meta.latest.rev, meta.ranges);
144
- if (this.isRetainedRev(prior.rev, meta.latest.rev, rangeFloor)) {
145
- return;
146
- }
147
- // Redundant: `prior`'s forward transform is retained, so it stays reconstructible by replay from
148
- // the nearest retained materialization below it. Delete routes to the driver's deleteMaterialized;
149
- // a no-op at the driver when `prior.rev` carried no materialization (e.g. a tombstone rev).
150
- await this.storage.saveMaterializedBlock(this.blockId, prior.actionId, undefined);
151
- log('prune blockId=%s rev=%d actionId=%s', this.blockId, prior.rev, prior.actionId);
152
- }
153
-
154
- async saveRevision(rev: number, actionId: ActionId): Promise<void> {
155
- await this.storage.saveRevision(this.blockId, rev, actionId);
156
- }
157
-
158
- async promotePendingTransaction(actionId: ActionId): Promise<void> {
159
- log('commit blockId=%s actionId=%s', this.blockId, actionId);
160
- await this.storage.promotePendingTransaction(this.blockId, actionId);
161
- }
162
-
163
- async setLatest(latest: ActionRev): Promise<void> {
164
- const meta = await this.storage.getMetadata(this.blockId);
165
- if (!meta) {
166
- throw new Error(`Block ${this.blockId} not found`);
167
- }
168
- // Capture the prior latest rev BEFORE overwriting: coverage anchors to the earliest held rev.
169
- const prevRev = meta.latest?.rev;
170
- meta.latest = latest;
171
- // NOTE: re-sorts (mergeRanges) the whole ranges array on every commit; if a block ever
172
- // accumulates many disjoint ranges and commits show as slow, keep a running merged structure.
173
- // `getBlock(r)` is served by materializeBlock's DESCENDING walk (highest committed rev <= r).
174
- // Once this node holds the chain from the block's earliest committed rev E, EVERY rev >= E is
175
- // serveable locally: a read at any r >= E resolves to the highest committed rev <= r (at worst
176
- // the latest, which is materialized), so coverage is the OPEN-ENDED span [E, +inf) — not the
177
- // single point [L, L+1) (which wrongly missed reads above L, e.g. a block read at the collection
178
- // tip after a later commit touched only its siblings) and not [0, +inf) (which wrongly claimed
179
- // the un-held revs below E). Claim open-ended from the prior latest (>= E via merge); the first
180
- // commit (prevRev undefined) anchors the span at E = L. mergeRanges folds it into the existing
181
- // [E, +inf). Only revs BELOW E miss inRanges, which is exactly the genuine-gap/restore case.
182
- // Range + latest advance in one saveMetadata write (atomic under the commit latch), so a crash
183
- // before this call advances neither.
184
- meta.ranges.unshift([prevRev ?? latest.rev]);
185
- meta.ranges = mergeRanges(meta.ranges);
186
- await this.storage.saveMetadata(this.blockId, meta);
187
- }
188
-
189
- async recover(): Promise<{ reconciled: boolean; latest?: ActionRev }> {
190
- const meta = await this.storage.getMetadata(this.blockId);
191
- if (!meta) {
192
- return { reconciled: false };
193
- }
194
-
195
- const currentRev = meta.latest?.rev ?? 0;
196
- let maxRev = currentRev;
197
- let maxActionId = meta.latest?.actionId;
198
-
199
- // Probe forward until we hit a gap or a revision whose action is not yet
200
- // in the committed log (Crash-D2 state retry-commit owns that advance).
201
- for (let next = currentRev + 1; ; next++) {
202
- const actionId = await this.storage.getRevision(this.blockId, next);
203
- if (actionId === undefined) break;
204
- const promoted = await this.storage.getTransaction(this.blockId, actionId);
205
- if (promoted === undefined) break;
206
- maxRev = next;
207
- maxActionId = actionId;
208
- }
209
-
210
- if (maxRev > currentRev && maxActionId !== undefined) {
211
- const advanced: ActionRev = { rev: maxRev, actionId: maxActionId };
212
- meta.latest = advanced;
213
- // The lost setLatest would have merged each recovered revision's range; redo that
214
- // here. Open-ended from currentRev+1 (see setLatest): every rev in (currentRev, maxRev]
215
- // was verified present in the committed log above, and any rev > maxRev resolves via the
216
- // descending walk to maxRev's materialization — so [currentRev+1, +inf) is honest. It joins
217
- // the prior [E, currentRev+1) (from the earlier setLatest) into one open-ended [E, +inf).
218
- meta.ranges.unshift([currentRev + 1]);
219
- meta.ranges = mergeRanges(meta.ranges);
220
- await this.storage.saveMetadata(this.blockId, meta);
221
- log('recover blockId=%s advanced latest from rev=%d to rev=%d', this.blockId, currentRev, maxRev);
222
- return { reconciled: true, latest: advanced };
223
- }
224
-
225
- return { reconciled: false, latest: meta.latest };
226
- }
227
-
228
- async saveReplica(block: IBlock, source?: ActionRev): Promise<ActionRev> {
229
- const rev = source?.rev ?? 1;
230
- // Deterministic fallback id when the sender carried no revision metadata, so a
231
- // re-push of the same block resolves to the same (rev, actionId) and stays
232
- // idempotent. Never random/time-based that would mint a new revision per retry.
233
- const actionId = source?.actionId ?? await hashString(`${this.blockId}:${JSON.stringify(block)}`);
234
-
235
- // Replica revision carries the materialized block. `{ insert: block }` satisfies saveRestored's
236
- // write invariants; on the serving path materializeBlock returns the materialized block directly
237
- // (single rev), so this transform is never appliedsee ticket notes.
238
- return await this.saveForwardRevision(
239
- rev,
240
- actionId,
241
- { action: { actionId, rev, transform: { insert: block } }, block },
242
- 'replica'
243
- );
244
- }
245
-
246
- async saveDeletion(source: ActionRev): Promise<ActionRev> {
247
- const { rev, actionId } = source;
248
-
249
- // Forward tombstone: a `{ delete: true }` transform and NO materialized block. saveRestored
250
- // skips materialization when `block` is absent, so the reverse-apply in materializeBlock
251
- // resolves this revision to an absent block (read-back as undefined).
252
- return await this.saveForwardRevision(
253
- rev,
254
- actionId,
255
- { action: { actionId, rev, transform: { delete: true } } },
256
- 'deletion'
257
- );
258
- }
259
-
260
- /**
261
- * Shared forward-write path for saveReplica and saveDeletion. Both append a single new revision
262
- * that ADVANCES `latest` (never rewrites history): acquire the block's metadata latch, apply the
263
- * monotonic guard, saveRestored a one-revision archive, then seed/advance/merge metadata.
264
- *
265
- * The only per-caller difference is the revision `body`: a replica carries `{ insert: block }`
266
- * plus the materialized `block`; a deletion carries `{ delete: true }` and no block. `rev` and
267
- * `actionId` are passed alongside `body` because the guard and the `latest` advance need them
268
- * independently of the archive body.
269
- */
270
- private async saveForwardRevision(
271
- rev: number,
272
- actionId: ActionId,
273
- body: { action: ActionTransform; block?: IBlock },
274
- logLabel: 'replica' | 'deletion'
275
- ): Promise<ActionRev> {
276
- // Serialize the read-modify-write on this block's metadata (mirrors ensureRevision). saveReplica
277
- // and saveDeletion deliberately SHARE this one lock id (keyed `saveReplica`, NOT per-method):
278
- // both do a read-modify-write of `meta.latest`, so they must be mutually exclusive on this block
279
- // to keep the monotonic guard sound against a concurrent replica+deletion.
280
- const lockId = `BlockStorage.saveReplica:${this.blockId}`;
281
- const release = await Latches.acquire(lockId);
282
- try {
283
- let meta = await this.storage.getMetadata(this.blockId);
284
-
285
- // Monotonic guard: an equal-or-newer revision is already held. The block (or tombstone) is
286
- // durably present; do not downgrade `latest` or rewrite the metadata.
287
- if (meta?.latest && meta.latest.rev >= rev) {
288
- log('%s:skip blockId=%s rev=%d held=%d', logLabel, this.blockId, rev, meta.latest.rev);
289
- return meta.latest;
290
- }
291
-
292
- // One-revision archive. A replica's body carries the materialized block; a deletion's body
293
- // omits it (forward tombstone). saveRestored skips materialization when `block` is absent,
294
- // so a tombstone reverse-applies to an absent block (read back as undefined).
295
- const archive: BlockArchive = {
296
- blockId: this.blockId,
297
- revisions: {
298
- [rev]: body
299
- },
300
- range: [rev, rev + 1]
301
- };
302
- await this.saveRestored(archive);
303
-
304
- // INVARIANT P: a block never holds a pending record AND a committed record for the same
305
- // action id. On the commit path `promotePendingTransaction` maintains it by MOVING the
306
- // record atomically; this forward path writes the committed transform directly (via
307
- // saveRestored above), so it owes the deletion itself. Without it, a node that pended the
308
- // action but diverged before committing keeps a record nothing can ever promote — reported
309
- // as a phantom conflicting action by every later `pend` on the block, which under
310
- // `policy: 'f'` refuses that node's participation in the block's writes permanently.
311
- //
312
- // Deliberately on the WRITE path only: the monotonic guard above returns before here, and
313
- // that early return must stay a true no-op (the earlier call that wrote the revision is the
314
- // one that owed the deletion). Deliberately here rather than in `saveRestored`, which is
315
- // also reached from ensureRevision's historical restore under a different latch, where a
316
- // deletion could race a concurrent promotePendingTransaction; this path holds
317
- // `BlockStorage.saveReplica:<id>` and (via StorageRepo.saveReplicatedBlock) the per-block
318
- // commit latch, so it is already mutually exclusive with a live commit.
319
- //
320
- // NOTE: deletes only this revision's actionId, not every pending whose action is already
321
- // committed. A broader sweep would repair records orphaned by routes that do not carry the
322
- // committing actionId; if orphaned pendings ever show up in the field on blocks whose
323
- // committing action id differs, widen to a sweep over listPendingTransactions filtered by
324
- // getTransaction.
325
- await this.storage.deletePendingTransaction(this.blockId, actionId);
326
-
327
- // Seed metadata when absent, advance latest, and merge the covered range.
328
- const prevRev = meta?.latest?.rev;
329
- if (!meta) {
330
- meta = { latest: undefined, ranges: [] };
331
- }
332
- meta.latest = { rev, actionId };
333
- // Open-ended coverage from the earliest held rev (see setLatest): the descending walk serves
334
- // any rev >= the anchor. A prior latest at prevRev (< rev per the monotonic guard) is a
335
- // materialized point, so anchor at prevRev; the first write (prevRev undefined) anchors at
336
- // rev. Freshness of a stale replica is a separate (replication-lag) concern from what this
337
- // node can locally reconstruct, which is exactly what ranges records.
338
- meta.ranges.unshift([prevRev ?? rev]);
339
- meta.ranges = mergeRanges(meta.ranges);
340
- await this.storage.saveMetadata(this.blockId, meta);
341
-
342
- log('%s:save blockId=%s rev=%d actionId=%s', logLabel, this.blockId, rev, actionId);
343
- return meta.latest;
344
- } finally {
345
- release();
346
- }
347
- }
348
-
349
- private async ensureRevision(meta: BlockMetadata, rev: number): Promise<void> {
350
- if (this.inRanges(rev, meta.ranges)) {
351
- return;
352
- }
353
-
354
- const lockId = `BlockStorage.ensureRevision:${this.blockId}`;
355
- const release = await Latches.acquire(lockId);
356
- try {
357
- const currentMeta = await this.storage.getMetadata(this.blockId);
358
- if (!currentMeta) {
359
- throw new Error(`Block ${this.blockId} metadata disappeared unexpectedly.`);
360
- }
361
- if (this.inRanges(rev, currentMeta.ranges)) {
362
- return;
363
- }
364
-
365
- const restored = await this.restoreBlock(rev);
366
- if (!restored) {
367
- throw new Error(`Block ${this.blockId} revision ${rev} not found during restore attempt.`);
368
- }
369
- await this.saveRestored(restored);
370
-
371
- currentMeta.ranges.unshift(restored.range);
372
- currentMeta.ranges = mergeRanges(currentMeta.ranges);
373
- await this.storage.saveMetadata(this.blockId, currentMeta);
374
-
375
- } finally {
376
- release();
377
- }
378
- }
379
-
380
- private async materializeBlock(meta: BlockMetadata, targetRev: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined> {
381
- let block: IBlock | undefined;
382
- let materializedActionRev: ActionRev | undefined;
383
- const actions: ActionRev[] = [];
384
-
385
- // Find the materialized block
386
- for await (const actionRev of this.storage.listRevisions(this.blockId, targetRev, 1)) {
387
- const materializedBlock = await this.storage.getMaterializedBlock(this.blockId, actionRev.actionId);
388
- if (materializedBlock) {
389
- block = materializedBlock;
390
- materializedActionRev = actionRev;
391
- break;
392
- } else {
393
- actions.push(actionRev);
394
- }
395
- }
396
-
397
- if (!block || !materializedActionRev) {
398
- // There is an implicit requirement that there must be a materialization of the block somewhere in it's history. If the log is truncated, a materialization must be made at the truncation point..
399
- throw new Error(`Failed to find materialized block ${this.blockId} for revision ${targetRev}`);
400
- }
401
-
402
- // Apply transforms in reverse order
403
- for (let i = actions.length - 1; i >= 0; --i) {
404
- const { actionId } = actions[i]!;
405
- const transform = await this.storage.getTransaction(this.blockId, actionId);
406
- if (!transform) {
407
- throw new Error(`Missing action ${actionId} for block ${this.blockId}`);
408
- }
409
- block = applyTransform(block, transform);
410
- }
411
-
412
- if (!block) {
413
- // The reverse-apply collapsed to a tombstone (a `{ delete: true }` revision) the block
414
- // is absent at this revision, not corrupt. Read it back as undefined (matching getBlock's
415
- // "no materialized content" contract) rather than throwing. The genuine-truncation throw
416
- // ("Failed to find materialized block") above still fires when no materialization exists.
417
- return undefined;
418
- }
419
- if (actions.length) {
420
- // Re-cache the recomputed materialization ONLY at a retained rev (checkpoint / range floor /
421
- // tip). Caching unconditionally would let a cold read at a non-checkpoint historical rev
422
- // re-add a materialization the checkpoint sweep is designed to remove — storage would regrow
423
- // via reads. Skipping it means a repeated cold read re-replays each time, bounded by
424
- // `checkpointInterval` transforms.
425
- // NOTE: cold non-checkpoint historical reads re-replay every time (up to `checkpointInterval`
426
- // forward transforms). Acceptable — historical reads are rare and replay is depth-bounded. If
427
- // they ever show as hot, cache at the nearest checkpoint below the target instead of skipping.
428
- // Read metadata FRESH for the retention decision: the `meta` passed in was captured by getBlock
429
- // BEFORE ensureRevision, which may have restored the containing range during this same read
430
- // (ensureRevision mutates its own re-read, not this snapshot). A stale `meta.ranges` would send
431
- // rangeFloorOf into its fallback (treats the target as its own floor ⇒ wrongly "retained"),
432
- // re-caching a rev the sweep means to prune — regrowing storage via reads of restored ranges.
433
- const retentionMeta = (await this.storage.getMetadata(this.blockId)) ?? meta;
434
- const cacheRev = actions[0]!.rev;
435
- const latestRev = retentionMeta.latest?.rev ?? cacheRev;
436
- const rangeFloor = this.rangeFloorOf(cacheRev, retentionMeta.ranges);
437
- if (this.isRetainedRev(cacheRev, latestRev, rangeFloor)) {
438
- await this.storage.saveMaterializedBlock(this.blockId, actions[0]!.actionId, block);
439
- }
440
- return { block, actionRev: actions[0]! };
441
- }
442
- return { block, actionRev: materializedActionRev };
443
- }
444
-
445
- private async restoreBlock(rev: number): Promise<BlockArchive | undefined> {
446
- if (!this.restoreCallback) return undefined;
447
- return await this.restoreCallback(this.blockId, rev);
448
- }
449
-
450
- private async saveRestored(archive: BlockArchive) {
451
- const revisions = Object.entries(archive.revisions)
452
- .map(([rev, data]) => ({ rev: Number(rev), data }));
453
-
454
- // Save all revisions, actions, and materializations
455
- for (const { rev, data: { action, block } } of revisions) {
456
- await Promise.all([
457
- this.storage.saveRevision(this.blockId, rev, action.actionId),
458
- this.storage.saveTransaction(this.blockId, action.actionId, action.transform),
459
- block ? this.storage.saveMaterializedBlock(this.blockId, action.actionId, block) : Promise.resolve()
460
- ]);
461
- }
462
- }
463
-
464
- private inRanges(rev: number, ranges: RevisionRange[]): boolean {
465
- return ranges.some(range =>
466
- rev >= range[0] && (range[1] === undefined || rev < range[1])
467
- );
468
- }
469
-
470
- /**
471
- * Checkpoint retention predicate. A materialization at `rev` must be kept iff it is the tip
472
- * (`latestRev` the common read target and the replay base for the next commit), the floor of its
473
- * contiguous range (`rangeFloor` the descending walk in {@link materializeBlock} has nothing below
474
- * the floor to fall back to), or a periodic checkpoint (`rev % checkpointInterval === 0`, which bounds
475
- * replay depth). Otherwise the materialization is prunable its forward transform is retained, so the
476
- * rev stays reconstructible by replay from the nearest retained materialization below it. The floor
477
- * clause is SEPARATE and mandatory: absolute `rev % K` checkpoints do not automatically land on the
478
- * floor (e.g. floor `E = 1`, `K = 32`).
479
- */
480
- private isRetainedRev(rev: number, latestRev: number, rangeFloor: number): boolean {
481
- return rev === latestRev
482
- || rev === rangeFloor
483
- || rev % this.checkpointInterval === 0;
484
- }
485
-
486
- /** Start of the contiguous `ranges` span containing `rev`. Falls back to `rev` itself when no span
487
- * contains it unreachable for a committed rev (setLatest always merges the containing span before a
488
- * prune/read runs), and the conservative direction (treats `rev` as its own floor retained). */
489
- private rangeFloorOf(rev: number, ranges: RevisionRange[]): number {
490
- for (const range of ranges) {
491
- const [start, end] = range;
492
- if (rev >= start && (end === undefined || rev < end)) {
493
- return start;
494
- }
495
- }
496
- return rev;
497
- }
498
- }
1
+ import type { BlockId, IBlock, Transform, ActionId, ActionRev, ActionTransform } from "@optimystic/db-core";
2
+ import { applyTransform, canonicalJson, hashString } from "@optimystic/db-core";
3
+ import type { BlockCommitProof } from "../cluster/commit-proof.js";
4
+ import type { BlockArchive, BlockMetadata, RestoreCallback, RevisionRange } from "./struct.js";
5
+ import type { IRawStorage } from "./i-raw-storage.js";
6
+ import { mergeRanges } from "./helpers.js";
7
+ import { RevisionNotCoveredError, type IBlockStorage } from "./i-block-storage.js";
8
+ import type { BlockWriteLatch } from "./block-latch.js";
9
+ import { createLogger } from "../logger.js";
10
+
11
+ const log = createLogger('block-storage');
12
+
13
+ /**
14
+ * Default checkpoint cadence: a full materialization is retained at every `CHECKPOINT_INTERVAL`th
15
+ * revision (plus the tip and each range floor). This bounds the maximum replay depth for any read
16
+ * to at most `CHECKPOINT_INTERVAL` forward transforms. See {@link BlockStorage.pruneSupersededMaterialization}.
17
+ */
18
+ const CHECKPOINT_INTERVAL = 32;
19
+
20
+ /**
21
+ * One revision entry of a fetched archive, after {@link BlockStorage.vetRestoredArchive} has
22
+ * established that its key really is a revision number and that it carries an action. The `rev` here
23
+ * is the KEY the entry was filed under — the number `saveRestored` writes it as — not
24
+ * `action.rev`, which is optional and is only cross-checked against this.
25
+ */
26
+ type RestoredRevision = { rev: number; action: ActionTransform; block?: IBlock };
27
+
28
+ export class BlockStorage implements IBlockStorage {
29
+ constructor(
30
+ private readonly blockId: BlockId,
31
+ private readonly storage: IRawStorage,
32
+ private readonly restoreCallback?: RestoreCallback,
33
+ /**
34
+ * Revisions where `rev % checkpointInterval === 0` retain a full materialization even after they
35
+ * stop being the tip. Optional (default {@link CHECKPOINT_INTERVAL}); tests inject a small value
36
+ * to exercise sweeping without committing 32+ revisions.
37
+ */
38
+ private readonly checkpointInterval: number = CHECKPOINT_INTERVAL
39
+ ) { }
40
+
41
+ async getLatest(): Promise<ActionRev | undefined> {
42
+ const meta = await this.storage.getMetadata(this.blockId);
43
+ return meta?.latest;
44
+ }
45
+
46
+ /**
47
+ * Guard every write: the token must have been minted for THIS block and must still be live. The
48
+ * type already proves the caller went through `acquireBlockWriteLatch`; this catches the two
49
+ * things the type cannot a token for block A presented to block B's storage, and a token
50
+ * stashed by a callback and used after its scope released the latch.
51
+ */
52
+ private assertLatch(latch: BlockWriteLatch): void {
53
+ if (latch.blockId !== this.blockId) {
54
+ throw new Error(`Block ${this.blockId}: write latch was acquired for block ${latch.blockId}`);
55
+ }
56
+ if (!latch.live) {
57
+ throw new Error(`Block ${this.blockId}: write latch has already been released`);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * LOCAL-ONLY read never fetches from a peer. A revision outside `meta.ranges` throws
63
+ * {@link RevisionNotCoveredError}; the one caller allowed to heal that (`StorageRepo.get`) does so
64
+ * with {@link restoreRevision} under the block's write latch and re-reads. Keeping the fetch out of
65
+ * here is what lets the commit path hold N block latches with no network I/O inside them.
66
+ */
67
+ async getBlock(rev?: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined> {
68
+ const meta = await this.storage.getMetadata(this.blockId);
69
+ if (!meta) {
70
+ // No metadata at all ⇒ this node has never seen the block, and reads report it absent
71
+ // WITHOUT consulting `restoreCallback`. That is deliberate, not an oversight: `restoreCallback`
72
+ // is reachable only from restoreRevision, which a caller invokes only after THIS method has
73
+ // reported a coverage gap on a block that has metadata so a never-seen block is never fetched.
74
+ // Attempting a fetch for it would turn every read of a genuinely non-existent block —
75
+ // the common case for an insert probing for a collision — into a network round trip, because
76
+ // storage cannot tell "nobody has this" from "I don't have this".
77
+ //
78
+ // The layer that CAN tell them apart makes that call instead: `CoordinatorRepo` acquires the
79
+ // block only once the cohort has corroborated a `(rev, actionId)` for it
80
+ // (`restoreCorroborated` → `acquireBlockFromCohort`), so an id no peer claims still costs
81
+ // nothing beyond the latest-query it already performed. Keep this early return as-is.
82
+ return undefined;
83
+ }
84
+
85
+ // Pending-only state (metadata seeded by savePendingTransaction, nothing committed) with no
86
+ // revision named: "no committed base here" is an ABSENCE, not a fault — answer `undefined`.
87
+ // StorageRepo.get then applies any pending overlay over that absent base. A NAMED rev on a
88
+ // pending-only block falls through: it is covered only if an earlier restore brought it in
89
+ // (a restore can serve real content with `latest` still undefined), otherwise it reports the
90
+ // gap below and StorageRepo.get decides whether the failed restore reads as absent.
91
+ if (meta.latest === undefined && rev === undefined) {
92
+ return undefined;
93
+ }
94
+
95
+ const targetRev = rev ?? meta.latest!.rev;
96
+ if (!this.inRanges(targetRev, meta.ranges)) {
97
+ throw new RevisionNotCoveredError(this.blockId, targetRev);
98
+ }
99
+ // A throw from here means revision records exist with no materialization anywhere under them —
100
+ // genuine corruption, which StorageRepo.get reports as `unmaterializable`.
101
+ return await this.materializeBlock(meta, targetRev);
102
+ }
103
+
104
+ async restoreRevision(rev: number, latch: BlockWriteLatch): Promise<void> {
105
+ this.assertLatch(latch);
106
+ // One metadata read, under the held latch: the caller's earlier `getBlock` observed a gap, but
107
+ // a queued-ahead restore or replica may have filled it before this latch was granted.
108
+ const meta = await this.storage.getMetadata(this.blockId);
109
+ if (!meta) {
110
+ // Same reasoning as getBlock's early return: a never-seen block is not restored here.
111
+ throw new Error(`Block ${this.blockId} has no metadata; a never-seen block is not restored here.`);
112
+ }
113
+ if (this.inRanges(rev, meta.ranges)) {
114
+ return;
115
+ }
116
+
117
+ const restored = await this.restoreBlock(rev);
118
+ // An archive off this wire is a peer's UNVERIFIED answer (see {@link saveRestored}), so it
119
+ // is vetted before a byte of it reaches storage. A rejected archive is indistinguishable to
120
+ // the caller from an absent one — same throw — because both mean the same thing: this node
121
+ // still cannot serve `rev`. The specific reason is logged rather than thrown so that
122
+ // `StorageRepo.get`'s healing helper keeps one rule for every restore failure.
123
+ const coverage = restored ? await this.vetRestoredArchive(restored, rev) : undefined;
124
+ if (!restored || !coverage) {
125
+ throw new Error(`Block ${this.blockId} revision ${rev} not found during restore attempt.`);
126
+ }
127
+ await this.saveRestored(restored);
128
+
129
+ // The vetted coverage, NOT `restored.range`. The declared range is checked for internal
130
+ // consistency above but is not what gets recorded — see {@link vetRestoredArchive} for why
131
+ // the pin has to be folded in, or the same restore repeats on every read forever.
132
+ meta.ranges.unshift(coverage);
133
+ meta.ranges = mergeRanges(meta.ranges);
134
+ await this.storage.saveMetadata(this.blockId, meta);
135
+ }
136
+
137
+ async getTransaction(actionId: ActionId): Promise<Transform | undefined> {
138
+ return await this.storage.getTransaction(this.blockId, actionId);
139
+ }
140
+
141
+ async getBlockProof(rev: number): Promise<BlockCommitProof | undefined> {
142
+ return await this.storage.getBlockProof(this.blockId, rev);
143
+ }
144
+
145
+ async saveBlockProof(rev: number, proof: BlockCommitProof, latch: BlockWriteLatch): Promise<void> {
146
+ this.assertLatch(latch);
147
+ await this.storage.saveBlockProof(this.blockId, rev, proof);
148
+ }
149
+
150
+ async getPendingTransaction(actionId: ActionId): Promise<Transform | undefined> {
151
+ return await this.storage.getPendingTransaction(this.blockId, actionId);
152
+ }
153
+
154
+ async *listPendingTransactions(): AsyncIterable<ActionId> {
155
+ yield* this.storage.listPendingTransactions(this.blockId);
156
+ }
157
+
158
+ async savePendingTransaction(actionId: ActionId, transform: Transform, latch: BlockWriteLatch): Promise<void> {
159
+ this.assertLatch(latch);
160
+ log('pend blockId=%s actionId=%s', this.blockId, actionId);
161
+ let meta = await this.storage.getMetadata(this.blockId);
162
+ if (!meta) {
163
+ // A freshly-pended block holds NO committed revision, so it can reconstruct
164
+ // nothing yet: seed empty ranges. The first commit anchors an OPEN-ENDED span at
165
+ // the earliest held rev E ([E, +inf)); later commits/recover merge into it via
166
+ // setLatest/recover. Seeding open-ended `[[0]]` would falsely claim coverage of the
167
+ // un-held revs below E and disable restoreRevision's restore path.
168
+ //
169
+ // This read-then-seed is exactly the window a concurrent replica used to land in (the
170
+ // seed then erased its `latest`); the latch the caller holds is what closes it.
171
+ meta = { latest: undefined, ranges: [] };
172
+ await this.storage.saveMetadata(this.blockId, meta);
173
+ }
174
+ await this.storage.savePendingTransaction(this.blockId, actionId, transform);
175
+ }
176
+
177
+ async deletePendingTransaction(actionId: ActionId, latch: BlockWriteLatch): Promise<void> {
178
+ this.assertLatch(latch);
179
+ log('cancel blockId=%s actionId=%s', this.blockId, actionId);
180
+ await this.storage.deletePendingTransaction(this.blockId, actionId);
181
+ }
182
+
183
+ async *listRevisions(startRev: number, endRev: number): AsyncIterable<ActionRev> {
184
+ yield* this.storage.listRevisions(this.blockId, startRev, endRev);
185
+ }
186
+
187
+ async saveMaterializedBlock(actionId: ActionId, block: IBlock | undefined, latch: BlockWriteLatch): Promise<void> {
188
+ this.assertLatch(latch);
189
+ await this.storage.saveMaterializedBlock(this.blockId, actionId, block);
190
+ }
191
+
192
+ async pruneSupersededMaterialization(prior: ActionRev, latch: BlockWriteLatch): Promise<void> {
193
+ this.assertLatch(latch);
194
+ const meta = await this.storage.getMetadata(this.blockId);
195
+ // No metadata / no committed tip yet ⇒ nothing has superseded `prior`; leave it.
196
+ if (!meta || meta.latest === undefined) {
197
+ return;
198
+ }
199
+ // `prior` is the PRIOR latest, so it shares the (latest) range containing meta.latest.rev its
200
+ // floor is that span's start. Retain if it is the tip, that floor, or a checkpoint rev.
201
+ const rangeFloor = this.rangeFloorOf(meta.latest.rev, meta.ranges);
202
+ if (this.isRetainedRev(prior.rev, meta.latest.rev, rangeFloor)) {
203
+ return;
204
+ }
205
+ // Redundant: `prior`'s forward transform is retained, so it stays reconstructible by replay from
206
+ // the nearest retained materialization below it. Delete routes to the driver's deleteMaterialized;
207
+ // a no-op at the driver when `prior.rev` carried no materialization (e.g. a tombstone rev).
208
+ await this.storage.saveMaterializedBlock(this.blockId, prior.actionId, undefined);
209
+ log('prune blockId=%s rev=%d actionId=%s', this.blockId, prior.rev, prior.actionId);
210
+ }
211
+
212
+ async saveRevision(rev: number, actionId: ActionId, latch: BlockWriteLatch): Promise<void> {
213
+ this.assertLatch(latch);
214
+ await this.storage.saveRevision(this.blockId, rev, actionId);
215
+ }
216
+
217
+ async promotePendingTransaction(actionId: ActionId, latch: BlockWriteLatch): Promise<void> {
218
+ this.assertLatch(latch);
219
+ log('commit blockId=%s actionId=%s', this.blockId, actionId);
220
+ await this.storage.promotePendingTransaction(this.blockId, actionId);
221
+ }
222
+
223
+ async setLatest(latest: ActionRev, latch: BlockWriteLatch): Promise<void> {
224
+ this.assertLatch(latch);
225
+ const meta = await this.storage.getMetadata(this.blockId);
226
+ if (!meta) {
227
+ throw new Error(`Block ${this.blockId} not found`);
228
+ }
229
+ // Capture the prior latest rev BEFORE overwriting: coverage anchors to the earliest held rev.
230
+ const prevRev = meta.latest?.rev;
231
+ meta.latest = latest;
232
+ // NOTE: re-sorts (mergeRanges) the whole ranges array on every commit; if a block ever
233
+ // accumulates many disjoint ranges and commits show as slow, keep a running merged structure.
234
+ // `getBlock(r)` is served by materializeBlock's DESCENDING walk (highest committed rev <= r).
235
+ // Once this node holds the chain from the block's earliest committed rev E, EVERY rev >= E is
236
+ // serveable locally: a read at any r >= E resolves to the highest committed rev <= r (at worst
237
+ // the latest, which is materialized), so coverage is the OPEN-ENDED span [E, +inf) not the
238
+ // single point [L, L+1) (which wrongly missed reads above L, e.g. a block read at the collection
239
+ // tip after a later commit touched only its siblings) and not [0, +inf) (which wrongly claimed
240
+ // the un-held revs below E). Claim open-ended from the prior latest (>= E via merge); the first
241
+ // commit (prevRev undefined) anchors the span at E = L. mergeRanges folds it into the existing
242
+ // [E, +inf). Only revs BELOW E miss inRanges, which is exactly the genuine-gap/restore case.
243
+ // Range + latest advance in one saveMetadata write (atomic under the block write latch), so a crash
244
+ // before this call advances neither.
245
+ meta.ranges.unshift([prevRev ?? latest.rev]);
246
+ meta.ranges = mergeRanges(meta.ranges);
247
+ await this.storage.saveMetadata(this.blockId, meta);
248
+ }
249
+
250
+ async recover(latch: BlockWriteLatch): Promise<{ reconciled: boolean; latest?: ActionRev }> {
251
+ this.assertLatch(latch);
252
+ const meta = await this.storage.getMetadata(this.blockId);
253
+ if (!meta) {
254
+ return { reconciled: false };
255
+ }
256
+
257
+ const currentRev = meta.latest?.rev ?? 0;
258
+ let maxRev = currentRev;
259
+ let maxActionId = meta.latest?.actionId;
260
+
261
+ // Probe forward until we hit a gap or a revision whose action is not yet
262
+ // in the committed log (Crash-D2 state retry-commit owns that advance).
263
+ for (let next = currentRev + 1; ; next++) {
264
+ const actionId = await this.storage.getRevision(this.blockId, next);
265
+ if (actionId === undefined) break;
266
+ const promoted = await this.storage.getTransaction(this.blockId, actionId);
267
+ if (promoted === undefined) break;
268
+ maxRev = next;
269
+ maxActionId = actionId;
270
+ }
271
+
272
+ if (maxRev > currentRev && maxActionId !== undefined) {
273
+ const advanced: ActionRev = { rev: maxRev, actionId: maxActionId };
274
+ meta.latest = advanced;
275
+ // The lost setLatest would have merged each recovered revision's range; redo that
276
+ // here. Open-ended from currentRev+1 (see setLatest): every rev in (currentRev, maxRev]
277
+ // was verified present in the committed log above, and any rev > maxRev resolves via the
278
+ // descending walk to maxRev's materialization so [currentRev+1, +inf) is honest. It joins
279
+ // the prior [E, currentRev+1) (from the earlier setLatest) into one open-ended [E, +inf).
280
+ meta.ranges.unshift([currentRev + 1]);
281
+ meta.ranges = mergeRanges(meta.ranges);
282
+ await this.storage.saveMetadata(this.blockId, meta);
283
+ log('recover blockId=%s advanced latest from rev=%d to rev=%d', this.blockId, currentRev, maxRev);
284
+ return { reconciled: true, latest: advanced };
285
+ }
286
+
287
+ return { reconciled: false, latest: meta.latest };
288
+ }
289
+
290
+ async saveReplica(block: IBlock, source: ActionRev | undefined, proof: BlockCommitProof | undefined, latch: BlockWriteLatch): Promise<ActionRev> {
291
+ this.assertLatch(latch);
292
+ const rev = source?.rev ?? 1;
293
+ // Deterministic fallback id when the sender carried no revision metadata, so a
294
+ // re-push of the same block resolves to the same (rev, actionId) and stays
295
+ // idempotent. Never random/time-based — that would mint a new revision per retry.
296
+ const actionId = source?.actionId ?? await hashString(`${this.blockId}:${JSON.stringify(block)}`);
297
+
298
+ // Replica revision carries the materialized block. `{ insert: block }` satisfies saveRestored's
299
+ // write invariants; on the serving path materializeBlock returns the materialized block directly
300
+ // (single rev), so this transform is never applied — see ticket notes.
301
+ return await this.saveForwardRevision(
302
+ rev,
303
+ actionId,
304
+ { action: { actionId, rev, transform: { insert: block } }, block },
305
+ 'replica',
306
+ proof
307
+ );
308
+ }
309
+
310
+ async saveDeletion(source: ActionRev, latch: BlockWriteLatch): Promise<ActionRev> {
311
+ this.assertLatch(latch);
312
+ const { rev, actionId } = source;
313
+
314
+ // Forward tombstone: a `{ delete: true }` transform and NO materialized block. saveRestored
315
+ // skips materialization when `block` is absent, so the reverse-apply in materializeBlock
316
+ // resolves this revision to an absent block (read-back as undefined).
317
+ return await this.saveForwardRevision(
318
+ rev,
319
+ actionId,
320
+ { action: { actionId, rev, transform: { delete: true } } },
321
+ 'deletion'
322
+ );
323
+ }
324
+
325
+ /**
326
+ * Shared forward-write path for saveReplica and saveDeletion. Both append a single new revision
327
+ * that ADVANCES `latest` (never rewrites history): acquire the block's metadata latch, apply the
328
+ * monotonic guard, saveRestored a one-revision archive, then seed/advance/merge metadata.
329
+ *
330
+ * The only per-caller difference is the revision `body`: a replica carries `{ insert: block }`
331
+ * plus the materialized `block`; a deletion carries `{ delete: true }` and no block. `rev` and
332
+ * `actionId` are passed alongside `body` because the guard and the `latest` advance need them
333
+ * independently of the archive body.
334
+ *
335
+ * `verifiedProof` travels OUTSIDE `body` on purpose see {@link saveRestored}: it is the one
336
+ * channel that persists a proof, and only {@link saveReplica} (whose caller verified the proof
337
+ * against `body.block`) supplies it.
338
+ */
339
+ private async saveForwardRevision(
340
+ rev: number,
341
+ actionId: ActionId,
342
+ body: { action: ActionTransform; block?: IBlock },
343
+ logLabel: 'replica' | 'deletion',
344
+ verifiedProof?: BlockCommitProof
345
+ ): Promise<ActionRev> {
346
+ // The read-modify-write of this block's metadata below is serialized by the write latch the
347
+ // caller already holds (asserted in saveReplica / saveDeletion) — the same latch every other
348
+ // writer of this block holds, so a concurrent replica, deletion, restore, commit, or pend
349
+ // cannot land inside the window between the read and the saveMetadata.
350
+ let meta = await this.storage.getMetadata(this.blockId);
351
+
352
+ // Monotonic guard: an equal-or-newer revision is already held. The block (or tombstone) is
353
+ // durably present; do not downgrade `latest` or rewrite the metadata.
354
+ //
355
+ // This skip returns before persisting anything, INCLUDING `verifiedProof` — deliberately.
356
+ // The proof was verified against the PUSHED bytes; persisting it here would attach it to
357
+ // this node's HELD materialization, whose bytes at the same `(rev, actionId)` may differ if
358
+ // this holder diverged. A stored proof whose declared digest contradicts local content makes
359
+ // this node serve content that fails its own proof, and `digest-mismatch` is ATTRIBUTABLE in
360
+ // `cluster/certified-claims.ts` — every receiver would penalize it.
361
+ //
362
+ // Back-filling a proof onto an already-held revision therefore happens one layer up, in
363
+ // `StorageRepo.saveReplicatedBlock`'s non-advancing branch, which routes it through
364
+ // `backFillProof` → `persistProofIfContentMatches` — the rule that persists only when the
365
+ // LOCAL materialization matches the digest the commit op declared. Keep this guard a true
366
+ // no-op; the digest check is what makes the back-fill safe, and it does not belong here.
367
+ if (meta?.latest && meta.latest.rev >= rev) {
368
+ log('%s:skip blockId=%s rev=%d held=%d', logLabel, this.blockId, rev, meta.latest.rev);
369
+ return meta.latest;
370
+ }
371
+
372
+ // One-revision archive. A replica's body carries the materialized block; a deletion's body
373
+ // omits it (forward tombstone). saveRestored skips materialization when `block` is absent,
374
+ // so a tombstone reverse-applies to an absent block (read back as undefined).
375
+ const archive: BlockArchive = {
376
+ blockId: this.blockId,
377
+ revisions: {
378
+ [rev]: body
379
+ },
380
+ range: [rev, rev + 1]
381
+ };
382
+ await this.saveRestored(archive, verifiedProof ? { rev, proof: verifiedProof } : undefined);
383
+
384
+ // INVARIANT P: a block never holds a pending record AND a committed record for the same
385
+ // action id. On the commit path `promotePendingTransaction` maintains it by MOVING the
386
+ // record atomically; this forward path writes the committed transform directly (via
387
+ // saveRestored above), so it owes the deletion itself. Without it, a node that pended the
388
+ // action but diverged before committing keeps a record nothing can ever promote — reported
389
+ // as a phantom conflicting action by every later `pend` on the block, which under
390
+ // `policy: 'f'` refuses that node's participation in the block's writes permanently.
391
+ //
392
+ // Deliberately on the WRITE path only: the monotonic guard above returns before here, and
393
+ // that early return must stay a true no-op (the earlier call that wrote the revision is the
394
+ // one that owed the deletion). Deliberately here rather than in `saveRestored`, which is
395
+ // also reached from restoreRevision's historical restore, where a held revision's pending
396
+ // record is not this writer's to delete. Both paths run under the block's write latch, so
397
+ // this deletion is already mutually exclusive with a live commit.
398
+ //
399
+ // NOTE: deletes only this revision's actionId, not every pending whose action is already
400
+ // committed. A broader sweep would repair records orphaned by routes that do not carry the
401
+ // committing actionId; if orphaned pendings ever show up in the field on blocks whose
402
+ // committing action id differs, widen to a sweep over listPendingTransactions filtered by
403
+ // getTransaction.
404
+ await this.storage.deletePendingTransaction(this.blockId, actionId);
405
+
406
+ // Seed metadata when absent, advance latest, and merge the covered range.
407
+ const prevRev = meta?.latest?.rev;
408
+ if (!meta) {
409
+ meta = { latest: undefined, ranges: [] };
410
+ }
411
+ meta.latest = { rev, actionId };
412
+ // Open-ended coverage from the earliest held rev (see setLatest): the descending walk serves
413
+ // any rev >= the anchor. A prior latest at prevRev (< rev per the monotonic guard) is a
414
+ // materialized point, so anchor at prevRev; the first write (prevRev undefined) anchors at
415
+ // rev. Freshness of a stale replica is a separate (replication-lag) concern from what this
416
+ // node can locally reconstruct, which is exactly what ranges records.
417
+ meta.ranges.unshift([prevRev ?? rev]);
418
+ meta.ranges = mergeRanges(meta.ranges);
419
+ await this.storage.saveMetadata(this.blockId, meta);
420
+
421
+ log('%s:save blockId=%s rev=%d actionId=%s', logLabel, this.blockId, rev, actionId);
422
+ return meta.latest;
423
+ }
424
+
425
+ private async materializeBlock(meta: BlockMetadata, targetRev: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined> {
426
+ let block: IBlock | undefined;
427
+ let materializedActionRev: ActionRev | undefined;
428
+ const actions: ActionRev[] = [];
429
+
430
+ // Find the materialized block
431
+ for await (const actionRev of this.storage.listRevisions(this.blockId, targetRev, 1)) {
432
+ const materializedBlock = await this.storage.getMaterializedBlock(this.blockId, actionRev.actionId);
433
+ if (materializedBlock) {
434
+ block = materializedBlock;
435
+ materializedActionRev = actionRev;
436
+ break;
437
+ } else {
438
+ actions.push(actionRev);
439
+ }
440
+ }
441
+
442
+ if (!block || !materializedActionRev) {
443
+ // There is an implicit requirement that there must be a materialization of the block somewhere in it's history. If the log is truncated, a materialization must be made at the truncation point..
444
+ throw new Error(`Failed to find materialized block ${this.blockId} for revision ${targetRev}`);
445
+ }
446
+
447
+ // Apply transforms in reverse order
448
+ for (let i = actions.length - 1; i >= 0; --i) {
449
+ const { actionId } = actions[i]!;
450
+ const transform = await this.storage.getTransaction(this.blockId, actionId);
451
+ if (!transform) {
452
+ throw new Error(`Missing action ${actionId} for block ${this.blockId}`);
453
+ }
454
+ block = applyTransform(block, transform);
455
+ }
456
+
457
+ if (!block) {
458
+ // The reverse-apply collapsed to a tombstone (a `{ delete: true }` revision) — the block
459
+ // is absent at this revision, not corrupt. Read it back as undefined (matching getBlock's
460
+ // "no materialized content" contract) rather than throwing. The genuine-truncation throw
461
+ // ("Failed to find materialized block") above still fires when no materialization exists.
462
+ return undefined;
463
+ }
464
+ if (actions.length) {
465
+ // Re-cache the recomputed materialization ONLY at a retained rev (checkpoint / range floor /
466
+ // tip). Caching unconditionally would let a cold read at a non-checkpoint historical rev
467
+ // re-add a materialization the checkpoint sweep is designed to remove — storage would regrow
468
+ // via reads. Skipping it means a repeated cold read re-replays each time, bounded by
469
+ // `checkpointInterval` transforms.
470
+ // NOTE: cold non-checkpoint historical reads re-replay every time (up to `checkpointInterval`
471
+ // forward transforms). Acceptable historical reads are rare and replay is depth-bounded. If
472
+ // they ever show as hot, cache at the nearest checkpoint below the target instead of skipping.
473
+ // NOTE: this `saveMaterializedBlock` is the ONE named exclusion from the storage invariant that
474
+ // every write to a block holds `blockWriteLatchKey(blockId)` (see block-latch.ts). It runs on the
475
+ // READ path, unlatched, and that is safe for CONTENT because it is not a read-modify-write of
476
+ // anything: the key is `(blockId, actionId)` and the value is a deterministic replay of
477
+ // transforms this node has already retained, so a concurrent SAVE of the same key writes the
478
+ // same bytes. It touches neither the metadata blob nor any revision record, so it cannot
479
+ // clobber `latest`. Taking the latch here would put a lock acquisition on every cold historical
480
+ // read and would deadlock the callers that already hold it. Dropping the re-cache entirely is
481
+ // the other way to close the exclusion, and is out of scope until someone measures the cold
482
+ // historical-read cost of doing without it.
483
+ //
484
+ // NOTE: the one racer that does NOT write the same bytes at this key is
485
+ // `pruneSupersededMaterialization`, which DELETES it (saveMaterializedBlock(..., undefined)).
486
+ // Losing that race resurrects a materialization the sweep just removed a bounded storage
487
+ // leak, never wrong content, since the resurrected bytes are a correct materialization of that
488
+ // rev. The fresh `retentionMeta` read below narrows the window but cannot close it: a commit
489
+ // can land and prune between that read and this save. If materialization storage is ever seen
490
+ // to grow under read load, close it by having the prune win — e.g. re-check retention inside
491
+ // the raw driver's save, or have the sweep re-run after the read.
492
+ //
493
+ // Read metadata FRESH for the retention decision: because this runs outside the block's write
494
+ // latch, a concurrent restore or commit may have moved `ranges` since `meta` was captured. A stale
495
+ // `meta.ranges` would send rangeFloorOf into its fallback (treats the target as its own floor
496
+ // ⇒ wrongly "retained"), re-caching a rev the sweep means to prune — regrowing storage via reads.
497
+ const retentionMeta = (await this.storage.getMetadata(this.blockId)) ?? meta;
498
+ const cacheRev = actions[0]!.rev;
499
+ const latestRev = retentionMeta.latest?.rev ?? cacheRev;
500
+ const rangeFloor = this.rangeFloorOf(cacheRev, retentionMeta.ranges);
501
+ if (this.isRetainedRev(cacheRev, latestRev, rangeFloor)) {
502
+ await this.storage.saveMaterializedBlock(this.blockId, actions[0]!.actionId, block);
503
+ }
504
+ return { block, actionRev: actions[0]! };
505
+ }
506
+ return { block, actionRev: materializedActionRev };
507
+ }
508
+
509
+ private async restoreBlock(rev: number): Promise<BlockArchive | undefined> {
510
+ if (!this.restoreCallback) return undefined;
511
+ return await this.restoreCallback(this.blockId, rev);
512
+ }
513
+
514
+ /**
515
+ * Vet an archive fetched for a PINNED restore of `rev`, returning the revision coverage to record
516
+ * for it — or `undefined` when the archive must be refused, in which case nothing is written at
517
+ * all and the reason is logged.
518
+ *
519
+ * This is the whole trust boundary for the restore wire. `restoreBlock`'s
520
+ * `RestorationCoordinator` verifies nothing about a response (`queryPeer` returns
521
+ * `response.archive` straight through), so every field below is a remote peer's assertion, and
522
+ * `saveRestored` writes keyed by REVISION and by ACTION ID — meaning an archive naming a
523
+ * revision or action id this node already holds would otherwise overwrite content that was never
524
+ * in question. The checks, in order:
525
+ *
526
+ * - **The archive is about this block.** `saveRestored` writes under `this.blockId` and ignores
527
+ * `archive.blockId`, so an answer about a different block would land as this block's history.
528
+ * - **Every revision key is a real revision.** Keys arrive as JSON strings; a non-numeric key
529
+ * coerces to `NaN` and would be stored as a garbage revision number. Min/max are folded rather
530
+ * than spread through `Math.min`/`Math.max`, which throws `RangeError` past ~125k arguments —
531
+ * reachable inside the 8 MiB sync-response cap (see `maxArchiveRevision`, same hazard).
532
+ * - **Each entry's own `rev`, when it declares one, agrees with the key it is filed under.**
533
+ * That disagreement IS the mislabel this ticket's family of bugs is about, in miniature.
534
+ * - **The archive answers the pin.** NOT "carries revision `rev`" — `ActionContext.rev` is a
535
+ * COLLECTION-wide revision, so it routinely sits above the revision at which this particular
536
+ * block last changed. A peer answering a pin at 9 for a block whose last commit was rev 2
537
+ * correctly serves rev 2, labelled as rev 2 (pinned in `test/block-archive-proof.spec.ts`).
538
+ * So the rule is that the archive's LOWEST revision is at or below the pin: `materializeBlock`
539
+ * descends from `rev`, so an archive entirely above the pin answers a different question and
540
+ * is exactly the "old bytes under a newer label" shape that overwrites good local data.
541
+ * - **The declared `range` agrees with the revisions actually carried** — it starts at the
542
+ * lowest (the floor must be present, or the descending walk has nothing to land on) and ends
543
+ * past the highest. An OPEN-ENDED range is refused outright: it would claim infinite coverage
544
+ * and permanently disable restore for this block on one unverified peer's say-so.
545
+ * (`RestoreCallback` allows open-ended for the UNPINNED call; `restoreRevision` never makes one.)
546
+ * - **Nothing already held is overwritten with different content** — see
547
+ * {@link noDivergentRewrite}.
548
+ *
549
+ * ## What gets recorded, and the one thing taken on trust
550
+ *
551
+ * The coverage returned is `[lowest, rev + 1)` — the archive's floor, up to the PIN and no
552
+ * further. Both halves of that are deliberate.
553
+ *
554
+ * Extending UP to the pin is an INFERENCE, and the only one here: a peer
555
+ * answering a pinned fetch with revision M ≤ N means "M is my highest committed revision of this
556
+ * block at or below N", i.e. nothing changed in (M, N]. This node cannot verify that locally.
557
+ *
558
+ * It is recorded anyway because the alternative is worse. `meta.ranges` is what
559
+ * {@link restoreRevision} consults to decide whether to fetch at all, so recording only the
560
+ * archive's literal `[M, M+1)` leaves `inRanges(N)` false and re-runs the ENTIRE restore — network
561
+ * round trip plus a full `saveRestored` write — on every later read at that pin, forever, never
562
+ * converging. The inference is also unavoidable rather than merely convenient: having the peer
563
+ * state the claim on the wire instead would not make it verifiable, only explicit, while breaking
564
+ * repair against every peer running an older build.
565
+ *
566
+ * Stopping AT the pin, on the other hand, discards coverage for any revision the archive
567
+ * volunteered ABOVE it. Those entries are still WRITTEN — an honest peer serves a contiguous
568
+ * span, so an archive of `{2, 3, 4}` answering a pin at 3 is normal, not hostile — they are
569
+ * merely not CLAIMED. `rev` is the one number in the exchange this node chose, so it is as far as
570
+ * its trust in the answer should reach; recording `highest + 1` instead would let the peer set
571
+ * the width of its own credibility by padding the archive with fabricated high revisions, and
572
+ * reads across that padded span would then be served from local content without ever re-asking.
573
+ * The cost is one redundant fetch the first time a revision above the pin is read; that fetch is
574
+ * idempotent (identical content is not a conflict, see {@link noDivergentRewrite}) and the
575
+ * coverage converges.
576
+ *
577
+ * NOTE: accepted tradeoff — a lying peer's answer is therefore STICKY across the whole span it
578
+ * was asked about: reads between M and N are served locally from M's content and never re-ask, so
579
+ * a later honest peer is never consulted for them. Weighed against an unbounded re-fetch loop and
580
+ * kept; that is the same "ranges records what this node can locally reconstruct, freshness is a
581
+ * separate concern" position `setLatest` and `saveForwardRevision` already take. Revisit if a
582
+ * restore ever gains a way to verify an archive (a commit proof chain over the served revision
583
+ * would do it) — at that point record only what verifies.
584
+ */
585
+ private async vetRestoredArchive(archive: BlockArchive, rev: number): Promise<RevisionRange | undefined> {
586
+ const refuse = (why: string, ...args: unknown[]): undefined => {
587
+ log(`restore:refused blockId=%s rev=%d ${why}`, this.blockId, rev, ...args);
588
+ return undefined;
589
+ };
590
+
591
+ if (archive.blockId !== this.blockId) {
592
+ return refuse('archive is for blockId=%s', archive.blockId);
593
+ }
594
+
595
+ const entries: RestoredRevision[] = [];
596
+ let lowest: number | undefined;
597
+ let highest: number | undefined;
598
+ for (const [key, entry] of Object.entries(archive.revisions ?? {})) {
599
+ const entryRev = Number(key);
600
+ // `String(entryRev) === key`, not merely "parses as an integer". `saveRestored` re-derives
601
+ // the number with its own `Number(key)`, so any key with a second spelling (`"02"`, `" 2"`,
602
+ // `"2e1"`) lets ONE archive file two entries under one revision: the vet checks both, the
603
+ // write keeps whichever `Object.entries` yields last, and which one that is was never the
604
+ // question either check answered.
605
+ if (!Number.isInteger(entryRev) || entryRev < 1 || String(entryRev) !== key) {
606
+ return refuse('revision key %s is not a revision', key);
607
+ }
608
+ const action = entry?.action;
609
+ if (!action?.actionId) {
610
+ return refuse('revision %d carries no action', entryRev);
611
+ }
612
+ if (action.rev !== undefined && action.rev !== entryRev) {
613
+ return refuse('revision %d is filed under an action declaring rev=%d', entryRev, action.rev);
614
+ }
615
+ entries.push({ rev: entryRev, action, block: entry.block });
616
+ if (lowest === undefined || entryRev < lowest) lowest = entryRev;
617
+ if (highest === undefined || entryRev > highest) highest = entryRev;
618
+ }
619
+ if (lowest === undefined || highest === undefined) {
620
+ return refuse('carries no revisions');
621
+ }
622
+
623
+ if (lowest > rev) {
624
+ return refuse('lowest revision %d is above the pin', lowest);
625
+ }
626
+
627
+ const range = archive.range;
628
+ if (!Array.isArray(range)) {
629
+ return refuse('declares no range');
630
+ }
631
+ const [start, end] = range;
632
+ if (start !== lowest) {
633
+ return refuse('range starts at %o but revisions start at %d', start, lowest);
634
+ }
635
+ if (end === undefined || !Number.isInteger(end) || end <= highest) {
636
+ return refuse('range ends at %o but revisions end at %d', end, highest);
637
+ }
638
+
639
+ if (!await this.noDivergentRewrite(entries, refuse)) {
640
+ return undefined;
641
+ }
642
+
643
+ return [lowest, rev + 1];
644
+ }
645
+
646
+ /**
647
+ * True when none of `entries` would overwrite content this node ALREADY holds with different
648
+ * content. False (having logged which entry, via `refuse`) when any would.
649
+ *
650
+ * The refusal is all-or-nothing: one divergent entry rejects the WHOLE archive rather than
651
+ * landing the entries this node happens to lack. Two reasons. An archive that contradicts locally
652
+ * held content is evidence the peer is wrong or hostile about this block, which makes the rest of
653
+ * it no more trustworthy than the part that was caught; and a partial apply would leave
654
+ * {@link vetRestoredArchive}'s coverage claiming a span the applied subset may not support.
655
+ *
656
+ * Identical content is NOT a conflict — a re-restore of the same archive must stay idempotent,
657
+ * which it has to be for the pin-extended coverage above to converge.
658
+ *
659
+ * NOTE: accepted tradeoff — first writer wins, permanently. Once a revision record is held, no
660
+ * later archive can replace it, so a lying peer that answers a gap FIRST makes every honest
661
+ * archive restating that revision refuse forever: the revision becomes unreadable rather than
662
+ * wrong. That is the deliberate direction — this guard exists precisely so a peer cannot rewrite
663
+ * held history, and it cannot tell "the held copy is the lie" from "the incoming copy is". Repair
664
+ * from that state is an operator action (drop the block's local records and re-fetch). Revisit
665
+ * only alongside a way to verify an archive, which would let the guard prefer the provable copy
666
+ * instead of the earlier one.
667
+ *
668
+ * Comparison is by `canonicalJson`, db-core's one deterministic encoding, so key ORDER across a
669
+ * JSON round trip over the wire never reads as divergence.
670
+ *
671
+ * The three keys mirror {@link saveRestored}'s three writes exactly; an entry that carries no
672
+ * `block` writes no materialization, so it cannot clobber one and is not checked for it.
673
+ *
674
+ * NOTE: costs up to three raw-storage reads per revision entry, on the restore path only — which
675
+ * has already paid for a network round trip, so it is not the term that matters. If a restore
676
+ * ever carries thousands of revisions and this shows up, check `getRevision` first and skip the
677
+ * other two for a revision this node does not hold at all.
678
+ */
679
+ private async noDivergentRewrite(
680
+ entries: RestoredRevision[],
681
+ refuse: (why: string, ...args: unknown[]) => undefined
682
+ ): Promise<boolean> {
683
+ for (const { rev, action, block } of entries) {
684
+ const heldActionId = await this.storage.getRevision(this.blockId, rev);
685
+ if (heldActionId !== undefined && heldActionId !== action.actionId) {
686
+ refuse('revision %d is already held as action %s, archive names %s',
687
+ rev, heldActionId, action.actionId);
688
+ return false;
689
+ }
690
+
691
+ const heldTransform = await this.storage.getTransaction(this.blockId, action.actionId);
692
+ if (heldTransform !== undefined && canonicalJson(heldTransform) !== canonicalJson(action.transform)) {
693
+ refuse('action %s (revision %d) is already held with a different transform', action.actionId, rev);
694
+ return false;
695
+ }
696
+
697
+ if (block) {
698
+ const heldBlock = await this.storage.getMaterializedBlock(this.blockId, action.actionId);
699
+ if (heldBlock !== undefined && canonicalJson(heldBlock) !== canonicalJson(block)) {
700
+ refuse('action %s (revision %d) is already materialized with different content', action.actionId, rev);
701
+ return false;
702
+ }
703
+ }
704
+ }
705
+ return true;
706
+ }
707
+
708
+ /**
709
+ * Persist a fetched archive's revisions locally.
710
+ *
711
+ * A revision entry's own `proof` is deliberately IGNORED. An archive is remote wire data —
712
+ * {@link restoreBlock}'s `RestorationCoordinator` fetch verifies nothing, and a peer chooses
713
+ * what to attach — so persisting a proof read out of the archive body would re-serve a hostile
714
+ * peer's artifact as evidence this node retained itself. A proof reaches storage only through
715
+ * `verified`, passed out-of-band alongside the archive, which exactly one caller chain supplies:
716
+ * `cluster/reconcile-block.ts` → `StorageRepo.saveReplicatedBlock` → {@link saveReplica} →
717
+ * {@link saveForwardRevision}, where `certifyContent` had already bound the proof to these exact
718
+ * bytes. A separate parameter rather than a caller obligation to strip is what makes "an
719
+ * unverified proof reached `saveBlockProof`" unrepresentable instead of merely documented.
720
+ *
721
+ * This is a WRITER, not a gate: it trusts what it is handed, and each of its two callers is
722
+ * responsible for having earned that on its own terms.
723
+ *
724
+ * - {@link restoreRevision} — the unverified restore wire — runs {@link vetRestoredArchive}
725
+ * first. Those checks are ABOUT the pinned request (does the archive answer the revision that
726
+ * was asked for?), and this function has no pin to check against, so they cannot live here.
727
+ * - {@link saveForwardRevision} — reached by `saveReplica`/`saveDeletion` through
728
+ * `StorageRepo.saveReplicatedBlock` — builds the archive it passes from local arguments, and
729
+ * on the replica path `cluster/reconcile-block.ts` has already bound those bytes to a verified
730
+ * proof. It writes strictly ABOVE its own `latest` (the monotonic guard returns first
731
+ * otherwise), so it cannot rewrite held history, and it deliberately pays nothing for the
732
+ * restore wire's checks.
733
+ *
734
+ * A THIRD caller would not inherit either argument. Any future one that takes an archive off a
735
+ * network must route through `vetRestoredArchive` (or an equivalent for its own trust model)
736
+ * before reaching here.
737
+ */
738
+ private async saveRestored(archive: BlockArchive, verified?: { rev: number; proof: BlockCommitProof }) {
739
+ const revisions = Object.entries(archive.revisions)
740
+ .map(([rev, data]) => ({ rev: Number(rev), data }));
741
+
742
+ // Save all revisions, actions, materializations, and the caller-verified proof (if any).
743
+ for (const { rev, data: { action, block } } of revisions) {
744
+ await Promise.all([
745
+ this.storage.saveRevision(this.blockId, rev, action.actionId),
746
+ this.storage.saveTransaction(this.blockId, action.actionId, action.transform),
747
+ block ? this.storage.saveMaterializedBlock(this.blockId, action.actionId, block) : Promise.resolve(),
748
+ verified?.rev === rev ? this.storage.saveBlockProof(this.blockId, rev, verified.proof) : Promise.resolve()
749
+ ]);
750
+ }
751
+ }
752
+
753
+ private inRanges(rev: number, ranges: RevisionRange[]): boolean {
754
+ return ranges.some(range =>
755
+ rev >= range[0] && (range[1] === undefined || rev < range[1])
756
+ );
757
+ }
758
+
759
+ /**
760
+ * Checkpoint retention predicate. A materialization at `rev` must be kept iff it is the tip
761
+ * (`latestRev` — the common read target and the replay base for the next commit), the floor of its
762
+ * contiguous range (`rangeFloor` — the descending walk in {@link materializeBlock} has nothing below
763
+ * the floor to fall back to), or a periodic checkpoint (`rev % checkpointInterval === 0`, which bounds
764
+ * replay depth). Otherwise the materialization is prunable — its forward transform is retained, so the
765
+ * rev stays reconstructible by replay from the nearest retained materialization below it. The floor
766
+ * clause is SEPARATE and mandatory: absolute `rev % K` checkpoints do not automatically land on the
767
+ * floor (e.g. floor `E = 1`, `K = 32`).
768
+ */
769
+ private isRetainedRev(rev: number, latestRev: number, rangeFloor: number): boolean {
770
+ return rev === latestRev
771
+ || rev === rangeFloor
772
+ || rev % this.checkpointInterval === 0;
773
+ }
774
+
775
+ /** Start of the contiguous `ranges` span containing `rev`. Falls back to `rev` itself when no span
776
+ * contains it — unreachable for a committed rev (setLatest always merges the containing span before a
777
+ * prune/read runs), and the conservative direction (treats `rev` as its own floor ⇒ retained). */
778
+ private rangeFloorOf(rev: number, ranges: RevisionRange[]): number {
779
+ for (const range of ranges) {
780
+ const [start, end] = range;
781
+ if (rev >= start && (end === undefined || rev < end)) {
782
+ return start;
783
+ }
784
+ }
785
+ return rev;
786
+ }
787
+ }