@optimystic/db-p2p 0.21.0 → 0.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/dist/src/cluster/client.d.ts +10 -0
  2. package/dist/src/cluster/client.d.ts.map +1 -1
  3. package/dist/src/cluster/client.js +30 -1
  4. package/dist/src/cluster/client.js.map +1 -1
  5. package/dist/src/cluster/cluster-policy.d.ts +13 -2
  6. package/dist/src/cluster/cluster-policy.d.ts.map +1 -1
  7. package/dist/src/cluster/cluster-policy.js +51 -4
  8. package/dist/src/cluster/cluster-policy.js.map +1 -1
  9. package/dist/src/cluster/cluster-repo.d.ts +42 -17
  10. package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
  11. package/dist/src/cluster/cluster-repo.js +229 -122
  12. package/dist/src/cluster/cluster-repo.js.map +1 -1
  13. package/dist/src/cluster/cluster-size-coupling.d.ts +28 -0
  14. package/dist/src/cluster/cluster-size-coupling.d.ts.map +1 -0
  15. package/dist/src/cluster/cluster-size-coupling.js +35 -0
  16. package/dist/src/cluster/cluster-size-coupling.js.map +1 -0
  17. package/dist/src/cluster/quorum-restore.d.ts +6 -0
  18. package/dist/src/cluster/quorum-restore.d.ts.map +1 -1
  19. package/dist/src/cluster/quorum-restore.js +1 -1
  20. package/dist/src/cluster/quorum-restore.js.map +1 -1
  21. package/dist/src/cluster/reconcile-block.d.ts.map +1 -1
  22. package/dist/src/cluster/reconcile-block.js +15 -3
  23. package/dist/src/cluster/reconcile-block.js.map +1 -1
  24. package/dist/src/cluster/service.d.ts +32 -1
  25. package/dist/src/cluster/service.d.ts.map +1 -1
  26. package/dist/src/cluster/service.js +43 -2
  27. package/dist/src/cluster/service.js.map +1 -1
  28. package/dist/src/cohort-topic/stream-util.d.ts +22 -6
  29. package/dist/src/cohort-topic/stream-util.d.ts.map +1 -1
  30. package/dist/src/cohort-topic/stream-util.js +56 -10
  31. package/dist/src/cohort-topic/stream-util.js.map +1 -1
  32. package/dist/src/dispute/dispute-service.d.ts.map +1 -1
  33. package/dist/src/dispute/dispute-service.js +9 -3
  34. package/dist/src/dispute/dispute-service.js.map +1 -1
  35. package/dist/src/index.d.ts +5 -0
  36. package/dist/src/index.d.ts.map +1 -1
  37. package/dist/src/index.js +5 -0
  38. package/dist/src/index.js.map +1 -1
  39. package/dist/src/libp2p-key-network.d.ts +134 -7
  40. package/dist/src/libp2p-key-network.d.ts.map +1 -1
  41. package/dist/src/libp2p-key-network.js +174 -37
  42. package/dist/src/libp2p-key-network.js.map +1 -1
  43. package/dist/src/libp2p-node-base.d.ts +3 -2
  44. package/dist/src/libp2p-node-base.d.ts.map +1 -1
  45. package/dist/src/libp2p-node-base.js +859 -778
  46. package/dist/src/libp2p-node-base.js.map +1 -1
  47. package/dist/src/libp2p-node-rn.d.ts +2 -2
  48. package/dist/src/libp2p-node-rn.d.ts.map +1 -1
  49. package/dist/src/libp2p-node-rn.js.map +1 -1
  50. package/dist/src/libp2p-node.d.ts +2 -2
  51. package/dist/src/libp2p-node.d.ts.map +1 -1
  52. package/dist/src/libp2p-node.js.map +1 -1
  53. package/dist/src/logger.d.ts +17 -1
  54. package/dist/src/logger.d.ts.map +1 -1
  55. package/dist/src/logger.js +19 -2
  56. package/dist/src/logger.js.map +1 -1
  57. package/dist/src/network/network-manager-service.d.ts +2 -0
  58. package/dist/src/network/network-manager-service.d.ts.map +1 -1
  59. package/dist/src/network/network-manager-service.js +4 -0
  60. package/dist/src/network/network-manager-service.js.map +1 -1
  61. package/dist/src/optimystic-node.d.ts +35 -0
  62. package/dist/src/optimystic-node.d.ts.map +1 -0
  63. package/dist/src/optimystic-node.js +2 -0
  64. package/dist/src/optimystic-node.js.map +1 -0
  65. package/dist/src/owned-block-seed.d.ts +6 -3
  66. package/dist/src/owned-block-seed.d.ts.map +1 -1
  67. package/dist/src/owned-block-seed.js +16 -3
  68. package/dist/src/owned-block-seed.js.map +1 -1
  69. package/dist/src/peer-address-book.d.ts +72 -0
  70. package/dist/src/peer-address-book.d.ts.map +1 -0
  71. package/dist/src/peer-address-book.js +123 -0
  72. package/dist/src/peer-address-book.js.map +1 -0
  73. package/dist/src/repo/client.d.ts.map +1 -1
  74. package/dist/src/repo/client.js +11 -2
  75. package/dist/src/repo/client.js.map +1 -1
  76. package/dist/src/repo/cluster-coordinator.d.ts +30 -0
  77. package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
  78. package/dist/src/repo/cluster-coordinator.js +95 -3
  79. package/dist/src/repo/cluster-coordinator.js.map +1 -1
  80. package/dist/src/repo/coordinator-repo.d.ts +78 -14
  81. package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
  82. package/dist/src/repo/coordinator-repo.js +266 -81
  83. package/dist/src/repo/coordinator-repo.js.map +1 -1
  84. package/dist/src/rn.d.ts +5 -0
  85. package/dist/src/rn.d.ts.map +1 -1
  86. package/dist/src/rn.js +5 -0
  87. package/dist/src/rn.js.map +1 -1
  88. package/dist/src/storage/block-storage.d.ts.map +1 -1
  89. package/dist/src/storage/block-storage.js +57 -5
  90. package/dist/src/storage/block-storage.js.map +1 -1
  91. package/dist/src/storage/cached-raw-storage.d.ts +83 -0
  92. package/dist/src/storage/cached-raw-storage.d.ts.map +1 -0
  93. package/dist/src/storage/cached-raw-storage.js +152 -0
  94. package/dist/src/storage/cached-raw-storage.js.map +1 -0
  95. package/dist/src/storage/cached-store-driver.d.ts +186 -0
  96. package/dist/src/storage/cached-store-driver.d.ts.map +1 -0
  97. package/dist/src/storage/cached-store-driver.js +775 -0
  98. package/dist/src/storage/cached-store-driver.js.map +1 -0
  99. package/dist/src/storage/i-block-storage.d.ts +20 -1
  100. package/dist/src/storage/i-block-storage.d.ts.map +1 -1
  101. package/dist/src/storage/i-raw-storage.d.ts +12 -5
  102. package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
  103. package/dist/src/storage/shared-cache-pool.d.ts +234 -0
  104. package/dist/src/storage/shared-cache-pool.d.ts.map +1 -0
  105. package/dist/src/storage/shared-cache-pool.js +354 -0
  106. package/dist/src/storage/shared-cache-pool.js.map +1 -0
  107. package/dist/src/storage/storage-repo.d.ts +56 -3
  108. package/dist/src/storage/storage-repo.d.ts.map +1 -1
  109. package/dist/src/storage/storage-repo.js +124 -18
  110. package/dist/src/storage/storage-repo.js.map +1 -1
  111. package/dist/src/testing/raw-storage-conformance.d.ts +2 -1
  112. package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -1
  113. package/dist/src/testing/raw-storage-conformance.js +52 -2
  114. package/dist/src/testing/raw-storage-conformance.js.map +1 -1
  115. package/package.json +3 -3
  116. package/readme.md +668 -653
  117. package/src/cluster/block-transfer.ts +424 -424
  118. package/src/cluster/client.ts +119 -88
  119. package/src/cluster/cluster-error.ts +64 -64
  120. package/src/cluster/cluster-policy.ts +203 -152
  121. package/src/cluster/cluster-repo.ts +245 -125
  122. package/src/cluster/cluster-size-coupling.ts +45 -0
  123. package/src/cluster/commit-cert.ts +139 -139
  124. package/src/cluster/i-transaction-state-store.ts +43 -43
  125. package/src/cluster/memory-transaction-state-store.ts +56 -56
  126. package/src/cluster/peer-key-binding.ts +37 -37
  127. package/src/cluster/persistent-transaction-state-store.ts +92 -92
  128. package/src/cluster/quorum-restore.ts +223 -223
  129. package/src/cluster/reconcile-block.ts +203 -191
  130. package/src/cluster/service.ts +293 -241
  131. package/src/cluster/supermajority-coupling.ts +37 -37
  132. package/src/cohort-topic/bootstrap-evidence-builder.ts +122 -122
  133. package/src/cohort-topic/bootstrap-evidence-verifiers.ts +132 -132
  134. package/src/cohort-topic/bootstrap-parent-reference.ts +159 -159
  135. package/src/cohort-topic/change-bridge.ts +109 -109
  136. package/src/cohort-topic/cohort-gossip-driver.ts +231 -231
  137. package/src/cohort-topic/cohort-gossip-transport.ts +84 -84
  138. package/src/cohort-topic/fret-trust-anchor.ts +153 -153
  139. package/src/cohort-topic/host.ts +2901 -2901
  140. package/src/cohort-topic/index.ts +13 -13
  141. package/src/cohort-topic/membership-publish-sink.ts +20 -20
  142. package/src/cohort-topic/membership-source.ts +68 -68
  143. package/src/cohort-topic/peer-codec.ts +31 -31
  144. package/src/cohort-topic/peer-sig.ts +86 -86
  145. package/src/cohort-topic/protocols.ts +71 -71
  146. package/src/cohort-topic/reactivity-membership-gate.ts +77 -77
  147. package/src/cohort-topic/size-estimator.ts +16 -16
  148. package/src/cohort-topic/stream-util.ts +135 -87
  149. package/src/cohort-topic/threshold-crypto.ts +239 -239
  150. package/src/cohort-topic/topic-router.ts +77 -77
  151. package/src/dispute/arbitrator-selection.ts +138 -138
  152. package/src/dispute/cascade.ts +524 -524
  153. package/src/dispute/dispute-service.ts +11 -5
  154. package/src/dispute/invalidation.ts +625 -625
  155. package/src/inbound-authorization.ts +190 -190
  156. package/src/index.ts +52 -47
  157. package/src/libp2p-key-network.ts +1120 -958
  158. package/src/libp2p-node-base.ts +1675 -1591
  159. package/src/libp2p-node-rn.ts +30 -30
  160. package/src/libp2p-node.ts +36 -36
  161. package/src/logger.ts +19 -2
  162. package/src/matchmaking/aggregate-counts.ts +104 -104
  163. package/src/matchmaking/index.ts +20 -20
  164. package/src/matchmaking/module.ts +363 -363
  165. package/src/matchmaking/protocols.ts +51 -51
  166. package/src/matchmaking/provider-manager.ts +95 -95
  167. package/src/matchmaking/query-handler.ts +88 -88
  168. package/src/matchmaking/query-transport.ts +492 -492
  169. package/src/matchmaking/seeker-manager.ts +64 -64
  170. package/src/matchmaking/seeker-walk-client.ts +293 -293
  171. package/src/matchmaking/traffic-validation.ts +195 -195
  172. package/src/network/network-manager-service.ts +5 -0
  173. package/src/optimystic-node.ts +36 -0
  174. package/src/owned-block-seed.ts +53 -40
  175. package/src/peer-address-book.ts +149 -0
  176. package/src/protocol-limits.ts +33 -33
  177. package/src/reactivity/forwarder-host.ts +438 -438
  178. package/src/reactivity/index.ts +19 -19
  179. package/src/reactivity/notify-transport.ts +144 -144
  180. package/src/reactivity/origination-manager.ts +192 -192
  181. package/src/reactivity/protocols.ts +61 -61
  182. package/src/reactivity/push-state-gossip.ts +291 -291
  183. package/src/reactivity/recover-transport.ts +408 -408
  184. package/src/reactivity/rotation-rereg-scheduler.ts +256 -256
  185. package/src/reactivity/subscriber-registry.ts +96 -96
  186. package/src/reactivity/subscription-manager.ts +450 -450
  187. package/src/reactivity/topic-bytes.ts +37 -37
  188. package/src/repo/client.ts +12 -2
  189. package/src/repo/cluster-coordinator.ts +99 -3
  190. package/src/repo/coordinator-repo.ts +305 -82
  191. package/src/repo/types.ts +7 -7
  192. package/src/rn.ts +39 -34
  193. package/src/rpc-deadline.ts +45 -45
  194. package/src/storage/arachnode-partition.ts +74 -74
  195. package/src/storage/block-storage.ts +59 -6
  196. package/src/storage/cached-raw-storage.ts +180 -0
  197. package/src/storage/cached-store-driver.ts +859 -0
  198. package/src/storage/i-block-storage.ts +20 -1
  199. package/src/storage/i-kv-store.ts +8 -8
  200. package/src/storage/i-raw-storage.ts +12 -5
  201. package/src/storage/kv-raw-storage.ts +135 -135
  202. package/src/storage/memory-kv-store.ts +28 -28
  203. package/src/storage/memory-storage.ts +25 -25
  204. package/src/storage/memory-store-driver.ts +157 -157
  205. package/src/storage/raw-store-codec.ts +42 -42
  206. package/src/storage/raw-store-driver.ts +80 -80
  207. package/src/storage/ring-selector.ts +317 -317
  208. package/src/storage/ring-shift-coordinator.ts +271 -271
  209. package/src/storage/shared-cache-pool.ts +452 -0
  210. package/src/storage/storage-repo.ts +1014 -903
  211. package/src/testing/cohort-topic-mesh-harness.ts +663 -663
  212. package/src/testing/index.ts +8 -8
  213. package/src/testing/matchmaking-mesh-harness.ts +475 -475
  214. package/src/testing/raw-storage-conformance.ts +453 -397
  215. package/src/testing/reactivity-mesh-harness.ts +922 -922
  216. package/dist/src/storage/restoration-coordinator-v2.d.ts +0 -67
  217. package/dist/src/storage/restoration-coordinator-v2.d.ts.map +0 -1
  218. package/dist/src/storage/restoration-coordinator-v2.js +0 -172
  219. package/dist/src/storage/restoration-coordinator-v2.js.map +0 -1
