@optimystic/db-p2p 0.14.1 → 0.16.2

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 (275) hide show
  1. package/README.md +616 -609
  2. package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
  3. package/dist/src/cluster/block-transfer-service.js +4 -3
  4. package/dist/src/cluster/block-transfer-service.js.map +1 -1
  5. package/dist/src/cluster/block-transfer.d.ts +52 -3
  6. package/dist/src/cluster/block-transfer.d.ts.map +1 -1
  7. package/dist/src/cluster/block-transfer.js +129 -6
  8. package/dist/src/cluster/block-transfer.js.map +1 -1
  9. package/dist/src/cluster/client.d.ts.map +1 -1
  10. package/dist/src/cluster/client.js +3 -1
  11. package/dist/src/cluster/client.js.map +1 -1
  12. package/dist/src/cluster/cluster-repo.d.ts +182 -11
  13. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  14. package/dist/src/cluster/cluster-repo.js +429 -69
  15. package/dist/src/cluster/cluster-repo.js.map +1 -1
  16. package/dist/src/cluster/peer-key-binding.d.ts +25 -0
  17. package/dist/src/cluster/peer-key-binding.d.ts.map +1 -0
  18. package/dist/src/cluster/peer-key-binding.js +39 -0
  19. package/dist/src/cluster/peer-key-binding.js.map +1 -0
  20. package/dist/src/cluster/quorum-restore.d.ts +72 -0
  21. package/dist/src/cluster/quorum-restore.d.ts.map +1 -0
  22. package/dist/src/cluster/quorum-restore.js +0 -0
  23. package/dist/src/cluster/quorum-restore.js.map +1 -0
  24. package/dist/src/cluster/rebalance-monitor.d.ts +23 -1
  25. package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
  26. package/dist/src/cluster/rebalance-monitor.js +17 -2
  27. package/dist/src/cluster/rebalance-monitor.js.map +1 -1
  28. package/dist/src/cluster/service.d.ts.map +1 -1
  29. package/dist/src/cluster/service.js +7 -1
  30. package/dist/src/cluster/service.js.map +1 -1
  31. package/dist/src/cluster/spread-on-churn.d.ts +7 -0
  32. package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
  33. package/dist/src/cluster/spread-on-churn.js +4 -1
  34. package/dist/src/cluster/spread-on-churn.js.map +1 -1
  35. package/dist/src/cluster/supermajority-coupling.d.ts +22 -0
  36. package/dist/src/cluster/supermajority-coupling.d.ts.map +1 -0
  37. package/dist/src/cluster/supermajority-coupling.js +27 -0
  38. package/dist/src/cluster/supermajority-coupling.js.map +1 -0
  39. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +46 -4
  40. package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -1
  41. package/dist/src/cohort-topic/cohort-gossip-driver.js +62 -7
  42. package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -1
  43. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +7 -0
  44. package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -1
  45. package/dist/src/cohort-topic/cohort-gossip-transport.js +9 -0
  46. package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -1
  47. package/dist/src/cohort-topic/host.d.ts +197 -27
  48. package/dist/src/cohort-topic/host.d.ts.map +1 -1
  49. package/dist/src/cohort-topic/host.js +643 -87
  50. package/dist/src/cohort-topic/host.js.map +1 -1
  51. package/dist/src/dispute/arbitrator-selection.d.ts +68 -7
  52. package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -1
  53. package/dist/src/dispute/arbitrator-selection.js +105 -16
  54. package/dist/src/dispute/arbitrator-selection.js.map +1 -1
  55. package/dist/src/dispute/cascade.d.ts +7 -0
  56. package/dist/src/dispute/cascade.d.ts.map +1 -1
  57. package/dist/src/dispute/cascade.js +1 -1
  58. package/dist/src/dispute/cascade.js.map +1 -1
  59. package/dist/src/dispute/client.d.ts.map +1 -1
  60. package/dist/src/dispute/client.js +3 -2
  61. package/dist/src/dispute/client.js.map +1 -1
  62. package/dist/src/dispute/dispute-service.d.ts +32 -3
  63. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  64. package/dist/src/dispute/dispute-service.js +82 -9
  65. package/dist/src/dispute/dispute-service.js.map +1 -1
  66. package/dist/src/dispute/index.d.ts +1 -1
  67. package/dist/src/dispute/index.d.ts.map +1 -1
  68. package/dist/src/dispute/index.js +1 -1
  69. package/dist/src/dispute/index.js.map +1 -1
  70. package/dist/src/dispute/invalidation.d.ts +25 -0
  71. package/dist/src/dispute/invalidation.d.ts.map +1 -1
  72. package/dist/src/dispute/invalidation.js +14 -2
  73. package/dist/src/dispute/invalidation.js.map +1 -1
  74. package/dist/src/dispute/service.d.ts.map +1 -1
  75. package/dist/src/dispute/service.js +7 -1
  76. package/dist/src/dispute/service.js.map +1 -1
  77. package/dist/src/index.d.ts +4 -1
  78. package/dist/src/index.d.ts.map +1 -1
  79. package/dist/src/index.js +4 -1
  80. package/dist/src/index.js.map +1 -1
  81. package/dist/src/it-utility.d.ts +1 -1
  82. package/dist/src/it-utility.d.ts.map +1 -1
  83. package/dist/src/it-utility.js +4 -13
  84. package/dist/src/it-utility.js.map +1 -1
  85. package/dist/src/libp2p-key-network.d.ts +98 -1
  86. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  87. package/dist/src/libp2p-key-network.js +272 -17
  88. package/dist/src/libp2p-key-network.js.map +1 -1
  89. package/dist/src/libp2p-node-base.d.ts +12 -0
  90. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  91. package/dist/src/libp2p-node-base.js +415 -87
  92. package/dist/src/libp2p-node-base.js.map +1 -1
  93. package/dist/src/matchmaking/query-transport.d.ts +2 -0
  94. package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
  95. package/dist/src/matchmaking/query-transport.js +6 -0
  96. package/dist/src/matchmaking/query-transport.js.map +1 -1
  97. package/dist/src/matchmaking/traffic-validation.js +1 -0
  98. package/dist/src/matchmaking/traffic-validation.js.map +1 -1
  99. package/dist/src/network/network-manager-service.d.ts +2 -2
  100. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  101. package/dist/src/network/network-manager-service.js +8 -4
  102. package/dist/src/network/network-manager-service.js.map +1 -1
  103. package/dist/src/owned-block-seed.d.ts +27 -0
  104. package/dist/src/owned-block-seed.d.ts.map +1 -0
  105. package/dist/src/owned-block-seed.js +37 -0
  106. package/dist/src/owned-block-seed.js.map +1 -0
  107. package/dist/src/protocol-client.d.ts +1 -0
  108. package/dist/src/protocol-client.d.ts.map +1 -1
  109. package/dist/src/protocol-client.js +9 -2
  110. package/dist/src/protocol-client.js.map +1 -1
  111. package/dist/src/protocol-limits.d.ts +32 -0
  112. package/dist/src/protocol-limits.d.ts.map +1 -0
  113. package/dist/src/protocol-limits.js +32 -0
  114. package/dist/src/protocol-limits.js.map +1 -0
  115. package/dist/src/repo/client.d.ts.map +1 -1
  116. package/dist/src/repo/client.js +3 -0
  117. package/dist/src/repo/client.js.map +1 -1
  118. package/dist/src/repo/cluster-coordinator.d.ts +41 -4
  119. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  120. package/dist/src/repo/cluster-coordinator.js +128 -80
  121. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  122. package/dist/src/repo/coordinator-repo.d.ts +38 -1
  123. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  124. package/dist/src/repo/coordinator-repo.js +92 -13
  125. package/dist/src/repo/coordinator-repo.js.map +1 -1
  126. package/dist/src/repo/service.d.ts.map +1 -1
  127. package/dist/src/repo/service.js +7 -1
  128. package/dist/src/repo/service.js.map +1 -1
  129. package/dist/src/reputation/peer-reputation.js +1 -1
  130. package/dist/src/reputation/peer-reputation.js.map +1 -1
  131. package/dist/src/reputation/types.d.ts +3 -1
  132. package/dist/src/reputation/types.d.ts.map +1 -1
  133. package/dist/src/reputation/types.js +3 -0
  134. package/dist/src/reputation/types.js.map +1 -1
  135. package/dist/src/rn.d.ts +5 -1
  136. package/dist/src/rn.d.ts.map +1 -1
  137. package/dist/src/rn.js +7 -1
  138. package/dist/src/rn.js.map +1 -1
  139. package/dist/src/storage/arachnode-fret-adapter.d.ts +30 -1
  140. package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
  141. package/dist/src/storage/arachnode-fret-adapter.js +15 -2
  142. package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
  143. package/dist/src/storage/arachnode-partition.d.ts +49 -0
  144. package/dist/src/storage/arachnode-partition.d.ts.map +1 -0
  145. package/dist/src/storage/arachnode-partition.js +61 -0
  146. package/dist/src/storage/arachnode-partition.js.map +1 -0
  147. package/dist/src/storage/block-storage.d.ts +40 -1
  148. package/dist/src/storage/block-storage.d.ts.map +1 -1
  149. package/dist/src/storage/block-storage.js +150 -57
  150. package/dist/src/storage/block-storage.js.map +1 -1
  151. package/dist/src/storage/i-block-storage.d.ts +8 -0
  152. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  153. package/dist/src/storage/i-raw-storage.d.ts +13 -0
  154. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  155. package/dist/src/storage/kv-raw-storage.d.ts +45 -0
  156. package/dist/src/storage/kv-raw-storage.d.ts.map +1 -0
  157. package/dist/src/storage/kv-raw-storage.js +113 -0
  158. package/dist/src/storage/kv-raw-storage.js.map +1 -0
  159. package/dist/src/storage/memory-storage.d.ts +20 -58
  160. package/dist/src/storage/memory-storage.d.ts.map +1 -1
  161. package/dist/src/storage/memory-storage.js +22 -148
  162. package/dist/src/storage/memory-storage.js.map +1 -1
  163. package/dist/src/storage/memory-store-driver.d.ts +39 -0
  164. package/dist/src/storage/memory-store-driver.d.ts.map +1 -0
  165. package/dist/src/storage/memory-store-driver.js +129 -0
  166. package/dist/src/storage/memory-store-driver.js.map +1 -0
  167. package/dist/src/storage/raw-store-codec.d.ts +10 -0
  168. package/dist/src/storage/raw-store-codec.d.ts.map +1 -0
  169. package/dist/src/storage/raw-store-codec.js +36 -0
  170. package/dist/src/storage/raw-store-codec.js.map +1 -0
  171. package/dist/src/storage/raw-store-driver.d.ts +69 -0
  172. package/dist/src/storage/raw-store-driver.d.ts.map +1 -0
  173. package/dist/src/storage/raw-store-driver.js +2 -0
  174. package/dist/src/storage/raw-store-driver.js.map +1 -0
  175. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +1 -1
  176. package/dist/src/storage/restoration-coordinator-v2.js +1 -1
  177. package/dist/src/storage/restoration-coordinator-v2.js.map +1 -1
  178. package/dist/src/storage/restoration-coordinator.d.ts +73 -0
  179. package/dist/src/storage/restoration-coordinator.d.ts.map +1 -0
  180. package/dist/src/storage/restoration-coordinator.js +181 -0
  181. package/dist/src/storage/restoration-coordinator.js.map +1 -0
  182. package/dist/src/storage/ring-selector.d.ts +92 -5
  183. package/dist/src/storage/ring-selector.d.ts.map +1 -1
  184. package/dist/src/storage/ring-selector.js +153 -51
  185. package/dist/src/storage/ring-selector.js.map +1 -1
  186. package/dist/src/storage/ring-shift-coordinator.d.ts +128 -0
  187. package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -0
  188. package/dist/src/storage/ring-shift-coordinator.js +196 -0
  189. package/dist/src/storage/ring-shift-coordinator.js.map +1 -0
  190. package/dist/src/storage/storage-monitor.d.ts +28 -0
  191. package/dist/src/storage/storage-monitor.d.ts.map +1 -1
  192. package/dist/src/storage/storage-monitor.js +42 -2
  193. package/dist/src/storage/storage-monitor.js.map +1 -1
  194. package/dist/src/storage/storage-repo.d.ts +19 -0
  195. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  196. package/dist/src/storage/storage-repo.js +172 -22
  197. package/dist/src/storage/storage-repo.js.map +1 -1
  198. package/dist/src/sync/client.d.ts.map +1 -1
  199. package/dist/src/sync/client.js +5 -1
  200. package/dist/src/sync/client.js.map +1 -1
  201. package/dist/src/sync/service.d.ts.map +1 -1
  202. package/dist/src/sync/service.js +2 -1
  203. package/dist/src/sync/service.js.map +1 -1
  204. package/dist/src/testing/cohort-topic-mesh-harness.d.ts +28 -4
  205. package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
  206. package/dist/src/testing/cohort-topic-mesh-harness.js +51 -10
  207. package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
  208. package/dist/src/testing/index.d.ts +1 -0
  209. package/dist/src/testing/index.d.ts.map +1 -1
  210. package/dist/src/testing/index.js +1 -0
  211. package/dist/src/testing/index.js.map +1 -1
  212. package/dist/src/testing/mesh-harness.d.ts.map +1 -1
  213. package/dist/src/testing/mesh-harness.js +15 -7
  214. package/dist/src/testing/mesh-harness.js.map +1 -1
  215. package/dist/src/testing/raw-storage-conformance.d.ts +24 -0
  216. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -0
  217. package/dist/src/testing/raw-storage-conformance.js +313 -0
  218. package/dist/src/testing/raw-storage-conformance.js.map +1 -0
  219. package/package.json +4 -4
  220. package/src/cluster/block-transfer-service.ts +4 -3
  221. package/src/cluster/block-transfer.ts +424 -265
  222. package/src/cluster/client.ts +3 -1
  223. package/src/cluster/cluster-repo.ts +481 -71
  224. package/src/cluster/peer-key-binding.ts +37 -0
  225. package/src/cluster/quorum-restore.ts +0 -0
  226. package/src/cluster/rebalance-monitor.ts +34 -3
  227. package/src/cluster/service.ts +7 -1
  228. package/src/cluster/spread-on-churn.ts +11 -1
  229. package/src/cluster/supermajority-coupling.ts +37 -0
  230. package/src/cohort-topic/cohort-gossip-driver.ts +96 -9
  231. package/src/cohort-topic/cohort-gossip-transport.ts +10 -0
  232. package/src/cohort-topic/host.ts +831 -105
  233. package/src/dispute/arbitrator-selection.ts +138 -28
  234. package/src/dispute/cascade.ts +8 -1
  235. package/src/dispute/client.ts +3 -2
  236. package/src/dispute/dispute-service.ts +94 -9
  237. package/src/dispute/index.ts +7 -1
  238. package/src/dispute/invalidation.ts +40 -2
  239. package/src/dispute/service.ts +7 -1
  240. package/src/index.ts +46 -43
  241. package/src/it-utility.ts +5 -13
  242. package/src/libp2p-key-network.ts +281 -18
  243. package/src/libp2p-node-base.ts +485 -79
  244. package/src/matchmaking/query-transport.ts +9 -0
  245. package/src/network/network-manager-service.ts +8 -2
  246. package/src/owned-block-seed.ts +40 -0
  247. package/src/protocol-client.ts +10 -3
  248. package/src/protocol-limits.ts +33 -0
  249. package/src/repo/client.ts +3 -0
  250. package/src/repo/cluster-coordinator.ts +145 -82
  251. package/src/repo/coordinator-repo.ts +96 -16
  252. package/src/repo/service.ts +7 -1
  253. package/src/reputation/types.ts +3 -0
  254. package/src/rn.ts +7 -1
  255. package/src/storage/arachnode-fret-adapter.ts +33 -2
  256. package/src/storage/arachnode-partition.ts +74 -0
  257. package/src/storage/block-storage.ts +169 -61
  258. package/src/storage/i-block-storage.ts +9 -0
  259. package/src/storage/i-raw-storage.ts +14 -0
  260. package/src/storage/kv-raw-storage.ts +135 -0
  261. package/src/storage/memory-storage.ts +25 -173
  262. package/src/storage/memory-store-driver.ts +157 -0
  263. package/src/storage/raw-store-codec.ts +42 -0
  264. package/src/storage/raw-store-driver.ts +80 -0
  265. package/src/storage/{restoration-coordinator-v2.ts → restoration-coordinator.ts} +43 -37
  266. package/src/storage/ring-selector.ts +317 -176
  267. package/src/storage/ring-shift-coordinator.ts +271 -0
  268. package/src/storage/storage-monitor.ts +70 -3
  269. package/src/storage/storage-repo.ts +710 -558
  270. package/src/sync/client.ts +5 -1
  271. package/src/sync/service.ts +2 -1
  272. package/src/testing/cohort-topic-mesh-harness.ts +61 -11
  273. package/src/testing/index.ts +1 -0
  274. package/src/testing/mesh-harness.ts +14 -6
  275. package/src/testing/raw-storage-conformance.ts +397 -0
