@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
@@ -3,6 +3,7 @@ import type { IPeerNetwork } from '@optimystic/db-core';
3
3
  import { ProtocolClient } from '../protocol-client.js';
4
4
  import { buildSyncProtocol, type SyncRequest, type SyncResponse } from './protocol.js';
5
5
  import { withRpcDeadlineDefaults, type RpcDeadlineOptions } from '../rpc-deadline.js';
6
+ import { MAX_BLOCK_MESSAGE_BYTES } from '../protocol-limits.js';
6
7
 
7
8
  /**
8
9
  * Client for sending sync requests to remote peers.
@@ -32,7 +33,10 @@ export class SyncClient extends ProtocolClient {
32
33
  * @throws Error if request fails or times out
33
34
  */
34
35
  async requestBlock(request: SyncRequest, options?: RpcDeadlineOptions): Promise<SyncResponse> {
35
- return await this.processMessage<SyncResponse>(request, this.protocol, withRpcDeadlineDefaults(options));
36
+ // Asymmetry: the *request* the server reads is a tiny SyncRequest (control cap
37
+ // on the server side), but the *response* read here is a BlockArchive carrying
38
+ // block data → block cap.
39
+ return await this.processMessage<SyncResponse>(request, this.protocol, { ...withRpcDeadlineDefaults(options), maxDataLength: MAX_BLOCK_MESSAGE_BYTES });
36
40
  }
37
41
 
