@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,558 +1,710 @@
1
- import type {
2
- IRepo, MessageOptions, BlockId, CommitRequest, CommitResult, GetBlockResults, PendRequest, PendResult, ActionBlocks,
3
- ActionId, BlockGets, ActionPending, PendSuccess, ActionTransform, ActionTransforms,
4
- GetBlockResult, IBlock, ActionRev,
5
- PendValidationHook,
6
- CollectionId, IBlockChangeNotifier, CollectionChangeListener, CollectionChangeEvent
7
- } from "@optimystic/db-core";
8
- import {
9
- Latches, transformForBlockId, applyTransform, groupBy, concatTransform, emptyTransforms,
10
- blockIdsForTransforms, transformsFromTransform
11
- } from "@optimystic/db-core";
12
- import { asyncIteratorToArray } from "../it-utility.js";
13
- import type { IBlockStorage } from "./i-block-storage.js";
14
- import type { IBlockReplicaStore } from "../cluster/block-transfer-service.js";
15
- import { createLogger } from "../logger.js";
16
-
17
- const log = createLogger('storage-repo');
18
-
19
- export type StorageRepoOptions = {
20
- /** Optional hook to validate transactions in PendRequests */
21
- validatePend?: PendValidationHook;
22
- };
23
-
24
- export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaStore {
25
- private readonly validatePend?: PendValidationHook;
26
- /** Per-collection change listeners; empty sets are pruned on unsubscribe. */
27
- private readonly changeListeners = new Map<CollectionId, Set<CollectionChangeListener>>();
28
- /** Catch-all change listeners — fire for EVERY collection's commit on this node. */
29
- private readonly anyChangeListeners = new Set<CollectionChangeListener>();
30
-
31
- constructor(
32
- private readonly createBlockStorage: (blockId: BlockId) => IBlockStorage,
33
- options?: StorageRepoOptions
34
- ) {
35
- this.validatePend = options?.validatePend;
36
- }
37
-
38
- /**
39
- * Subscribe to commits that mutate `collectionId`'s blocks on this node.
40
- * Returns an idempotent unsubscribe. See {@link IBlockChangeNotifier}.
41
- */
42
- onCollectionChange(collectionId: CollectionId, listener: CollectionChangeListener): () => void {
43
- let set = this.changeListeners.get(collectionId);
44
- if (!set) {
45
- set = new Set();
46
- this.changeListeners.set(collectionId, set);
47
- }
48
- set.add(listener);
49
- let unsubscribed = false;
50
- return () => {
51
- if (unsubscribed) return;
52
- unsubscribed = true;
53
- const current = this.changeListeners.get(collectionId);
54
- if (current) {
55
- current.delete(listener);
56
- if (current.size === 0) {
57
- this.changeListeners.delete(collectionId);
58
- }
59
- }
60
- };
61
- }
62
-
63
- /**
64
- * Subscribe to commits mutating ANY collection on this node — the catch-all feed the
65
- * cohort-topic origination bridge consumes (it cannot enumerate collection ids ahead of time,
66
- * so a per-collection {@link onCollectionChange} subscription cannot see every commit). Fires for
67
- * the same `(pending committed)` transitions as {@link onCollectionChange}, but across every
68
- * collection. Returns an idempotent unsubscribe; a throwing listener is isolated + logged.
69
- */
70
- onAnyCollectionChange(listener: CollectionChangeListener): () => void {
71
- this.anyChangeListeners.add(listener);
72
- let unsubscribed = false;
73
- return () => {
74
- if (unsubscribed) return;
75
- unsubscribed = true;
76
- this.anyChangeListeners.delete(listener);
77
- };
78
- }
79
-
80
- /**
81
- * Fire one {@link CollectionChangeEvent} per distinct collection that was
82
- * newly committed. Called AFTER the commit critical section (locks released),
83
- * fire-and-forget synchronous; a throwing listener is isolated and logged. Each event reaches
84
- * both that collection's {@link onCollectionChange} subscribers and every
85
- * {@link onAnyCollectionChange} catch-all subscriber.
86
- *
87
- * `tailId` is the `CommitRequest.tailId` on the commit path; `undefined` on read-driven
88
- * promotions (the get/emitPromotions path has no commit request). A single commit is for one
89
- * collection's chain in practice, so all events from one commit share the same `tailId`.
90
- */
91
- private emitCollectionChanges(collectionBlocks: Map<CollectionId, BlockId[]>, actionId: ActionId, rev: number, tailId?: BlockId): void {
92
- const hasCatchAll = this.anyChangeListeners.size > 0;
93
- for (const [collectionId, blockIds] of collectionBlocks) {
94
- const listeners = this.changeListeners.get(collectionId);
95
- if ((!listeners || listeners.size === 0) && !hasCatchAll) {
96
- continue;
97
- }
98
- const event: CollectionChangeEvent = { collectionId, blockIds, actionId, rev, tailId };
99
- if (listeners && listeners.size > 0) {
100
- this.fireChangeListeners(listeners, event);
101
- }
102
- if (hasCatchAll) {
103
- this.fireChangeListeners(this.anyChangeListeners, event);
104
- }
105
- }
106
- }
107
-
108
- /** Dispatch `event` to a snapshot of `listeners` (safe under mid-emit (un)subscribe), isolating + logging any throw. */
109
- private fireChangeListeners(listeners: Set<CollectionChangeListener>, event: CollectionChangeEvent): void {
110
- for (const listener of Array.from(listeners)) {
111
- try {
112
- listener(event);
113
- } catch (err) {
114
- log('onCollectionChange listener threw for collection=%s: %o', event.collectionId, err);
115
- }
116
- }
117
- }
118
-
119
- async get({ blockIds, context }: BlockGets, _options?: MessageOptions): Promise<GetBlockResults> {
120
- const distinctBlockIds = Array.from(new Set(blockIds));
121
- log('get blockIds=%d', distinctBlockIds.length);
122
- // Read-driven promotions that land durably here, captured so we can emit a
123
- // change event per durable landing after the parallel reads complete (mirrors
124
- // commit's "emit after the work" ordering). The array is shared across the
125
- // parallel map closures below — safe because each push happens synchronously
126
- // between awaits (single-threaded), never concurrently.
127
- const promotions: { collectionId: CollectionId, blockId: BlockId, actionId: ActionId, rev: number }[] = [];
128
- const results = await Promise.all(distinctBlockIds.map(async (blockId) => {
129
- const blockStorage = this.createBlockStorage(blockId);
130
-
131
- // Ensure that all outstanding transactions in the context are committed
132
- if (context) {
133
- const latest = await blockStorage.getLatest();
134
- const missing = latest
135
- ? context.committed.filter(c => c.rev > latest.rev)
136
- : context.committed;
137
- for (const { actionId, rev } of missing.sort((a, b) => a.rev - b.rev)) {
138
- const pending = await blockStorage.getPendingTransaction(actionId);
139
- if (pending) {
140
- const collectionId = await this.internalCommit(blockId, actionId, rev, blockStorage);
141
- if (collectionId !== undefined) {
142
- promotions.push({ collectionId, blockId, actionId, rev });
143
- }
144
- }
145
- }
146
- }
147
-
148
- const blockRev = await blockStorage.getBlock(context?.rev);
149
-
150
- // Include pending action if requested handled first so a pending-only
151
- // insert (no committed revision yet) can still be served by applying the
152
- // pending transform to an undefined prior block.
153
- if (context?.actionId !== undefined) {
154
- const pendingTransform = await blockStorage.getPendingTransaction(context.actionId);
155
- if (!pendingTransform) {
156
- throw new Error(`Pending action ${context.actionId} not found`);
157
- }
158
- const block = applyTransform(blockRev?.block, pendingTransform);
159
- return [blockId, {
160
- block,
161
- state: {
162
- latest: await blockStorage.getLatest(),
163
- pendings: [context.actionId]
164
- }
165
- }];
166
- }
167
-
168
- if (!blockRev) {
169
- return [blockId, { state: {} } as GetBlockResult];
170
- }
171
-
172
- const pendings = await asyncIteratorToArray(blockStorage.listPendingTransactions());
173
- return [blockId, {
174
- block: blockRev.block,
175
- state: {
176
- latest: await blockStorage.getLatest(),
177
- pendings
178
- }
179
- }];
180
- }));
181
-
182
- // Emit per durable read-driven landing (Option A — emit eagerly). Done after the
183
- // parallel reads complete so emission stays outside the per-block work, matching
184
- // commit's ordering. No-op when nothing was promoted.
185
- this.emitPromotions(promotions);
186
-
187
- return Object.fromEntries(results);
188
- }
189
-
190
- /**
191
- * Emit a {@link CollectionChangeEvent} for each read-driven promotion that landed
192
- * during a {@link get}. A single get() can promote multiple distinct actions, each
193
- * at its own `(actionId, rev)`, so group by `(actionId, rev)` and route each group
194
- * through {@link emitCollectionChanges} once.
195
- */
196
- private emitPromotions(promotions: { collectionId: CollectionId, blockId: BlockId, actionId: ActionId, rev: number }[]): void {
197
- if (promotions.length === 0) {
198
- return;
199
- }
200
- const groups = new Map<string, { actionId: ActionId, rev: number, collectionBlocks: Map<CollectionId, BlockId[]> }>();
201
- for (const { collectionId, blockId, actionId, rev } of promotions) {
202
- const key = `${actionId} ${rev}`;
203
- let group = groups.get(key);
204
- if (!group) {
205
- group = { actionId, rev, collectionBlocks: new Map() };
206
- groups.set(key, group);
207
- }
208
- const list = group.collectionBlocks.get(collectionId) ?? [];
209
- list.push(blockId);
210
- group.collectionBlocks.set(collectionId, list);
211
- }
212
- for (const { actionId, rev, collectionBlocks } of groups.values()) {
213
- this.emitCollectionChanges(collectionBlocks, actionId, rev);
214
- }
215
- }
216
-
217
- async pend(request: PendRequest, _options?: MessageOptions): Promise<PendResult> {
218
- // Validate transaction if present and validation hook is configured
219
- if (this.validatePend && request.transaction && request.operationsHash) {
220
- const validationResult = await this.validatePend(request.transaction, request.operationsHash);
221
- if (!validationResult.valid) {
222
- return {
223
- success: false,
224
- reason: validationResult.reason ?? 'Transaction validation failed'
225
- };
226
- }
227
- }
228
-
229
- const blockIds = blockIdsForTransforms(request.transforms);
230
- log('pend actionId=%s blockIds=%d rev=%s', request.actionId, blockIds.length, request.rev);
231
- const pendings: ActionPending[] = [];
232
- const missing: ActionTransforms[] = [];
233
-
234
- // Potential race condition: A concurrent commit operation could complete
235
- // between the conflict checks (latest.rev, listPendingTransactions) and the
236
- // savePendingTransaction call below. This pend operation might succeed based on
237
- // stale information, but the subsequent commit for this pend would likely
238
- // fail correctly later if a conflict arose. Locking here could make the initial
239
- // check more accurate but adds overhead. The current approach prioritizes
240
- // letting the commit be the final arbiter.
241
- for (const blockId of blockIds) {
242
- const blockStorage = this.createBlockStorage(blockId);
243
- const transforms = transformForBlockId(request.transforms, blockId);
244
-
245
- // First handle any pending actions
246
- const pending = await asyncIteratorToArray(blockStorage.listPendingTransactions());
247
- pendings.push(...pending.map(actionId => ({ blockId, actionId })));
248
-
249
- // Handle any conflicting revisions
250
- if (request.rev !== undefined || transforms.insert) {
251
- const latest = await blockStorage.getLatest();
252
- if (latest && latest.rev >= (request.rev ?? 0)) {
253
- const transforms = await asyncIteratorToArray(blockStorage.listRevisions(request.rev ?? 0, latest.rev));
254
- for (const actionRev of transforms) {
255
- const transform = await blockStorage.getTransaction(actionRev.actionId);
256
- if (!transform) {
257
- throw new Error(`Missing action ${actionRev.actionId} for block ${blockId}`);
258
- }
259
- missing.push({
260
- actionId: actionRev.actionId,
261
- rev: actionRev.rev,
262
- transforms: transformsFromTransform(transform, blockId)
263
- });
264
- }
265
- }
266
- }
267
- }
268
-
269
- if (missing.length) {
270
- log('pend:stale actionId=%s missing=%d', request.actionId, missing.length);
271
- return {
272
- success: false,
273
- missing
274
- };
275
- }
276
-
277
- if (pendings.length > 0) {
278
- if (request.policy === 'f') { // Fail on pending actions
279
- return { success: false, pending: pendings };
280
- } else if (request.policy === 'r') { // Return populated pending actions
281
- return {
282
- success: false,
283
- pending: await Promise.all(pendings.map(async action => {
284
- const blockStorage = this.createBlockStorage(action.blockId);
285
- return {
286
- blockId: action.blockId,
287
- actionId: action.actionId,
288
- transform: (await blockStorage.getPendingTransaction(action.actionId))
289
- ?? (await blockStorage.getTransaction(action.actionId))! // Possible that since enumeration, the action has been promoted
290
- }
291
- }))
292
- };
293
- }
294
- }
295
-
296
-
297
- // Simultaneously save pending action for each block
298
- // Note: that this is not atomic, after we checked for conflicts and pending actions
299
- // new pending or committed actions may have been added. This is okay, because
300
- // this check during pend is conservative.
301
- await Promise.all(blockIds.map(blockId => {
302
- const blockStorage = this.createBlockStorage(blockId);
303
- const blockTransform = transformForBlockId(request.transforms, blockId);
304
- return blockStorage.savePendingTransaction(request.actionId, blockTransform);
305
- }));
306
-
307
- return {
308
- success: true,
309
- pending: pendings,
310
- blockIds
311
- } as PendSuccess;
312
- }
313
-
314
- async cancel(actionRef: ActionBlocks, _options?: MessageOptions): Promise<void> {
315
- log('cancel actionId=%s blockIds=%d', actionRef.actionId, actionRef.blockIds.length);
316
- await Promise.all(actionRef.blockIds.map(blockId => {
317
- const blockStorage = this.createBlockStorage(blockId);
318
- return blockStorage.deletePendingTransaction(actionRef.actionId);
319
- }));
320
- }
321
-
322
- async commit(request: CommitRequest, _options?: MessageOptions): Promise<CommitResult> {
323
- log('commit actionId=%s rev=%d blockIds=%d', request.actionId, request.rev, request.blockIds.length);
324
- const uniqueBlockIds = Array.from(new Set(request.blockIds)).sort();
325
- const releases: (() => void)[] = [];
326
- // Collects the blocks newly committed in this call, grouped by collection,
327
- // so we can emit change events once locks are released. Blocks that land before
328
- // a mid-loop failure stay here and are still emitted (Option A — emit eagerly):
329
- // they are durably committed and a retry rolls the remainder forward.
330
- const collectionBlocks = new Map<CollectionId, BlockId[]>();
331
- // Captured when internalCommit throws mid-loop; we break (rather than return)
332
- // so locks release and accumulated landings still emit before we report failure.
333
- let failure: { reason: string } | undefined;
334
-
335
- try {
336
- // Acquire locks sequentially based on sorted IDs to prevent deadlocks
337
- for (const id of uniqueBlockIds) {
338
- const lockId = `StorageRepo.commit:${id}`;
339
- const release = await Latches.acquire(lockId);
340
- releases.push(release);
341
- }
342
-
343
- // --- Start of Critical Section ---
344
-
345
- const blockStorages = request.blockIds.map(blockId => ({
346
- blockId,
347
- storage: this.createBlockStorage(blockId)
348
- }));
349
-
350
- // Partition blocks into:
351
- // - alreadyDone: latest.rev === request.rev && latest.actionId === request.actionId
352
- // (idempotent retry — a prior commit of this same action already landed here;
353
- // skip rather than treat as a conflict. Needed to rollforward stranded blocks
354
- // after a mid-batch crash committed some but not all blocks.)
355
- // - missedCommits: latest.rev >= request.rev but not the same actionId → real stale conflict.
356
- // - toCommit: latest.rev < request.rev or no latest yet → run internalCommit.
357
- const toCommit: { blockId: BlockId, storage: IBlockStorage }[] = [];
358
- const missedCommits: { blockId: BlockId, transforms: ActionTransform[] }[] = [];
359
- for (const entry of blockStorages) {
360
- const { blockId, storage } = entry;
361
- const latest = await storage.getLatest();
362
- if (latest && latest.rev >= request.rev) {
363
- if (latest.rev === request.rev && latest.actionId === request.actionId) {
364
- // Idempotent no-op for this block — already committed with this exact (actionId, rev).
365
- continue;
366
- }
367
- const transforms: ActionTransform[] = [];
368
- for await (const actionRev of storage.listRevisions(request.rev, latest.rev)) {
369
- const transform = await storage.getTransaction(actionRev.actionId);
370
- if (!transform) {
371
- throw new Error(`Missing action ${actionRev.actionId} for block ${blockId}`);
372
- }
373
- transforms.push({
374
- actionId: actionRev.actionId,
375
- rev: actionRev.rev,
376
- transform
377
- });
378
- }
379
- missedCommits.push({ blockId, transforms }); // Push, even if transforms is empty, because we want to reject the older version
380
- continue;
381
- }
382
- toCommit.push(entry);
383
- }
384
-
385
- if (missedCommits.length) {
386
- log('commit:stale actionId=%s missed=%d', request.actionId, missedCommits.length);
387
- return { // Return directly, locks will be released in finally
388
- success: false,
389
- missing: perBlockActionTransformsToPerAction(missedCommits)
390
- };
391
- }
392
-
393
- // Check for missing pending actions only on blocks that still need to commit.
394
- // Already-done blocks will have had their pending promoted, so skipping them here
395
- // is what makes the idempotent rollforward work.
396
- const missingPends: { blockId: BlockId, actionId: ActionId }[] = [];
397
- for (const { blockId, storage } of toCommit) {
398
- const pendingAction = await storage.getPendingTransaction(request.actionId);
399
- if (!pendingAction) {
400
- missingPends.push({ blockId, actionId: request.actionId });
401
- }
402
- }
403
-
404
- if (missingPends.length) {
405
- throw new Error(`Pending action ${request.actionId} not found for block(s): ${missingPends.map(p => p.blockId).join(', ')}`);
406
- }
407
-
408
- // Commit the action for each block that still needs it.
409
- // This loop will execute atomically for all blocks due to the acquired locks.
410
- for (const { blockId, storage } of toCommit) {
411
- try {
412
- // internalCommit will throw if it encounters an issue
413
- const collectionId = await this.internalCommit(blockId, request.actionId, request.rev, storage);
414
- if (collectionId !== undefined) {
415
- const list = collectionBlocks.get(collectionId) ?? [];
416
- list.push(blockId);
417
- collectionBlocks.set(collectionId, list);
418
- }
419
- } catch (err) {
420
- // Partial-commit recovery: blocks already in collectionBlocks DID land
421
- // durably and must still emit; a retry with the same (actionId, rev)
422
- // treats them as idempotent no-ops and advances the remainder. Break
423
- // instead of returning so locks release and those landings emit below.
424
- failure = { reason: err instanceof Error ? err.message : 'Unknown error during commit' };
425
- break;
426
- }
427
- }
428
- }
429
- finally {
430
- // Release locks in reverse order of acquisition
431
- releases.reverse().forEach(release => release());
432
- }
433
-
434
- // Notify after the critical section, for every block newly committed here —
435
- // including those that landed before a mid-loop failure (alreadyDone / stale
436
- // partitions never reach `collectionBlocks`).
437
- this.emitCollectionChanges(collectionBlocks, request.actionId, request.rev, request.tailId);
438
-
439
- return failure ? { success: false, reason: failure.reason } : { success: true };
440
- }
441
-
442
- /**
443
- * Reconciles `metadata.latest` for a single block with the highest contiguous
444
- * fully-promoted revision in durable storage. Use after a crash between
445
- * `promotePendingTransaction` and `setLatest` when retry-commit cannot help
446
- * (the pending record is already gone) but the revision and committed-log entry
447
- * are durable. Idempotent and monotonic.
448
- */
449
- async recoverBlock(blockId: BlockId): Promise<void> {
450
- log('recoverBlock blockId=%s', blockId);
451
- const storage = this.createBlockStorage(blockId);
452
- await storage.recover();
453
- }
454
-
455
- /**
456
- * Persist a replica of a block received out-of-band (churn re-replication) into
457
- * local storage. Distinct from the {@link IRepo} commit funnel: the block arrives
458
- * already materialized from a departing owner, not as a pend/commit. See
459
- * {@link IBlockStorage.saveReplica} for the durability/monotonicity contract.
460
- *
461
- * Held under the same `StorageRepo.commit:<id>` latch as {@link commit} so the
462
- * replica's read-modify-write of `latest` is mutually exclusive with a concurrent
463
- * local commit on the same block otherwise `saveReplica`'s monotonic guard could
464
- * read a stale `latest` and clobber a commit that advanced it in between.
465
- */
466
- async saveReplicatedBlock(blockId: BlockId, block: IBlock, source?: ActionRev): Promise<void> {
467
- log('saveReplicatedBlock blockId=%s rev=%s', blockId, source?.rev);
468
- const storage = this.createBlockStorage(blockId);
469
- const release = await Latches.acquire(`StorageRepo.commit:${blockId}`);
470
- // Captured under the latch; emitted after release to match commit's ordering.
471
- let landed: { collectionId: CollectionId, actionId: ActionId, rev: number } | undefined;
472
- try {
473
- const priorLatest = await storage.getLatest();
474
- const effective = await storage.saveReplica(block, source);
475
- // Advanced iff there was no prior revision or the effective rev moved past it. On the
476
- // monotonic no-op, saveReplica returns the held latest unchanged → effective.rev === priorLatest.rev.
477
- const advanced = priorLatest === undefined || effective.rev > priorLatest.rev;
478
- const collectionId = block.header?.collectionId;
479
- if (advanced && collectionId !== undefined) {
480
- landed = { collectionId, actionId: effective.actionId, rev: effective.rev };
481
- }
482
- } finally {
483
- release();
484
- }
485
- // Replica-persist has no CommitRequest, hence no tailId — like a read-driven promotion,
486
- // this wakes local onCollectionChange watchers but is cert-gated out of cohort-topic
487
- // re-origination downstream (change-bridge selfIsCohortMember treats a tail-less event as
488
- // never a member).
489
- if (landed) {
490
- this.emitCollectionChanges(
491
- new Map([[landed.collectionId, [blockId]]]),
492
- landed.actionId,
493
- landed.rev,
494
- );
495
- }
496
- }
497
-
498
- private async internalCommit(blockId: BlockId, actionId: ActionId, rev: number, storage: IBlockStorage): Promise<CollectionId | undefined> {
499
- // Note: This method is called within the locked critical section of commit()
500
- // So, operations like getPendingTransaction, getLatest, getBlock, saveMaterializedBlock,
501
- // saveRevision, promotePendingTransaction, setLatest are protected against
502
- // concurrent commits for the *same blockId*.
503
-
504
- const transform = await storage.getPendingTransaction(actionId);
505
- // No need to check if !transform here, as the caller (commit) already verified this.
506
- // If it's null here, it indicates a logic error or race condition bypassed the lock (unlikely).
507
- if (!transform) {
508
- throw new Error(`Consistency Error: Pending action ${actionId} disappeared for block ${blockId} within critical section.`);
509
- }
510
-
511
- // Get prior materialized block if it exists
512
- const latest = await storage.getLatest();
513
- const priorBlock = latest
514
- ? (await storage.getBlock(latest.rev))?.block
515
- : undefined;
516
-
517
- // Apply transform and save materialized block
518
- // applyTransform handles undefined priorBlock correctly for inserts
519
- const newBlock = applyTransform(priorBlock, transform);
520
-
521
- if (newBlock) {
522
- await storage.saveMaterializedBlock(actionId, newBlock);
523
- }
524
-
525
- // Save revision and promote action *before* updating latest
526
- // This ensures that if the process crashes between these steps,
527
- // the 'latest' pointer doesn't point to a revision that hasn't been fully recorded.
528
- await storage.saveRevision(rev, actionId);
529
- await storage.promotePendingTransaction(actionId);
530
-
531
- // Update latest revision *last*
532
- await storage.setLatest({ actionId, rev });
533
-
534
- // Report the affected collection for change-event routing. For a delete the
535
- // materialized block is undefined, so fall back to the prior block's header.
536
- // Either may be absent only for a malformed/headerless block — return
537
- // undefined so the caller skips it rather than emitting a bogus event.
538
- return newBlock?.header.collectionId ?? priorBlock?.header.collectionId;
539
- }
540
- }
541
-
542
- /** Converts list of missing actions per block into a list of missing actions across blocks. */
543
- function perBlockActionTransformsToPerAction(missing: { blockId: BlockId; transforms: ActionTransform[]; }[]) {
544
- const missingFlat = missing.flatMap(({ blockId, transforms }) =>
545
- transforms.map(transform => ({ blockId, transform }))
546
- );
547
- const missingByActionId = groupBy(missingFlat, ({ transform }) => transform.actionId);
548
- return Object.entries(missingByActionId).map(([actionId, items]) =>
549
- items.reduce((acc, { blockId, transform }) => {
550
- concatTransform(acc.transforms, blockId, transform.transform);
551
- return acc;
552
- }, {
553
- actionId: actionId as ActionId,
554
- rev: items[0]!.transform.rev, // Assumption: all missing actionIds share the same revision
555
- transforms: emptyTransforms()
556
- })
557
- );
558
- }
1
+ import type {
2
+ IRepo, MessageOptions, BlockId, CommitRequest, CommitResult, GetBlockResults, PendRequest, PendResult, ActionBlocks,
3
+ ActionId, BlockGets, ActionPending, PendSuccess, ActionTransform, ActionTransforms,
4
+ GetBlockResult, IBlock, ActionRev,
5
+ PendValidationHook,
6
+ CollectionId, IBlockChangeNotifier, CollectionChangeListener, CollectionChangeEvent
7
+ } from "@optimystic/db-core";
8
+ import {
9
+ Latches, transformForBlockId, applyTransform, groupBy, concatTransform, emptyTransforms,
10
+ blockIdsForTransforms, transformsFromTransform
11
+ } from "@optimystic/db-core";
12
+ import { asyncIteratorToArray } from "../it-utility.js";
13
+ import type { IBlockStorage } from "./i-block-storage.js";
14
+ import type { IBlockReplicaStore } from "../cluster/block-transfer-service.js";
15
+ import { createLogger } from "../logger.js";
16
+
17
+ const log = createLogger('storage-repo');
18
+
19
+ /**
20
+ * Single source of truth for the per-block commit latch key. Held by {@link StorageRepo.commit} and
21
+ * {@link StorageRepo.saveReplicatedBlock}, and — through an injected runner ({@link withBlockCommitLatch})
22
+ * — by the invalidation-apply path. Every out-of-band writer of a block's `meta.latest` must serialize
23
+ * on this key against a concurrent local commit on the same block; keeping all call sites on this helper
24
+ * is what prevents the key from drifting between them.
25
+ */
26
+ export const commitLatchKey = (blockId: BlockId): string => `StorageRepo.commit:${blockId}`;
27
+
28
+ /**
29
+ * Runs `fn` while holding the per-block commit latch {@link commitLatchKey}. This is the capability the
30
+ * dispute module's `applyInvalidation` is handed (through its context) so its compensating
31
+ * `saveReplica`/`saveDeletion` read-modify-write of `meta.latest` is mutually exclusive with a concurrent
32
+ * {@link StorageRepo.commit} on the same block — otherwise an invalidation advancing `latest` outside
33
+ * that latch is invisible to commit's staleness guard and can be clobbered (a non-monotonic regression).
34
+ *
35
+ * Acquire/release is per call, so a caller holds at most one block latch at any instant and cannot
36
+ * deadlock against commit's sorted, up-front multi-latch acquisition.
37
+ */
38
+ export async function withBlockCommitLatch<T>(blockId: BlockId, fn: () => Promise<T>): Promise<T> {
39
+ const release = await Latches.acquire(commitLatchKey(blockId));
40
+ try {
41
+ return await fn();
42
+ } finally {
43
+ release();
44
+ }
45
+ }
46
+
47
+ export type StorageRepoOptions = {
48
+ /** Optional hook to validate transactions in PendRequests */
49
+ validatePend?: PendValidationHook;
50
+ };
51
+
52
+ export class StorageRepo implements IRepo, IBlockChangeNotifier, IBlockReplicaStore {
53
+ private readonly validatePend?: PendValidationHook;
54
+ /** Per-collection change listeners; empty sets are pruned on unsubscribe. */
55
+ private readonly changeListeners = new Map<CollectionId, Set<CollectionChangeListener>>();
56
+ /** Catch-all change listeners — fire for EVERY collection's commit on this node. */
57
+ private readonly anyChangeListeners = new Set<CollectionChangeListener>();
58
+
59
+ constructor(
60
+ private readonly createBlockStorage: (blockId: BlockId) => IBlockStorage,
61
+ options?: StorageRepoOptions
62
+ ) {
63
+ this.validatePend = options?.validatePend;
64
+ }
65
+
66
+ /**
67
+ * Subscribe to commits that mutate `collectionId`'s blocks on this node.
68
+ * Returns an idempotent unsubscribe. See {@link IBlockChangeNotifier}.
69
+ */
70
+ onCollectionChange(collectionId: CollectionId, listener: CollectionChangeListener): () => void {
71
+ let set = this.changeListeners.get(collectionId);
72
+ if (!set) {
73
+ set = new Set();
74
+ this.changeListeners.set(collectionId, set);
75
+ }
76
+ set.add(listener);
77
+ let unsubscribed = false;
78
+ return () => {
79
+ if (unsubscribed) return;
80
+ unsubscribed = true;
81
+ const current = this.changeListeners.get(collectionId);
82
+ if (current) {
83
+ current.delete(listener);
84
+ if (current.size === 0) {
85
+ this.changeListeners.delete(collectionId);
86
+ }
87
+ }
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Subscribe to commits mutating ANY collection on this node — the catch-all feed the
93
+ * cohort-topic origination bridge consumes (it cannot enumerate collection ids ahead of time,
94
+ * so a per-collection {@link onCollectionChange} subscription cannot see every commit). Fires for
95
+ * the same `(pending committed)` transitions as {@link onCollectionChange}, but across every
96
+ * collection. Returns an idempotent unsubscribe; a throwing listener is isolated + logged.
97
+ */
98
+ onAnyCollectionChange(listener: CollectionChangeListener): () => void {
99
+ this.anyChangeListeners.add(listener);
100
+ let unsubscribed = false;
101
+ return () => {
102
+ if (unsubscribed) return;
103
+ unsubscribed = true;
104
+ this.anyChangeListeners.delete(listener);
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Fire one {@link CollectionChangeEvent} per distinct collection that was
110
+ * newly committed. Called AFTER the commit critical section (locks released),
111
+ * fire-and-forget synchronous; a throwing listener is isolated and logged. Each event reaches
112
+ * both that collection's {@link onCollectionChange} subscribers and every
113
+ * {@link onAnyCollectionChange} catch-all subscriber.
114
+ *
115
+ * `tailId` is the `CommitRequest.tailId` on the commit path; `undefined` on read-driven
116
+ * promotions (the get/emitPromotions path has no commit request). A single commit is for one
117
+ * collection's chain in practice, so all events from one commit share the same `tailId`.
118
+ */
119
+ private emitCollectionChanges(collectionBlocks: Map<CollectionId, BlockId[]>, actionId: ActionId, rev: number, tailId?: BlockId): void {
120
+ const hasCatchAll = this.anyChangeListeners.size > 0;
121
+ for (const [collectionId, blockIds] of collectionBlocks) {
122
+ const listeners = this.changeListeners.get(collectionId);
123
+ if ((!listeners || listeners.size === 0) && !hasCatchAll) {
124
+ continue;
125
+ }
126
+ const event: CollectionChangeEvent = { collectionId, blockIds, actionId, rev, tailId };
127
+ if (listeners && listeners.size > 0) {
128
+ this.fireChangeListeners(listeners, event);
129
+ }
130
+ if (hasCatchAll) {
131
+ this.fireChangeListeners(this.anyChangeListeners, event);
132
+ }
133
+ }
134
+ }
135
+
136
+ /** Dispatch `event` to a snapshot of `listeners` (safe under mid-emit (un)subscribe), isolating + logging any throw. */
137
+ private fireChangeListeners(listeners: Set<CollectionChangeListener>, event: CollectionChangeEvent): void {
138
+ for (const listener of Array.from(listeners)) {
139
+ try {
140
+ listener(event);
141
+ } catch (err) {
142
+ log('onCollectionChange listener threw for collection=%s: %o', event.collectionId, err);
143
+ }
144
+ }
145
+ }
146
+
147
+ async get({ blockIds, context }: BlockGets, _options?: MessageOptions): Promise<GetBlockResults> {
148
+ const distinctBlockIds = Array.from(new Set(blockIds));
149
+ log('get blockIds=%d', distinctBlockIds.length);
150
+ // Read-driven promotions that land durably here, captured so we can emit a
151
+ // change event per durable landing after the parallel reads complete (mirrors
152
+ // commit's "emit after the work" ordering). The array is shared across the
153
+ // parallel map closures below — safe because each push happens synchronously
154
+ // between awaits (single-threaded), never concurrently.
155
+ const promotions: { collectionId: CollectionId, blockId: BlockId, actionId: ActionId, rev: number }[] = [];
156
+ const results = await Promise.all(distinctBlockIds.map(async (blockId) => {
157
+ const blockStorage = this.createBlockStorage(blockId);
158
+
159
+ // Ensure that all outstanding transactions in the context are committed.
160
+ // This promotes a landed-elsewhere pending via internalCommit, which mutates
161
+ // meta.latest — the same read-modify-write commit()/saveReplicatedBlock guard
162
+ // with the per-block commit latch. It MUST hold that latch too, or a promotion
163
+ // racing a concurrent commit on the block regresses latest non-monotonically /
164
+ // cross-writes a revision. Cheap unlatched pre-scan first so the common
165
+ // contextless read and no-pending read never pay for latch acquisition; the
166
+ // authoritative decision is re-made inside the latch.
167
+ if (context) {
168
+ const preLatest = await blockStorage.getLatest();
169
+ const preMissing = preLatest
170
+ ? context.committed.filter(c => c.rev > preLatest.rev)
171
+ : context.committed;
172
+ if (preMissing.length > 0) {
173
+ await withBlockCommitLatch(blockId, async () => {
174
+ // Re-read authoritative state under the latch: a concurrent commit may have
175
+ // promoted or superseded a pending between the unlatched pre-scan and here.
176
+ // Recompute which committed entries are still ahead of `latest` (drops the
177
+ // superseded, rev <= latest.rev) and re-fetch each pending inside the loop
178
+ // (skips the already-promoted, pending gone). This makes read-driven
179
+ // promotion idempotent under races, mirroring commit()'s alreadyDone/stale
180
+ // partitioning.
181
+ const latest = await blockStorage.getLatest();
182
+ const missing = latest
183
+ ? context.committed.filter(c => c.rev > latest.rev)
184
+ : context.committed;
185
+ // Sort a COPY: when `latest` is undefined, `missing` aliases the caller's
186
+ // `context.committed` array, and an in-place `.sort()` would reorder the shared
187
+ // request context under the caller's feet.
188
+ for (const { actionId, rev } of [...missing].sort((a, b) => a.rev - b.rev)) {
189
+ const pending = await blockStorage.getPendingTransaction(actionId);
190
+ if (pending) {
191
+ const collectionId = await this.internalCommit(blockId, actionId, rev, blockStorage);
192
+ if (collectionId !== undefined) {
193
+ promotions.push({ collectionId, blockId, actionId, rev });
194
+ }
195
+ }
196
+ }
197
+ });
198
+ }
199
+ }
200
+
201
+ // NOTE: a Crash-D3 block (durably promoted + revision saved, but the setLatest lost so
202
+ // meta.latest is stale and the pending record is gone) reads as empty/stale here — a
203
+ // context-driven get skips promotion (pending gone) and a default getBlock() sees the
204
+ // stale latest. It is soft-wedged (stale), not hard-wedged: the next commit-retry for
205
+ // (actionId, rev) self-heals it via storage.recover() in commit(). Not repaired lazily on
206
+ // the read path because get() holds no commit latch; if stale reads on unwritten blocks
207
+ // ever become a problem, add a latched lazy recover() here.
208
+ const blockRev = await blockStorage.getBlock(context?.rev);
209
+
210
+ // Include pending action if requested — handled first so a pending-only
211
+ // insert (no committed revision yet) can still be served by applying the
212
+ // pending transform to an undefined prior block.
213
+ if (context?.actionId !== undefined) {
214
+ const pendingTransform = await blockStorage.getPendingTransaction(context.actionId);
215
+ if (!pendingTransform) {
216
+ throw new Error(`Pending action ${context.actionId} not found`);
217
+ }
218
+ const block = applyTransform(blockRev?.block, pendingTransform);
219
+ return [blockId, {
220
+ block,
221
+ state: {
222
+ latest: await blockStorage.getLatest(),
223
+ pendings: [context.actionId]
224
+ }
225
+ }];
226
+ }
227
+
228
+ if (!blockRev) {
229
+ return [blockId, { state: {} } as GetBlockResult];
230
+ }
231
+
232
+ const pendings = await asyncIteratorToArray(blockStorage.listPendingTransactions());
233
+ return [blockId, {
234
+ block: blockRev.block,
235
+ state: {
236
+ latest: await blockStorage.getLatest(),
237
+ pendings
238
+ }
239
+ }];
240
+ }));
241
+
242
+ // Emit per durable read-driven landing (Option A — emit eagerly). Done after the
243
+ // parallel reads complete so emission stays outside the per-block work, matching
244
+ // commit's ordering. No-op when nothing was promoted.
245
+ this.emitPromotions(promotions);
246
+
247
+ return Object.fromEntries(results);
248
+ }
249
+
250
+ /**
251
+ * Emit a {@link CollectionChangeEvent} for each read-driven promotion that landed
252
+ * during a {@link get}. A single get() can promote multiple distinct actions, each
253
+ * at its own `(actionId, rev)`, so group by `(actionId, rev)` and route each group
254
+ * through {@link emitCollectionChanges} once.
255
+ */
256
+ private emitPromotions(promotions: { collectionId: CollectionId, blockId: BlockId, actionId: ActionId, rev: number }[]): void {
257
+ if (promotions.length === 0) {
258
+ return;
259
+ }
260
+ const groups = new Map<string, { actionId: ActionId, rev: number, collectionBlocks: Map<CollectionId, BlockId[]> }>();
261
+ for (const { collectionId, blockId, actionId, rev } of promotions) {
262
+ const key = `${actionId} ${rev}`;
263
+ let group = groups.get(key);
264
+ if (!group) {
265
+ group = { actionId, rev, collectionBlocks: new Map() };
266
+ groups.set(key, group);
267
+ }
268
+ const list = group.collectionBlocks.get(collectionId) ?? [];
269
+ list.push(blockId);
270
+ group.collectionBlocks.set(collectionId, list);
271
+ }
272
+ for (const { actionId, rev, collectionBlocks } of groups.values()) {
273
+ this.emitCollectionChanges(collectionBlocks, actionId, rev);
274
+ }
275
+ }
276
+
277
+ async pend(request: PendRequest, _options?: MessageOptions): Promise<PendResult> {
278
+ // Validate transaction if present and validation hook is configured
279
+ if (this.validatePend && request.transaction && request.operationsHash) {
280
+ const validationResult = await this.validatePend(request.transaction, request.operationsHash);
281
+ if (!validationResult.valid) {
282
+ return {
283
+ success: false,
284
+ reason: validationResult.reason ?? 'Transaction validation failed'
285
+ };
286
+ }
287
+ }
288
+
289
+ const blockIds = blockIdsForTransforms(request.transforms);
290
+ log('pend actionId=%s blockIds=%d rev=%s', request.actionId, blockIds.length, request.rev);
291
+ const pendings: ActionPending[] = [];
292
+ const missing: ActionTransforms[] = [];
293
+
294
+ // Potential race condition: A concurrent commit operation could complete
295
+ // between the conflict checks (latest.rev, listPendingTransactions) and the
296
+ // savePendingTransaction call below. This pend operation might succeed based on
297
+ // stale information, but the subsequent commit for this pend would likely
298
+ // fail correctly later if a conflict arose. Locking here could make the initial
299
+ // check more accurate but adds overhead. The current approach prioritizes
300
+ // letting the commit be the final arbiter.
301
+ for (const blockId of blockIds) {
302
+ const blockStorage = this.createBlockStorage(blockId);
303
+ const transforms = transformForBlockId(request.transforms, blockId);
304
+
305
+ // First handle any pending actions
306
+ const pending = await asyncIteratorToArray(blockStorage.listPendingTransactions());
307
+ pendings.push(...pending.map(actionId => ({ blockId, actionId })));
308
+
309
+ // Handle any conflicting revisions
310
+ if (request.rev !== undefined || transforms.insert) {
311
+ const latest = await blockStorage.getLatest();
312
+ if (latest && latest.rev >= (request.rev ?? 0)) {
313
+ const transforms = await asyncIteratorToArray(blockStorage.listRevisions(request.rev ?? 0, latest.rev));
314
+ for (const actionRev of transforms) {
315
+ const transform = await blockStorage.getTransaction(actionRev.actionId);
316
+ if (!transform) {
317
+ throw new Error(`Missing action ${actionRev.actionId} for block ${blockId}`);
318
+ }
319
+ missing.push({
320
+ actionId: actionRev.actionId,
321
+ rev: actionRev.rev,
322
+ transforms: transformsFromTransform(transform, blockId)
323
+ });
324
+ }
325
+ }
326
+ }
327
+ }
328
+
329
+ if (missing.length) {
330
+ log('pend:stale actionId=%s missing=%d', request.actionId, missing.length);
331
+ return {
332
+ success: false,
333
+ missing
334
+ };
335
+ }
336
+
337
+ if (pendings.length > 0) {
338
+ if (request.policy === 'f') { // Fail on pending actions
339
+ return { success: false, pending: pendings };
340
+ } else if (request.policy === 'r') { // Return populated pending actions
341
+ return {
342
+ success: false,
343
+ pending: await Promise.all(pendings.map(async action => {
344
+ const blockStorage = this.createBlockStorage(action.blockId);
345
+ return {
346
+ blockId: action.blockId,
347
+ actionId: action.actionId,
348
+ transform: (await blockStorage.getPendingTransaction(action.actionId))
349
+ ?? (await blockStorage.getTransaction(action.actionId))! // Possible that since enumeration, the action has been promoted
350
+ }
351
+ }))
352
+ };
353
+ }
354
+ }
355
+
356
+
357
+ // Simultaneously save pending action for each block
358
+ // Note: that this is not atomic, after we checked for conflicts and pending actions
359
+ // new pending or committed actions may have been added. This is okay, because
360
+ // this check during pend is conservative.
361
+ await Promise.all(blockIds.map(blockId => {
362
+ const blockStorage = this.createBlockStorage(blockId);
363
+ const blockTransform = transformForBlockId(request.transforms, blockId);
364
+ return blockStorage.savePendingTransaction(request.actionId, blockTransform);
365
+ }));
366
+
367
+ return {
368
+ success: true,
369
+ pending: pendings,
370
+ blockIds
371
+ } as PendSuccess;
372
+ }
373
+
374
+ async cancel(actionRef: ActionBlocks, _options?: MessageOptions): Promise<void> {
375
+ log('cancel actionId=%s blockIds=%d', actionRef.actionId, actionRef.blockIds.length);
376
+ await Promise.all(actionRef.blockIds.map(blockId => {
377
+ const blockStorage = this.createBlockStorage(blockId);
378
+ return blockStorage.deletePendingTransaction(actionRef.actionId);
379
+ }));
380
+ }
381
+
382
+ async commit(request: CommitRequest, _options?: MessageOptions): Promise<CommitResult> {
383
+ log('commit actionId=%s rev=%d blockIds=%d', request.actionId, request.rev, request.blockIds.length);
384
+ const uniqueBlockIds = Array.from(new Set(request.blockIds)).sort();
385
+ const releases: (() => void)[] = [];
386
+ // Collects the blocks newly committed in this call, grouped by collection,
387
+ // so we can emit change events once locks are released. Blocks that land before
388
+ // a mid-loop failure stay here and are still emitted (Option A — emit eagerly):
389
+ // they are durably committed and a retry rolls the remainder forward.
390
+ const collectionBlocks = new Map<CollectionId, BlockId[]>();
391
+ // Captured when internalCommit throws mid-loop; we break (rather than return)
392
+ // so locks release and accumulated landings still emit before we report failure.
393
+ let failure: { reason: string } | undefined;
394
+
395
+ try {
396
+ // Acquire locks sequentially based on sorted IDs to prevent deadlocks
397
+ for (const id of uniqueBlockIds) {
398
+ const lockId = commitLatchKey(id);
399
+ const release = await Latches.acquire(lockId);
400
+ releases.push(release);
401
+ }
402
+
403
+ // --- Start of Critical Section ---
404
+
405
+ const blockStorages = request.blockIds.map(blockId => ({
406
+ blockId,
407
+ storage: this.createBlockStorage(blockId)
408
+ }));
409
+
410
+ // Partition blocks into:
411
+ // - alreadyDone: latest.rev === request.rev && latest.actionId === request.actionId
412
+ // (idempotent retry a prior commit of this same action already landed here;
413
+ // skip rather than treat as a conflict. Needed to rollforward stranded blocks
414
+ // after a mid-batch crash committed some but not all blocks.)
415
+ // - missedCommits: latest.rev >= request.rev but not the same actionId → real stale conflict.
416
+ // - toCommit: latest.rev < request.rev or no latest yet → run internalCommit.
417
+ const toCommit: { blockId: BlockId, storage: IBlockStorage }[] = [];
418
+ const missedCommits: { blockId: BlockId, transforms: ActionTransform[] }[] = [];
419
+ for (const entry of blockStorages) {
420
+ const { blockId, storage } = entry;
421
+ const latest = await storage.getLatest();
422
+ if (latest && latest.rev >= request.rev) {
423
+ if (latest.rev === request.rev && latest.actionId === request.actionId) {
424
+ // Idempotent no-op for this block already committed with this exact (actionId, rev).
425
+ continue;
426
+ }
427
+ const transforms: ActionTransform[] = [];
428
+ for await (const actionRev of storage.listRevisions(request.rev, latest.rev)) {
429
+ const transform = await storage.getTransaction(actionRev.actionId);
430
+ if (!transform) {
431
+ throw new Error(`Missing action ${actionRev.actionId} for block ${blockId}`);
432
+ }
433
+ transforms.push({
434
+ actionId: actionRev.actionId,
435
+ rev: actionRev.rev,
436
+ transform
437
+ });
438
+ }
439
+ missedCommits.push({ blockId, transforms }); // Push, even if transforms is empty, because we want to reject the older version
440
+ continue;
441
+ }
442
+ toCommit.push(entry);
443
+ }
444
+
445
+ if (missedCommits.length) {
446
+ log('commit:stale actionId=%s missed=%d', request.actionId, missedCommits.length);
447
+ return { // Return directly, locks will be released in finally
448
+ success: false,
449
+ missing: perBlockActionTransformsToPerAction(missedCommits)
450
+ };
451
+ }
452
+
453
+ // Check for missing pending actions only on blocks that still need to commit.
454
+ // Already-done blocks will have had their pending promoted, so skipping them here
455
+ // is what makes the idempotent rollforward work.
456
+ //
457
+ // A toCommit block whose pending is absent is one of two states:
458
+ // - Crash-D3: the action was durably promoted and its revision saved, but the crash
459
+ // lost the setLatest, so meta.latest is still < request.rev and the pending record
460
+ // is gone. getTransaction(actionId) returns the promoted transform. Self-heal here
461
+ // via storage.recover() (redoes the lost setLatest, advancing latest to the highest
462
+ // contiguous promoted rev, >= request.rev). recover() is idempotent + monotonic, so
463
+ // calling it under the already-held commit latch is safe. Recovered blocks are then
464
+ // excluded from the internalCommit loop below their pending is gone, so
465
+ // internalCommit would throw.
466
+ // - Genuine missing pend: the action was never promoted (getTransaction → undefined),
467
+ // so the pend is truly missing. Throw exactly as before.
468
+ // Crash-D2 never reaches this branch: its pending record is still present.
469
+ const missingPends: { blockId: BlockId, actionId: ActionId }[] = [];
470
+ const recovered = new Set<BlockId>();
471
+ for (const { blockId, storage } of toCommit) {
472
+ const pendingAction = await storage.getPendingTransaction(request.actionId);
473
+ if (pendingAction) {
474
+ continue;
475
+ }
476
+ const promoted = await storage.getTransaction(request.actionId);
477
+ if (!promoted) {
478
+ missingPends.push({ blockId, actionId: request.actionId });
479
+ continue;
480
+ }
481
+ // Crash-D3 signature (pending absent + action durably promoted). Redo the lost setLatest.
482
+ const result = await storage.recover();
483
+ if (result.latest !== undefined && result.latest.rev >= request.rev) {
484
+ recovered.add(blockId);
485
+ } else {
486
+ // Torn/partial state: recover() could not advance latest to request.rev (metadata
487
+ // absent, or a revision entry missing despite the promoted transaction). Fall back
488
+ // to treating the block as a genuine missing-pend error rather than silently succeeding.
489
+ missingPends.push({ blockId, actionId: request.actionId });
490
+ }
491
+ }
492
+
493
+ // NOTE: if a batch ever held BOTH a recovered D3 block and a genuine missing-pend block,
494
+ // this throw fires after recover() already advanced the D3 block durably, so that block's
495
+ // change event is skipped (the retry then treats it as alreadyDone and never re-emits;
496
+ // durable state stays correct, only the emit is lost). Judged unreachable today: a single
497
+ // crash mid-internalCommit leaves exactly one D3 block, with the rest alreadyDone or
498
+ // pending-present a never-pended block cannot coexist with it in one retry. If a path
499
+ // ever produces that mix, emit recovered blocks' events before throwing here.
500
+ if (missingPends.length) {
501
+ throw new Error(`Pending action ${request.actionId} not found for block(s): ${missingPends.map(p => p.blockId).join(', ')}`);
502
+ }
503
+
504
+ // The original commit crashed before setLatest, so it also never emitted a change event
505
+ // for a recovered (Crash-D3) block. Now that recover() has committed it at request.rev,
506
+ // report its collection so downstream watchers wake mirroring internalCommit. Resolve
507
+ // the collectionId from the now-materialized block; a delete materializes to a tombstone
508
+ // (getBlock undefined), so fall back to the prior materialized block's header exactly as
509
+ // internalCommit does — otherwise a recovered delete would silently fail to wake watchers.
510
+ // Only when neither resolves (a delete-only block with no prior materialization) is the
511
+ // emit skipped, the same terminal fallback internalCommit uses.
512
+ for (const { blockId, storage } of toCommit) {
513
+ if (!recovered.has(blockId)) {
514
+ continue;
515
+ }
516
+ const collectionId = (await storage.getBlock(request.rev))?.block.header.collectionId
517
+ ?? (await storage.getBlock(request.rev - 1))?.block.header.collectionId;
518
+ if (collectionId !== undefined) {
519
+ const list = collectionBlocks.get(collectionId) ?? [];
520
+ list.push(blockId);
521
+ collectionBlocks.set(collectionId, list);
522
+ }
523
+ }
524
+
525
+ // Commit the action for each block that still needs it.
526
+ // This loop will execute atomically for all blocks due to the acquired locks.
527
+ // Recovered (Crash-D3) blocks are already committed at request.rev and their pending is
528
+ // gone, so skip them — internalCommit would throw on the missing pending record.
529
+ for (const { blockId, storage } of toCommit) {
530
+ if (recovered.has(blockId)) {
531
+ continue;
532
+ }
533
+ try {
534
+ // internalCommit will throw if it encounters an issue
535
+ const collectionId = await this.internalCommit(blockId, request.actionId, request.rev, storage);
536
+ if (collectionId !== undefined) {
537
+ const list = collectionBlocks.get(collectionId) ?? [];
538
+ list.push(blockId);
539
+ collectionBlocks.set(collectionId, list);
540
+ }
541
+ } catch (err) {
542
+ // Partial-commit recovery: blocks already in collectionBlocks DID land
543
+ // durably and must still emit; a retry with the same (actionId, rev)
544
+ // treats them as idempotent no-ops and advances the remainder. Break
545
+ // instead of returning so locks release and those landings emit below.
546
+ failure = { reason: err instanceof Error ? err.message : 'Unknown error during commit' };
547
+ break;
548
+ }
549
+ }
550
+ }
551
+ finally {
552
+ // Release locks in reverse order of acquisition
553
+ releases.reverse().forEach(release => release());
554
+ }
555
+
556
+ // Notify after the critical section, for every block newly committed here —
557
+ // including those that landed before a mid-loop failure (alreadyDone / stale
558
+ // partitions never reach `collectionBlocks`).
559
+ this.emitCollectionChanges(collectionBlocks, request.actionId, request.rev, request.tailId);
560
+
561
+ return failure ? { success: false, reason: failure.reason } : { success: true };
562
+ }
563
+
564
+ /**
565
+ * Reconciles `metadata.latest` for a single block with the highest contiguous
566
+ * fully-promoted revision in durable storage. Use after a crash between
567
+ * `promotePendingTransaction` and `setLatest` when retry-commit cannot help
568
+ * (the pending record is already gone) but the revision and committed-log entry
569
+ * are durable. Idempotent and monotonic.
570
+ */
571
+ async recoverBlock(blockId: BlockId): Promise<void> {
572
+ log('recoverBlock blockId=%s', blockId);
573
+ const storage = this.createBlockStorage(blockId);
574
+ // Hold the per-block commit latch: recover() is a read-modify-write of meta.latest that
575
+ // blindly writes back the metadata object it read, so its "advance only" guard is TOCTOU —
576
+ // racing a concurrent commit()/saveReplicatedBlock that advanced latest in between would
577
+ // clobber it (a non-monotonic regression). Same latching invariant as every other
578
+ // latest-mutating site. commit() calls storage.recover() directly under its own held latch,
579
+ // so it never routes through here — no double-acquire / deadlock.
580
+ await withBlockCommitLatch(blockId, () => storage.recover());
581
+ }
582
+
583
+ /**
584
+ * Persist a replica of a block received out-of-band (churn re-replication) into
585
+ * local storage. Distinct from the {@link IRepo} commit funnel: the block arrives
586
+ * already materialized from a departing owner, not as a pend/commit. See
587
+ * {@link IBlockStorage.saveReplica} for the durability/monotonicity contract.
588
+ *
589
+ * Held under the same `StorageRepo.commit:<id>` latch as {@link commit} so the
590
+ * replica's read-modify-write of `latest` is mutually exclusive with a concurrent
591
+ * local commit on the same block — otherwise `saveReplica`'s monotonic guard could
592
+ * read a stale `latest` and clobber a commit that advanced it in between.
593
+ */
594
+ async saveReplicatedBlock(blockId: BlockId, block: IBlock, source?: ActionRev): Promise<void> {
595
+ log('saveReplicatedBlock blockId=%s rev=%s', blockId, source?.rev);
596
+ const storage = this.createBlockStorage(blockId);
597
+ const release = await Latches.acquire(commitLatchKey(blockId));
598
+ // Captured under the latch; emitted after release to match commit's ordering.
599
+ let landed: { collectionId: CollectionId, actionId: ActionId, rev: number } | undefined;
600
+ try {
601
+ const priorLatest = await storage.getLatest();
602
+ const effective = await storage.saveReplica(block, source);
603
+ // Advanced iff there was no prior revision or the effective rev moved past it. On the
604
+ // monotonic no-op, saveReplica returns the held latest unchanged → effective.rev === priorLatest.rev.
605
+ const advanced = priorLatest === undefined || effective.rev > priorLatest.rev;
606
+ const collectionId = block.header?.collectionId;
607
+ if (advanced && collectionId !== undefined) {
608
+ landed = { collectionId, actionId: effective.actionId, rev: effective.rev };
609
+ }
610
+ } finally {
611
+ release();
612
+ }
613
+ // Replica-persist has no CommitRequest, hence no tailId — like a read-driven promotion,
614
+ // this wakes local onCollectionChange watchers but is cert-gated out of cohort-topic
615
+ // re-origination downstream (change-bridge selfIsCohortMember treats a tail-less event as
616
+ // never a member).
617
+ if (landed) {
618
+ this.emitCollectionChanges(
619
+ new Map([[landed.collectionId, [blockId]]]),
620
+ landed.actionId,
621
+ landed.rev,
622
+ );
623
+ }
624
+ }
625
+
626
+ private async internalCommit(blockId: BlockId, actionId: ActionId, rev: number, storage: IBlockStorage): Promise<CollectionId | undefined> {
627
+ // Note: This method is called under the per-block commit latch — by commit() (within its
628
+ // locked critical section) and by the read-driven promotion in get() (which now takes the
629
+ // same latch). So, operations like getPendingTransaction, getLatest, getBlock,
630
+ // saveMaterializedBlock, saveRevision, promotePendingTransaction, setLatest are protected
631
+ // against concurrent commits for the *same blockId*.
632
+
633
+ const transform = await storage.getPendingTransaction(actionId);
634
+ // No need to check if !transform here, as the caller (commit) already verified this.
635
+ // If it's null here, it indicates a logic error or race condition bypassed the lock (unlikely).
636
+ if (!transform) {
637
+ throw new Error(`Consistency Error: Pending action ${actionId} disappeared for block ${blockId} within critical section.`);
638
+ }
639
+
640
+ // Get prior materialized block if it exists
641
+ const latest = await storage.getLatest();
642
+ const priorBlock = latest
643
+ ? (await storage.getBlock(latest.rev))?.block
644
+ : undefined;
645
+
646
+ // Apply transform and save materialized block
647
+ // applyTransform handles undefined priorBlock correctly for inserts
648
+ const newBlock = applyTransform(priorBlock, transform);
649
+
650
+ if (newBlock) {
651
+ await storage.saveMaterializedBlock(actionId, newBlock);
652
+ }
653
+
654
+ // Save revision and promote action *before* updating latest
655
+ // This ensures that if the process crashes between these steps,
656
+ // the 'latest' pointer doesn't point to a revision that hasn't been fully recorded.
657
+ await storage.saveRevision(rev, actionId);
658
+ await storage.promotePendingTransaction(actionId);
659
+
660
+ // Update latest revision *last*
661
+ await storage.setLatest({ actionId, rev });
662
+
663
+ // Prune the now-superseded prior materialization (checkpoint retention). Runs LAST — after the
664
+ // new rev's materialization + revision + transform + setLatest are all durable — so no crash
665
+ // point can leave a rev unrecoverable: a crash BEFORE this leaves a redundant (harmless)
666
+ // materialization the next commit's prune reclaims; a crash AFTER is fully consistent. The prune
667
+ // only ever deletes a materialization reconstructible from the retained floor + transforms. Runs
668
+ // under the per-block commit latch already held here, so it serializes against concurrent commits.
669
+ // NOTE: prune targets ONLY the immediate prior. A crash between setLatest and this call leaves that
670
+ // one prior materialization un-pruned; since a later commit prunes ITS OWN prior (never the earlier
671
+ // leaked rev), that copy is NOT auto-reclaimed — a bounded (≤1 block-copy per crash), harmless leak
672
+ // (state stays consistent + reconstructible). If crash-before-prune leaks ever accumulate materially,
673
+ // add a bounded look-back (prune non-retained mats in [rev-checkpointInterval, rev)) here, or a
674
+ // periodic reconciliation sweep — do NOT reintroduce a per-read re-cache.
675
+ if (latest !== undefined) {
676
+ await storage.pruneSupersededMaterialization(latest);
677
+ }
678
+
679
+ // Report the affected collection for change-event routing. For a delete the
680
+ // materialized block is undefined, so fall back to the prior block's header.
681
+ // Either may be absent only for a malformed/headerless block — return
682
+ // undefined so the caller skips it rather than emitting a bogus event.
683
+ return newBlock?.header.collectionId ?? priorBlock?.header.collectionId;
684
+ }
685
+ }
686
+
687
+ /**
688
+ * Converts list of missing actions per block into a list of missing actions across blocks.
689
+ *
690
+ * NOTE: relies on each (actionId, blockId) pair appearing at most once — one revision per action
691
+ * per block. If a block ever records two revisions under the same actionId, concatTransform's
692
+ * last-wins merge would silently drop the earlier revision's ops for that block; group by
693
+ * (actionId, rev) instead. See debt-concat-transform-overlapping-updates.
694
+ */
695
+ function perBlockActionTransformsToPerAction(missing: { blockId: BlockId; transforms: ActionTransform[]; }[]) {
696
+ const missingFlat = missing.flatMap(({ blockId, transforms }) =>
697
+ transforms.map(transform => ({ blockId, transform }))
698
+ );
699
+ const missingByActionId = groupBy(missingFlat, ({ transform }) => transform.actionId);
700
+ return Object.entries(missingByActionId).map(([actionId, items]) =>
701
+ items.reduce((acc, { blockId, transform }) => {
702
+ acc.transforms = concatTransform(acc.transforms, blockId, transform.transform);
703
+ return acc;
704
+ }, {
705
+ actionId: actionId as ActionId,
706
+ rev: items[0]!.transform.rev, // Assumption: an action commits at one revision, so every block's entry for this actionId agrees. Distinct actionIds may still carry distinct revs.
707
+ transforms: emptyTransforms()
708
+ })
709
+ );
710
+ }