@@ -1,4 +1,4 @@
1
- import type { BlockId, IBlock, Transform, ActionId, ActionRev } from "@optimystic/db-core";
1
+ import type { BlockId, IBlock, Transform, ActionId, ActionRev, ActionTransform } from "@optimystic/db-core";
2
2
  import { Latches, applyTransform, hashString } from "@optimystic/db-core";
3
3
  import type { BlockArchive, BlockMetadata, RestoreCallback, RevisionRange } from "./struct.js";
4
4
  import type { IRawStorage } from "./i-raw-storage.js";
@@ -8,11 +8,24 @@ import { createLogger } from "../logger.js";
8
8
 
9
9
  const log = createLogger('block-storage');
10
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
+
11
18
  export class BlockStorage implements IBlockStorage {
12
19
  constructor(
13
20
  private readonly blockId: BlockId,
14
21
  private readonly storage: IRawStorage,
15
- private readonly restoreCallback?: RestoreCallback
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
16
29
  ) { }
17
30
 
18
31
  async getLatest(): Promise<ActionRev | undefined> {
@@ -54,7 +67,12 @@ export class BlockStorage implements IBlockStorage {
54
67
  log('pend blockId=%s actionId=%s', this.blockId, actionId);
55
68
  let meta = await this.storage.getMetadata(this.blockId);
56
69
  if (!meta) {
57
- meta = { latest: undefined, ranges: [[0]] };
70
+ // A freshly-pended block holds NO committed revision, so it can reconstruct
71
+ // nothing yet: seed empty ranges. The first commit anchors an OPEN-ENDED span at
72
+ // the earliest held rev E ([E, +inf)); later commits/recover merge into it via
73
+ // setLatest/recover. Seeding open-ended `[[0]]` would falsely claim coverage of the
74
+ // un-held revs below E and disable ensureRevision's restore path.
75
+ meta = { latest: undefined, ranges: [] };
58
76
  await this.storage.saveMetadata(this.blockId, meta);
59
77
  }
60
78
  await this.storage.savePendingTransaction(this.blockId, actionId, transform);
@@ -73,6 +91,25 @@ export class BlockStorage implements IBlockStorage {
73
91
  await this.storage.saveMaterializedBlock(this.blockId, actionId, block);
74
92
  }
75
93
 
94
+ async pruneSupersededMaterialization(prior: ActionRev): Promise<void> {
95
+ const meta = await this.storage.getMetadata(this.blockId);
96
+ // No metadata / no committed tip yet ⇒ nothing has superseded `prior`; leave it.
97
+ if (!meta || meta.latest === undefined) {
98
+ return;
99
+ }
100
+ // `prior` is the PRIOR latest, so it shares the (latest) range containing meta.latest.rev — its
101
+ // floor is that span's start. Retain if it is the tip, that floor, or a checkpoint rev.
102
+ const rangeFloor = this.rangeFloorOf(meta.latest.rev, meta.ranges);
103
+ if (this.isRetainedRev(prior.rev, meta.latest.rev, rangeFloor)) {
104
+ return;
105
+ }
106
+ // Redundant: `prior`'s forward transform is retained, so it stays reconstructible by replay from
107
+ // the nearest retained materialization below it. Delete routes to the driver's deleteMaterialized;
108
+ // a no-op at the driver when `prior.rev` carried no materialization (e.g. a tombstone rev).
109
+ await this.storage.saveMaterializedBlock(this.blockId, prior.actionId, undefined);
110
+ log('prune blockId=%s rev=%d actionId=%s', this.blockId, prior.rev, prior.actionId);
111
+ }
112
+
76
113
  async saveRevision(rev: number, actionId: ActionId): Promise<void> {
77
114
  await this.storage.saveRevision(this.blockId, rev, actionId);
78
115
  }
@@ -87,7 +124,24 @@ export class BlockStorage implements IBlockStorage {
87
124
  if (!meta) {
88
125
  throw new Error(`Block ${this.blockId} not found`);
89
126
  }
127
+ // Capture the prior latest rev BEFORE overwriting: coverage anchors to the earliest held rev.
128
+ const prevRev = meta.latest?.rev;
90
129
  meta.latest = latest;
130
+ // NOTE: re-sorts (mergeRanges) the whole ranges array on every commit; if a block ever
131
+ // accumulates many disjoint ranges and commits show as slow, keep a running merged structure.
132
+ // `getBlock(r)` is served by materializeBlock's DESCENDING walk (highest committed rev <= r).
133
+ // Once this node holds the chain from the block's earliest committed rev E, EVERY rev >= E is
134
+ // serveable locally: a read at any r >= E resolves to the highest committed rev <= r (at worst
135
+ // the latest, which is materialized), so coverage is the OPEN-ENDED span [E, +inf) — not the
136
+ // single point [L, L+1) (which wrongly missed reads above L, e.g. a block read at the collection
137
+ // tip after a later commit touched only its siblings) and not [0, +inf) (which wrongly claimed
138
+ // the un-held revs below E). Claim open-ended from the prior latest (>= E via merge); the first
139
+ // commit (prevRev undefined) anchors the span at E = L. mergeRanges folds it into the existing
140
+ // [E, +inf). Only revs BELOW E miss inRanges, which is exactly the genuine-gap/restore case.
141
+ // Range + latest advance in one saveMetadata write (atomic under the commit latch), so a crash
142
+ // before this call advances neither.
143
+ meta.ranges.unshift([prevRev ?? latest.rev]);
144
+ meta.ranges = mergeRanges(meta.ranges);
91
145
  await this.storage.saveMetadata(this.blockId, meta);
92
146
  }
93
147
 
@@ -115,6 +169,13 @@ export class BlockStorage implements IBlockStorage {
115
169
  if (maxRev > currentRev && maxActionId !== undefined) {
116
170
  const advanced: ActionRev = { rev: maxRev, actionId: maxActionId };
117
171
  meta.latest = advanced;
172
+ // The lost setLatest would have merged each recovered revision's range; redo that
173
+ // here. Open-ended from currentRev+1 (see setLatest): every rev in (currentRev, maxRev]
174
+ // was verified present in the committed log above, and any rev > maxRev resolves via the
175
+ // descending walk to maxRev's materialization — so [currentRev+1, +inf) is honest. It joins
176
+ // the prior [E, currentRev+1) (from the earlier setLatest) into one open-ended [E, +inf).
177
+ meta.ranges.unshift([currentRev + 1]);
178
+ meta.ranges = mergeRanges(meta.ranges);
118
179
  await this.storage.saveMetadata(this.blockId, meta);
119
180
  log('recover blockId=%s advanced latest from rev=%d to rev=%d', this.blockId, currentRev, maxRev);
120
181
  return { reconciled: true, latest: advanced };
@@ -130,92 +191,91 @@ export class BlockStorage implements IBlockStorage {
130
191
  // idempotent. Never random/time-based — that would mint a new revision per retry.
131
192
  const actionId = source?.actionId ?? await hashString(`${this.blockId}:${JSON.stringify(block)}`);
132
193
 
133
- // Serialize the read-modify-write on this block's metadata (mirrors ensureRevision).
134
- const lockId = `BlockStorage.saveReplica:${this.blockId}`;
135
- const release = await Latches.acquire(lockId);
136
- try {
137
- let meta = await this.storage.getMetadata(this.blockId);
138
-
139
- // Monotonic guard: an equal-or-newer revision is already held. The block is
140
- // durably present; do not downgrade `latest` or rewrite the metadata.
141
- if (meta?.latest && meta.latest.rev >= rev) {
142
- log('replica:skip blockId=%s rev=%d held=%d', this.blockId, rev, meta.latest.rev);
143
- return meta.latest;
144
- }
145
-
146
- // Write rev → actionId, the action transform, and the materialized block.
147
- // `{ insert: block }` satisfies saveRestored's write invariants; on the serving
148
- // path materializeBlock returns the materialized block directly (single rev), so
149
- // this transform is never applied — see ticket notes.
150
- const archive: BlockArchive = {
151
- blockId: this.blockId,
152
- revisions: {
153
- [rev]: {
154
- action: { actionId, rev, transform: { insert: block } },
155
- block
156
- }
157
- },
158
- range: [rev, rev + 1]
159
- };
160
- await this.saveRestored(archive);
161
-
162
- // Seed metadata when absent, advance latest, and merge the covered range.
163
- if (!meta) {
164
- meta = { latest: undefined, ranges: [] };
165
- }
166
- meta.latest = { rev, actionId };
167
- meta.ranges.unshift([rev, rev + 1]);
168
- meta.ranges = mergeRanges(meta.ranges);
169
- await this.storage.saveMetadata(this.blockId, meta);
170
-
171
- log('replica:save blockId=%s rev=%d actionId=%s', this.blockId, rev, actionId);
172
- return meta.latest;
173
- } finally {
174
- release();
175
- }
194
+ // Replica revision carries the materialized block. `{ insert: block }` satisfies saveRestored's
195
+ // write invariants; on the serving path materializeBlock returns the materialized block directly
196
+ // (single rev), so this transform is never applied — see ticket notes.
197
+ return await this.saveForwardRevision(
198
+ rev,
199
+ actionId,
200
+ { action: { actionId, rev, transform: { insert: block } }, block },
201
+ 'replica'
202
+ );
176
203
  }
177
204
 
178
205
  async saveDeletion(source: ActionRev): Promise<ActionRev> {
179
206
  const { rev, actionId } = source;
180
207
 
181
- // Share the saveReplica latch: both do a read-modify-write of `meta.latest`, so they must be
182
- // mutually exclusive on this block to keep the monotonic guard sound.
208
+ // Forward tombstone: a `{ delete: true }` transform and NO materialized block. saveRestored
209
+ // skips materialization when `block` is absent, so the reverse-apply in materializeBlock
210
+ // resolves this revision to an absent block (read-back as undefined).
211
+ return await this.saveForwardRevision(
212
+ rev,
213
+ actionId,
214
+ { action: { actionId, rev, transform: { delete: true } } },
215
+ 'deletion'
216
+ );
217
+ }
218
+
219
+ /**
220
+ * Shared forward-write path for saveReplica and saveDeletion. Both append a single new revision
221
+ * that ADVANCES `latest` (never rewrites history): acquire the block's metadata latch, apply the
222
+ * monotonic guard, saveRestored a one-revision archive, then seed/advance/merge metadata.
223
+ *
224
+ * The only per-caller difference is the revision `body`: a replica carries `{ insert: block }`
225
+ * plus the materialized `block`; a deletion carries `{ delete: true }` and no block. `rev` and
226
+ * `actionId` are passed alongside `body` because the guard and the `latest` advance need them
227
+ * independently of the archive body.
228
+ */
229
+ private async saveForwardRevision(
230
+ rev: number,
231
+ actionId: ActionId,
232
+ body: { action: ActionTransform; block?: IBlock },
233
+ logLabel: 'replica' | 'deletion'
234
+ ): Promise<ActionRev> {
235
+ // Serialize the read-modify-write on this block's metadata (mirrors ensureRevision). saveReplica
236
+ // and saveDeletion deliberately SHARE this one lock id (keyed `saveReplica`, NOT per-method):
237
+ // both do a read-modify-write of `meta.latest`, so they must be mutually exclusive on this block
238
+ // to keep the monotonic guard sound against a concurrent replica+deletion.
183
239
  const lockId = `BlockStorage.saveReplica:${this.blockId}`;
184
240
  const release = await Latches.acquire(lockId);
185
241
  try {
186
242
  let meta = await this.storage.getMetadata(this.blockId);
187
243
 
188
- // Monotonic guard: an equal-or-newer revision is already held. Do not downgrade `latest`
189
- // or rewrite metadata the tombstone (or a later revision) is already durable.
244
+ // Monotonic guard: an equal-or-newer revision is already held. The block (or tombstone) is
245
+ // durably present; do not downgrade `latest` or rewrite the metadata.
190
246
  if (meta?.latest && meta.latest.rev >= rev) {
191
- log('deletion:skip blockId=%s rev=%d held=%d', this.blockId, rev, meta.latest.rev);
247
+ log('%s:skip blockId=%s rev=%d held=%d', logLabel, this.blockId, rev, meta.latest.rev);
192
248
  return meta.latest;
193
249
  }
194
250
 
195
- // Forward tombstone: a `{ delete: true }` transform and NO materialized block. saveRestored
196
- // skips materialization when `block` is absent, so the reverse-apply in materializeBlock
197
- // resolves this revision to an absent block (read-back as undefined).
251
+ // One-revision archive. A replica's body carries the materialized block; a deletion's body
252
+ // omits it (forward tombstone). saveRestored skips materialization when `block` is absent,
253
+ // so a tombstone reverse-applies to an absent block (read back as undefined).
198
254
  const archive: BlockArchive = {
199
255
  blockId: this.blockId,
200
256
  revisions: {
201
- [rev]: {
202
- action: { actionId, rev, transform: { delete: true } }
203
- }
257
+ [rev]: body
204
258
  },
205
259
  range: [rev, rev + 1]
206
260
  };
207
261
  await this.saveRestored(archive);
208
262
 
209
263
  // Seed metadata when absent, advance latest, and merge the covered range.
264
+ const prevRev = meta?.latest?.rev;
210
265
  if (!meta) {
211
266
  meta = { latest: undefined, ranges: [] };
212
267
  }
213
268
  meta.latest = { rev, actionId };
214
- meta.ranges.unshift([rev, rev + 1]);
269
+ // Open-ended coverage from the earliest held rev (see setLatest): the descending walk serves
270
+ // any rev >= the anchor. A prior latest at prevRev (< rev per the monotonic guard) is a
271
+ // materialized point, so anchor at prevRev; the first write (prevRev undefined) anchors at
272
+ // rev. Freshness of a stale replica is a separate (replication-lag) concern from what this
273
+ // node can locally reconstruct, which is exactly what ranges records.
274
+ meta.ranges.unshift([prevRev ?? rev]);
215
275
  meta.ranges = mergeRanges(meta.ranges);
216
276
  await this.storage.saveMetadata(this.blockId, meta);
217
277
 
218
- log('deletion:save blockId=%s rev=%d actionId=%s', this.blockId, rev, actionId);
278
+ log('%s:save blockId=%s rev=%d actionId=%s', logLabel, this.blockId, rev, actionId);
219
279
  return meta.latest;
220
280
  } finally {
221
281
  release();
@@ -253,7 +313,7 @@ export class BlockStorage implements IBlockStorage {
253
313
  }
254
314
  }
255
315
 
256
- private async materializeBlock(_meta: BlockMetadata, targetRev: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined> {
316
+ private async materializeBlock(meta: BlockMetadata, targetRev: number): Promise<{ block: IBlock, actionRev: ActionRev } | undefined> {
257
317
  let block: IBlock | undefined;
258
318
  let materializedActionRev: ActionRev | undefined;
259
319
  const actions: ActionRev[] = [];
@@ -293,7 +353,26 @@ export class BlockStorage implements IBlockStorage {
293
353
  return undefined;
294
354
  }
295
355
  if (actions.length) {
296
- await this.storage.saveMaterializedBlock(this.blockId, actions[0]!.actionId, block);
356
+ // Re-cache the recomputed materialization ONLY at a retained rev (checkpoint / range floor /
357
+ // tip). Caching unconditionally would let a cold read at a non-checkpoint historical rev
358
+ // re-add a materialization the checkpoint sweep is designed to remove — storage would regrow
359
+ // via reads. Skipping it means a repeated cold read re-replays each time, bounded by
360
+ // `checkpointInterval` transforms.
361
+ // NOTE: cold non-checkpoint historical reads re-replay every time (up to `checkpointInterval`
362
+ // forward transforms). Acceptable — historical reads are rare and replay is depth-bounded. If
363
+ // they ever show as hot, cache at the nearest checkpoint below the target instead of skipping.
364
+ // Read metadata FRESH for the retention decision: the `meta` passed in was captured by getBlock
365
+ // BEFORE ensureRevision, which may have restored the containing range during this same read
366
+ // (ensureRevision mutates its own re-read, not this snapshot). A stale `meta.ranges` would send
367
+ // rangeFloorOf into its fallback (treats the target as its own floor ⇒ wrongly "retained"),
368
+ // re-caching a rev the sweep means to prune — regrowing storage via reads of restored ranges.
369
+ const retentionMeta = (await this.storage.getMetadata(this.blockId)) ?? meta;
370
+ const cacheRev = actions[0]!.rev;
371
+ const latestRev = retentionMeta.latest?.rev ?? cacheRev;
372
+ const rangeFloor = this.rangeFloorOf(cacheRev, retentionMeta.ranges);
373
+ if (this.isRetainedRev(cacheRev, latestRev, rangeFloor)) {
374
+ await this.storage.saveMaterializedBlock(this.blockId, actions[0]!.actionId, block);
375
+ }
297
376
  return { block, actionRev: actions[0]! };
298
377
  }
299
378
  return { block, actionRev: materializedActionRev };
@@ -323,4 +402,33 @@ export class BlockStorage implements IBlockStorage {
323
402
  rev >= range[0] && (range[1] === undefined || rev < range[1])
324
403
  );
325
404
  }
405
+
406
+ /**
407
+ * Checkpoint retention predicate. A materialization at `rev` must be kept iff it is the tip
408
+ * (`latestRev` — the common read target and the replay base for the next commit), the floor of its
409
+ * contiguous range (`rangeFloor` — the descending walk in {@link materializeBlock} has nothing below
410
+ * the floor to fall back to), or a periodic checkpoint (`rev % checkpointInterval === 0`, which bounds
411
+ * replay depth). Otherwise the materialization is prunable — its forward transform is retained, so the
412
+ * rev stays reconstructible by replay from the nearest retained materialization below it. The floor
413
+ * clause is SEPARATE and mandatory: absolute `rev % K` checkpoints do not automatically land on the
414
+ * floor (e.g. floor `E = 1`, `K = 32`).
415
+ */
416
+ private isRetainedRev(rev: number, latestRev: number, rangeFloor: number): boolean {
417
+ return rev === latestRev
418
+ || rev === rangeFloor
419
+ || rev % this.checkpointInterval === 0;
420
+ }
421
+
422
+ /** Start of the contiguous `ranges` span containing `rev`. Falls back to `rev` itself when no span
423
+ * contains it — unreachable for a committed rev (setLatest always merges the containing span before a
424
+ * prune/read runs), and the conservative direction (treats `rev` as its own floor ⇒ retained). */
425
+ private rangeFloorOf(rev: number, ranges: RevisionRange[]): number {
426
+ for (const range of ranges) {
427
+ const [start, end] = range;
428
+ if (rev >= start && (end === undefined || rev < end)) {
429
+ return start;
430
+ }
431
+ }
432
+ return rev;
433
+ }
326
434
  }
@@ -35,6 +35,15 @@ export interface IBlockStorage {
35
35
  /** Saves a materialized block */
36
36
  saveMaterializedBlock(actionId: ActionId, block: IBlock | undefined): Promise<void>;
37
37
 
38
+ /**
39
+ * Delete the materialized copy at `prior` if it is now redundant under the checkpoint
40
+ * retention policy (not the tip, not its range floor, not a checkpoint rev). The forward
41
+ * transform for `prior.rev` is retained, so the rev stays reconstructible by replay.
42
+ * No-op if `prior.rev` must be retained or has no materialization (e.g. a tombstone rev).
43
+ * Must be called under the per-block commit latch (serialized against concurrent commit).
44
+ */
45
+ pruneSupersededMaterialization(prior: ActionRev): Promise<void>;
46
+
38
47
  /** Saves a revision */
39
48
  saveRevision(rev: number, actionId: ActionId): Promise<void>;
40
49
 
@@ -37,4 +37,18 @@ export interface IRawStorage {
37
37
  * advisory — `StorageMonitor` treats a missing implementation as 0.
38
38
  */
39
39
  getApproximateBytesUsed?(): Promise<number>;
40
+
41
+ /**
42
+ * Enumerate the block ids that currently have durable state in this backend
43
+ * (one id per block that has committed/replicated metadata). Used at node
44
+ * startup to seed the resilience monitors' owned-block tracked set from blocks
45
+ * already on disk from a previous run, so churn-spread / rebalance protection
46
+ * covers them without waiting for each to be touched again.
47
+ *
48
+ * Streamed (AsyncIterable) so a large store does not force the whole id list
49
+ * into memory at once. Order is unspecified. Optional: a backend that omits it
50
+ * (or an in-memory backend with nothing durable across a restart) simply yields
51
+ * no seed — the monitors still populate over time via the live change feed.
52
+ */
53
+ listBlockIds?(): AsyncIterable<BlockId>;
40
54
  }
@@ -0,0 +1,135 @@
1
+ import type { BlockId, ActionId, ActionRev, Transform, IBlock } from "@optimystic/db-core";
2
+ import type { BlockMetadata } from "./struct.js";
3
+ import type { IRawStorage } from "./i-raw-storage.js";
4
+ import type { RawStoreDriver } from "./raw-store-driver.js";
5
+ import { encodeJson, decodeJson, encodeActionId, decodeActionId } from "./raw-store-codec.js";
6
+
7
+ /**
8
+ * Shared ordered-KV storage kernel. Implements the full {@link IRawStorage}
9
+ * surface over a {@link RawStoreDriver}, owning all value (de)serialization and
10
+ * call orchestration so each backend only has to expose its five logical stores
11
+ * as bytes-valued maps over its native mechanism.
12
+ *
13
+ * The genuinely-shared logic lives ABOVE the storage primitive: the JSON/string
14
+ * codec for the four value types, `listRevisions`' lo/hi/reverse bound
15
+ * computation, `saveMaterializedBlock`'s put-or-delete branch, and the
16
+ * passthroughs. Key layout / storage topology stays in the driver, because the
17
+ * backends do NOT share one — LevelDB is a single ordered byte keyspace, SQLite
18
+ * five relational tables, IndexedDB five object stores, the filesystem a
19
+ * directory tree.
20
+ */
21
+ export class KvRawStorage implements IRawStorage {
22
+ /**
23
+ * Optional passthroughs are wired in the constructor ONLY when the driver
24
+ * provides them, so a `StorageMonitor` / owned-block seed that feature-detects
25
+ * (`typeof storage.listBlockIds === 'function'`) sees the driver's true
26
+ * capability instead of a stub that silently reports 0 / no seed.
27
+ */
28
+ listBlockIds?: () => AsyncIterable<BlockId>;
29
+ getApproximateBytesUsed?: () => Promise<number>;
30
+
31
+ constructor(private readonly driver: RawStoreDriver) {
32
+ if (driver.listBlockIds) {
33
+ this.listBlockIds = () => driver.listBlockIds!();
34
+ }
35
+ if (driver.approximateBytesUsed) {
36
+ this.getApproximateBytesUsed = () => driver.approximateBytesUsed!();
37
+ }
38
+ }
39
+
40
+ // --- Metadata ---
41
+
42
+ async getMetadata(blockId: BlockId): Promise<BlockMetadata | undefined> {
43
+ const bytes = await this.driver.getMetadata(blockId);
44
+ return bytes === undefined ? undefined : decodeJson<BlockMetadata>(bytes);
45
+ }
46
+
47
+ // NOTE: every value write funnels through the driver put/delete calls in the
48
+ // methods below (saveMetadata / saveRevision / save*Transaction /
49
+ // saveMaterializedBlock). This is the single choke point where an incremental
50
+ // byte counter would hook in, replacing the per-driver full-scan
51
+ // getApproximateBytesUsed. The chosen capacity-estimate mechanism is instead a
52
+ // short-TTL cache over the full scan in StorageMonitor (see storage-monitor.ts
53
+ // `usedBytesCacheTtlMs`) — backend-agnostic, no write-path changes, and ring
54
+ // selection tolerates the bounded staleness by design. The counter remains a
55
+ // future option only if TTL staleness ever proves insufficient; do NOT
56
+ // implement it here — leave the write path a single seam.
57
+ async saveMetadata(blockId: BlockId, metadata: BlockMetadata): Promise<void> {
58
+ await this.driver.putMetadata(blockId, encodeJson(metadata));
59
+ }
60
+
61
+ // --- Revisions ---
62
+
63
+ async getRevision(blockId: BlockId, rev: number): Promise<ActionId | undefined> {
64
+ const bytes = await this.driver.getRevision(blockId, rev);
65
+ return bytes === undefined ? undefined : decodeActionId(bytes);
66
+ }
67
+
68
+ async saveRevision(blockId: BlockId, rev: number, actionId: ActionId): Promise<void> {
69
+ await this.driver.putRevision(blockId, rev, encodeActionId(actionId));
70
+ }
71
+
72
+ async *listRevisions(blockId: BlockId, startRev: number, endRev: number): AsyncIterable<ActionRev> {
73
+ // Both bounds inclusive; ordering follows startRev→endRev direction. Empty
74
+ // revs are skipped by the driver's range. Preserves the exact semantics of
75
+ // every existing backend.
76
+ const ascending = startRev <= endRev;
77
+ const lo = ascending ? startRev : endRev;
78
+ const hi = ascending ? endRev : startRev;
79
+ for await (const [rev, bytes] of this.driver.rangeRevisions(blockId, lo, hi, !ascending)) {
80
+ yield { rev, actionId: decodeActionId(bytes) };
81
+ }
82
+ }
83
+
84
+ // --- Pending transactions ---
85
+
86
+ async getPendingTransaction(blockId: BlockId, actionId: ActionId): Promise<Transform | undefined> {
87
+ const bytes = await this.driver.getPending(blockId, actionId);
88
+ return bytes === undefined ? undefined : decodeJson<Transform>(bytes);
89
+ }
90
+
91
+ async savePendingTransaction(blockId: BlockId, actionId: ActionId, transform: Transform): Promise<void> {
92
+ await this.driver.putPending(blockId, actionId, encodeJson(transform));
93
+ }
94
+
95
+ async deletePendingTransaction(blockId: BlockId, actionId: ActionId): Promise<void> {
96
+ await this.driver.deletePending(blockId, actionId);
97
+ }
98
+
99
+ async *listPendingTransactions(blockId: BlockId): AsyncIterable<ActionId> {
100
+ yield* this.driver.listPendingActionIds(blockId);
101
+ }
102
+
103
+ // --- Committed transactions ---
104
+
105
+ async getTransaction(blockId: BlockId, actionId: ActionId): Promise<Transform | undefined> {
106
+ const bytes = await this.driver.getTransaction(blockId, actionId);
107
+ return bytes === undefined ? undefined : decodeJson<Transform>(bytes);
108
+ }
109
+
110
+ async saveTransaction(blockId: BlockId, actionId: ActionId, transform: Transform): Promise<void> {
111
+ await this.driver.putTransaction(blockId, actionId, encodeJson(transform));
112
+ }
113
+
114
+ // --- Materialized blocks ---
115
+
116
+ async getMaterializedBlock(blockId: BlockId, actionId: ActionId): Promise<IBlock | undefined> {
117
+ const bytes = await this.driver.getMaterialized(blockId, actionId);
118
+ return bytes === undefined ? undefined : decodeJson<IBlock>(bytes);
119
+ }
120
+
121
+ async saveMaterializedBlock(blockId: BlockId, actionId: ActionId, block?: IBlock): Promise<void> {
122
+ // Present ⇒ put the materialized block; absent ⇒ delete (tombstone / truncation).
123
+ if (block) {
124
+ await this.driver.putMaterialized(blockId, actionId, encodeJson(block));
125
+ } else {
126
+ await this.driver.deleteMaterialized(blockId, actionId);
127
+ }
128
+ }
129
+
130
+ // --- Promote (the only cross-key atomic op; driver owns atomicity) ---
131
+
132
+ async promotePendingTransaction(blockId: BlockId, actionId: ActionId): Promise<void> {
133
+ await this.driver.promote(blockId, actionId);
134
+ }
135
+ }