38
42
  /**
@@ -4,6 +4,7 @@ import { buildSyncProtocol, type SyncRequest, type SyncResponse } from './protoc
4
4
  import { pipe } from 'it-pipe';
5
5
  import { toString as u8ToString } from 'uint8arrays/to-string';
6
6
  import * as lp from 'it-length-prefixed';
7
+ import { MAX_CONTROL_MESSAGE_BYTES } from '../protocol-limits.js';
7
8
  import type { Uint8ArrayList } from 'uint8arraylist';
8
9
 
9
10
  export interface SyncServiceInit {
@@ -114,7 +115,7 @@ export class SyncService implements Startable {
114
115
  const actualStream = stream.stream ?? stream;
115
116
  const responses = pipe(
116
117
  actualStream,
117
- (source: any) => lp.decode(source),
118
+ (source: any) => lp.decode(source, { maxDataLength: MAX_CONTROL_MESSAGE_BYTES }),
118
119
  processStream,
119
120
  (source: any) => lp.encode(source)
120
121
  );
@@ -56,18 +56,18 @@ import { DEFAULT_COHORT_TOPIC_PROTOCOLS as PROTOCOLS } from '../cohort-topic/pro
56
56
 
57
57
  export { PROTOCOLS };
58
58
 
59
- export const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
59
+ export { delay } from '@optimystic/db-core/test';
60
+ import { delay } from '@optimystic/db-core/test';
60
61
 
61
- /** Poll `predicate` until true or `timeoutMs` elapses (deterministic-but-not-flaky async settle). */
62
- export async function waitFor(predicate: () => boolean, timeoutMs = 2_000, intervalMs = 5): Promise<boolean> {
62
+ // NOTE: backward-compat wrapper returning boolean; downstream tickets (3-8) replace each
63
+ // call site with the throw-on-timeout canonical waitFor from @optimystic/db-core/test.
64
+ export async function waitFor(predicate: () => boolean | Promise<boolean>, timeoutMs = 2_000, intervalMs = 5): Promise<boolean> {
63
65
  const deadline = Date.now() + timeoutMs;
64
66
  while (Date.now() < deadline) {
65
- if (predicate()) {
66
- return true;
67
- }
67
+ if (await predicate()) return true;
68
68
  await delay(intervalMs);
69
69
  }
70
- return predicate();
70
+ return !!(await predicate());
71
71
  }
72
72
 
73
73
  // --- members (real Ed25519 keys) ---
@@ -268,6 +268,13 @@ export interface MeshOptions {
268
268
  export interface RouteTraceEntry {
269
269
  readonly key: string;
270
270
  readonly result: RegisterResult;
271
+ /**
272
+ * Whether the routed frame carried a participant signature. A participant's own walk probes are always
273
+ * signed; the cold-start forwarder→parent link frame is unsigned (`signature: ""`, the interim gap the
274
+ * `cohort-topic-parent-child-link` follow-on closes). {@link walkTraceFrom} keeps only signed entries so
275
+ * a follow-on instantiation's background parent-link RPC does not pollute a walk's reconstructed trace.
276
+ */
277
+ readonly signed: boolean;
271
278
  }
272
279
 
273
280
  export class CohortMesh {
@@ -337,17 +344,18 @@ export class CohortMesh {
337
344
  private async routeAct(msg: RouteAndMaybeActV1): Promise<NearAnchorV1 | { commitCertificate: string }> {
338
345
  const key = b64urlToBytes(msg.key);
339
346
  this.routeKeys.push(msg.key);
347
+ const signed = routedFrameIsSigned(msg.activity);
340
348
  const target = this.nearest(key);
341
349
  const handler = this.activity.get(target.idStr);
342
350
  if (handler === undefined || this.down.has(target.idStr)) {
343
351
  // No in-cluster activity to run (cold / unreachable target) → a bare anchor hint; the walk
344
352
  // treats it as `no_state`.
345
- this.routeTrace.push({ key: msg.key, result: 'no_state' });
353
+ this.routeTrace.push({ key: msg.key, result: 'no_state', signed });
346
354
  return { v: 1, anchors: [], cohort_hint: [], estimated_cluster_size: this.members.length, confidence: 1 };
347
355
  }
348
356
  const cohort = this.assembleCohort(key, msg.want_k);
349
357
  const reply = await handler(msg.activity ?? '', cohort, msg.min_sigs, msg.correlation_id);
350
- this.routeTrace.push({ key: msg.key, result: replyResult(reply) });
358
+ this.routeTrace.push({ key: msg.key, result: replyResult(reply), signed });
351
359
  return reply;
352
360
  }
353
361
 
@@ -379,6 +387,24 @@ export class CohortMesh {
379
387
  }
380
388
  }
381
389
 
390
+ /**
391
+ * Whether the routed `RegisterV1` frame carries a participant signature. Decodes the activity best-effort
392
+ * (an undecodable / absent frame is treated as signed, so only a genuinely unsigned frame — the cold-start
393
+ * forwarder→parent link — is flagged). Lets {@link walkTraceFrom} drop the background parent-link RPC a
394
+ * `followOn` instantiation fires, which would otherwise alias a walk's own probe coord.
395
+ */
396
+ function routedFrameIsSigned(activity: string | undefined): boolean {
397
+ if (activity === undefined || activity === '') {
398
+ return true;
399
+ }
400
+ try {
401
+ const frame = decodeCohortMessage(b64urlToBytes(activity)) as { signature?: unknown };
402
+ return typeof frame.signature !== 'string' || frame.signature.length > 0;
403
+ } catch {
404
+ return true;
405
+ }
406
+ }
407
+
382
408
  /** The reply classification a `routeAct` resolved with: decode the commit certificate's `RegisterReplyV1`. */
383
409
  function replyResult(reply: NearAnchorV1 | { commitCertificate: string }): RegisterResult {
384
410
  if ('commitCertificate' in reply) {
@@ -443,6 +469,7 @@ export async function signedWillingness(from: Member, coord: Uint8Array, epoch:
443
469
  fromMember: bytesToB64url(from.bytes),
444
470
  coord: bytesToB64url(coord),
445
471
  cohortEpoch: bytesToB64url(epoch),
472
+ treeTier: 0,
446
473
  willingnessBits, // default 'f' → willing at every tier
447
474
  loadBuckets: [0, 0, 0, 0],
448
475
  windowSeconds: 60,
@@ -458,6 +485,8 @@ export interface SignedRegisterOptions {
458
485
  readonly tier?: number;
459
486
  readonly treeTier?: number;
460
487
  readonly bootstrap?: boolean;
488
+ /** Follow-on cold-start re-issue (treeTier >= 1); mutually exclusive with bootstrap, so pass `bootstrap: false`. */
489
+ readonly followOn?: boolean;
461
490
  readonly ttl?: number;
462
491
  }
463
492
 
@@ -470,6 +499,7 @@ export async function signedRegister(participant: Member, topic: Uint8Array, now
470
499
  participantCoord: bytesToB64url(participant.bytes),
471
500
  ttl: opts.ttl ?? 90_000,
472
501
  bootstrap: opts.bootstrap ?? true,
502
+ ...(opts.followOn ? { followOn: true } : {}),
473
503
  timestamp: now,
474
504
  correlationId: bytesToB64url(new TextEncoder().encode(correlationId)),
475
505
  };
@@ -522,10 +552,16 @@ export function coordTierMap(participant: Member, topic: Uint8Array, dMax: numbe
522
552
  return map;
523
553
  }
524
554
 
525
- /** Reconstruct a {@link WalkTrace} from the mesh's recorded `routeTrace`, keeping only this walk's coords. */
555
+ /**
556
+ * Reconstruct a {@link WalkTrace} from the mesh's recorded `routeTrace`, keeping only this walk's coords.
557
+ * Unsigned frames are excluded: a `followOn` cold-start instantiates a child whose background
558
+ * forwarder→parent link RPC routes to the (participant-independent) `coord_0` this walk also probes, so
559
+ * without the filter that link's `no_state` would alias the walk's own root probe and fabricate a spurious
560
+ * inward/outward move. Only the participant's own signed probes belong to the walk trace.
561
+ */
526
562
  export function walkTraceFrom(routeTrace: readonly RouteTraceEntry[], tierMap: Map<string, number>, dMax: number): WalkTrace {
527
563
  const probes = routeTrace
528
- .filter((e) => tierMap.has(e.key))
564
+ .filter((e) => tierMap.has(e.key) && e.signed)
529
565
  .map((e) => ({ treeTier: tierMap.get(e.key)!, result: e.result }));
530
566
  return { dMax, probes };
531
567
  }
@@ -585,6 +621,20 @@ export interface TopicSetup {
585
621
  * registration needs a seed. Operating on the cohort (not all `N` nodes) keeps setup `O(wantK²)` at
586
622
  * scale; for a whole-network cohort (`wantK = N`) it covers every node, matching the live-tier milestone.
587
623
  */
624
+ /**
625
+ * Pump one gossip round on **every live coord engine across every node**, then let the async inbound
626
+ * `/cohort-gossip` handlers settle. This is the cold-bootstrap counterpart to {@link setupTopic}'s manual
627
+ * willingness pre-seed: with no seed, the idle-but-willing willingness heartbeat (change A) plus cold-sibling
628
+ * engine instantiation (change B) must carry a fresh cohort from cold to a willingness quorum on their own,
629
+ * and this drives the rounds that make that happen. Call it repeatedly — each call is one "wave": engines a
630
+ * previous wave's heartbeats just instantiated on siblings only get pumped (and so reciprocate their own
631
+ * willingness) on the next wave.
632
+ */
633
+ export async function pumpMeshGossip(mesh: CohortMesh, now: number, settleMs = 30): Promise<void> {
634
+ await Promise.all(mesh.nodes.flatMap((n) => n.host.registry.all().map((e) => e.gossipRound(now))));
635
+ await delay(settleMs);
636
+ }
637
+
588
638
  export async function setupTopic(mesh: CohortMesh, topic: Uint8Array, tierAddr = addressing): Promise<TopicSetup> {
589
639
  const coord0 = tierAddr.coord0(topic);
590
640
  const seedParticipant = mesh.nodes[0]!.member.bytes; // dummy participantCoord (unused at tier 0)
@@ -1 +1,2 @@
1
1
  export * from './mesh-harness.js';
2
+ export * from './raw-storage-conformance.js';
@@ -2,7 +2,7 @@ import type { PeerId, PrivateKey } from '@libp2p/interface';
2
2
  import type { IKeyNetwork, ClusterPeers, ICluster, ClusterRecord, IRepo, BlockId, ActionRev, ClusterConsensusConfig, ITransactor, PeerId as DbPeerId } from '@optimystic/db-core';
3
3
  import type { FindCoordinatorOptions } from '@optimystic/db-core';
4
4
  import type { IPeerNetwork } from '@optimystic/db-core';
5
- import { NetworkTransactor } from '@optimystic/db-core';
5
+ import { NetworkTransactor, DEFAULT_SUPER_MAJORITY_THRESHOLD } from '@optimystic/db-core';
6
6
  import { peerIdFromPrivateKey } from '@libp2p/peer-id';
7
7
  import { generateKeyPair } from '@libp2p/crypto/keys';
8
8
  import { ClusterMember, clusterMember, type ReconcileBlockCallback } from '../cluster/cluster-repo.js';
@@ -141,12 +141,15 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
141
141
  );
142
142
 
143
143
  const consensusConfig: ClusterConsensusConfig = {
144
- superMajorityThreshold: options.superMajorityThreshold ?? 0.75,
144
+ superMajorityThreshold: options.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
145
145
  simpleMajorityThreshold: 0.51,
146
146
  minAbsoluteClusterSize: 2,
147
147
  allowClusterDownsize: options.allowClusterDownsize ?? true,
148
148
  clusterSizeTolerance: 0.5,
149
- partitionDetectionWindow: 60000
149
+ partitionDetectionWindow: 60000,
150
+ // Harness spins up small/single-node meshes on purpose; opt into running
151
+ // below the safe floor without a confident network-size estimate.
152
+ allowUnvalidatedSmallCluster: true
150
153
  };
151
154
 
152
155
  // Active reconciliation simulation: when a member commits a block it never
@@ -227,7 +230,9 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
227
230
  // Ensure metadata exists
228
231
  const meta = await localRawStorage.getMetadata(blockId as BlockId);
229
232
  if (!meta) {
230
- await localRawStorage.saveMetadata(blockId as BlockId, { latest: undefined, ranges: [[0]] });
233
+ // Seed empty ranges (honest: nothing reconstructible yet); the setLatest
234
+ // below merges [latest.rev, latest.rev+1] once the revision is persisted.
235
+ await localRawStorage.saveMetadata(blockId as BlockId, { latest: undefined, ranges: [] });
231
236
  }
232
237
  await localBlockStorage.saveMaterializedBlock(latest.actionId, entry.block);
233
238
  await localBlockStorage.saveRevision(latest.rev, latest.actionId);
@@ -257,8 +262,11 @@ export async function createMesh(nodeCount: number, options: MeshOptions): Promi
257
262
  (peerId: PeerId) => createClusterClient(peerId) as any,
258
263
  {
259
264
  clusterSize: options.clusterSize ?? nodeCount,
260
- superMajorityThreshold: options.superMajorityThreshold ?? 0.75,
261
- allowClusterDownsize: options.allowClusterDownsize ?? true
265
+ superMajorityThreshold: options.superMajorityThreshold ?? DEFAULT_SUPER_MAJORITY_THRESHOLD,
266
+ allowClusterDownsize: options.allowClusterDownsize ?? true,
267
+ // Harness meshes run below the safe floor on purpose; opt the coordinator
268
+ // side into that so validateSmallCluster admits them (fails closed by default).
269
+ allowUnvalidatedSmallCluster: true
262
270
  }
263
271
  );
264
272
  node.coordinatorRepo = factory({
@@ -0,0 +1,397 @@
1
+ import { expect } from 'chai';
2
+ import type { BlockId, ActionId, IBlock, BlockHeader, Transform, Transforms } from '@optimystic/db-core';
3
+ import type { IRawStorage } from '../storage/i-raw-storage.js';
4
+ import type { BlockMetadata } from '../storage/struct.js';
5
+ import { StorageRepo } from '../storage/storage-repo.js';
6
+ import { BlockStorage } from '../storage/block-storage.js';
7
+
8
+ /**
9
+ * What `makeStorage` hands each conformance case: a fresh `IRawStorage` and a
10
+ * `cleanup` that releases it (close a handle, delete a temp dir, etc.). The
11
+ * memory driver's cleanup is a no-op; disk/db backends do real teardown.
12
+ */
13
+ export interface ConformanceHarness {
14
+ storage: IRawStorage;
15
+ cleanup: () => Promise<void>;
16
+ }
17
+
18
+ const makeHeader = (id: string): BlockHeader => ({
19
+ id: id as BlockId,
20
+ type: 'test',
21
+ collectionId: 'collection-1' as BlockId
22
+ });
23
+
24
+ const makeBlock = (id: string, data?: Record<string, unknown>): IBlock => ({
25
+ header: makeHeader(id),
26
+ ...data
27
+ });
28
+
29
+ const makeInsertTransforms = (blockId: BlockId, block: IBlock): Transforms => ({
30
+ inserts: { [blockId]: block },
31
+ updates: {},
32
+ deletes: []
33
+ });
34
+
35
+ async function collect<T>(iter: AsyncIterable<T>): Promise<T[]> {
36
+ const out: T[] = [];
37
+ for await (const item of iter) out.push(item);
38
+ return out;
39
+ }
40
+
41
+ /**
42
+ * Registers a `describe(name, ...)` block asserting that a raw-storage backend
43
+ * behaves identically to every other backend. This is the ONE maintained parity
44
+ * target that replaces the four copied `test/*-storage.spec.ts` specs — each
45
+ * driver package calls it from its own `test/` with a factory for its backend.
46
+ *
47
+ * It covers the union of what the existing per-backend specs assert PLUS the
48
+ * behaviors the shared kernel makes parity-critical: clone-on-store/read (now
49
+ * structural via the byte boundary), promote atomicity + the exact missing-pend
50
+ * error, drain-before-yield iteration, and a `BlockStorage`-level slice that
51
+ * guards the `meta.ranges` open-ended seeding invariant and tombstone read-back.
52
+ */
53
+ export function runRawStorageConformance(
54
+ name: string,
55
+ makeStorage: () => Promise<ConformanceHarness>
56
+ ): void {
57
+ describe(name, () => {
58
+ let harness: ConformanceHarness;
59
+ let storage: IRawStorage;
60
+
61
+ beforeEach(async () => {
62
+ harness = await makeStorage();
63
+ storage = harness.storage;
64
+ });
65
+
66
+ afterEach(async () => {
67
+ await harness.cleanup();
68
+ });
69
+
70
+ // --- Metadata ---
71
+
72
+ it('getMetadata returns undefined on a miss', async () => {
73
+ expect(await storage.getMetadata('absent' as BlockId)).to.equal(undefined);
74
+ });
75
+
76
+ it('metadata round-trips, including a one-element open-ended range', async () => {
77
+ const blockId = 'meta-block' as BlockId;
78
+ // [1, 3] is a bounded range; [5] is OPEN-ENDED (undefined upper bound). The byte
79
+ // codec must preserve the open-ended encoding exactly — no range normalization.
80
+ const meta: BlockMetadata = { ranges: [[1, 3], [5]], latest: { rev: 5, actionId: 'tx:a5' as ActionId } };
81
+
82
+ await storage.saveMetadata(blockId, meta);
83
+ const got = await storage.getMetadata(blockId);
84
+
85
+ expect(got).to.deep.equal({ ranges: [[1, 3], [5]], latest: { rev: 5, actionId: 'tx:a5' } });
86
+ });
87
+
88
+ // --- Revisions ---
89
+
90
+ it('getRevision returns undefined on a miss', async () => {
91
+ expect(await storage.getRevision('block' as BlockId, 1)).to.equal(undefined);
92
+ });
93
+
94
+ it('revision round-trips', async () => {
95
+ const blockId = 'rev-block' as BlockId;
96
+ await storage.saveRevision(blockId, 7, 'tx:a7' as ActionId);
97
+ expect(await storage.getRevision(blockId, 7)).to.equal('tx:a7');
98
+ });
99
+
100
+ it('listRevisions is ascending, inclusive on both bounds, and skips sparse gaps', async () => {
101
+ const blockId = 'list-asc' as BlockId;
102
+ await storage.saveRevision(blockId, 1, 'a1' as ActionId);
103
+ await storage.saveRevision(blockId, 2, 'a2' as ActionId);
104
+ await storage.saveRevision(blockId, 4, 'a4' as ActionId); // rev 3 absent
105
+
106
+ const revs = await collect(storage.listRevisions(blockId, 1, 4));
107
+ expect(revs).to.deep.equal([
108
+ { rev: 1, actionId: 'a1' },
109
+ { rev: 2, actionId: 'a2' },
110
+ { rev: 4, actionId: 'a4' }
111
+ ]);
112
+ });
113
+
114
+ it('listRevisions descends when startRev > endRev (bounds still inclusive)', async () => {
115
+ const blockId = 'list-desc' as BlockId;
116
+ await storage.saveRevision(blockId, 1, 'a1' as ActionId);
117
+ await storage.saveRevision(blockId, 2, 'a2' as ActionId);
118
+ await storage.saveRevision(blockId, 4, 'a4' as ActionId);
119
+
120
+ const revs = await collect(storage.listRevisions(blockId, 4, 1));
121
+ expect(revs).to.deep.equal([
122
+ { rev: 4, actionId: 'a4' },
123
+ { rev: 2, actionId: 'a2' },
124
+ { rev: 1, actionId: 'a1' }
125
+ ]);
126
+ });
127
+
128
+ it('listRevisions with a single inclusive bound yields that one rev', async () => {
129
+ const blockId = 'list-single' as BlockId;
130
+ await storage.saveRevision(blockId, 3, 'a3' as ActionId);
131
+ expect(await collect(storage.listRevisions(blockId, 3, 3))).to.deep.equal([{ rev: 3, actionId: 'a3' }]);
132
+ });
133
+
134
+ it('listRevisions is scoped to one blockId (a second block does not leak in)', async () => {
135
+ const a = 'scope-a' as BlockId;
136
+ const b = 'scope-b' as BlockId;
137
+ await storage.saveRevision(a, 1, 'a1' as ActionId);
138
+ await storage.saveRevision(b, 1, 'b1' as ActionId);
139
+ await storage.saveRevision(b, 2, 'b2' as ActionId);
140
+
141
+ expect(await collect(storage.listRevisions(a, 1, 5))).to.deep.equal([{ rev: 1, actionId: 'a1' }]);
142
+ });
143
+
144
+ it('listRevisions over an empty range yields nothing', async () => {
145
+ expect(await collect(storage.listRevisions('empty' as BlockId, 1, 10))).to.deep.equal([]);
146
+ });
147
+
148
+ it('listRevisions tolerates awaits interleaved between yields (drain-before-yield)', async () => {
149
+ const blockId = 'drain-revs' as BlockId;
150
+ await storage.saveRevision(blockId, 1, 'a1' as ActionId);
151
+ await storage.saveRevision(blockId, 2, 'a2' as ActionId);
152
+ await storage.saveRevision(blockId, 3, 'a3' as ActionId);
153
+
154
+ const seen: Array<[number, ActionId]> = [];
155
+ for await (const { rev, actionId } of storage.listRevisions(blockId, 1, 3)) {
156
+ // An unrelated storage read between yields would auto-commit / invalidate a
157
+ // native cursor that straddled the await, if the driver failed to drain first.
158
+ await storage.getMetadata('other' as BlockId);
159
+ seen.push([rev, actionId]);
160
+ }
161
+ expect(seen).to.deep.equal([[1, 'a1'], [2, 'a2'], [3, 'a3']]);
162
+ });
163
+
164
+ // --- Pending transactions ---
165
+
166
+ it('getPendingTransaction returns undefined on a miss', async () => {
167
+ expect(await storage.getPendingTransaction('block' as BlockId, 'a' as ActionId)).to.equal(undefined);
168
+ });
169
+
170
+ it('pending transaction round-trips', async () => {
171
+ const blockId = 'pend-block' as BlockId;
172
+ const transform: Transform = { insert: makeBlock('pend-block', { items: ['x'] }) };
173
+ await storage.savePendingTransaction(blockId, 'a1' as ActionId, transform);
174
+ expect(await storage.getPendingTransaction(blockId, 'a1' as ActionId)).to.deep.equal(transform);
175
+ });
176
+
177
+ it('listPendingTransactions is scoped to one block', async () => {
178
+ const a = 'pend-a' as BlockId;
179
+ const b = 'pend-b' as BlockId;
180
+ await storage.savePendingTransaction(a, 'a1' as ActionId, { delete: true });
181
+ await storage.savePendingTransaction(a, 'a2' as ActionId, { delete: true });
182
+ await storage.savePendingTransaction(b, 'b1' as ActionId, { delete: true });
183
+
184
+ const ids = new Set(await collect(storage.listPendingTransactions(a)));
185
+ expect(ids).to.deep.equal(new Set(['a1', 'a2']));
186
+ });
187
+
188
+ it('deletePendingTransaction removes the pending entry', async () => {
189
+ const blockId = 'pend-del' as BlockId;
190
+ await storage.savePendingTransaction(blockId, 'a1' as ActionId, { delete: true });
191
+ await storage.deletePendingTransaction(blockId, 'a1' as ActionId);
192
+
193
+ expect(await storage.getPendingTransaction(blockId, 'a1' as ActionId)).to.equal(undefined);
194
+ expect(await collect(storage.listPendingTransactions(blockId))).to.deep.equal([]);
195
+ });
196
+
197
+ it('listPendingTransactions tolerates awaits interleaved between yields (drain-before-yield)', async () => {
198
+ const blockId = 'drain-pend' as BlockId;
199
+ await storage.savePendingTransaction(blockId, 'a1' as ActionId, { delete: true });
200
+ await storage.savePendingTransaction(blockId, 'a2' as ActionId, { delete: true });
201
+
202
+ const seen: ActionId[] = [];
203
+ for await (const id of storage.listPendingTransactions(blockId)) {
204
+ await storage.getMetadata('other' as BlockId);
205
+ seen.push(id);
206
+ }
207
+ expect(new Set(seen)).to.deep.equal(new Set(['a1', 'a2']));
208
+ });
209
+
210
+ // --- Committed transactions ---
211
+
212
+ it('getTransaction returns undefined on a miss', async () => {
213
+ expect(await storage.getTransaction('block' as BlockId, 'a' as ActionId)).to.equal(undefined);
214
+ });
215
+
216
+ it('committed transaction round-trips', async () => {
217
+ const blockId = 'tx-block' as BlockId;
218
+ const transform: Transform = { updates: [['items', 0, 0, ['y']]] };
219
+ await storage.saveTransaction(blockId, 'a1' as ActionId, transform);
220
+ expect(await storage.getTransaction(blockId, 'a1' as ActionId)).to.deep.equal(transform);
221
+ });
222
+
223
+ // --- Materialized blocks ---
224
+
225
+ it('getMaterializedBlock returns undefined on a miss', async () => {
226
+ expect(await storage.getMaterializedBlock('block' as BlockId, 'a' as ActionId)).to.equal(undefined);
227
+ });
228
+
229
+ it('materialized block round-trips', async () => {
230
+ const blockId = 'mat-block' as BlockId;
231
+ const block = makeBlock('mat-block', { items: ['z'] });
232
+ await storage.saveMaterializedBlock(blockId, 'a1' as ActionId, block);
233
+ expect(await storage.getMaterializedBlock(blockId, 'a1' as ActionId)).to.deep.equal(block);
234
+ });
235
+
236
+ it('saveMaterializedBlock(undefined) deletes; a subsequent get returns undefined', async () => {
237
+ const blockId = 'mat-del' as BlockId;
238
+ await storage.saveMaterializedBlock(blockId, 'a1' as ActionId, makeBlock('mat-del'));
239
+ await storage.saveMaterializedBlock(blockId, 'a1' as ActionId, undefined);
240
+ expect(await storage.getMaterializedBlock(blockId, 'a1' as ActionId)).to.equal(undefined);
241
+ });
242
+
243
+ // --- Promote (the only cross-key atomic op) ---
244
+
245
+ it('promotePendingTransaction moves pending → committed atomically', async () => {
246
+ const blockId = 'promote-block' as BlockId;
247
+ const transform: Transform = { insert: makeBlock('promote-block') };
248
+ await storage.savePendingTransaction(blockId, 'a1' as ActionId, transform);
249
+
250
+ await storage.promotePendingTransaction(blockId, 'a1' as ActionId);
251
+
252
+ expect(await storage.getTransaction(blockId, 'a1' as ActionId), 'committed after promote').to.deep.equal(transform);
253
+ expect(await storage.getPendingTransaction(blockId, 'a1' as ActionId), 'pending removed after promote').to.equal(undefined);
254
+ });
255
+
256
+ it('promotePendingTransaction throws the exact message when no pending entry exists', async () => {
257
+ const blockId = 'promote-missing' as BlockId;
258
+ let error: Error | undefined;
259
+ try {
260
+ await storage.promotePendingTransaction(blockId, 'nope' as ActionId);
261
+ } catch (err) {
262
+ error = err as Error;
263
+ }
264
+ expect(error, 'promote of a missing pending throws').to.not.equal(undefined);
265
+ expect(error!.message).to.equal(`Pending action nope not found for block ${blockId}`);
266
+ });
267
+
268
+ // --- Clone-on-store / clone-on-read (now structural via the byte boundary) ---
269
+
270
+ it('saveMetadata snapshots — a later caller mutation does not corrupt stored state', async () => {
271
+ const blockId = 'clone-meta' as BlockId;
272
+ const meta: BlockMetadata = { ranges: [[1, 2]], latest: { rev: 1, actionId: 'a1' as ActionId } };
273
+ await storage.saveMetadata(blockId, meta);
274
+
275
+ // Mutate the caller's object AFTER saving — must not reach stored state.
276
+ meta.ranges.push([9, 10]);
277
+ meta.latest!.rev = 99;
278
+
279
+ const stored = await storage.getMetadata(blockId);
280
+ expect(stored).to.deep.equal({ ranges: [[1, 2]], latest: { rev: 1, actionId: 'a1' } });
281
+ });
282
+
283
+ it('savePendingTransaction / saveTransaction snapshot — a later caller mutation does not corrupt stored state', async () => {
284
+ const blockId = 'clone-tx' as BlockId;
285
+ const pend: Transform = { insert: makeBlock('clone-tx', { items: ['p-original'] }) };
286
+ const committed: Transform = { insert: makeBlock('clone-tx', { items: ['c-original'] }) };
287
+ await storage.savePendingTransaction(blockId, 'p1' as ActionId, pend);
288
+ await storage.saveTransaction(blockId, 'c1' as ActionId, committed);
289
+
290
+ // Mutate both caller references AFTER saving — neither must reach stored state.
291
+ (pend.insert as IBlock & { items: string[] }).items.push('mutated');
292
+ pend.delete = true;
293
+ (committed.insert as IBlock & { items: string[] }).items.push('mutated');
294
+
295
+ const storedPend = await storage.getPendingTransaction(blockId, 'p1' as ActionId);
296
+ expect((storedPend!.insert as IBlock & { items: string[] }).items).to.deep.equal(['p-original']);
297
+ expect(storedPend!.delete).to.equal(undefined);
298
+ const storedCommitted = await storage.getTransaction(blockId, 'c1' as ActionId);
299
+ expect((storedCommitted!.insert as IBlock & { items: string[] }).items).to.deep.equal(['c-original']);
300
+ });
301
+
302
+ it('getMetadata returns an independent copy each read (mutating one does not affect another)', async () => {
303
+ const blockId = 'clone-meta-read' as BlockId;
304
+ await storage.saveMetadata(blockId, { ranges: [[1, 2]], latest: { rev: 1, actionId: 'a1' as ActionId } });
305
+
306
+ const first = await storage.getMetadata(blockId);
307
+ first!.ranges.push([9, 10]);
308
+
309
+ const second = await storage.getMetadata(blockId);
310
+ expect(second!.ranges).to.deep.equal([[1, 2]]);
311
+ });
312
+
313
+ it('saveMaterializedBlock snapshots and getMaterializedBlock returns a fresh object', async () => {
314
+ const blockId = 'clone-mat' as BlockId;
315
+ const block = makeBlock('clone-mat', { items: ['original'] });
316
+ await storage.saveMaterializedBlock(blockId, 'a1' as ActionId, block);
317
+
318
+ // Mutate the caller's block after saving.
319
+ (block as IBlock & { items: string[] }).items.push('mutated');
320
+
321
+ const stored = await storage.getMaterializedBlock(blockId, 'a1' as ActionId) as IBlock & { items: string[] };
322
+ expect(stored.items, 'stored copy unaffected by post-save mutation').to.deep.equal(['original']);
323
+
324
+ // Mutate a returned copy; a fresh get is unaffected.
325
+ stored.items.push('mutated-again');
326
+ const reread = await storage.getMaterializedBlock(blockId, 'a1' as ActionId) as IBlock & { items: string[] };
327
+ expect(reread.items, 'fresh read unaffected by mutation of a prior read').to.deep.equal(['original']);
328
+ });
329
+
330
+ // --- listBlockIds (optional; only when the driver implements it) ---
331
+
332
+ it('listBlockIds yields exactly the blocks with metadata', async function () {
333
+ const listBlockIds = storage.listBlockIds;
334
+ if (typeof listBlockIds !== 'function') {
335
+ this.skip();
336
+ } else {
337
+ await storage.saveMetadata('b1' as BlockId, { ranges: [], latest: undefined });
338
+ await storage.saveMetadata('b2' as BlockId, { ranges: [], latest: undefined });
339
+ // Pended but never committed → no metadata → must not be enumerated.
340
+ await storage.savePendingTransaction('pending-only' as BlockId, 'x' as ActionId, { delete: true });
341
+
342
+ const ids = new Set(await collect(listBlockIds.call(storage)));
343
+ expect(ids).to.deep.equal(new Set(['b1', 'b2']));
344
+ }
345
+ });
346
+
347
+ it('listBlockIds yields nothing for an empty store', async function () {
348
+ const listBlockIds = storage.listBlockIds;
349
+ if (typeof listBlockIds !== 'function') {
350
+ this.skip();
351
+ } else {
352
+ expect(await collect(listBlockIds.call(storage))).to.deep.equal([]);
353
+ }
354
+ });
355
+
356
+ // --- BlockStorage-level parity slice (over the same driver) ---
357
+
358
+ it('BlockStorage pend → commit seeds open-ended ranges [[E]] (never [[0]]) and getBlock serves it', async () => {
359
+ const blockId = 'parity-commit' as BlockId;
360
+ const repo = new StorageRepo((id) => new BlockStorage(id, storage));
361
+
362
+ await repo.pend({
363
+ actionId: 'a1' as ActionId,
364
+ transforms: makeInsertTransforms(blockId, makeBlock('parity-commit', { items: [] })),
365
+ policy: 'c'
366
+ });
367
+ const commit = await repo.commit({ actionId: 'a1' as ActionId, blockIds: [blockId], tailId: blockId, rev: 1 });
368
+ expect(commit.success, 'commit succeeded').to.equal(true);
369
+
370
+ const meta = await storage.getMetadata(blockId);
371
+ expect(meta!.latest?.rev).to.equal(1);
372
+ // Open-ended from the earliest committed rev E=1 — NOT the over-claim [[0]].
373
+ expect(meta!.ranges, 'coverage open-ended from E=1').to.deep.equal([[1]]);
374
+
375
+ const got = await new BlockStorage(blockId, storage).getBlock();
376
+ expect(got?.block.header.id, 'committed block served').to.equal('parity-commit');
377
+ });
378
+
379
+ it('BlockStorage saveReplica → saveDeletion reads the tombstoned rev back as undefined', async () => {
380
+ const blockId = 'parity-tombstone' as BlockId;
381
+ const bs = new BlockStorage(blockId, storage);
382
+
383
+ await bs.saveReplica(makeBlock('parity-tombstone', { items: ['live'] }), { rev: 1, actionId: 'r1' as ActionId });
384
+ const meta = await storage.getMetadata(blockId);
385
+ // saveReplica seeds open-ended coverage anchored at the replica rev, never [[0]].
386
+ expect(meta!.ranges, 'replica seeds open-ended [[1]]').to.deep.equal([[1]]);
387
+
388
+ const latest = await bs.saveDeletion({ rev: 2, actionId: 'd2' as ActionId });
389
+ expect(latest.rev).to.equal(2);
390
+
391
+ // getBlock at the tombstone rev reverse-applies { delete: true } → absent block.
392
+ expect(await bs.getBlock(), 'tombstoned rev reads back undefined').to.equal(undefined);
393
+ // The prior revision still materializes.
394
+ expect((await bs.getBlock(1))?.block.header.id, 'rev 1 still serves the live block').to.equal('parity-tombstone');
395
+ });
396
+ });
397
+ }