@optimystic/db-p2p 0.14.1 → 0.16.3
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.
- package/README.md +616 -609
- package/dist/src/cluster/block-transfer-service.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer-service.js +4 -3
- package/dist/src/cluster/block-transfer-service.js.map +1 -1
- package/dist/src/cluster/block-transfer.d.ts +52 -3
- package/dist/src/cluster/block-transfer.d.ts.map +1 -1
- package/dist/src/cluster/block-transfer.js +129 -6
- package/dist/src/cluster/block-transfer.js.map +1 -1
- package/dist/src/cluster/client.d.ts.map +1 -1
- package/dist/src/cluster/client.js +3 -1
- package/dist/src/cluster/client.js.map +1 -1
- package/dist/src/cluster/cluster-repo.d.ts +182 -11
- package/dist/src/cluster/cluster-repo.d.ts.map +1 -1
- package/dist/src/cluster/cluster-repo.js +429 -69
- package/dist/src/cluster/cluster-repo.js.map +1 -1
- package/dist/src/cluster/peer-key-binding.d.ts +25 -0
- package/dist/src/cluster/peer-key-binding.d.ts.map +1 -0
- package/dist/src/cluster/peer-key-binding.js +39 -0
- package/dist/src/cluster/peer-key-binding.js.map +1 -0
- package/dist/src/cluster/quorum-restore.d.ts +72 -0
- package/dist/src/cluster/quorum-restore.d.ts.map +1 -0
- package/dist/src/cluster/quorum-restore.js +0 -0
- package/dist/src/cluster/quorum-restore.js.map +1 -0
- package/dist/src/cluster/rebalance-monitor.d.ts +23 -1
- package/dist/src/cluster/rebalance-monitor.d.ts.map +1 -1
- package/dist/src/cluster/rebalance-monitor.js +17 -2
- package/dist/src/cluster/rebalance-monitor.js.map +1 -1
- package/dist/src/cluster/service.d.ts.map +1 -1
- package/dist/src/cluster/service.js +7 -1
- package/dist/src/cluster/service.js.map +1 -1
- package/dist/src/cluster/spread-on-churn.d.ts +7 -0
- package/dist/src/cluster/spread-on-churn.d.ts.map +1 -1
- package/dist/src/cluster/spread-on-churn.js +4 -1
- package/dist/src/cluster/spread-on-churn.js.map +1 -1
- package/dist/src/cluster/supermajority-coupling.d.ts +22 -0
- package/dist/src/cluster/supermajority-coupling.d.ts.map +1 -0
- package/dist/src/cluster/supermajority-coupling.js +27 -0
- package/dist/src/cluster/supermajority-coupling.js.map +1 -0
- package/dist/src/cohort-topic/cohort-gossip-driver.d.ts +46 -4
- package/dist/src/cohort-topic/cohort-gossip-driver.d.ts.map +1 -1
- package/dist/src/cohort-topic/cohort-gossip-driver.js +62 -7
- package/dist/src/cohort-topic/cohort-gossip-driver.js.map +1 -1
- package/dist/src/cohort-topic/cohort-gossip-transport.d.ts +7 -0
- package/dist/src/cohort-topic/cohort-gossip-transport.d.ts.map +1 -1
- package/dist/src/cohort-topic/cohort-gossip-transport.js +9 -0
- package/dist/src/cohort-topic/cohort-gossip-transport.js.map +1 -1
- package/dist/src/cohort-topic/host.d.ts +197 -27
- package/dist/src/cohort-topic/host.d.ts.map +1 -1
- package/dist/src/cohort-topic/host.js +643 -87
- package/dist/src/cohort-topic/host.js.map +1 -1
- package/dist/src/dispute/arbitrator-selection.d.ts +68 -7
- package/dist/src/dispute/arbitrator-selection.d.ts.map +1 -1
- package/dist/src/dispute/arbitrator-selection.js +105 -16
- package/dist/src/dispute/arbitrator-selection.js.map +1 -1
- package/dist/src/dispute/cascade.d.ts +7 -0
- package/dist/src/dispute/cascade.d.ts.map +1 -1
- package/dist/src/dispute/cascade.js +1 -1
- package/dist/src/dispute/cascade.js.map +1 -1
- package/dist/src/dispute/client.d.ts.map +1 -1
- package/dist/src/dispute/client.js +3 -2
- package/dist/src/dispute/client.js.map +1 -1
- package/dist/src/dispute/dispute-service.d.ts +32 -3
- package/dist/src/dispute/dispute-service.d.ts.map +1 -1
- package/dist/src/dispute/dispute-service.js +82 -9
- package/dist/src/dispute/dispute-service.js.map +1 -1
- package/dist/src/dispute/index.d.ts +1 -1
- package/dist/src/dispute/index.d.ts.map +1 -1
- package/dist/src/dispute/index.js +1 -1
- package/dist/src/dispute/index.js.map +1 -1
- package/dist/src/dispute/invalidation.d.ts +25 -0
- package/dist/src/dispute/invalidation.d.ts.map +1 -1
- package/dist/src/dispute/invalidation.js +14 -2
- package/dist/src/dispute/invalidation.js.map +1 -1
- package/dist/src/dispute/service.d.ts.map +1 -1
- package/dist/src/dispute/service.js +7 -1
- package/dist/src/dispute/service.js.map +1 -1
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/it-utility.d.ts +1 -1
- package/dist/src/it-utility.d.ts.map +1 -1
- package/dist/src/it-utility.js +4 -13
- package/dist/src/it-utility.js.map +1 -1
- package/dist/src/libp2p-key-network.d.ts +98 -1
- package/dist/src/libp2p-key-network.d.ts.map +1 -1
- package/dist/src/libp2p-key-network.js +272 -17
- package/dist/src/libp2p-key-network.js.map +1 -1
- package/dist/src/libp2p-node-base.d.ts +12 -0
- package/dist/src/libp2p-node-base.d.ts.map +1 -1
- package/dist/src/libp2p-node-base.js +441 -89
- package/dist/src/libp2p-node-base.js.map +1 -1
- package/dist/src/matchmaking/query-transport.d.ts +2 -0
- package/dist/src/matchmaking/query-transport.d.ts.map +1 -1
- package/dist/src/matchmaking/query-transport.js +6 -0
- package/dist/src/matchmaking/query-transport.js.map +1 -1
- package/dist/src/matchmaking/traffic-validation.js +1 -0
- package/dist/src/matchmaking/traffic-validation.js.map +1 -1
- package/dist/src/network/network-manager-service.d.ts +2 -2
- package/dist/src/network/network-manager-service.d.ts.map +1 -1
- package/dist/src/network/network-manager-service.js +8 -4
- package/dist/src/network/network-manager-service.js.map +1 -1
- package/dist/src/owned-block-seed.d.ts +27 -0
- package/dist/src/owned-block-seed.d.ts.map +1 -0
- package/dist/src/owned-block-seed.js +37 -0
- package/dist/src/owned-block-seed.js.map +1 -0
- package/dist/src/protocol-client.d.ts +1 -0
- package/dist/src/protocol-client.d.ts.map +1 -1
- package/dist/src/protocol-client.js +9 -2
- package/dist/src/protocol-client.js.map +1 -1
- package/dist/src/protocol-limits.d.ts +32 -0
- package/dist/src/protocol-limits.d.ts.map +1 -0
- package/dist/src/protocol-limits.js +32 -0
- package/dist/src/protocol-limits.js.map +1 -0
- package/dist/src/repo/client.d.ts.map +1 -1
- package/dist/src/repo/client.js +3 -0
- package/dist/src/repo/client.js.map +1 -1
- package/dist/src/repo/cluster-coordinator.d.ts +41 -4
- package/dist/src/repo/cluster-coordinator.d.ts.map +1 -1
- package/dist/src/repo/cluster-coordinator.js +128 -80
- package/dist/src/repo/cluster-coordinator.js.map +1 -1
- package/dist/src/repo/coordinator-repo.d.ts +38 -1
- package/dist/src/repo/coordinator-repo.d.ts.map +1 -1
- package/dist/src/repo/coordinator-repo.js +92 -13
- package/dist/src/repo/coordinator-repo.js.map +1 -1
- package/dist/src/repo/service.d.ts.map +1 -1
- package/dist/src/repo/service.js +7 -1
- package/dist/src/repo/service.js.map +1 -1
- package/dist/src/reputation/peer-reputation.js +1 -1
- package/dist/src/reputation/peer-reputation.js.map +1 -1
- package/dist/src/reputation/types.d.ts +3 -1
- package/dist/src/reputation/types.d.ts.map +1 -1
- package/dist/src/reputation/types.js +3 -0
- package/dist/src/reputation/types.js.map +1 -1
- package/dist/src/rn.d.ts +5 -1
- package/dist/src/rn.d.ts.map +1 -1
- package/dist/src/rn.js +7 -1
- package/dist/src/rn.js.map +1 -1
- package/dist/src/storage/arachnode-fret-adapter.d.ts +30 -1
- package/dist/src/storage/arachnode-fret-adapter.d.ts.map +1 -1
- package/dist/src/storage/arachnode-fret-adapter.js +15 -2
- package/dist/src/storage/arachnode-fret-adapter.js.map +1 -1
- package/dist/src/storage/arachnode-partition.d.ts +49 -0
- package/dist/src/storage/arachnode-partition.d.ts.map +1 -0
- package/dist/src/storage/arachnode-partition.js +61 -0
- package/dist/src/storage/arachnode-partition.js.map +1 -0
- package/dist/src/storage/block-storage.d.ts +40 -1
- package/dist/src/storage/block-storage.d.ts.map +1 -1
- package/dist/src/storage/block-storage.js +150 -57
- package/dist/src/storage/block-storage.js.map +1 -1
- package/dist/src/storage/i-block-storage.d.ts +8 -0
- package/dist/src/storage/i-block-storage.d.ts.map +1 -1
- package/dist/src/storage/i-raw-storage.d.ts +13 -0
- package/dist/src/storage/i-raw-storage.d.ts.map +1 -1
- package/dist/src/storage/kv-raw-storage.d.ts +45 -0
- package/dist/src/storage/kv-raw-storage.d.ts.map +1 -0
- package/dist/src/storage/kv-raw-storage.js +113 -0
- package/dist/src/storage/kv-raw-storage.js.map +1 -0
- package/dist/src/storage/memory-storage.d.ts +20 -58
- package/dist/src/storage/memory-storage.d.ts.map +1 -1
- package/dist/src/storage/memory-storage.js +22 -148
- package/dist/src/storage/memory-storage.js.map +1 -1
- package/dist/src/storage/memory-store-driver.d.ts +39 -0
- package/dist/src/storage/memory-store-driver.d.ts.map +1 -0
- package/dist/src/storage/memory-store-driver.js +129 -0
- package/dist/src/storage/memory-store-driver.js.map +1 -0
- package/dist/src/storage/raw-store-codec.d.ts +10 -0
- package/dist/src/storage/raw-store-codec.d.ts.map +1 -0
- package/dist/src/storage/raw-store-codec.js +36 -0
- package/dist/src/storage/raw-store-codec.js.map +1 -0
- package/dist/src/storage/raw-store-driver.d.ts +69 -0
- package/dist/src/storage/raw-store-driver.d.ts.map +1 -0
- package/dist/src/storage/raw-store-driver.js +2 -0
- package/dist/src/storage/raw-store-driver.js.map +1 -0
- package/dist/src/storage/restoration-coordinator-v2.d.ts.map +1 -1
- package/dist/src/storage/restoration-coordinator-v2.js +1 -1
- package/dist/src/storage/restoration-coordinator-v2.js.map +1 -1
- package/dist/src/storage/restoration-coordinator.d.ts +73 -0
- package/dist/src/storage/restoration-coordinator.d.ts.map +1 -0
- package/dist/src/storage/restoration-coordinator.js +181 -0
- package/dist/src/storage/restoration-coordinator.js.map +1 -0
- package/dist/src/storage/ring-selector.d.ts +92 -5
- package/dist/src/storage/ring-selector.d.ts.map +1 -1
- package/dist/src/storage/ring-selector.js +153 -51
- package/dist/src/storage/ring-selector.js.map +1 -1
- package/dist/src/storage/ring-shift-coordinator.d.ts +128 -0
- package/dist/src/storage/ring-shift-coordinator.d.ts.map +1 -0
- package/dist/src/storage/ring-shift-coordinator.js +196 -0
- package/dist/src/storage/ring-shift-coordinator.js.map +1 -0
- package/dist/src/storage/storage-monitor.d.ts +28 -0
- package/dist/src/storage/storage-monitor.d.ts.map +1 -1
- package/dist/src/storage/storage-monitor.js +42 -2
- package/dist/src/storage/storage-monitor.js.map +1 -1
- package/dist/src/storage/storage-repo.d.ts +19 -0
- package/dist/src/storage/storage-repo.d.ts.map +1 -1
- package/dist/src/storage/storage-repo.js +172 -22
- package/dist/src/storage/storage-repo.js.map +1 -1
- package/dist/src/sync/client.d.ts.map +1 -1
- package/dist/src/sync/client.js +5 -1
- package/dist/src/sync/client.js.map +1 -1
- package/dist/src/sync/service.d.ts.map +1 -1
- package/dist/src/sync/service.js +2 -1
- package/dist/src/sync/service.js.map +1 -1
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts +28 -4
- package/dist/src/testing/cohort-topic-mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/cohort-topic-mesh-harness.js +51 -10
- package/dist/src/testing/cohort-topic-mesh-harness.js.map +1 -1
- package/dist/src/testing/index.d.ts +1 -0
- package/dist/src/testing/index.d.ts.map +1 -1
- package/dist/src/testing/index.js +1 -0
- package/dist/src/testing/index.js.map +1 -1
- package/dist/src/testing/mesh-harness.d.ts.map +1 -1
- package/dist/src/testing/mesh-harness.js +15 -7
- package/dist/src/testing/mesh-harness.js.map +1 -1
- package/dist/src/testing/raw-storage-conformance.d.ts +24 -0
- package/dist/src/testing/raw-storage-conformance.d.ts.map +1 -0
- package/dist/src/testing/raw-storage-conformance.js +313 -0
- package/dist/src/testing/raw-storage-conformance.js.map +1 -0
- package/package.json +4 -4
- package/src/cluster/block-transfer-service.ts +4 -3
- package/src/cluster/block-transfer.ts +424 -265
- package/src/cluster/client.ts +3 -1
- package/src/cluster/cluster-repo.ts +481 -71
- package/src/cluster/peer-key-binding.ts +37 -0
- package/src/cluster/quorum-restore.ts +0 -0
- package/src/cluster/rebalance-monitor.ts +34 -3
- package/src/cluster/service.ts +7 -1
- package/src/cluster/spread-on-churn.ts +11 -1
- package/src/cluster/supermajority-coupling.ts +37 -0
- package/src/cohort-topic/cohort-gossip-driver.ts +96 -9
- package/src/cohort-topic/cohort-gossip-transport.ts +10 -0
- package/src/cohort-topic/host.ts +831 -105
- package/src/dispute/arbitrator-selection.ts +138 -28
- package/src/dispute/cascade.ts +8 -1
- package/src/dispute/client.ts +3 -2
- package/src/dispute/dispute-service.ts +94 -9
- package/src/dispute/index.ts +7 -1
- package/src/dispute/invalidation.ts +40 -2
- package/src/dispute/service.ts +7 -1
- package/src/index.ts +46 -43
- package/src/it-utility.ts +5 -13
- package/src/libp2p-key-network.ts +281 -18
- package/src/libp2p-node-base.ts +511 -81
- package/src/matchmaking/query-transport.ts +9 -0
- package/src/network/network-manager-service.ts +8 -2
- package/src/owned-block-seed.ts +40 -0
- package/src/protocol-client.ts +10 -3
- package/src/protocol-limits.ts +33 -0
- package/src/repo/client.ts +3 -0
- package/src/repo/cluster-coordinator.ts +145 -82
- package/src/repo/coordinator-repo.ts +96 -16
- package/src/repo/service.ts +7 -1
- package/src/reputation/types.ts +3 -0
- package/src/rn.ts +7 -1
- package/src/storage/arachnode-fret-adapter.ts +33 -2
- package/src/storage/arachnode-partition.ts +74 -0
- package/src/storage/block-storage.ts +169 -61
- package/src/storage/i-block-storage.ts +9 -0
- package/src/storage/i-raw-storage.ts +14 -0
- package/src/storage/kv-raw-storage.ts +135 -0
- package/src/storage/memory-storage.ts +25 -173
- package/src/storage/memory-store-driver.ts +157 -0
- package/src/storage/raw-store-codec.ts +42 -0
- package/src/storage/raw-store-driver.ts +80 -0
- package/src/storage/{restoration-coordinator-v2.ts → restoration-coordinator.ts} +43 -37
- package/src/storage/ring-selector.ts +317 -176
- package/src/storage/ring-shift-coordinator.ts +271 -0
- package/src/storage/storage-monitor.ts +70 -3
- package/src/storage/storage-repo.ts +710 -558
- package/src/sync/client.ts +5 -1
- package/src/sync/service.ts +2 -1
- package/src/testing/cohort-topic-mesh-harness.ts +61 -11
- package/src/testing/index.ts +1 -0
- package/src/testing/mesh-harness.ts +14 -6
- package/src/testing/raw-storage-conformance.ts +397 -0
package/src/sync/client.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
/**
|
package/src/sync/service.ts
CHANGED
|
@@ -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
|
|
59
|
+
export { delay } from '@optimystic/db-core/test';
|
|
60
|
+
import { delay } from '@optimystic/db-core/test';
|
|
60
61
|
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
/**
|
|
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)
|
package/src/testing/index.ts
CHANGED
|
@@ -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 ??
|
|
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
|
-
|
|
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 ??
|
|
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
|
+
}
|