@optimystic/db-core 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 (301) hide show
  1. package/dist/src/blocks/block-store.d.ts +18 -1
  2. package/dist/src/blocks/block-store.d.ts.map +1 -1
  3. package/dist/src/blocks/helpers.d.ts +2 -2
  4. package/dist/src/blocks/helpers.d.ts.map +1 -1
  5. package/dist/src/blocks/helpers.js +2 -2
  6. package/dist/src/blocks/helpers.js.map +1 -1
  7. package/dist/src/btree/btree.d.ts +27 -6
  8. package/dist/src/btree/btree.d.ts.map +1 -1
  9. package/dist/src/btree/btree.js +104 -61
  10. package/dist/src/btree/btree.js.map +1 -1
  11. package/dist/src/chain/chain.d.ts.map +1 -1
  12. package/dist/src/chain/chain.js +11 -1
  13. package/dist/src/chain/chain.js.map +1 -1
  14. package/dist/src/cluster/index.d.ts +1 -0
  15. package/dist/src/cluster/index.d.ts.map +1 -1
  16. package/dist/src/cluster/index.js +1 -0
  17. package/dist/src/cluster/index.js.map +1 -1
  18. package/dist/src/cluster/membership.d.ts +49 -0
  19. package/dist/src/cluster/membership.d.ts.map +1 -0
  20. package/dist/src/cluster/membership.js +75 -0
  21. package/dist/src/cluster/membership.js.map +1 -0
  22. package/dist/src/cluster/structs.d.ts +50 -1
  23. package/dist/src/cluster/structs.d.ts.map +1 -1
  24. package/dist/src/cluster/structs.js +9 -1
  25. package/dist/src/cluster/structs.js.map +1 -1
  26. package/dist/src/cohort-topic/addressing.d.ts +8 -2
  27. package/dist/src/cohort-topic/addressing.d.ts.map +1 -1
  28. package/dist/src/cohort-topic/addressing.js +11 -3
  29. package/dist/src/cohort-topic/addressing.js.map +1 -1
  30. package/dist/src/cohort-topic/antidos/bootstrap-evidence.d.ts +13 -7
  31. package/dist/src/cohort-topic/antidos/bootstrap-evidence.d.ts.map +1 -1
  32. package/dist/src/cohort-topic/antidos/bootstrap-evidence.js +17 -6
  33. package/dist/src/cohort-topic/antidos/bootstrap-evidence.js.map +1 -1
  34. package/dist/src/cohort-topic/antidos/replay-guard.d.ts +17 -0
  35. package/dist/src/cohort-topic/antidos/replay-guard.d.ts.map +1 -1
  36. package/dist/src/cohort-topic/antidos/replay-guard.js +33 -0
  37. package/dist/src/cohort-topic/antidos/replay-guard.js.map +1 -1
  38. package/dist/src/cohort-topic/antidos/topic-budget.d.ts +7 -0
  39. package/dist/src/cohort-topic/antidos/topic-budget.d.ts.map +1 -1
  40. package/dist/src/cohort-topic/antidos/topic-budget.js +8 -2
  41. package/dist/src/cohort-topic/antidos/topic-budget.js.map +1 -1
  42. package/dist/src/cohort-topic/coldstart.d.ts +34 -14
  43. package/dist/src/cohort-topic/coldstart.d.ts.map +1 -1
  44. package/dist/src/cohort-topic/coldstart.js +25 -11
  45. package/dist/src/cohort-topic/coldstart.js.map +1 -1
  46. package/dist/src/cohort-topic/gossip/bus.d.ts +12 -1
  47. package/dist/src/cohort-topic/gossip/bus.d.ts.map +1 -1
  48. package/dist/src/cohort-topic/gossip/bus.js +27 -1
  49. package/dist/src/cohort-topic/gossip/bus.js.map +1 -1
  50. package/dist/src/cohort-topic/gossip/view.d.ts +8 -0
  51. package/dist/src/cohort-topic/gossip/view.d.ts.map +1 -1
  52. package/dist/src/cohort-topic/gossip/view.js +17 -1
  53. package/dist/src/cohort-topic/gossip/view.js.map +1 -1
  54. package/dist/src/cohort-topic/member-engine.d.ts +10 -3
  55. package/dist/src/cohort-topic/member-engine.d.ts.map +1 -1
  56. package/dist/src/cohort-topic/member-engine.js +29 -14
  57. package/dist/src/cohort-topic/member-engine.js.map +1 -1
  58. package/dist/src/cohort-topic/membership/publisher.d.ts +14 -7
  59. package/dist/src/cohort-topic/membership/publisher.d.ts.map +1 -1
  60. package/dist/src/cohort-topic/membership/publisher.js +15 -27
  61. package/dist/src/cohort-topic/membership/publisher.js.map +1 -1
  62. package/dist/src/cohort-topic/membership/verifier.d.ts +35 -0
  63. package/dist/src/cohort-topic/membership/verifier.d.ts.map +1 -1
  64. package/dist/src/cohort-topic/membership/verifier.js +88 -3
  65. package/dist/src/cohort-topic/membership/verifier.js.map +1 -1
  66. package/dist/src/cohort-topic/promotion.d.ts +7 -0
  67. package/dist/src/cohort-topic/promotion.d.ts.map +1 -1
  68. package/dist/src/cohort-topic/promotion.js +16 -2
  69. package/dist/src/cohort-topic/promotion.js.map +1 -1
  70. package/dist/src/cohort-topic/registration/renewal.d.ts +18 -0
  71. package/dist/src/cohort-topic/registration/renewal.d.ts.map +1 -1
  72. package/dist/src/cohort-topic/registration/renewal.js +54 -0
  73. package/dist/src/cohort-topic/registration/renewal.js.map +1 -1
  74. package/dist/src/cohort-topic/registration/types.d.ts +12 -0
  75. package/dist/src/cohort-topic/registration/types.d.ts.map +1 -1
  76. package/dist/src/cohort-topic/registration/types.js +14 -0
  77. package/dist/src/cohort-topic/registration/types.js.map +1 -1
  78. package/dist/src/cohort-topic/service.d.ts +4 -3
  79. package/dist/src/cohort-topic/service.d.ts.map +1 -1
  80. package/dist/src/cohort-topic/service.js +38 -12
  81. package/dist/src/cohort-topic/service.js.map +1 -1
  82. package/dist/src/cohort-topic/sig/payloads.d.ts +23 -7
  83. package/dist/src/cohort-topic/sig/payloads.d.ts.map +1 -1
  84. package/dist/src/cohort-topic/sig/payloads.js +22 -6
  85. package/dist/src/cohort-topic/sig/payloads.js.map +1 -1
  86. package/dist/src/cohort-topic/traffic.d.ts +6 -0
  87. package/dist/src/cohort-topic/traffic.d.ts.map +1 -1
  88. package/dist/src/cohort-topic/traffic.js +16 -1
  89. package/dist/src/cohort-topic/traffic.js.map +1 -1
  90. package/dist/src/cohort-topic/walk.d.ts +22 -7
  91. package/dist/src/cohort-topic/walk.d.ts.map +1 -1
  92. package/dist/src/cohort-topic/walk.js +78 -18
  93. package/dist/src/cohort-topic/walk.js.map +1 -1
  94. package/dist/src/cohort-topic/wire/codec.d.ts +3 -1
  95. package/dist/src/cohort-topic/wire/codec.d.ts.map +1 -1
  96. package/dist/src/cohort-topic/wire/codec.js +7 -1
  97. package/dist/src/cohort-topic/wire/codec.js.map +1 -1
  98. package/dist/src/cohort-topic/wire/index.d.ts +1 -1
  99. package/dist/src/cohort-topic/wire/index.d.ts.map +1 -1
  100. package/dist/src/cohort-topic/wire/index.js +1 -1
  101. package/dist/src/cohort-topic/wire/index.js.map +1 -1
  102. package/dist/src/cohort-topic/wire/payloads.d.ts +5 -4
  103. package/dist/src/cohort-topic/wire/payloads.d.ts.map +1 -1
  104. package/dist/src/cohort-topic/wire/payloads.js +14 -5
  105. package/dist/src/cohort-topic/wire/payloads.js.map +1 -1
  106. package/dist/src/cohort-topic/wire/primitives.d.ts +65 -0
  107. package/dist/src/cohort-topic/wire/primitives.d.ts.map +1 -0
  108. package/dist/src/cohort-topic/wire/primitives.js +173 -0
  109. package/dist/src/cohort-topic/wire/primitives.js.map +1 -0
  110. package/dist/src/cohort-topic/wire/types.d.ts +132 -5
  111. package/dist/src/cohort-topic/wire/types.d.ts.map +1 -1
  112. package/dist/src/cohort-topic/wire/validate.d.ts +15 -5
  113. package/dist/src/cohort-topic/wire/validate.d.ts.map +1 -1
  114. package/dist/src/cohort-topic/wire/validate.js +146 -123
  115. package/dist/src/cohort-topic/wire/validate.js.map +1 -1
  116. package/dist/src/collection/collection.d.ts +23 -12
  117. package/dist/src/collection/collection.d.ts.map +1 -1
  118. package/dist/src/collection/collection.js +111 -35
  119. package/dist/src/collection/collection.js.map +1 -1
  120. package/dist/src/collection/struct.d.ts +37 -2
  121. package/dist/src/collection/struct.d.ts.map +1 -1
  122. package/dist/src/collection/struct.js +16 -1
  123. package/dist/src/collection/struct.js.map +1 -1
  124. package/dist/src/collections/tree/tree.d.ts +18 -1
  125. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  126. package/dist/src/collections/tree/tree.js +36 -6
  127. package/dist/src/collections/tree/tree.js.map +1 -1
  128. package/dist/src/index.d.ts +1 -0
  129. package/dist/src/index.d.ts.map +1 -1
  130. package/dist/src/index.js +1 -0
  131. package/dist/src/index.js.map +1 -1
  132. package/dist/src/log/log.d.ts +26 -3
  133. package/dist/src/log/log.d.ts.map +1 -1
  134. package/dist/src/log/log.js +34 -7
  135. package/dist/src/log/log.js.map +1 -1
  136. package/dist/src/log/struct.d.ts +5 -4
  137. package/dist/src/log/struct.d.ts.map +1 -1
  138. package/dist/src/log/struct.js.map +1 -1
  139. package/dist/src/matchmaking/wire.d.ts +2 -1
  140. package/dist/src/matchmaking/wire.d.ts.map +1 -1
  141. package/dist/src/matchmaking/wire.js +13 -92
  142. package/dist/src/matchmaking/wire.js.map +1 -1
  143. package/dist/src/network/i-key-network.d.ts +9 -0
  144. package/dist/src/network/i-key-network.d.ts.map +1 -1
  145. package/dist/src/network/i-repo.d.ts +8 -0
  146. package/dist/src/network/i-repo.d.ts.map +1 -1
  147. package/dist/src/network/struct.d.ts +10 -0
  148. package/dist/src/network/struct.d.ts.map +1 -1
  149. package/dist/src/reactivity/push-state.d.ts.map +1 -1
  150. package/dist/src/reactivity/push-state.js +4 -33
  151. package/dist/src/reactivity/push-state.js.map +1 -1
  152. package/dist/src/reactivity/wire-validate.d.ts +7 -22
  153. package/dist/src/reactivity/wire-validate.d.ts.map +1 -1
  154. package/dist/src/reactivity/wire-validate.js +7 -73
  155. package/dist/src/reactivity/wire-validate.js.map +1 -1
  156. package/dist/src/reactivity/wire.js +3 -3
  157. package/dist/src/reactivity/wire.js.map +1 -1
  158. package/dist/src/testing/async-wait.d.ts +23 -0
  159. package/dist/src/testing/async-wait.d.ts.map +1 -0
  160. package/dist/src/testing/async-wait.js +42 -0
  161. package/dist/src/testing/async-wait.js.map +1 -0
  162. package/dist/src/testing/index.d.ts +3 -0
  163. package/dist/src/testing/index.d.ts.map +1 -0
  164. package/dist/src/testing/index.js +3 -0
  165. package/dist/src/testing/index.js.map +1 -0
  166. package/dist/src/testing/test-transactor.d.ts +44 -0
  167. package/dist/src/testing/test-transactor.d.ts.map +1 -0
  168. package/dist/src/testing/test-transactor.js +423 -0
  169. package/dist/src/testing/test-transactor.js.map +1 -0
  170. package/dist/src/transaction/actions-engine.d.ts +6 -3
  171. package/dist/src/transaction/actions-engine.d.ts.map +1 -1
  172. package/dist/src/transaction/actions-engine.js +8 -7
  173. package/dist/src/transaction/actions-engine.js.map +1 -1
  174. package/dist/src/transaction/coordinator.d.ts +43 -22
  175. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  176. package/dist/src/transaction/coordinator.js +443 -178
  177. package/dist/src/transaction/coordinator.js.map +1 -1
  178. package/dist/src/transaction/errors.d.ts +82 -0
  179. package/dist/src/transaction/errors.d.ts.map +1 -0
  180. package/dist/src/transaction/errors.js +93 -0
  181. package/dist/src/transaction/errors.js.map +1 -0
  182. package/dist/src/transaction/index.d.ts +6 -3
  183. package/dist/src/transaction/index.d.ts.map +1 -1
  184. package/dist/src/transaction/index.js +4 -2
  185. package/dist/src/transaction/index.js.map +1 -1
  186. package/dist/src/transaction/operations-hash.d.ts +119 -0
  187. package/dist/src/transaction/operations-hash.d.ts.map +1 -0
  188. package/dist/src/transaction/operations-hash.js +180 -0
  189. package/dist/src/transaction/operations-hash.js.map +1 -0
  190. package/dist/src/transaction/read-dependency-collector.d.ts +43 -0
  191. package/dist/src/transaction/read-dependency-collector.d.ts.map +1 -0
  192. package/dist/src/transaction/read-dependency-collector.js +69 -0
  193. package/dist/src/transaction/read-dependency-collector.js.map +1 -0
  194. package/dist/src/transaction/session.d.ts +12 -3
  195. package/dist/src/transaction/session.d.ts.map +1 -1
  196. package/dist/src/transaction/session.js +35 -9
  197. package/dist/src/transaction/session.js.map +1 -1
  198. package/dist/src/transaction/transaction.d.ts +162 -1
  199. package/dist/src/transaction/transaction.d.ts.map +1 -1
  200. package/dist/src/transaction/transaction.js +85 -3
  201. package/dist/src/transaction/transaction.js.map +1 -1
  202. package/dist/src/transaction/validator.d.ts +3 -11
  203. package/dist/src/transaction/validator.d.ts.map +1 -1
  204. package/dist/src/transaction/validator.js +69 -28
  205. package/dist/src/transaction/validator.js.map +1 -1
  206. package/dist/src/transactor/network-transactor.d.ts +35 -0
  207. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  208. package/dist/src/transactor/network-transactor.js +146 -40
  209. package/dist/src/transactor/network-transactor.js.map +1 -1
  210. package/dist/src/transactor/transactor-source.d.ts +19 -5
  211. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  212. package/dist/src/transactor/transactor-source.js +43 -11
  213. package/dist/src/transactor/transactor-source.js.map +1 -1
  214. package/dist/src/transform/atomic-proxy.d.ts +35 -5
  215. package/dist/src/transform/atomic-proxy.d.ts.map +1 -1
  216. package/dist/src/transform/atomic-proxy.js +39 -7
  217. package/dist/src/transform/atomic-proxy.js.map +1 -1
  218. package/dist/src/transform/cache-source.d.ts +36 -5
  219. package/dist/src/transform/cache-source.d.ts.map +1 -1
  220. package/dist/src/transform/cache-source.js +84 -8
  221. package/dist/src/transform/cache-source.js.map +1 -1
  222. package/dist/src/transform/helpers.d.ts.map +1 -1
  223. package/dist/src/transform/helpers.js +8 -2
  224. package/dist/src/transform/helpers.js.map +1 -1
  225. package/dist/src/transform/tracker.d.ts +17 -2
  226. package/dist/src/transform/tracker.d.ts.map +1 -1
  227. package/dist/src/transform/tracker.js +66 -11
  228. package/dist/src/transform/tracker.js.map +1 -1
  229. package/dist/src/utility/backoff.d.ts +48 -0
  230. package/dist/src/utility/backoff.d.ts.map +1 -0
  231. package/dist/src/utility/backoff.js +60 -0
  232. package/dist/src/utility/backoff.js.map +1 -0
  233. package/dist/src/utility/pending.d.ts +1 -0
  234. package/dist/src/utility/pending.d.ts.map +1 -1
  235. package/dist/src/utility/pending.js +3 -1
  236. package/dist/src/utility/pending.js.map +1 -1
  237. package/package.json +4 -4
  238. package/src/blocks/block-store.ts +19 -1
  239. package/src/blocks/helpers.ts +3 -3
  240. package/src/btree/btree.ts +108 -63
  241. package/src/chain/chain.ts +11 -1
  242. package/src/cluster/index.ts +1 -0
  243. package/src/cluster/membership.ts +85 -0
  244. package/src/cluster/structs.ts +51 -1
  245. package/src/cohort-topic/addressing.ts +12 -4
  246. package/src/cohort-topic/antidos/bootstrap-evidence.ts +21 -9
  247. package/src/cohort-topic/antidos/replay-guard.ts +37 -0
  248. package/src/cohort-topic/antidos/topic-budget.ts +17 -2
  249. package/src/cohort-topic/coldstart.ts +48 -15
  250. package/src/cohort-topic/gossip/bus.ts +39 -2
  251. package/src/cohort-topic/gossip/view.ts +26 -1
  252. package/src/cohort-topic/member-engine.ts +36 -15
  253. package/src/cohort-topic/membership/publisher.ts +25 -33
  254. package/src/cohort-topic/membership/verifier.ts +119 -3
  255. package/src/cohort-topic/promotion.ts +23 -2
  256. package/src/cohort-topic/registration/renewal.ts +73 -1
  257. package/src/cohort-topic/registration/types.ts +15 -0
  258. package/src/cohort-topic/service.ts +41 -15
  259. package/src/cohort-topic/sig/payloads.ts +29 -9
  260. package/src/cohort-topic/traffic.ts +23 -1
  261. package/src/cohort-topic/walk.ts +95 -24
  262. package/src/cohort-topic/wire/codec.ts +12 -0
  263. package/src/cohort-topic/wire/index.ts +2 -0
  264. package/src/cohort-topic/wire/payloads.ts +14 -5
  265. package/src/cohort-topic/wire/primitives.ts +188 -0
  266. package/src/cohort-topic/wire/types.ts +136 -4
  267. package/src/cohort-topic/wire/validate.ts +168 -134
  268. package/src/collection/collection.ts +123 -40
  269. package/src/collection/struct.ts +44 -2
  270. package/src/collections/tree/tree.ts +42 -5
  271. package/src/index.ts +1 -0
  272. package/src/log/log.ts +37 -9
  273. package/src/log/struct.ts +5 -4
  274. package/src/matchmaking/wire.ts +25 -102
  275. package/src/network/i-key-network.ts +10 -0
  276. package/src/network/i-repo.ts +8 -0
  277. package/src/network/struct.ts +10 -0
  278. package/src/reactivity/push-state.ts +4 -37
  279. package/src/reactivity/wire-validate.ts +7 -81
  280. package/src/reactivity/wire.ts +3 -3
  281. package/src/testing/async-wait.ts +65 -0
  282. package/src/testing/index.ts +2 -0
  283. package/src/testing/test-transactor.ts +468 -0
  284. package/src/transaction/actions-engine.ts +8 -6
  285. package/src/transaction/coordinator.ts +498 -229
  286. package/src/transaction/errors.ts +91 -0
  287. package/src/transaction/index.ts +23 -3
  288. package/src/transaction/operations-hash.ts +196 -0
  289. package/src/transaction/read-dependency-collector.ts +78 -0
  290. package/src/transaction/session.ts +37 -9
  291. package/src/transaction/transaction.ts +211 -4
  292. package/src/transaction/validator.ts +73 -48
  293. package/src/transactor/network-transactor.ts +164 -49
  294. package/src/transactor/transactor-source.ts +47 -12
  295. package/src/transform/atomic-proxy.ts +92 -49
  296. package/src/transform/cache-source.ts +87 -9
  297. package/src/transform/helpers.ts +8 -2
  298. package/src/transform/tracker.ts +69 -11
  299. package/src/utility/backoff.ts +95 -0
  300. package/src/utility/pending.ts +3 -1
  301. package/src/transaction/context.ts +0 -103