@@ -1,397 +1,453 @@
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
- }
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, tombstone read-back,
52
+ * and the exact id population `listBlockIds` yields on the node path.
53
+ */
54
+ export function runRawStorageConformance(
55
+ name: string,
56
+ makeStorage: () => Promise<ConformanceHarness>
57
+ ): void {
58
+ describe(name, () => {
59
+ let harness: ConformanceHarness;
60
+ let storage: IRawStorage;
61
+
62
+ beforeEach(async () => {
63
+ harness = await makeStorage();
64
+ storage = harness.storage;
65
+ });
66
+
67
+ afterEach(async () => {
68
+ await harness.cleanup();
69
+ });
70
+
71
+ // --- Metadata ---
72
+
73
+ it('getMetadata returns undefined on a miss', async () => {
74
+ expect(await storage.getMetadata('absent' as BlockId)).to.equal(undefined);
75
+ });
76
+
77
+ it('metadata round-trips, including a one-element open-ended range', async () => {
78
+ const blockId = 'meta-block' as BlockId;
79
+ // [1, 3] is a bounded range; [5] is OPEN-ENDED (undefined upper bound). The byte
80
+ // codec must preserve the open-ended encoding exactly no range normalization.
81
+ const meta: BlockMetadata = { ranges: [[1, 3], [5]], latest: { rev: 5, actionId: 'tx:a5' as ActionId } };
82
+
83
+ await storage.saveMetadata(blockId, meta);
84
+ const got = await storage.getMetadata(blockId);
85
+
86
+ expect(got).to.deep.equal({ ranges: [[1, 3], [5]], latest: { rev: 5, actionId: 'tx:a5' } });
87
+ });
88
+
89
+ // --- Revisions ---
90
+
91
+ it('getRevision returns undefined on a miss', async () => {
92
+ expect(await storage.getRevision('block' as BlockId, 1)).to.equal(undefined);
93
+ });
94
+
95
+ it('revision round-trips', async () => {
96
+ const blockId = 'rev-block' as BlockId;
97
+ await storage.saveRevision(blockId, 7, 'tx:a7' as ActionId);
98
+ expect(await storage.getRevision(blockId, 7)).to.equal('tx:a7');
99
+ });
100
+
101
+ it('listRevisions is ascending, inclusive on both bounds, and skips sparse gaps', async () => {
102
+ const blockId = 'list-asc' as BlockId;
103
+ await storage.saveRevision(blockId, 1, 'a1' as ActionId);
104
+ await storage.saveRevision(blockId, 2, 'a2' as ActionId);
105
+ await storage.saveRevision(blockId, 4, 'a4' as ActionId); // rev 3 absent
106
+
107
+ const revs = await collect(storage.listRevisions(blockId, 1, 4));
108
+ expect(revs).to.deep.equal([
109
+ { rev: 1, actionId: 'a1' },
110
+ { rev: 2, actionId: 'a2' },
111
+ { rev: 4, actionId: 'a4' }
112
+ ]);
113
+ });
114
+
115
+ it('listRevisions descends when startRev > endRev (bounds still inclusive)', async () => {
116
+ const blockId = 'list-desc' as BlockId;
117
+ await storage.saveRevision(blockId, 1, 'a1' as ActionId);
118
+ await storage.saveRevision(blockId, 2, 'a2' as ActionId);
119
+ await storage.saveRevision(blockId, 4, 'a4' as ActionId);
120
+
121
+ const revs = await collect(storage.listRevisions(blockId, 4, 1));
122
+ expect(revs).to.deep.equal([
123
+ { rev: 4, actionId: 'a4' },
124
+ { rev: 2, actionId: 'a2' },
125
+ { rev: 1, actionId: 'a1' }
126
+ ]);
127
+ });
128
+
129
+ it('listRevisions with a single inclusive bound yields that one rev', async () => {
130
+ const blockId = 'list-single' as BlockId;
131
+ await storage.saveRevision(blockId, 3, 'a3' as ActionId);
132
+ expect(await collect(storage.listRevisions(blockId, 3, 3))).to.deep.equal([{ rev: 3, actionId: 'a3' }]);
133
+ });
134
+
135
+ it('listRevisions is scoped to one blockId (a second block does not leak in)', async () => {
136
+ const a = 'scope-a' as BlockId;
137
+ const b = 'scope-b' as BlockId;
138
+ await storage.saveRevision(a, 1, 'a1' as ActionId);
139
+ await storage.saveRevision(b, 1, 'b1' as ActionId);
140
+ await storage.saveRevision(b, 2, 'b2' as ActionId);
141
+
142
+ expect(await collect(storage.listRevisions(a, 1, 5))).to.deep.equal([{ rev: 1, actionId: 'a1' }]);
143
+ });
144
+
145
+ it('listRevisions over an empty range yields nothing', async () => {
146
+ expect(await collect(storage.listRevisions('empty' as BlockId, 1, 10))).to.deep.equal([]);
147
+ });
148
+
149
+ it('listRevisions tolerates awaits interleaved between yields (drain-before-yield)', async () => {
150
+ const blockId = 'drain-revs' as BlockId;
151
+ await storage.saveRevision(blockId, 1, 'a1' as ActionId);
152
+ await storage.saveRevision(blockId, 2, 'a2' as ActionId);
153
+ await storage.saveRevision(blockId, 3, 'a3' as ActionId);
154
+
155
+ const seen: Array<[number, ActionId]> = [];
156
+ for await (const { rev, actionId } of storage.listRevisions(blockId, 1, 3)) {
157
+ // An unrelated storage read between yields would auto-commit / invalidate a
158
+ // native cursor that straddled the await, if the driver failed to drain first.
159
+ await storage.getMetadata('other' as BlockId);
160
+ seen.push([rev, actionId]);
161
+ }
162
+ expect(seen).to.deep.equal([[1, 'a1'], [2, 'a2'], [3, 'a3']]);
163
+ });
164
+
165
+ // --- Pending transactions ---
166
+
167
+ it('getPendingTransaction returns undefined on a miss', async () => {
168
+ expect(await storage.getPendingTransaction('block' as BlockId, 'a' as ActionId)).to.equal(undefined);
169
+ });
170
+
171
+ it('pending transaction round-trips', async () => {
172
+ const blockId = 'pend-block' as BlockId;
173
+ const transform: Transform = { insert: makeBlock('pend-block', { items: ['x'] }) };
174
+ await storage.savePendingTransaction(blockId, 'a1' as ActionId, transform);
175
+ expect(await storage.getPendingTransaction(blockId, 'a1' as ActionId)).to.deep.equal(transform);
176
+ });
177
+
178
+ it('listPendingTransactions is scoped to one block', async () => {
179
+ const a = 'pend-a' as BlockId;
180
+ const b = 'pend-b' as BlockId;
181
+ await storage.savePendingTransaction(a, 'a1' as ActionId, { delete: true });
182
+ await storage.savePendingTransaction(a, 'a2' as ActionId, { delete: true });
183
+ await storage.savePendingTransaction(b, 'b1' as ActionId, { delete: true });
184
+
185
+ const ids = new Set(await collect(storage.listPendingTransactions(a)));
186
+ expect(ids).to.deep.equal(new Set(['a1', 'a2']));
187
+ });
188
+
189
+ it('deletePendingTransaction removes the pending entry', async () => {
190
+ const blockId = 'pend-del' as BlockId;
191
+ await storage.savePendingTransaction(blockId, 'a1' as ActionId, { delete: true });
192
+ await storage.deletePendingTransaction(blockId, 'a1' as ActionId);
193
+
194
+ expect(await storage.getPendingTransaction(blockId, 'a1' as ActionId)).to.equal(undefined);
195
+ expect(await collect(storage.listPendingTransactions(blockId))).to.deep.equal([]);
196
+ });
197
+
198
+ it('deletePendingTransaction on an absent entry is a tolerated no-op', async () => {
199
+ // Load-bearing across backends: the commit path deletes a pending record it has NOT
200
+ // checked for presence `StorageRepo.dropUnpromotablePendings` over a whole batch, and
201
+ // `BlockStorage.saveForwardRevision` maintaining Invariant P — so a backend that threw
202
+ // (or rejected) on a miss would turn every ordinary commit into a logged failure.
203
+ const blockId = 'pend-del-absent' as BlockId;
204
+ await storage.deletePendingTransaction(blockId, 'never-pended' as ActionId);
205
+
206
+ // Also a no-op beside a LIVE pending for a different action: the delete must be scoped
207
+ // to its action id, never a per-block clear.
208
+ await storage.savePendingTransaction(blockId, 'a1' as ActionId, { delete: true });
209
+ await storage.deletePendingTransaction(blockId, 'other' as ActionId);
210
+ expect(await storage.getPendingTransaction(blockId, 'a1' as ActionId)).to.deep.equal({ delete: true });
211
+
212
+ // Idempotent: deleting the same entry twice is fine.
213
+ await storage.deletePendingTransaction(blockId, 'a1' as ActionId);
214
+ await storage.deletePendingTransaction(blockId, 'a1' as ActionId);
215
+ expect(await collect(storage.listPendingTransactions(blockId))).to.deep.equal([]);
216
+ });
217
+
218
+ it('listPendingTransactions tolerates awaits interleaved between yields (drain-before-yield)', async () => {
219
+ const blockId = 'drain-pend' as BlockId;
220
+ await storage.savePendingTransaction(blockId, 'a1' as ActionId, { delete: true });
221
+ await storage.savePendingTransaction(blockId, 'a2' as ActionId, { delete: true });
222
+
223
+ const seen: ActionId[] = [];
224
+ for await (const id of storage.listPendingTransactions(blockId)) {
225
+ await storage.getMetadata('other' as BlockId);
226
+ seen.push(id);
227
+ }
228
+ expect(new Set(seen)).to.deep.equal(new Set(['a1', 'a2']));
229
+ });
230
+
231
+ // --- Committed transactions ---
232
+
233
+ it('getTransaction returns undefined on a miss', async () => {
234
+ expect(await storage.getTransaction('block' as BlockId, 'a' as ActionId)).to.equal(undefined);
235
+ });
236
+
237
+ it('committed transaction round-trips', async () => {
238
+ const blockId = 'tx-block' as BlockId;
239
+ const transform: Transform = { updates: [['items', 0, 0, ['y']]] };
240
+ await storage.saveTransaction(blockId, 'a1' as ActionId, transform);
241
+ expect(await storage.getTransaction(blockId, 'a1' as ActionId)).to.deep.equal(transform);
242
+ });
243
+
244
+ // --- Materialized blocks ---
245
+
246
+ it('getMaterializedBlock returns undefined on a miss', async () => {
247
+ expect(await storage.getMaterializedBlock('block' as BlockId, 'a' as ActionId)).to.equal(undefined);
248
+ });
249
+
250
+ it('materialized block round-trips', async () => {
251
+ const blockId = 'mat-block' as BlockId;
252
+ const block = makeBlock('mat-block', { items: ['z'] });
253
+ await storage.saveMaterializedBlock(blockId, 'a1' as ActionId, block);
254
+ expect(await storage.getMaterializedBlock(blockId, 'a1' as ActionId)).to.deep.equal(block);
255
+ });
256
+
257
+ it('saveMaterializedBlock(undefined) deletes; a subsequent get returns undefined', async () => {
258
+ const blockId = 'mat-del' as BlockId;
259
+ await storage.saveMaterializedBlock(blockId, 'a1' as ActionId, makeBlock('mat-del'));
260
+ await storage.saveMaterializedBlock(blockId, 'a1' as ActionId, undefined);
261
+ expect(await storage.getMaterializedBlock(blockId, 'a1' as ActionId)).to.equal(undefined);
262
+ });
263
+
264
+ // --- Promote (the only cross-key atomic op) ---
265
+
266
+ it('promotePendingTransaction moves pending → committed atomically', async () => {
267
+ const blockId = 'promote-block' as BlockId;
268
+ const transform: Transform = { insert: makeBlock('promote-block') };
269
+ await storage.savePendingTransaction(blockId, 'a1' as ActionId, transform);
270
+
271
+ await storage.promotePendingTransaction(blockId, 'a1' as ActionId);
272
+
273
+ expect(await storage.getTransaction(blockId, 'a1' as ActionId), 'committed after promote').to.deep.equal(transform);
274
+ expect(await storage.getPendingTransaction(blockId, 'a1' as ActionId), 'pending removed after promote').to.equal(undefined);
275
+ });
276
+
277
+ it('promotePendingTransaction throws the exact message when no pending entry exists', async () => {
278
+ const blockId = 'promote-missing' as BlockId;
279
+ let error: Error | undefined;
280
+ try {
281
+ await storage.promotePendingTransaction(blockId, 'nope' as ActionId);
282
+ } catch (err) {
283
+ error = err as Error;
284
+ }
285
+ expect(error, 'promote of a missing pending throws').to.not.equal(undefined);
286
+ expect(error!.message).to.equal(`Pending action nope not found for block ${blockId}`);
287
+ });
288
+
289
+ // --- Clone-on-store / clone-on-read (now structural via the byte boundary) ---
290
+
291
+ it('saveMetadata snapshots a later caller mutation does not corrupt stored state', async () => {
292
+ const blockId = 'clone-meta' as BlockId;
293
+ const meta: BlockMetadata = { ranges: [[1, 2]], latest: { rev: 1, actionId: 'a1' as ActionId } };
294
+ await storage.saveMetadata(blockId, meta);
295
+
296
+ // Mutate the caller's object AFTER saving — must not reach stored state.
297
+ meta.ranges.push([9, 10]);
298
+ meta.latest!.rev = 99;
299
+
300
+ const stored = await storage.getMetadata(blockId);
301
+ expect(stored).to.deep.equal({ ranges: [[1, 2]], latest: { rev: 1, actionId: 'a1' } });
302
+ });
303
+
304
+ it('savePendingTransaction / saveTransaction snapshot a later caller mutation does not corrupt stored state', async () => {
305
+ const blockId = 'clone-tx' as BlockId;
306
+ const pend: Transform = { insert: makeBlock('clone-tx', { items: ['p-original'] }) };
307
+ const committed: Transform = { insert: makeBlock('clone-tx', { items: ['c-original'] }) };
308
+ await storage.savePendingTransaction(blockId, 'p1' as ActionId, pend);
309
+ await storage.saveTransaction(blockId, 'c1' as ActionId, committed);
310
+
311
+ // Mutate both caller references AFTER saving — neither must reach stored state.
312
+ (pend.insert as IBlock & { items: string[] }).items.push('mutated');
313
+ pend.delete = true;
314
+ (committed.insert as IBlock & { items: string[] }).items.push('mutated');
315
+
316
+ const storedPend = await storage.getPendingTransaction(blockId, 'p1' as ActionId);
317
+ expect((storedPend!.insert as IBlock & { items: string[] }).items).to.deep.equal(['p-original']);
318
+ expect(storedPend!.delete).to.equal(undefined);
319
+ const storedCommitted = await storage.getTransaction(blockId, 'c1' as ActionId);
320
+ expect((storedCommitted!.insert as IBlock & { items: string[] }).items).to.deep.equal(['c-original']);
321
+ });
322
+
323
+ it('getMetadata returns an independent copy each read (mutating one does not affect another)', async () => {
324
+ const blockId = 'clone-meta-read' as BlockId;
325
+ await storage.saveMetadata(blockId, { ranges: [[1, 2]], latest: { rev: 1, actionId: 'a1' as ActionId } });
326
+
327
+ const first = await storage.getMetadata(blockId);
328
+ first!.ranges.push([9, 10]);
329
+
330
+ const second = await storage.getMetadata(blockId);
331
+ expect(second!.ranges).to.deep.equal([[1, 2]]);
332
+ });
333
+
334
+ it('saveMaterializedBlock snapshots and getMaterializedBlock returns a fresh object', async () => {
335
+ const blockId = 'clone-mat' as BlockId;
336
+ const block = makeBlock('clone-mat', { items: ['original'] });
337
+ await storage.saveMaterializedBlock(blockId, 'a1' as ActionId, block);
338
+
339
+ // Mutate the caller's block after saving.
340
+ (block as IBlock & { items: string[] }).items.push('mutated');
341
+
342
+ const stored = await storage.getMaterializedBlock(blockId, 'a1' as ActionId) as IBlock & { items: string[] };
343
+ expect(stored.items, 'stored copy unaffected by post-save mutation').to.deep.equal(['original']);
344
+
345
+ // Mutate a returned copy; a fresh get is unaffected.
346
+ stored.items.push('mutated-again');
347
+ const reread = await storage.getMaterializedBlock(blockId, 'a1' as ActionId) as IBlock & { items: string[] };
348
+ expect(reread.items, 'fresh read unaffected by mutation of a prior read').to.deep.equal(['original']);
349
+ });
350
+
351
+ // --- listBlockIds (optional; only when the driver implements it) ---
352
+
353
+ it('listBlockIds yields exactly the blocks with metadata', async function () {
354
+ const listBlockIds = storage.listBlockIds;
355
+ if (typeof listBlockIds !== 'function') {
356
+ this.skip();
357
+ } else {
358
+ await storage.saveMetadata('b1' as BlockId, { ranges: [], latest: undefined });
359
+ await storage.saveMetadata('b2' as BlockId, { ranges: [], latest: undefined });
360
+ // RAW-DRIVER LAYER: this pends straight through the driver, which writes no metadata, so
361
+ // the block is not enumerated. It is NOT the node's behavior — a pend through
362
+ // `BlockStorage` seeds metadata first, and IS enumerated. See the parity case below.
363
+ await storage.savePendingTransaction('pending-only' as BlockId, 'x' as ActionId, { delete: true });
364
+
365
+ const ids = new Set(await collect(listBlockIds.call(storage)));
366
+ expect(ids).to.deep.equal(new Set(['b1', 'b2']));
367
+ }
368
+ });
369
+
370
+ it('listBlockIds yields nothing for an empty store', async function () {
371
+ const listBlockIds = storage.listBlockIds;
372
+ if (typeof listBlockIds !== 'function') {
373
+ this.skip();
374
+ } else {
375
+ expect(await collect(listBlockIds.call(storage))).to.deep.equal([]);
376
+ }
377
+ });
378
+
379
+ // --- BlockStorage-level parity slice (over the same driver) ---
380
+
381
+ it('BlockStorage pend commit seeds open-ended ranges [[E]] (never [[0]]) and getBlock serves it', async () => {
382
+ const blockId = 'parity-commit' as BlockId;
383
+ const repo = new StorageRepo((id) => new BlockStorage(id, storage));
384
+
385
+ await repo.pend({
386
+ actionId: 'a1' as ActionId,
387
+ transforms: makeInsertTransforms(blockId, makeBlock('parity-commit', { items: [] })),
388
+ policy: 'c'
389
+ });
390
+ const commit = await repo.commit({ actionId: 'a1' as ActionId, blockIds: [blockId], tailId: blockId, rev: 1 });
391
+ expect(commit.success, 'commit succeeded').to.equal(true);
392
+
393
+ const meta = await storage.getMetadata(blockId);
394
+ expect(meta!.latest?.rev).to.equal(1);
395
+ // Open-ended from the earliest committed rev E=1 — NOT the over-claim [[0]].
396
+ expect(meta!.ranges, 'coverage open-ended from E=1').to.deep.equal([[1]]);
397
+
398
+ const got = await new BlockStorage(blockId, storage).getBlock();
399
+ expect(got?.block.header.id, 'committed block served').to.equal('parity-commit');
400
+ });
401
+
402
+ // The population `listBlockIds` really yields on the node path, pinned per backend. The startup
403
+ // owned-block seed (`seedOwnedBlocksFromStorage`) adds every enumerated id to the resilience
404
+ // monitors' tracked set, and `BlockStorage.savePendingTransaction` seeds metadata for a block
405
+ // that has none — so a pend-only block IS enumerated and IS seeded. The interface doc forbids
406
+ // filtering that out by reading each block's metadata (a per-block read at startup); this case
407
+ // is what stops a backend from "helpfully" adding such a filter.
408
+ it('listBlockIds enumerates a BlockStorage pend-only block (metadata seeded before the transform)', async function () {
409
+ const listBlockIds = storage.listBlockIds;
410
+ if (typeof listBlockIds !== 'function') {
411
+ this.skip();
412
+ } else {
413
+ const committed = 'parity-list-committed' as BlockId;
414
+ const pendOnly = 'parity-list-pend-only' as BlockId;
415
+ const repo = new StorageRepo((id) => new BlockStorage(id, storage));
416
+
417
+ await repo.pend({
418
+ actionId: 'a1' as ActionId,
419
+ transforms: makeInsertTransforms(committed, makeBlock('parity-list-committed', { items: [] })),
420
+ policy: 'c'
421
+ });
422
+ await repo.commit({ actionId: 'a1' as ActionId, blockIds: [committed], tailId: committed, rev: 1 });
423
+
424
+ await repo.pend({
425
+ actionId: 'a2' as ActionId,
426
+ transforms: makeInsertTransforms(pendOnly, makeBlock('parity-list-pend-only', { items: [] })),
427
+ policy: 'c'
428
+ });
429
+ expect(await storage.getMetadata(pendOnly), 'pend seeded metadata for a block that had none').to.exist;
430
+
431
+ expect(new Set(await collect(listBlockIds.call(storage)))).to.deep.equal(new Set([committed, pendOnly]));
432
+ }
433
+ });
434
+
435
+ it('BlockStorage saveReplica → saveDeletion reads the tombstoned rev back as undefined', async () => {
436
+ const blockId = 'parity-tombstone' as BlockId;
437
+ const bs = new BlockStorage(blockId, storage);
438
+
439
+ await bs.saveReplica(makeBlock('parity-tombstone', { items: ['live'] }), { rev: 1, actionId: 'r1' as ActionId });
440
+ const meta = await storage.getMetadata(blockId);
441
+ // saveReplica seeds open-ended coverage anchored at the replica rev, never [[0]].
442
+ expect(meta!.ranges, 'replica seeds open-ended [[1]]').to.deep.equal([[1]]);
443
+
444
+ const latest = await bs.saveDeletion({ rev: 2, actionId: 'd2' as ActionId });
445
+ expect(latest.rev).to.equal(2);
446
+
447
+ // getBlock at the tombstone rev reverse-applies { delete: true } → absent block.
448
+ expect(await bs.getBlock(), 'tombstoned rev reads back undefined').to.equal(undefined);
449
+ // The prior revision still materializes.
450
+ expect((await bs.getBlock(1))?.block.header.id, 'rev 1 still serves the live block').to.equal('parity-tombstone');
451
+ });
452
+ });
453
+ }