@@ -51,6 +51,26 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
51
51
  private readonly getRepo: (peerId: PeerId) => IRepo;
52
52
  private readonly localChangeNotifier: IBlockChangeNotifier | undefined;
53
53
 
54
+ /**
55
+ * Per-transaction coordinator cache: `actionId → (blockId → resolved coordinator)`.
56
+ * {@link pend} populates it from its final (retry-adjusted) batch assignment; commit
57
+ * reads it via {@link resolveCoordinator} before falling back to a live
58
+ * `findCoordinator`, so a block's coordinator is resolved once per transaction across
59
+ * the pend→commit window instead of once at pend and again at commit.
60
+ *
61
+ * Keyed by `actionId`, which is unique per transaction, so an entry is only ever read
62
+ * by commits of the SAME transaction — the ones that immediately follow its pend. Once
63
+ * those finish, nothing reads the entry again (a later transaction has a fresh
64
+ * actionId), so it carries no cross-transaction staleness even if it lingers. The TTL
65
+ * and size cap in {@link txnCoordinatorsFor} are therefore only a memory backstop that
66
+ * reclaims entries from transactions that pend but never commit — NOT a staleness
67
+ * bound. This is why keying by actionId gives the same "thrown away when the
68
+ * transaction ends" safety as threading a Map through the call, without touching the
69
+ * ITransactor contract.
70
+ */
71
+ private readonly txnCoordinatorCache = new Map<ActionId, { coordinators: Map<BlockId, PeerId>; expires: number }>();
72
+ private static readonly MAX_TXN_COORDINATOR_CACHE_ENTRIES = 1000;
73
+
54
74
  constructor(
55
75
  init: NetworkTransactorInit,
56
76
  ) {
@@ -109,56 +129,69 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
109
129
  error = e as Error;
110
130
  }
111
131
 
112
- // Second-chance retry: if batch failed to respond OR responded with "not found"
113
- // Different cluster members may have different views; retry with other coordinators
132
+ // Second-chance retry: ONLY for a genuine no-response a batch with no valid
133
+ // response, or a response missing an entry for a requested block id. An
134
+ // authoritative "absent" answer (a valid response that carries an entry for
135
+ // every requested block id, even one whose entry has only `state` and no
136
+ // materialized `block`) is FINAL and must not retry. A block that genuinely
137
+ // does not exist yet surfaces as `{ state: {} }` (an entry that is present) —
138
+ // retrying it doubles the round-trips on the common createOrOpen "does this
139
+ // block exist?" probe. Cross-member reconciliation for a missing block has
140
+ // already happened one layer down: CoordinatorRepo.get detects `isMissing` and
141
+ // consults cluster peers before it responds, so by the time an authoritative
142
+ // absent reaches here there is nothing left for a transactor-level retry to
143
+ // discover. See ticket txn-perf-authoritative-notfound.
114
144
  const hasValidResponse = (b: CoordinatorBatch<BlockId[], GetBlockResults>) => {
115
145
  return b.request?.isResponse === true && b.request.response != null;
116
146
  };
117
147
 
118
- const hasBlockInResponse = (b: CoordinatorBatch<BlockId[], GetBlockResults>) => {
148
+ // A batch is answered when its response carries an entry for EVERY requested
149
+ // block id. An entry present with only `state` (no `block`) is an authoritative
150
+ // "absent", which counts as answered — not a gap.
151
+ const isAuthoritative = (b: CoordinatorBatch<BlockId[], GetBlockResults>) => {
119
152
  if (!hasValidResponse(b)) return false;
120
153
  const resp = b.request!.response! as GetBlockResults;
121
- return b.payload.some(bid => {
122
- const entry = resp[bid];
123
- return entry && typeof entry === 'object' && 'block' in entry && entry.block != null;
124
- });
154
+ return b.payload.every(bid => resp[bid] !== undefined);
125
155
  };
126
156
 
127
- // Retry batches that either failed to respond OR responded with "not found"
128
- // This provides tolerance for different cluster member views
157
+ // Retry only genuine no-response / partial-response batches. An authoritative
158
+ // absent answer is not retried.
129
159
  const retryable = Array.from(allBatches(batches)).filter(b =>
130
- !hasValidResponse(b as any) || !hasBlockInResponse(b as any)
160
+ !isAuthoritative(b as any)
131
161
  ) as CoordinatorBatch<BlockId[], GetBlockResults>[];
132
162
 
133
163
  if (retryable.length > 0 && Date.now() < expiration) {
134
164
  log('get:retry retryable=%d', retryable.length);
135
- try {
136
- const excludedByRoot = new Map<CoordinatorBatch<BlockId[], GetBlockResults>, Set<PeerId>>();
137
- for (const b of retryable) {
138
- const excluded = new Set<PeerId>([b.peerId, ...((b.excludedPeers ?? []) as PeerId[])]);
139
- excludedByRoot.set(b, excluded);
140
- const retries = await createBatchesForPayload<BlockId[], GetBlockResults>(
141
- b.payload,
142
- b.payload,
165
+ // Fan out the per-batch retries concurrently. Each root batch builds its own
166
+ // excluded-peer set and attaches its own `subsumedBy`, so the retry rounds are
167
+ // independent per root and safe to run in parallel.
168
+ const retryOutcomes = await Promise.allSettled(retryable.map(async b => {
169
+ const excluded = new Set<PeerId>([b.peerId, ...((b.excludedPeers ?? []) as PeerId[])]);
170
+ const retries = await createBatchesForPayload<BlockId[], GetBlockResults>(
171
+ b.payload,
172
+ b.payload,
173
+ (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)],
174
+ Array.from(excluded),
175
+ async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
176
+ );
177
+ if (retries.length > 0) {
178
+ b.subsumedBy = [...(b.subsumedBy ?? []), ...retries];
179
+ await processBatches(
180
+ retries,
181
+ (batch) => this.getRepo(batch.peerId).get({ blockIds: batch.payload, context: blockGets.context }, { expiration, dialTimeoutMs: this.dialTimeoutMs }),
182
+ batch => batch.payload,
143
183
  (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)],
144
- Array.from(excluded),
184
+ expiration,
145
185
  async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
146
186
  );
147
- if (retries.length > 0) {
148
- b.subsumedBy = [...(b.subsumedBy ?? []), ...retries];
149
- await processBatches(
150
- retries,
151
- (batch) => this.getRepo(batch.peerId).get({ blockIds: batch.payload, context: blockGets.context }, { expiration, dialTimeoutMs: this.dialTimeoutMs }),
152
- batch => batch.payload,
153
- (gets, blockId, mergeWithGets) => [...(mergeWithGets ?? []), ...gets.filter(id => id === blockId)],
154
- expiration,
155
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
156
- );
157
- }
158
187
  }
159
- } catch (e) {
160
- // keep original error if any
161
- if (!error) error = e as Error;
188
+ }));
189
+ // First-error-wins: keep any pre-existing error, otherwise adopt the first
190
+ // rejection across the concurrent retries (retryable order is preserved).
191
+ for (const outcome of retryOutcomes) {
192
+ if (outcome.status === 'rejected' && !error) {
193
+ error = outcome.reason instanceof Error ? outcome.reason : new Error(String(outcome.reason));
194
+ }
162
195
  }
163
196
  }
164
197
 
@@ -437,7 +470,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
437
470
  expiration,
438
471
  dialTimeoutMs: this.dialTimeoutMs,
439
472
  coordinatingBlockIds: batch.coordinatingBlockIds
440
- } as any
473
+ }
441
474
  ),
442
475
  batch => blockIdsForTransforms(batch.payload),
443
476
  transformForBlock,
@@ -446,13 +479,10 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
446
479
  );
447
480
  // Cache resolved coordinators for follow-up commit to hit the same peers
448
481
  try {
449
- const pn: any = this.keyNetwork as any;
450
- if (typeof pn?.recordCoordinator === 'function') {
451
- for (const b of Array.from(allBatches(batches))) {
452
- pn.recordCoordinator(await blockIdToBytes(b.blockId), b.peerId);
453
- }
482
+ for (const b of Array.from(allBatches(batches))) {
483
+ this.keyNetwork.recordCoordinator?.(await blockIdToBytes(b.blockId), b.peerId);
454
484
  }
455
- } catch (e) { console.warn('Failed to record coordinator hint', e); }
485
+ } catch (e) { log('WARN: Failed to record coordinator hint %o', e); }
456
486
  } catch (e) {
457
487
  error = e as Error;
458
488
  }
@@ -477,7 +507,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
477
507
 
478
508
  if (error) { // If any failures, cancel all pending actions as background microtask
479
509
  log('pend:cancel actionId=%s', blockAction.actionId);
480
- void Promise.resolve().then(() => this.cancelBatch(batches, { blockIds, actionId: blockAction.actionId }));
510
+ void Promise.resolve().then(() => this.cancelBatch(batches, { blockIds, actionId: blockAction.actionId })).catch(e => log('WARN: cancel after pend failure rejected: %o', e));
481
511
  const stale = Array.from(allBatches(batches, b => b.request?.isResponse as boolean && !b.request!.response!.success));
482
512
  if (stale.length > 0) { // Any active stale failures should preempt reporting connection or other potential transient errors (we have information)
483
513
  log('pend:stale actionId=%s staleCount=%d', blockAction.actionId, stale.length);
@@ -491,6 +521,26 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
491
521
 
492
522
  // Collect replies back into result structure
493
523
  const completed = Array.from(allBatches(batches, b => b.request?.isResponse as boolean && b.request!.response!.success));
524
+
525
+ // Seed the per-transaction coordinator cache from the final (retry-adjusted) batch
526
+ // assignment so the follow-up commit reuses pend's resolution without a fresh
527
+ // findCoordinator round or a hop through the optional recordCoordinator hint. We read
528
+ // blockIdsForTransforms(b.payload) rather than the anchor b.blockId so EVERY block a
529
+ // consolidated batch coordinates is recorded — and against the peer that actually
530
+ // pended it, since a block re-homed by a retry lands in the retry batch's payload.
531
+ // NOTE: this cache assumes cluster membership is stable for the transaction's
532
+ // lifetime — the coordinator resolved here is reused verbatim at commit. Transactions
533
+ // are short, so that holds today. If a future change lets clusters churn *within* a
534
+ // single transaction (e.g. very long-running commits), a cached coordinator could
535
+ // point at a peer no longer in the cohort; commit self-heals (a failed cached peer is
536
+ // excluded and re-resolved live by processBatches), at the cost of one wasted round-trip.
537
+ const txnCoordinators = this.txnCoordinatorsFor(blockAction.actionId);
538
+ for (const b of completed) {
539
+ for (const bid of blockIdsForTransforms(b.payload)) {
540
+ txnCoordinators.set(bid, b.peerId);
541
+ }
542
+ }
543
+
494
544
  log('pend:done actionId=%s ms=%d batches=%d', blockAction.actionId, Date.now() - t0, batches.length);
495
545
  return {
496
546
  success: true,
@@ -558,7 +608,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
558
608
  if (error) {
559
609
  // Non-tail block commit failures should not fail the overall action once the tail has committed.
560
610
  // Proceed and rely on reconciliation paths (e.g. reads with context) to finalize state on lagging peers.
561
- try { console.warn('[NetworkTransactor] non-tail commit had errors; proceeding after tail commit:', error.message); } catch { /* ignore */ }
611
+ try { log('WARN: non-tail commit had errors; proceeding after tail commit: %s', error.message); } catch { /* ignore */ }
562
612
  }
563
613
  }
564
614
 
@@ -569,12 +619,18 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
569
619
  private async commitBlock(blockId: BlockId, blockIds: BlockId[], actionId: ActionId, rev: number, tailId?: BlockId): Promise<CommitResult> {
570
620
  const { batches: tailBatches, error: tailError } = await this.commitBlocks({ blockIds: [blockId], actionId, rev, tailId });
571
621
  if (tailError) {
572
- // Cancel all pending actions as background microtask
573
- Promise.resolve().then(() => this.cancel({ blockIds, actionId }));
622
+ // commit is a pure attempt: stale → { success:false }, transient → throw. Cancellation
623
+ // is the CALLER's responsibility (coordinator cancelPhase; TransactorSource.transact),
624
+ // which owns the retry budget and the committed-vs-pending picture — self-cancelling here
625
+ // would tear down a pend a caller's retry loop is still working against, and double-cancel.
574
626
  // Collect and return any active stale failures
575
627
  const stale = Array.from(allBatches(tailBatches, b => b.request?.isResponse as boolean && !b.request!.response!.success));
576
628
  if (stale.length > 0) {
577
- return { missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing!)), success: false as const };
629
+ // NOTE: a reason-only StaleFailure (success:false, no `missing`) lands here too and
630
+ // returns { missing: [], success:false } — the `reason` string is dropped rather than
631
+ // surfaced via `throw tailError`. Fine today (failure still propagates); if reason-only
632
+ // commit rejections ever need their diagnostic reason, gate this branch on non-empty missing.
633
+ return { missing: distinctBlockActionTransforms(stale.flatMap(b => (b.request!.response! as StaleFailure).missing).filter((x): x is ActionTransforms => x !== undefined)), success: false as const };
578
634
  }
579
635
  throw tailError;
580
636
  }
@@ -584,7 +640,9 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
584
640
  /** Attempts to commit a set of blocks, and handles failures and errors */
585
641
  private async commitBlocks({ blockIds, actionId, rev, tailId }: RepoCommitRequest) {
586
642
  const expiration = Date.now() + this.timeoutMs;
587
- const batches = await this.batchesForPayload<BlockId[], CommitResult>(blockIds, blockIds, mergeBlocks, []);
643
+ // Thread the transaction's actionId so both the initial batch assembly and any
644
+ // per-block retry re-resolution prefer the coordinator pend already resolved.
645
+ const batches = await this.batchesForPayload<BlockId[], CommitResult>(blockIds, blockIds, mergeBlocks, [], actionId);
588
646
  log('commitBlocks actionId=%s rev=%d batches=%d', actionId, rev, batches.length);
589
647
  let error: Error | undefined;
590
648
  try {
@@ -594,7 +652,7 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
594
652
  batch => batch.payload,
595
653
  mergeBlocks,
596
654
  expiration,
597
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
655
+ async (blockId, options) => this.resolveCoordinator(blockId, options, actionId)
598
656
  );
599
657
  } catch (e) {
600
658
  error = e as Error;
@@ -623,17 +681,74 @@ export class NetworkTransactor implements ITransactor, IBlockChangeNotifier {
623
681
  blockIds: BlockId[],
624
682
  payload: TPayload,
625
683
  getBlockPayload: (payload: TPayload, blockId: BlockId, mergeWithPayload: TPayload | undefined) => TPayload,
626
- excludedPeers: PeerId[]
684
+ excludedPeers: PeerId[],
685
+ /** When set, prefer a coordinator this transaction already resolved at pend (see {@link resolveCoordinator}). */
686
+ actionId?: ActionId
627
687
  ): Promise<CoordinatorBatch<TPayload, TResponse>[]> {
628
688
  return createBatchesForPayload<TPayload, TResponse>(
629
689
  blockIds,
630
690
  payload,
631
691
  getBlockPayload,
632
692
  excludedPeers,
633
- async (blockId, options) => this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options)
693
+ async (blockId, options) => this.resolveCoordinator(blockId, options, actionId)
634
694
  );
635
695
  }
636
696
 
697
+ /**
698
+ * Resolve the coordinator for `blockId`, preferring one this transaction already
699
+ * resolved during pend (the per-transaction cache) before falling back to a live
700
+ * `findCoordinator`. A cached coordinator that is in `excludedPeers` (already tried and
701
+ * failed during a retry) is skipped so a retry can't loop on a dead coordinator. A
702
+ * cache miss — including every call with no `actionId` (get/cancel) — never fails; it
703
+ * always falls through to live resolution.
704
+ */
705
+ private async resolveCoordinator(
706
+ blockId: BlockId,
707
+ options: { excludedPeers: PeerId[] },
708
+ actionId: ActionId | undefined
709
+ ): Promise<PeerId> {
710
+ if (actionId !== undefined) {
711
+ const cached = this.txnCoordinatorCache.get(actionId)?.coordinators.get(blockId);
712
+ if (cached && !options.excludedPeers.some(p => p.toString() === cached.toString())) {
713
+ return cached;
714
+ }
715
+ }
716
+ return this.keyNetwork.findCoordinator(await blockIdToBytes(blockId), options);
717
+ }
718
+
719
+ /**
720
+ * Get (creating if absent) the per-transaction coordinator map for `actionId`,
721
+ * refreshing its expiry and lazily sweeping expired sibling entries. See
722
+ * {@link txnCoordinatorCache} for why the TTL/size cap here is a memory backstop and
723
+ * not a staleness bound.
724
+ */
725
+ private txnCoordinatorsFor(actionId: ActionId): Map<BlockId, PeerId> {
726
+ const now = Date.now();
727
+ // Reclaim entries from transactions that pended but never committed. A live entry is
728
+ // never stale (unique actionId; read only by its own transaction's commit), so
729
+ // sweeping lazily on write is safe.
730
+ for (const [aid, entry] of this.txnCoordinatorCache) {
731
+ if (entry.expires <= now) this.txnCoordinatorCache.delete(aid);
732
+ }
733
+ // Comfortably covers a normal pend→commit gap (~2 op budgets) with a fixed floor;
734
+ // an entry outliving this only loses the optimization (commit re-resolves live),
735
+ // never correctness.
736
+ const ttlMs = Math.max(this.timeoutMs * 2, 60_000);
737
+ const existing = this.txnCoordinatorCache.get(actionId);
738
+ if (existing) {
739
+ existing.expires = now + ttlMs;
740
+ return existing.coordinators;
741
+ }
742
+ const created = { coordinators: new Map<BlockId, PeerId>(), expires: now + ttlMs };
743
+ this.txnCoordinatorCache.set(actionId, created);
744
+ while (this.txnCoordinatorCache.size > NetworkTransactor.MAX_TXN_COORDINATOR_CACHE_ENTRIES) {
745
+ const oldest = this.txnCoordinatorCache.keys().next().value as ActionId | undefined;
746
+ if (oldest == null || oldest === actionId) break;
747
+ this.txnCoordinatorCache.delete(oldest);
748
+ }
749
+ return created.coordinators;
750
+ }
751
+
637
752
  /** Cancels a pending transaction by canceling all blocks associated with the transaction, including failed peers */
638
753
  private async cancelBatch<TPayload, TResponse>(
639
754
  batches: CoordinatorBatch<TPayload, TResponse>[],
@@ -1,16 +1,26 @@
1
1
  import { randomBytes } from '@noble/hashes/utils.js'
2
2
  import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
3
- import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, Transforms } from "../index.js";
3
+ import type { IBlock, BlockId, BlockHeader, ITransactor, ActionId, StaleFailure, ActionContext, BlockType, BlockSource, ReadPurpose, Transforms } from "../index.js";
4
4
  import type { ReadDependency } from "../transaction/transaction.js";
5
+ import { ReadDependencyCollector } from "../transaction/read-dependency-collector.js";
5
6
 
6
7
  export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlock> {
7
- private readDependencies: ReadDependency[] = [];
8
+ /** Shared with this collection's CacheSource so cache hits also record dependencies.
9
+ * Defaults to a private instance so internal log-walk sources (which never need a
10
+ * transaction read set) work standalone. */
11
+ private readonly collector: ReadDependencyCollector;
12
+ /** Last revision observed per id, so CacheSource can learn the revision on a miss-load
13
+ * (it calls {@link getReadRevision} right after this source serves the block). */
14
+ private readRevisions = new Map<BlockId, number>();
8
15
 
9
16
  constructor(
10
17
  private readonly collectionId: BlockId,
11
18
  private readonly transactor: ITransactor,
12
19
  public actionContext: ActionContext | undefined,
13
- ) { }
20
+ collector?: ReadDependencyCollector,
21
+ ) {
22
+ this.collector = collector ?? new ReadDependencyCollector();
23
+ }
14
24
 
15
25
  createBlockHeader(type: BlockType, newId?: BlockId): BlockHeader {
16
26
  return {
@@ -25,24 +35,46 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
25
35
  return uint8ArrayToString(randomBytes(32), 'base64url')
26
36
  }
27
37
 
28
- async tryGet(id: BlockId): Promise<TBlock | undefined> {
38
+ async tryGet(id: BlockId, purpose: ReadPurpose = 'value'): Promise<TBlock | undefined> {
29
39
  const result = await this.transactor.get({ blockIds: [id], context: this.actionContext });
30
- if (result) {
31
- const { block, state } = result[id]!;
32
- // Record read dependency for optimistic concurrency control
33
- this.readDependencies.push({ blockId: id, revision: state.latest?.rev ?? 0 });
40
+ // Guard the per-key entry: some transactors return a sparse result that omits `id`
41
+ // entirely (e.g. block genuinely not found), so `result` is a truthy object but
42
+ // `result[id]` is undefined. Destructuring that would throw a TypeError.
43
+ const entry = result?.[id];
44
+ if (entry) {
45
+ const { block, state } = entry;
46
+ // Record a read dependency only for a block that actually exists. A transactor may return a
47
+ // populated entry with `block: undefined` for a genuinely-missing block (TestTransactor does;
48
+ // the Network transactor always populates the key); recording there would add a phantom
49
+ // dependency for a nonexistent block. This makes the "absent reads nothing" contract uniform
50
+ // with the sparse-result case (entry omitted) — see transactor-source.spec.ts sparse test.
51
+ if (block) {
52
+ // Record read dependency for optimistic concurrency control, carrying the caller's
53
+ // read purpose (default `value`) so a purely-structural navigation read can later be
54
+ // dropped from the conflict set (see ReadDependencyCollector / Theorem 5).
55
+ const rev = state.latest?.rev ?? 0;
56
+ this.collector.record(id, rev, purpose);
57
+ this.readRevisions.set(id, rev);
58
+ }
34
59
  // TODO: if the state reports that there is a pending action, record this so that we are sure to update before syncing
35
60
  //state.pendings
36
61
  return block as TBlock;
37
62
  }
38
63
  }
39
64
 
65
+ /** The revision observed the last time this source served {@link id} (from its committed
66
+ * state), or undefined if this source has never served it. CacheSource reads this on a
67
+ * miss-load to learn the revision to record and store. */
68
+ getReadRevision(id: BlockId): number | undefined {
69
+ return this.readRevisions.get(id);
70
+ }
71
+
40
72
  getReadDependencies(): ReadDependency[] {
41
- return this.readDependencies;
73
+ return this.collector.getReadDependencies();
42
74
  }
43
75
 
44
76
  clearReadDependencies(): void {
45
- this.readDependencies = [];
77
+ this.collector.clear();
46
78
  }
47
79
 
48
80
  /**
@@ -55,10 +87,13 @@ export class TransactorSource<TBlock extends IBlock> implements BlockSource<TBlo
55
87
  * is transitioning to a new block. Ignored if the given headerId is not present in the transforms.
56
88
  * @param tailId - The Id of the collection's log tail block. If specified, this block's transform is performed next
57
89
  * (prior to the rest of the block operations), to resolve the "winner" of a race to commit to the collection.
90
+ * @param priority - Aged, advisory retry priority (default 0). Rides on the pend so a repeatedly-losing
91
+ * single-collection sync out-ranks fresh rivals in a concurrent race (`resolveRace`); fairness-only, never
92
+ * affects validity. Omitted from the pend when 0 so the common first-attempt pend serializes exactly as before.
58
93
  * @returns A promise that resolves to undefined if the action is successful, or a StaleFailure if the action is stale.
59
94
  */
60
- async transact(transform: Transforms, actionId: ActionId, rev: number, headerId: BlockId, tailId: BlockId): Promise<undefined | StaleFailure> {
61
- const pendResult = await this.transactor.pend({ transforms: transform, actionId, rev, policy: 'r' });
95
+ async transact(transform: Transforms, actionId: ActionId, rev: number, headerId: BlockId, tailId: BlockId, priority = 0): Promise<undefined | StaleFailure> {
96
+ const pendResult = await this.transactor.pend({ transforms: transform, actionId, rev, policy: 'r', ...(priority > 0 ? { priority } : {}) });
62
97
  if (!pendResult.success) {
63
98
  return pendResult;
64
99
  }
@@ -1,49 +1,92 @@
1
- import { Atomic } from './atomic.js';
2
- import type { IBlock, BlockId, BlockStore, BlockType, BlockHeader, BlockOperation } from '../index.js';
3
-
4
- /**
5
- * A BlockStore proxy that enables scoped atomic operations.
6
- * Operations normally delegate directly to the underlying store,
7
- * but during an `atomic()` call, they route through an Atomic tracker
8
- * that commits all-or-nothing on success, or rolls back on error.
9
- *
10
- * Both the BTree and its trunk should share the same AtomicProxy instance
11
- * so that all mutations (including root pointer updates) are part of the
12
- * same atomic batch.
13
- */
14
- export class AtomicProxy<T extends IBlock> implements BlockStore<T> {
15
- private _base: BlockStore<T>;
16
- private _active: BlockStore<T>;
17
-
18
- constructor(store: BlockStore<T>) {
19
- this._base = store;
20
- this._active = store;
21
- }
22
-
23
- async tryGet(id: BlockId): Promise<T | undefined> { return this._active.tryGet(id); }
24
- insert(block: T): void { this._active.insert(block); }
25
- update(blockId: BlockId, op: BlockOperation): void { this._active.update(blockId, op); }
26
- delete(blockId: BlockId): void { this._active.delete(blockId); }
27
- generateId(): BlockId { return this._active.generateId(); }
28
- createBlockHeader(type: BlockType, newId?: BlockId): BlockHeader { return this._active.createBlockHeader(type, newId); }
29
-
30
- /** Execute fn within an atomic scope. All store mutations are collected
31
- * and committed on success, or discarded on error. Re-entrant safe. */
32
- async atomic<R>(fn: () => Promise<R>): Promise<R> {
33
- if (this._active !== this._base) {
34
- return fn(); // Already in atomic context
35
- }
36
- const atomic = new Atomic<T>(this._base);
37
- this._active = atomic;
38
- try {
39
- const result = await fn();
40
- atomic.commit();
41
- return result;
42
- } catch (e) {
43
- atomic.reset();
44
- throw e;
45
- } finally {
46
- this._active = this._base;
47
- }
48
- }
49
- }
1
+ import { Atomic } from './atomic.js';
2
+ import type { IBlock, BlockId, BlockStore, BlockType, BlockHeader, BlockOperation, ReadPurpose } from '../index.js';
3
+
4
+ /**
5
+ * Opaque handle to an in-flight atomic scope.
6
+ *
7
+ * A method that opens a scope (via {@link AtomicProxy.atomic}) receives the scope as its
8
+ * callback argument. To make a *nested* call reuse the enclosing scope — rather than open
9
+ * (and separately commit) a second one — pass that handle back as the `parent` argument of
10
+ * the nested `atomic()` call. This is how genuine nesting is distinguished from a second,
11
+ * unrelated concurrent scope, portably (no `AsyncLocalStorage`, which is Node-only).
12
+ */
13
+ export interface AtomicScope { readonly __atomicScope: unique symbol; }
14
+
15
+ /**
16
+ * A BlockStore proxy that enables scoped atomic operations.
17
+ * Operations normally delegate directly to the underlying store,
18
+ * but during an `atomic()` call, they route through an Atomic tracker
19
+ * that commits all-or-nothing on success, or rolls back on error.
20
+ *
21
+ * Both the BTree and its trunk should share the same AtomicProxy instance
22
+ * so that all mutations (including root pointer updates) are part of the
23
+ * same atomic batch.
24
+ *
25
+ * Concurrency model: top-level `atomic()` calls are *serialized* through a promise queue,
26
+ * so a second, un-awaited scope started while the first is still in flight waits for the
27
+ * first to commit/roll back before opening its own tracker — it never shares the first's
28
+ * tracker. A genuinely nested call (one that hands back its enclosing scope) bypasses the
29
+ * queue and reuses that scope, so it neither deadlocks on itself nor double-commits.
30
+ */
31
+ export class AtomicProxy<T extends IBlock> implements BlockStore<T> {
32
+ private _base: BlockStore<T>;
33
+ private _active: BlockStore<T>;
34
+ /** Tail of the serialization queue; each new top-level scope awaits the prior one. */
35
+ private _tail: Promise<void> = Promise.resolve();
36
+ /** The scope currently executing, or undefined between scopes. Serialization guarantees
37
+ * at most one is active at a time, so identity against `parent` cleanly separates a
38
+ * nested call (parent === current) from a foreign concurrent one (parent absent/stale). */
39
+ private _current?: AtomicScope;
40
+
41
+ constructor(store: BlockStore<T>) {
42
+ this._base = store;
43
+ this._active = store;
44
+ }
45
+
46
+ async tryGet(id: BlockId, purpose?: ReadPurpose): Promise<T | undefined> { return this._active.tryGet(id, purpose); }
47
+ /** Forward a leaf-value upgrade to the active store (duck-typed; the Tracker/CacheSource chain
48
+ * implements it). Keeps navigation-read filtering working for a B-tree bound to this proxy. */
49
+ markReadValue(id: BlockId): void { (this._active as { markReadValue?: (id: BlockId) => void }).markReadValue?.(id); }
50
+ insert(block: T): void { this._active.insert(block); }
51
+ update(blockId: BlockId, op: BlockOperation): void { this._active.update(blockId, op); }
52
+ delete(blockId: BlockId): void { this._active.delete(blockId); }
53
+ generateId(): BlockId { return this._active.generateId(); }
54
+ createBlockHeader(type: BlockType, newId?: BlockId): BlockHeader { return this._active.createBlockHeader(type, newId); }
55
+
56
+ /** Execute fn within an atomic scope. All store mutations are collected and committed on
57
+ * success, or discarded on error. `fn` receives a scope handle; pass it as the `parent`
58
+ * of any nested `atomic()` call that should join this scope instead of opening its own.
59
+ * Re-entrant safe (nesting reuses the parent scope) and concurrency safe (unrelated
60
+ * overlapping scopes serialize rather than sharing a tracker). */
61
+ async atomic<R>(fn: (scope: AtomicScope) => Promise<R>, parent?: AtomicScope): Promise<R> {
62
+ // Genuine nesting: the caller handed back the scope it is already inside. Reuse it —
63
+ // no second tracker, no second commit — and skip the queue, since waiting on the very
64
+ // scope we are running inside would deadlock.
65
+ if (parent !== undefined && parent === this._current) {
66
+ return fn(parent);
67
+ }
68
+ // Top-level scope (first, or a foreign call that overlapped one in flight): take a
69
+ // place in the serialization queue so it runs against its own tracker only after the
70
+ // prior scope has committed/rolled back.
71
+ const prior = this._tail;
72
+ let release!: () => void;
73
+ this._tail = new Promise<void>(resolve => { release = resolve; });
74
+ await prior;
75
+ const atomic = new Atomic<T>(this._base);
76
+ const scope = atomic as unknown as AtomicScope;
77
+ this._current = scope;
78
+ this._active = atomic;
79
+ try {
80
+ const result = await fn(scope);
81
+ atomic.commit();
82
+ return result;
83
+ } catch (e) {
84
+ atomic.reset();
85
+ throw e;
86
+ } finally {
87
+ this._active = this._base;
88
+ this._current = undefined;
89
+ release();
90
+ }
91
+ }
92
+ }