@novasamatech/host-container 0.7.4 → 0.7.5-1
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.
|
@@ -1,113 +1,104 @@
|
|
|
1
1
|
import type { HexString } from '@novasamatech/host-api';
|
|
2
2
|
import type { JsonRpcProvider } from 'polkadot-api';
|
|
3
|
-
type
|
|
4
|
-
type PendingRequest = {
|
|
5
|
-
resolve: (result: unknown) => void;
|
|
6
|
-
reject: (error: unknown) => void;
|
|
7
|
-
};
|
|
8
|
-
type FollowSubscription = {
|
|
9
|
-
chainSubId: string;
|
|
10
|
-
eventListener: (event: unknown) => void;
|
|
11
|
-
pendingRequestId?: string;
|
|
12
|
-
};
|
|
13
|
-
type ChainEntry = {
|
|
14
|
-
connection: JsonRpcConnection;
|
|
15
|
-
pendingRequests: Map<string, PendingRequest>;
|
|
16
|
-
followSubscriptions: Map<string, FollowSubscription>;
|
|
17
|
-
refCount: number;
|
|
18
|
-
};
|
|
3
|
+
type FollowEvent = Record<string, unknown>;
|
|
19
4
|
export type ChainConnectionManager = ReturnType<typeof createChainConnectionManager>;
|
|
20
|
-
export declare function createChainConnectionManager(factory: (genesisHash: HexString) => JsonRpcProvider | null
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
5
|
+
export declare function createChainConnectionManager(factory: (genesisHash: HexString) => JsonRpcProvider | null, options?: {
|
|
6
|
+
refollowTimeoutMs?: number;
|
|
7
|
+
}): {
|
|
8
|
+
getOrCreateChain(genesisHash: HexString): boolean;
|
|
9
|
+
startFollow(genesisHash: HexString, withRuntime: boolean, onEvent: (event: FollowEvent) => void): {
|
|
24
10
|
followId: string;
|
|
25
11
|
};
|
|
26
|
-
stopFollow
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
};
|
|
66
|
-
} | {
|
|
67
|
-
tag: "OperationStorageItems";
|
|
68
|
-
value: {
|
|
69
|
-
readonly operationId: string;
|
|
70
|
-
readonly items: {
|
|
71
|
-
key: HexString;
|
|
72
|
-
value: `0x${string}`;
|
|
73
|
-
hash: `0x${string}`;
|
|
74
|
-
closestDescendantMerkleValue: `0x${string}`;
|
|
75
|
-
}[];
|
|
76
|
-
};
|
|
77
|
-
} | {
|
|
78
|
-
tag: "OperationStorageDone";
|
|
79
|
-
value: {
|
|
80
|
-
readonly operationId: string;
|
|
81
|
-
};
|
|
82
|
-
} | {
|
|
83
|
-
tag: "OperationWaitingForContinue";
|
|
84
|
-
value: {
|
|
85
|
-
readonly operationId: string;
|
|
86
|
-
};
|
|
87
|
-
} | {
|
|
88
|
-
tag: "OperationInaccessible";
|
|
89
|
-
value: {
|
|
90
|
-
readonly operationId: string;
|
|
91
|
-
};
|
|
92
|
-
} | {
|
|
93
|
-
tag: "OperationError";
|
|
94
|
-
value: {
|
|
95
|
-
readonly operationId: string;
|
|
96
|
-
readonly error: string;
|
|
97
|
-
};
|
|
98
|
-
} | {
|
|
99
|
-
tag: "Stop";
|
|
100
|
-
value: undefined;
|
|
12
|
+
stopFollow(genesisHash: HexString, followId: string): void;
|
|
13
|
+
hasActiveFollow(genesisHash: HexString): boolean;
|
|
14
|
+
chainHeadOp(genesisHash: HexString, method: string, params: unknown[]): Promise<unknown>;
|
|
15
|
+
sendRequest(genesisHash: HexString, method: string, params: unknown[]): Promise<unknown>;
|
|
16
|
+
releaseChain(genesisHash: HexString): void;
|
|
17
|
+
dispose(): void;
|
|
18
|
+
convertJsonRpcEventToTyped: typeof convertJsonRpcEventToTyped;
|
|
19
|
+
convertOperationStartedResult: typeof convertOperationStartedResult;
|
|
20
|
+
convertStorageQueryTypeToJsonRpc: typeof convertStorageQueryTypeToJsonRpc;
|
|
21
|
+
};
|
|
22
|
+
export declare function convertJsonRpcEventToTyped(event: Record<string, unknown>): {
|
|
23
|
+
tag: "Initialized";
|
|
24
|
+
value: {
|
|
25
|
+
readonly finalizedBlockHashes: HexString[];
|
|
26
|
+
readonly finalizedBlockRuntime: unknown;
|
|
27
|
+
};
|
|
28
|
+
} | {
|
|
29
|
+
tag: "NewBlock";
|
|
30
|
+
value: {
|
|
31
|
+
readonly blockHash: HexString;
|
|
32
|
+
readonly parentBlockHash: HexString;
|
|
33
|
+
readonly newRuntime: unknown;
|
|
34
|
+
};
|
|
35
|
+
} | {
|
|
36
|
+
tag: "BestBlockChanged";
|
|
37
|
+
value: {
|
|
38
|
+
readonly bestBlockHash: HexString;
|
|
39
|
+
};
|
|
40
|
+
} | {
|
|
41
|
+
tag: "Finalized";
|
|
42
|
+
value: {
|
|
43
|
+
readonly finalizedBlockHashes: HexString[];
|
|
44
|
+
readonly prunedBlockHashes: HexString[];
|
|
45
|
+
};
|
|
46
|
+
} | {
|
|
47
|
+
tag: "OperationBodyDone";
|
|
48
|
+
value: {
|
|
49
|
+
readonly operationId: string;
|
|
50
|
+
readonly value: HexString[];
|
|
101
51
|
};
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
|
|
52
|
+
} | {
|
|
53
|
+
tag: "OperationCallDone";
|
|
54
|
+
value: {
|
|
55
|
+
readonly operationId: string;
|
|
56
|
+
readonly output: HexString;
|
|
57
|
+
};
|
|
58
|
+
} | {
|
|
59
|
+
tag: "OperationStorageItems";
|
|
60
|
+
value: {
|
|
61
|
+
readonly operationId: string;
|
|
62
|
+
readonly items: {
|
|
63
|
+
key: HexString;
|
|
64
|
+
value: `0x${string}`;
|
|
65
|
+
hash: `0x${string}`;
|
|
66
|
+
closestDescendantMerkleValue: `0x${string}`;
|
|
67
|
+
}[];
|
|
68
|
+
};
|
|
69
|
+
} | {
|
|
70
|
+
tag: "OperationStorageDone";
|
|
71
|
+
value: {
|
|
72
|
+
readonly operationId: string;
|
|
73
|
+
};
|
|
74
|
+
} | {
|
|
75
|
+
tag: "OperationWaitingForContinue";
|
|
76
|
+
value: {
|
|
77
|
+
readonly operationId: string;
|
|
78
|
+
};
|
|
79
|
+
} | {
|
|
80
|
+
tag: "OperationInaccessible";
|
|
81
|
+
value: {
|
|
82
|
+
readonly operationId: string;
|
|
83
|
+
};
|
|
84
|
+
} | {
|
|
85
|
+
tag: "OperationError";
|
|
86
|
+
value: {
|
|
87
|
+
readonly operationId: string;
|
|
88
|
+
readonly error: string;
|
|
89
|
+
};
|
|
90
|
+
} | {
|
|
91
|
+
tag: "Stop";
|
|
92
|
+
value: undefined;
|
|
93
|
+
};
|
|
94
|
+
export declare function convertOperationStartedResult(result: unknown): {
|
|
95
|
+
tag: "Started";
|
|
96
|
+
value: {
|
|
97
|
+
readonly operationId: string;
|
|
110
98
|
};
|
|
111
|
-
|
|
99
|
+
} | {
|
|
100
|
+
tag: "LimitReached";
|
|
101
|
+
value: undefined;
|
|
112
102
|
};
|
|
103
|
+
export declare function convertStorageQueryTypeToJsonRpc(type: string): string;
|
|
113
104
|
export {};
|
|
@@ -1,310 +1,277 @@
|
|
|
1
1
|
import { enumValue } from '@novasamatech/host-api';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
if (pending) {
|
|
32
|
-
pendingRequests.delete(String(message.id));
|
|
33
|
-
if ('error' in message) {
|
|
34
|
-
pending.reject(message.error);
|
|
35
|
-
}
|
|
36
|
-
else {
|
|
37
|
-
pending.resolve(message.result);
|
|
38
|
-
}
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
// Subscription notification: has 'method' and 'params'
|
|
43
|
-
if ('method' in message && 'params' in message) {
|
|
44
|
-
const params = message.params;
|
|
45
|
-
if (params?.subscription) {
|
|
46
|
-
const subId = String(params.subscription);
|
|
47
|
-
for (const follow of followSubscriptions.values()) {
|
|
48
|
-
if (follow.chainSubId === subId) {
|
|
49
|
-
follow.eventListener(params.result);
|
|
50
|
-
break;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
2
|
+
import { StopError, createClient as createSubstrateClient } from '@polkadot-api/substrate-client';
|
|
3
|
+
// Operation events that signal a chain-head op is finished and its
|
|
4
|
+
// per-operation subscription can be torn down.
|
|
5
|
+
const TERMINAL_OPERATION_EVENTS = new Set([
|
|
6
|
+
'operationBodyDone',
|
|
7
|
+
'operationCallDone',
|
|
8
|
+
'operationStorageDone',
|
|
9
|
+
'operationError',
|
|
10
|
+
'operationInaccessible',
|
|
11
|
+
]);
|
|
12
|
+
// How long to hold a chain-head op when there's no active follow, waiting for
|
|
13
|
+
// the papp to issue a fresh Follow after a Stop. Long enough to absorb a
|
|
14
|
+
// realistic round-trip; short enough that a never-issuing papp fails fast.
|
|
15
|
+
const REFOLLOW_TIMEOUT_MS = 5_000;
|
|
16
|
+
function executeChainHeadOp(response, onEvent, method, params) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
response._request(method, params, {
|
|
19
|
+
onSuccess: (result, onSubscribeOperation) => {
|
|
20
|
+
const operationId = result?.operationId;
|
|
21
|
+
if (operationId) {
|
|
22
|
+
let unsub = () => undefined;
|
|
23
|
+
unsub = onSubscribeOperation(operationId, {
|
|
24
|
+
next: e => {
|
|
25
|
+
onEvent(e);
|
|
26
|
+
if (TERMINAL_OPERATION_EVENTS.has(e.event))
|
|
27
|
+
unsub();
|
|
28
|
+
},
|
|
29
|
+
error: () => unsub(),
|
|
30
|
+
});
|
|
53
31
|
}
|
|
54
|
-
|
|
55
|
-
});
|
|
56
|
-
chains.set(genesisHash, entry);
|
|
57
|
-
return entry;
|
|
58
|
-
}
|
|
59
|
-
function sendRequest(genesisHash, method, params) {
|
|
60
|
-
const entry = chains.get(genesisHash);
|
|
61
|
-
if (!entry)
|
|
62
|
-
return Promise.reject(new Error(`No connection for chain ${genesisHash}`));
|
|
63
|
-
const id = getNextId();
|
|
64
|
-
return new Promise((resolve, reject) => {
|
|
65
|
-
entry.pendingRequests.set(id, { resolve, reject });
|
|
66
|
-
entry.connection.send({ jsonrpc: '2.0', id, method, params });
|
|
67
|
-
});
|
|
68
|
-
}
|
|
69
|
-
function startFollow(genesisHash, withRuntime, onEvent) {
|
|
70
|
-
const entry = chains.get(genesisHash);
|
|
71
|
-
if (!entry)
|
|
72
|
-
throw new Error(`No connection for chain ${genesisHash}`);
|
|
73
|
-
const followId = getNextId();
|
|
74
|
-
const requestId = getNextId();
|
|
75
|
-
const follow = {
|
|
76
|
-
chainSubId: '', // will be set synchronously when response arrives
|
|
77
|
-
eventListener: onEvent,
|
|
78
|
-
pendingRequestId: requestId,
|
|
79
|
-
};
|
|
80
|
-
entry.followSubscriptions.set(followId, follow);
|
|
81
|
-
// Bypass sendRequest to avoid Promise microtask deferral.
|
|
82
|
-
// The response handler sets chainSubId synchronously, so when the next
|
|
83
|
-
// message (the notification) is processed, chainSubId is already set.
|
|
84
|
-
entry.pendingRequests.set(requestId, {
|
|
85
|
-
resolve: result => {
|
|
86
|
-
follow.chainSubId = result;
|
|
87
|
-
follow.pendingRequestId = undefined;
|
|
88
|
-
},
|
|
89
|
-
reject: () => {
|
|
90
|
-
follow.pendingRequestId = undefined;
|
|
91
|
-
entry.followSubscriptions.delete(followId);
|
|
32
|
+
resolve(result);
|
|
92
33
|
},
|
|
34
|
+
onError: reject,
|
|
93
35
|
});
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (!follow)
|
|
108
|
-
return;
|
|
109
|
-
entry.followSubscriptions.delete(followId);
|
|
110
|
-
if (follow.chainSubId) {
|
|
111
|
-
const id = getNextId();
|
|
112
|
-
entry.connection.send({
|
|
113
|
-
jsonrpc: '2.0',
|
|
114
|
-
id,
|
|
115
|
-
method: 'chainHead_v1_unfollow',
|
|
116
|
-
params: [follow.chainSubId],
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
else if (follow.pendingRequestId) {
|
|
120
|
-
// Follow response hasn't arrived yet — replace the pending resolve to send unfollow when it does
|
|
121
|
-
entry.pendingRequests.set(follow.pendingRequestId, {
|
|
122
|
-
resolve: result => {
|
|
123
|
-
const chainSubId = result;
|
|
124
|
-
if (chainSubId) {
|
|
125
|
-
const unfollowId = getNextId();
|
|
126
|
-
entry.connection.send({
|
|
127
|
-
jsonrpc: '2.0',
|
|
128
|
-
id: unfollowId,
|
|
129
|
-
method: 'chainHead_v1_unfollow',
|
|
130
|
-
params: [chainSubId],
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
|
-
reject: () => {
|
|
135
|
-
/* follow already cleaned up */
|
|
136
|
-
},
|
|
137
|
-
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export function createChainConnectionManager(factory, options = {}) {
|
|
39
|
+
const refollowTimeoutMs = options.refollowTimeoutMs ?? REFOLLOW_TIMEOUT_MS;
|
|
40
|
+
const chains = new Map();
|
|
41
|
+
let nextFollowId = 0;
|
|
42
|
+
function teardown(entry) {
|
|
43
|
+
for (const follow of entry.follows.values())
|
|
44
|
+
follow.response?.unfollow();
|
|
45
|
+
entry.follows.clear();
|
|
46
|
+
for (const op of entry.pendingOps) {
|
|
47
|
+
clearTimeout(op.timer);
|
|
48
|
+
op.reject(new Error('Chain disposed'));
|
|
138
49
|
}
|
|
50
|
+
entry.pendingOps = [];
|
|
51
|
+
entry.client.destroy();
|
|
139
52
|
}
|
|
140
|
-
function
|
|
53
|
+
function activeFollow(genesisHash) {
|
|
141
54
|
const entry = chains.get(genesisHash);
|
|
142
55
|
if (!entry)
|
|
143
56
|
return null;
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return follow.chainSubId;
|
|
57
|
+
for (const follow of entry.follows.values()) {
|
|
58
|
+
if (follow.response)
|
|
59
|
+
return follow;
|
|
148
60
|
}
|
|
149
61
|
return null;
|
|
150
62
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
for (const follow of entry.followSubscriptions.values()) {
|
|
158
|
-
if (follow.chainSubId) {
|
|
159
|
-
const id = getNextId();
|
|
160
|
-
entry.connection.send({
|
|
161
|
-
jsonrpc: '2.0',
|
|
162
|
-
id,
|
|
163
|
-
method: 'chainHead_v1_unfollow',
|
|
164
|
-
params: [follow.chainSubId],
|
|
165
|
-
});
|
|
166
|
-
}
|
|
63
|
+
return {
|
|
64
|
+
getOrCreateChain(genesisHash) {
|
|
65
|
+
const existing = chains.get(genesisHash);
|
|
66
|
+
if (existing) {
|
|
67
|
+
existing.refCount++;
|
|
68
|
+
return true;
|
|
167
69
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
70
|
+
const provider = factory(genesisHash);
|
|
71
|
+
if (!provider)
|
|
72
|
+
return false;
|
|
73
|
+
chains.set(genesisHash, {
|
|
74
|
+
client: createSubstrateClient(provider),
|
|
75
|
+
follows: new Map(),
|
|
76
|
+
pendingOps: [],
|
|
77
|
+
recoveringSince: null,
|
|
78
|
+
refCount: 1,
|
|
79
|
+
});
|
|
80
|
+
return true;
|
|
81
|
+
},
|
|
82
|
+
startFollow(genesisHash, withRuntime, onEvent) {
|
|
83
|
+
const entry = chains.get(genesisHash);
|
|
84
|
+
if (!entry)
|
|
85
|
+
throw new Error(`No connection for chain ${genesisHash}`);
|
|
86
|
+
const followId = `f${nextFollowId++}`;
|
|
87
|
+
const follow = { response: null, onEvent };
|
|
88
|
+
entry.follows.set(followId, follow);
|
|
89
|
+
const response = entry.client.chainHead(withRuntime,
|
|
90
|
+
// substrate-client renames the spec's `event` field to `type`. Restore it.
|
|
91
|
+
({ type, ...rest }) => onEvent({ event: type, ...rest }), error => {
|
|
92
|
+
// The follow is dead. Drop the entry so it doesn't accumulate as a
|
|
93
|
+
// tombstone; clear the response field on the (now-detached)
|
|
94
|
+
// FollowEntry for any handle still holding it. For a spec stop,
|
|
95
|
+
// surface the event to the papp and open the recovery window so
|
|
96
|
+
// chain-head ops issued before the papp refollows queue rather
|
|
97
|
+
// than fail. Non-Stop errors get no recovery window — those are
|
|
98
|
+
// genuine failures, not protocol-driven restarts.
|
|
99
|
+
entry.follows.delete(followId);
|
|
100
|
+
follow.response = null;
|
|
101
|
+
if (error instanceof StopError) {
|
|
102
|
+
onEvent({ event: 'stop' });
|
|
103
|
+
entry.recoveringSince = Date.now();
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
follow.response = response;
|
|
107
|
+
// Fresh follow ends any in-flight recovery window. Drain ops queued
|
|
108
|
+
// during the gap through this follow's onEvent so operation events
|
|
109
|
+
// still reach the papp.
|
|
110
|
+
entry.recoveringSince = null;
|
|
111
|
+
if (entry.pendingOps.length > 0) {
|
|
112
|
+
const queued = entry.pendingOps;
|
|
113
|
+
entry.pendingOps = [];
|
|
114
|
+
for (const op of queued) {
|
|
115
|
+
clearTimeout(op.timer);
|
|
116
|
+
executeChainHeadOp(response, onEvent, op.method, op.params).then(op.resolve, op.reject);
|
|
184
117
|
}
|
|
185
118
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
return
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
hash: item.hash ?? null,
|
|
232
|
-
closestDescendantMerkleValue: item.closestDescendantMerkleValue ?? null,
|
|
233
|
-
})),
|
|
234
|
-
});
|
|
235
|
-
case 'operationStorageDone':
|
|
236
|
-
return enumValue('OperationStorageDone', {
|
|
237
|
-
operationId: event.operationId,
|
|
238
|
-
});
|
|
239
|
-
case 'operationWaitingForContinue':
|
|
240
|
-
return enumValue('OperationWaitingForContinue', {
|
|
241
|
-
operationId: event.operationId,
|
|
242
|
-
});
|
|
243
|
-
case 'operationInaccessible':
|
|
244
|
-
return enumValue('OperationInaccessible', {
|
|
245
|
-
operationId: event.operationId,
|
|
246
|
-
});
|
|
247
|
-
case 'operationError':
|
|
248
|
-
return enumValue('OperationError', {
|
|
249
|
-
operationId: event.operationId,
|
|
250
|
-
error: event.error,
|
|
251
|
-
});
|
|
252
|
-
case 'stop':
|
|
253
|
-
return enumValue('Stop', undefined);
|
|
254
|
-
default:
|
|
255
|
-
return enumValue('Stop', undefined);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
function convertRuntime(runtime) {
|
|
259
|
-
if (!runtime || typeof runtime !== 'object')
|
|
260
|
-
return undefined;
|
|
261
|
-
const rt = runtime;
|
|
262
|
-
if (rt.type === 'valid') {
|
|
263
|
-
const spec = rt.spec;
|
|
264
|
-
const apis = spec.apis;
|
|
265
|
-
return enumValue('Valid', {
|
|
266
|
-
specName: spec.specName,
|
|
267
|
-
implName: spec.implName,
|
|
268
|
-
specVersion: spec.specVersion,
|
|
269
|
-
implVersion: spec.implVersion,
|
|
270
|
-
transactionVersion: spec.transactionVersion,
|
|
271
|
-
apis: apis ? Object.entries(apis).map(([name, version]) => [name, version]) : [],
|
|
119
|
+
return { followId };
|
|
120
|
+
},
|
|
121
|
+
stopFollow(genesisHash, followId) {
|
|
122
|
+
const entry = chains.get(genesisHash);
|
|
123
|
+
if (!entry)
|
|
124
|
+
return;
|
|
125
|
+
const follow = entry.follows.get(followId);
|
|
126
|
+
if (!follow)
|
|
127
|
+
return;
|
|
128
|
+
entry.follows.delete(followId);
|
|
129
|
+
follow.response?.unfollow();
|
|
130
|
+
},
|
|
131
|
+
hasActiveFollow(genesisHash) {
|
|
132
|
+
return activeFollow(genesisHash) !== null;
|
|
133
|
+
},
|
|
134
|
+
chainHeadOp(genesisHash, method, params) {
|
|
135
|
+
const follow = activeFollow(genesisHash);
|
|
136
|
+
if (follow?.response)
|
|
137
|
+
return executeChainHeadOp(follow.response, follow.onEvent, method, params);
|
|
138
|
+
// No active follow. If we're inside a recovery window opened by a
|
|
139
|
+
// recent Stop, hold the op so the brief gap before the papp's refollow
|
|
140
|
+
// doesn't surface as an error. Otherwise fail fast — no Stop, no
|
|
141
|
+
// expected refollow.
|
|
142
|
+
const entry = chains.get(genesisHash);
|
|
143
|
+
if (!entry || entry.recoveringSince === null) {
|
|
144
|
+
return Promise.reject(new Error('No active follow for this chain'));
|
|
145
|
+
}
|
|
146
|
+
if (Date.now() - entry.recoveringSince > refollowTimeoutMs) {
|
|
147
|
+
entry.recoveringSince = null;
|
|
148
|
+
return Promise.reject(new Error('No active follow for this chain'));
|
|
149
|
+
}
|
|
150
|
+
return new Promise((resolve, reject) => {
|
|
151
|
+
const op = {
|
|
152
|
+
method,
|
|
153
|
+
params,
|
|
154
|
+
resolve,
|
|
155
|
+
reject,
|
|
156
|
+
timer: setTimeout(() => {
|
|
157
|
+
const idx = entry.pendingOps.indexOf(op);
|
|
158
|
+
if (idx !== -1)
|
|
159
|
+
entry.pendingOps.splice(idx, 1);
|
|
160
|
+
reject(new Error('No active follow for this chain'));
|
|
161
|
+
}, refollowTimeoutMs),
|
|
162
|
+
};
|
|
163
|
+
entry.pendingOps.push(op);
|
|
272
164
|
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
const
|
|
282
|
-
if (
|
|
283
|
-
return
|
|
165
|
+
},
|
|
166
|
+
sendRequest(genesisHash, method, params) {
|
|
167
|
+
const entry = chains.get(genesisHash);
|
|
168
|
+
if (!entry)
|
|
169
|
+
return Promise.reject(new Error(`No connection for chain ${genesisHash}`));
|
|
170
|
+
return entry.client.request(method, params);
|
|
171
|
+
},
|
|
172
|
+
releaseChain(genesisHash) {
|
|
173
|
+
const entry = chains.get(genesisHash);
|
|
174
|
+
if (!entry)
|
|
175
|
+
return;
|
|
176
|
+
if (--entry.refCount <= 0) {
|
|
177
|
+
teardown(entry);
|
|
178
|
+
chains.delete(genesisHash);
|
|
284
179
|
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
Hash: 'hash',
|
|
292
|
-
ClosestDescendantMerkleValue: 'closestDescendantMerkleValue',
|
|
293
|
-
DescendantsValues: 'descendantsValues',
|
|
294
|
-
DescendantsHashes: 'descendantsHashes',
|
|
295
|
-
};
|
|
296
|
-
return map[type] ?? 'value';
|
|
297
|
-
}
|
|
298
|
-
return {
|
|
299
|
-
getOrCreateChain,
|
|
300
|
-
sendRequest,
|
|
301
|
-
startFollow,
|
|
302
|
-
stopFollow,
|
|
303
|
-
getChainFollowSubId,
|
|
304
|
-
releaseChain,
|
|
305
|
-
dispose,
|
|
180
|
+
},
|
|
181
|
+
dispose() {
|
|
182
|
+
for (const entry of chains.values())
|
|
183
|
+
teardown(entry);
|
|
184
|
+
chains.clear();
|
|
185
|
+
},
|
|
306
186
|
convertJsonRpcEventToTyped,
|
|
307
187
|
convertOperationStartedResult,
|
|
308
188
|
convertStorageQueryTypeToJsonRpc,
|
|
309
189
|
};
|
|
310
190
|
}
|
|
191
|
+
// === JSON-RPC ↔ typed conversion (pure, no closure state) ===
|
|
192
|
+
function convertRuntime(runtime) {
|
|
193
|
+
if (!runtime || typeof runtime !== 'object')
|
|
194
|
+
return undefined;
|
|
195
|
+
const rt = runtime;
|
|
196
|
+
if (rt.type === 'valid') {
|
|
197
|
+
const spec = rt.spec;
|
|
198
|
+
const apis = spec.apis;
|
|
199
|
+
return enumValue('Valid', {
|
|
200
|
+
specName: spec.specName,
|
|
201
|
+
implName: spec.implName,
|
|
202
|
+
specVersion: spec.specVersion,
|
|
203
|
+
implVersion: spec.implVersion,
|
|
204
|
+
transactionVersion: spec.transactionVersion,
|
|
205
|
+
apis: apis ? Object.entries(apis).map(([name, version]) => [name, version]) : [],
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
if (rt.type === 'invalid')
|
|
209
|
+
return enumValue('Invalid', { error: rt.error });
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
|
212
|
+
export function convertJsonRpcEventToTyped(event) {
|
|
213
|
+
switch (event.event) {
|
|
214
|
+
case 'initialized':
|
|
215
|
+
return enumValue('Initialized', {
|
|
216
|
+
finalizedBlockHashes: event.finalizedBlockHashes,
|
|
217
|
+
finalizedBlockRuntime: convertRuntime(event.finalizedBlockRuntime),
|
|
218
|
+
});
|
|
219
|
+
case 'newBlock':
|
|
220
|
+
return enumValue('NewBlock', {
|
|
221
|
+
blockHash: event.blockHash,
|
|
222
|
+
parentBlockHash: event.parentBlockHash,
|
|
223
|
+
newRuntime: convertRuntime(event.newRuntime),
|
|
224
|
+
});
|
|
225
|
+
case 'bestBlockChanged':
|
|
226
|
+
return enumValue('BestBlockChanged', { bestBlockHash: event.bestBlockHash });
|
|
227
|
+
case 'finalized':
|
|
228
|
+
return enumValue('Finalized', {
|
|
229
|
+
finalizedBlockHashes: event.finalizedBlockHashes,
|
|
230
|
+
prunedBlockHashes: event.prunedBlockHashes,
|
|
231
|
+
});
|
|
232
|
+
case 'operationBodyDone':
|
|
233
|
+
return enumValue('OperationBodyDone', {
|
|
234
|
+
operationId: event.operationId,
|
|
235
|
+
value: event.value,
|
|
236
|
+
});
|
|
237
|
+
case 'operationCallDone':
|
|
238
|
+
return enumValue('OperationCallDone', {
|
|
239
|
+
operationId: event.operationId,
|
|
240
|
+
output: event.output,
|
|
241
|
+
});
|
|
242
|
+
case 'operationStorageItems':
|
|
243
|
+
return enumValue('OperationStorageItems', {
|
|
244
|
+
operationId: event.operationId,
|
|
245
|
+
items: event.items.map(item => ({
|
|
246
|
+
key: item.key,
|
|
247
|
+
value: item.value ?? null,
|
|
248
|
+
hash: item.hash ?? null,
|
|
249
|
+
closestDescendantMerkleValue: item.closestDescendantMerkleValue ?? null,
|
|
250
|
+
})),
|
|
251
|
+
});
|
|
252
|
+
case 'operationStorageDone':
|
|
253
|
+
return enumValue('OperationStorageDone', { operationId: event.operationId });
|
|
254
|
+
case 'operationWaitingForContinue':
|
|
255
|
+
return enumValue('OperationWaitingForContinue', { operationId: event.operationId });
|
|
256
|
+
case 'operationInaccessible':
|
|
257
|
+
return enumValue('OperationInaccessible', { operationId: event.operationId });
|
|
258
|
+
case 'operationError':
|
|
259
|
+
return enumValue('OperationError', {
|
|
260
|
+
operationId: event.operationId,
|
|
261
|
+
error: event.error,
|
|
262
|
+
});
|
|
263
|
+
case 'stop':
|
|
264
|
+
default:
|
|
265
|
+
return enumValue('Stop', undefined);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
export function convertOperationStartedResult(result) {
|
|
269
|
+
const r = result;
|
|
270
|
+
return r?.result === 'started'
|
|
271
|
+
? enumValue('Started', { operationId: r.operationId })
|
|
272
|
+
: enumValue('LimitReached', undefined);
|
|
273
|
+
}
|
|
274
|
+
// 'Value' → 'value', 'ClosestDescendantMerkleValue' → 'closestDescendantMerkleValue', …
|
|
275
|
+
export function convertStorageQueryTypeToJsonRpc(type) {
|
|
276
|
+
return type.charAt(0).toLowerCase() + type.slice(1);
|
|
277
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { createChainConnectionManager } from './chainConnectionManager.js';
|
|
3
|
+
const GENESIS = '0xabc';
|
|
4
|
+
const createMockProvider = () => {
|
|
5
|
+
const send = vi.fn();
|
|
6
|
+
const disconnect = vi.fn();
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
let onMessage = null;
|
|
9
|
+
const provider = cb => {
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
onMessage = cb;
|
|
12
|
+
return { send, disconnect };
|
|
13
|
+
};
|
|
14
|
+
return {
|
|
15
|
+
provider,
|
|
16
|
+
send,
|
|
17
|
+
disconnect,
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
19
|
+
simulateMessage: (msg) => onMessage?.(msg),
|
|
20
|
+
};
|
|
21
|
+
};
|
|
22
|
+
const sentMessages = (mock) => mock.send.mock.calls.map(([msg]) => msg);
|
|
23
|
+
const findCallId = (mock, method) => {
|
|
24
|
+
const match = sentMessages(mock).find(msg => msg.method === method);
|
|
25
|
+
if (!match)
|
|
26
|
+
throw new Error(`no '${method}' call found`);
|
|
27
|
+
return match.id;
|
|
28
|
+
};
|
|
29
|
+
describe('chainConnectionManager', () => {
|
|
30
|
+
describe('startFollow / stop event handling', () => {
|
|
31
|
+
it('sends chainHead_v1_follow on startFollow', () => {
|
|
32
|
+
const mock = createMockProvider();
|
|
33
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
34
|
+
manager.getOrCreateChain(GENESIS);
|
|
35
|
+
manager.startFollow(GENESIS, true, vi.fn());
|
|
36
|
+
const calls = sentMessages(mock);
|
|
37
|
+
expect(calls).toHaveLength(1);
|
|
38
|
+
expect(calls[0]).toMatchObject({ method: 'chainHead_v1_follow', params: [true] });
|
|
39
|
+
});
|
|
40
|
+
it('hasActiveFollow becomes true on startFollow and stays true through the server confirmation', () => {
|
|
41
|
+
// The follow lifecycle is owned by substrate-client's chainHead, which
|
|
42
|
+
// queues chain-head ops on the underlying followSubscription Promise
|
|
43
|
+
// until the server assigns a chainSubId. So a follow is "active" from
|
|
44
|
+
// the moment startFollow returns; ops issued in the setup window are
|
|
45
|
+
// not rejected — they queue and fire once the chainSubId arrives.
|
|
46
|
+
const mock = createMockProvider();
|
|
47
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
48
|
+
manager.getOrCreateChain(GENESIS);
|
|
49
|
+
manager.startFollow(GENESIS, true, vi.fn());
|
|
50
|
+
expect(manager.hasActiveFollow(GENESIS)).toBe(true);
|
|
51
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: findCallId(mock, 'chainHead_v1_follow'), result: 'sub-id-1' });
|
|
52
|
+
expect(manager.hasActiveFollow(GENESIS)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
it('forwards a synthetic stop event to the listener and clears hasActiveFollow', () => {
|
|
55
|
+
const mock = createMockProvider();
|
|
56
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
57
|
+
const events = [];
|
|
58
|
+
manager.getOrCreateChain(GENESIS);
|
|
59
|
+
manager.startFollow(GENESIS, true, e => events.push(e));
|
|
60
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: findCallId(mock, 'chainHead_v1_follow'), result: 'sub-id-1' });
|
|
61
|
+
mock.simulateMessage({
|
|
62
|
+
jsonrpc: '2.0',
|
|
63
|
+
method: 'chainHead_v1_followEvent',
|
|
64
|
+
params: { subscription: 'sub-id-1', result: { event: 'stop' } },
|
|
65
|
+
});
|
|
66
|
+
expect(events).toEqual([{ event: 'stop' }]);
|
|
67
|
+
expect(manager.hasActiveFollow(GENESIS)).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
it('forwards initialized/newBlock follow events with the spec-shape `event` field restored', () => {
|
|
70
|
+
const mock = createMockProvider();
|
|
71
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
72
|
+
const events = [];
|
|
73
|
+
manager.getOrCreateChain(GENESIS);
|
|
74
|
+
manager.startFollow(GENESIS, true, e => events.push(e));
|
|
75
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: findCallId(mock, 'chainHead_v1_follow'), result: 'sub-id-1' });
|
|
76
|
+
mock.simulateMessage({
|
|
77
|
+
jsonrpc: '2.0',
|
|
78
|
+
method: 'chainHead_v1_followEvent',
|
|
79
|
+
params: {
|
|
80
|
+
subscription: 'sub-id-1',
|
|
81
|
+
result: {
|
|
82
|
+
event: 'initialized',
|
|
83
|
+
finalizedBlockHashes: ['0xdead'],
|
|
84
|
+
finalizedBlockRuntime: {
|
|
85
|
+
type: 'valid',
|
|
86
|
+
spec: { specName: 'x', implName: 'y', specVersion: 1, implVersion: 1, apis: {} },
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
expect(events).toEqual([
|
|
92
|
+
expect.objectContaining({
|
|
93
|
+
event: 'initialized',
|
|
94
|
+
finalizedBlockHashes: ['0xdead'],
|
|
95
|
+
}),
|
|
96
|
+
]);
|
|
97
|
+
});
|
|
98
|
+
it('drops the dead follow entry on stop so it does not leak as a tombstone', () => {
|
|
99
|
+
const mock = createMockProvider();
|
|
100
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
101
|
+
manager.getOrCreateChain(GENESIS);
|
|
102
|
+
const { followId } = manager.startFollow(GENESIS, true, vi.fn());
|
|
103
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: findCallId(mock, 'chainHead_v1_follow'), result: 'sub-id-1' });
|
|
104
|
+
mock.simulateMessage({
|
|
105
|
+
jsonrpc: '2.0',
|
|
106
|
+
method: 'chainHead_v1_followEvent',
|
|
107
|
+
params: { subscription: 'sub-id-1', result: { event: 'stop' } },
|
|
108
|
+
});
|
|
109
|
+
// stopFollow on the now-dead followId is a no-op (entry already cleaned
|
|
110
|
+
// up). The unfollow RPC must NOT be sent — the follow is already gone.
|
|
111
|
+
const beforeStop = sentMessages(mock).length;
|
|
112
|
+
manager.stopFollow(GENESIS, followId);
|
|
113
|
+
expect(sentMessages(mock)).toHaveLength(beforeStop);
|
|
114
|
+
});
|
|
115
|
+
it('a fresh follow after a stop is tracked under its new chainSubId', () => {
|
|
116
|
+
const mock = createMockProvider();
|
|
117
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
118
|
+
manager.getOrCreateChain(GENESIS);
|
|
119
|
+
manager.startFollow(GENESIS, true, vi.fn());
|
|
120
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: findCallId(mock, 'chainHead_v1_follow'), result: 'sub-id-1' });
|
|
121
|
+
mock.simulateMessage({
|
|
122
|
+
jsonrpc: '2.0',
|
|
123
|
+
method: 'chainHead_v1_followEvent',
|
|
124
|
+
params: { subscription: 'sub-id-1', result: { event: 'stop' } },
|
|
125
|
+
});
|
|
126
|
+
expect(manager.hasActiveFollow(GENESIS)).toBe(false);
|
|
127
|
+
manager.startFollow(GENESIS, true, vi.fn());
|
|
128
|
+
const followCalls = sentMessages(mock).filter(m => m.method === 'chainHead_v1_follow');
|
|
129
|
+
expect(followCalls).toHaveLength(2);
|
|
130
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: followCalls[1].id, result: 'sub-id-2' });
|
|
131
|
+
expect(manager.hasActiveFollow(GENESIS)).toBe(true);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
describe('chainHeadOp', () => {
|
|
135
|
+
it('rejects with "No active follow for this chain" when no follow is active', async () => {
|
|
136
|
+
const mock = createMockProvider();
|
|
137
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
138
|
+
manager.getOrCreateChain(GENESIS);
|
|
139
|
+
// No startFollow.
|
|
140
|
+
await expect(manager.chainHeadOp(GENESIS, 'chainHead_v1_header', ['0xhash'])).rejects.toThrow('No active follow for this chain');
|
|
141
|
+
});
|
|
142
|
+
it('rejects after the refollow timeout elapses with no fresh Follow', async () => {
|
|
143
|
+
const mock = createMockProvider();
|
|
144
|
+
const manager = createChainConnectionManager(() => mock.provider, { refollowTimeoutMs: 30 });
|
|
145
|
+
manager.getOrCreateChain(GENESIS);
|
|
146
|
+
manager.startFollow(GENESIS, true, vi.fn());
|
|
147
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: findCallId(mock, 'chainHead_v1_follow'), result: 'sub-id-1' });
|
|
148
|
+
mock.simulateMessage({
|
|
149
|
+
jsonrpc: '2.0',
|
|
150
|
+
method: 'chainHead_v1_followEvent',
|
|
151
|
+
params: { subscription: 'sub-id-1', result: { event: 'stop' } },
|
|
152
|
+
});
|
|
153
|
+
await expect(manager.chainHeadOp(GENESIS, 'chainHead_v1_header', ['0xhash'])).rejects.toThrow('No active follow for this chain');
|
|
154
|
+
});
|
|
155
|
+
it('still queues when the papp closes its dead subscription before opening a new one', async () => {
|
|
156
|
+
// Recovery window is gated on the most recent Stop, not on whether an
|
|
157
|
+
// entry happens to remain in the follows map. So even if the papp's
|
|
158
|
+
// pattern is "stopFollow on the old subscription, then startFollow on
|
|
159
|
+
// a new one", ops issued in the gap still queue.
|
|
160
|
+
const mock = createMockProvider();
|
|
161
|
+
const manager = createChainConnectionManager(() => mock.provider, { refollowTimeoutMs: 1_000 });
|
|
162
|
+
manager.getOrCreateChain(GENESIS);
|
|
163
|
+
const { followId } = manager.startFollow(GENESIS, true, vi.fn());
|
|
164
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: findCallId(mock, 'chainHead_v1_follow'), result: 'sub-id-1' });
|
|
165
|
+
mock.simulateMessage({
|
|
166
|
+
jsonrpc: '2.0',
|
|
167
|
+
method: 'chainHead_v1_followEvent',
|
|
168
|
+
params: { subscription: 'sub-id-1', result: { event: 'stop' } },
|
|
169
|
+
});
|
|
170
|
+
// Papp closes its OLD subscription on Stop, BEFORE opening NEW.
|
|
171
|
+
manager.stopFollow(GENESIS, followId);
|
|
172
|
+
// Op issued in the gap must queue, not fail fast.
|
|
173
|
+
const opPromise = manager.chainHeadOp(GENESIS, 'chainHead_v1_header', ['0xhash']);
|
|
174
|
+
expect(sentMessages(mock).find(m => m.method === 'chainHead_v1_header')).toBeUndefined();
|
|
175
|
+
manager.startFollow(GENESIS, true, vi.fn());
|
|
176
|
+
const newFollowId = sentMessages(mock).filter(m => m.method === 'chainHead_v1_follow')[1].id;
|
|
177
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: newFollowId, result: 'sub-id-2' });
|
|
178
|
+
await Promise.resolve();
|
|
179
|
+
const headerCall = sentMessages(mock).find(m => m.method === 'chainHead_v1_header');
|
|
180
|
+
expect(headerCall?.params).toEqual(['sub-id-2', '0xhash']);
|
|
181
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: headerCall.id, result: '0xheader' });
|
|
182
|
+
await expect(opPromise).resolves.toBe('0xheader');
|
|
183
|
+
});
|
|
184
|
+
it('queues a chain-head op issued in the stop→refollow gap and drains it into the new follow', async () => {
|
|
185
|
+
const mock = createMockProvider();
|
|
186
|
+
const manager = createChainConnectionManager(() => mock.provider, { refollowTimeoutMs: 1_000 });
|
|
187
|
+
manager.getOrCreateChain(GENESIS);
|
|
188
|
+
manager.startFollow(GENESIS, true, vi.fn());
|
|
189
|
+
const firstFollowId = findCallId(mock, 'chainHead_v1_follow');
|
|
190
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: firstFollowId, result: 'sub-id-1' });
|
|
191
|
+
// Stop invalidates the first follow but leaves the entry around so ops can queue.
|
|
192
|
+
mock.simulateMessage({
|
|
193
|
+
jsonrpc: '2.0',
|
|
194
|
+
method: 'chainHead_v1_followEvent',
|
|
195
|
+
params: { subscription: 'sub-id-1', result: { event: 'stop' } },
|
|
196
|
+
});
|
|
197
|
+
// Op issued while no follow is active — should not fail immediately.
|
|
198
|
+
const opPromise = manager.chainHeadOp(GENESIS, 'chainHead_v1_header', ['0xhash']);
|
|
199
|
+
// No header request hit the wire yet (still queued).
|
|
200
|
+
expect(sentMessages(mock).find(m => m.method === 'chainHead_v1_header')).toBeUndefined();
|
|
201
|
+
// Papp issues a fresh Follow.
|
|
202
|
+
manager.startFollow(GENESIS, true, vi.fn());
|
|
203
|
+
const secondFollowId = sentMessages(mock).filter(m => m.method === 'chainHead_v1_follow')[1].id;
|
|
204
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: secondFollowId, result: 'sub-id-2' });
|
|
205
|
+
// The queued op drains into the new follow on the next microtask
|
|
206
|
+
// (substrate-client's fRequest waits on the followSubscription Promise).
|
|
207
|
+
await Promise.resolve();
|
|
208
|
+
const headerCall = sentMessages(mock).find(m => m.method === 'chainHead_v1_header');
|
|
209
|
+
expect(headerCall?.params).toEqual(['sub-id-2', '0xhash']);
|
|
210
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: headerCall.id, result: '0xheader' });
|
|
211
|
+
await expect(opPromise).resolves.toBe('0xheader');
|
|
212
|
+
});
|
|
213
|
+
it('rejects queued ops when the chain is disposed before a refollow', async () => {
|
|
214
|
+
const mock = createMockProvider();
|
|
215
|
+
const manager = createChainConnectionManager(() => mock.provider, { refollowTimeoutMs: 1_000 });
|
|
216
|
+
manager.getOrCreateChain(GENESIS);
|
|
217
|
+
manager.startFollow(GENESIS, true, vi.fn());
|
|
218
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: findCallId(mock, 'chainHead_v1_follow'), result: 'sub-id-1' });
|
|
219
|
+
mock.simulateMessage({
|
|
220
|
+
jsonrpc: '2.0',
|
|
221
|
+
method: 'chainHead_v1_followEvent',
|
|
222
|
+
params: { subscription: 'sub-id-1', result: { event: 'stop' } },
|
|
223
|
+
});
|
|
224
|
+
const opPromise = manager.chainHeadOp(GENESIS, 'chainHead_v1_header', ['0xhash']);
|
|
225
|
+
manager.releaseChain(GENESIS);
|
|
226
|
+
await expect(opPromise).rejects.toThrow('Chain disposed');
|
|
227
|
+
});
|
|
228
|
+
it('auto-prepends the chainSubId when forwarding a chain-head op to the provider', async () => {
|
|
229
|
+
const mock = createMockProvider();
|
|
230
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
231
|
+
manager.getOrCreateChain(GENESIS);
|
|
232
|
+
manager.startFollow(GENESIS, true, vi.fn());
|
|
233
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: findCallId(mock, 'chainHead_v1_follow'), result: 'sub-id-1' });
|
|
234
|
+
const promise = manager.chainHeadOp(GENESIS, 'chainHead_v1_header', ['0xblockHash']);
|
|
235
|
+
const headerCall = sentMessages(mock).find(m => m.method === 'chainHead_v1_header');
|
|
236
|
+
expect(headerCall?.params).toEqual(['sub-id-1', '0xblockHash']);
|
|
237
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: headerCall.id, result: '0xheader' });
|
|
238
|
+
await expect(promise).resolves.toBe('0xheader');
|
|
239
|
+
});
|
|
240
|
+
it('forwards operation events streamed against the started operationId to the follow listener', async () => {
|
|
241
|
+
const mock = createMockProvider();
|
|
242
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
243
|
+
const events = [];
|
|
244
|
+
manager.getOrCreateChain(GENESIS);
|
|
245
|
+
manager.startFollow(GENESIS, true, e => events.push(e));
|
|
246
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: findCallId(mock, 'chainHead_v1_follow'), result: 'sub-id-1' });
|
|
247
|
+
const promise = manager.chainHeadOp(GENESIS, 'chainHead_v1_storage', ['0xhash', [], null]);
|
|
248
|
+
const storageCall = sentMessages(mock).find(m => m.method === 'chainHead_v1_storage');
|
|
249
|
+
mock.simulateMessage({
|
|
250
|
+
jsonrpc: '2.0',
|
|
251
|
+
id: storageCall.id,
|
|
252
|
+
result: { result: 'started', operationId: 'op-1', discardedItems: 0 },
|
|
253
|
+
});
|
|
254
|
+
await expect(promise).resolves.toMatchObject({ result: 'started', operationId: 'op-1' });
|
|
255
|
+
// Now operation events streamed via the follow notification must reach the listener.
|
|
256
|
+
const items = { event: 'operationStorageItems', operationId: 'op-1', items: [] };
|
|
257
|
+
mock.simulateMessage({
|
|
258
|
+
jsonrpc: '2.0',
|
|
259
|
+
method: 'chainHead_v1_followEvent',
|
|
260
|
+
params: { subscription: 'sub-id-1', result: items },
|
|
261
|
+
});
|
|
262
|
+
const done = { event: 'operationStorageDone', operationId: 'op-1' };
|
|
263
|
+
mock.simulateMessage({
|
|
264
|
+
jsonrpc: '2.0',
|
|
265
|
+
method: 'chainHead_v1_followEvent',
|
|
266
|
+
params: { subscription: 'sub-id-1', result: done },
|
|
267
|
+
});
|
|
268
|
+
expect(events).toContainEqual(items);
|
|
269
|
+
expect(events).toContainEqual(done);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
describe('basic flows', () => {
|
|
273
|
+
it('sendRequest forwards a non-chainHead JSON-RPC and resolves on response', async () => {
|
|
274
|
+
const mock = createMockProvider();
|
|
275
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
276
|
+
manager.getOrCreateChain(GENESIS);
|
|
277
|
+
const promise = manager.sendRequest(GENESIS, 'chainSpec_v1_genesisHash', []);
|
|
278
|
+
const call = sentMessages(mock).find(m => m.method === 'chainSpec_v1_genesisHash');
|
|
279
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: call.id, result: '0xgenesis' });
|
|
280
|
+
await expect(promise).resolves.toBe('0xgenesis');
|
|
281
|
+
});
|
|
282
|
+
it('sendRequest rejects on error response', async () => {
|
|
283
|
+
const mock = createMockProvider();
|
|
284
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
285
|
+
manager.getOrCreateChain(GENESIS);
|
|
286
|
+
const promise = manager.sendRequest(GENESIS, 'chainSpec_v1_genesisHash', []);
|
|
287
|
+
const call = sentMessages(mock).find(m => m.method === 'chainSpec_v1_genesisHash');
|
|
288
|
+
mock.simulateMessage({
|
|
289
|
+
jsonrpc: '2.0',
|
|
290
|
+
id: call.id,
|
|
291
|
+
error: { code: -32000, message: 'unsupported' },
|
|
292
|
+
});
|
|
293
|
+
await expect(promise).rejects.toBeInstanceOf(Error);
|
|
294
|
+
});
|
|
295
|
+
it('releaseChain only disconnects when refCount drops to zero', () => {
|
|
296
|
+
const mock = createMockProvider();
|
|
297
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
298
|
+
manager.getOrCreateChain(GENESIS);
|
|
299
|
+
manager.getOrCreateChain(GENESIS);
|
|
300
|
+
manager.releaseChain(GENESIS);
|
|
301
|
+
expect(mock.disconnect).not.toHaveBeenCalled();
|
|
302
|
+
manager.releaseChain(GENESIS);
|
|
303
|
+
expect(mock.disconnect).toHaveBeenCalledTimes(1);
|
|
304
|
+
});
|
|
305
|
+
it('stopFollow sends chainHead_v1_unfollow with the current chainSubId', () => {
|
|
306
|
+
const mock = createMockProvider();
|
|
307
|
+
const manager = createChainConnectionManager(() => mock.provider);
|
|
308
|
+
manager.getOrCreateChain(GENESIS);
|
|
309
|
+
const { followId } = manager.startFollow(GENESIS, true, vi.fn());
|
|
310
|
+
mock.simulateMessage({ jsonrpc: '2.0', id: findCallId(mock, 'chainHead_v1_follow'), result: 'sub-id-1' });
|
|
311
|
+
manager.stopFollow(GENESIS, followId);
|
|
312
|
+
const unfollowCall = sentMessages(mock).find(m => m.method === 'chainHead_v1_unfollow');
|
|
313
|
+
expect(unfollowCall?.params).toEqual(['sub-id-1']);
|
|
314
|
+
expect(manager.hasActiveFollow(GENESIS)).toBe(false);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
});
|
package/dist/createContainer.js
CHANGED
|
@@ -353,12 +353,11 @@ export function createContainer(provider) {
|
|
|
353
353
|
return enumValue('v1', resultErr(new GenericError({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR })));
|
|
354
354
|
}
|
|
355
355
|
const { genesisHash, hash } = message.value;
|
|
356
|
-
|
|
357
|
-
if (!realSubId) {
|
|
356
|
+
if (!manager.hasActiveFollow(genesisHash)) {
|
|
358
357
|
return enumValue('v1', resultErr(new GenericError({ reason: 'No active follow for this chain' })));
|
|
359
358
|
}
|
|
360
359
|
try {
|
|
361
|
-
const result = await manager.
|
|
360
|
+
const result = await manager.chainHeadOp(genesisHash, 'chainHead_v1_header', [hash]);
|
|
362
361
|
return enumValue('v1', resultOk(result));
|
|
363
362
|
}
|
|
364
363
|
catch (e) {
|
|
@@ -371,12 +370,11 @@ export function createContainer(provider) {
|
|
|
371
370
|
return enumValue('v1', resultErr(new GenericError({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR })));
|
|
372
371
|
}
|
|
373
372
|
const { genesisHash, hash } = message.value;
|
|
374
|
-
|
|
375
|
-
if (!realSubId) {
|
|
373
|
+
if (!manager.hasActiveFollow(genesisHash)) {
|
|
376
374
|
return enumValue('v1', resultErr(new GenericError({ reason: 'No active follow for this chain' })));
|
|
377
375
|
}
|
|
378
376
|
try {
|
|
379
|
-
const result = await manager.
|
|
377
|
+
const result = await manager.chainHeadOp(genesisHash, 'chainHead_v1_body', [hash]);
|
|
380
378
|
return enumValue('v1', resultOk(manager.convertOperationStartedResult(result)));
|
|
381
379
|
}
|
|
382
380
|
catch (e) {
|
|
@@ -389,8 +387,7 @@ export function createContainer(provider) {
|
|
|
389
387
|
return enumValue('v1', resultErr(new GenericError({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR })));
|
|
390
388
|
}
|
|
391
389
|
const { genesisHash, hash, items, childTrie } = message.value;
|
|
392
|
-
|
|
393
|
-
if (!realSubId) {
|
|
390
|
+
if (!manager.hasActiveFollow(genesisHash)) {
|
|
394
391
|
return enumValue('v1', resultErr(new GenericError({ reason: 'No active follow for this chain' })));
|
|
395
392
|
}
|
|
396
393
|
const jsonRpcItems = items.map((item) => ({
|
|
@@ -398,8 +395,7 @@ export function createContainer(provider) {
|
|
|
398
395
|
type: manager.convertStorageQueryTypeToJsonRpc(item.type),
|
|
399
396
|
}));
|
|
400
397
|
try {
|
|
401
|
-
const result = await manager.
|
|
402
|
-
realSubId,
|
|
398
|
+
const result = await manager.chainHeadOp(genesisHash, 'chainHead_v1_storage', [
|
|
403
399
|
hash,
|
|
404
400
|
jsonRpcItems,
|
|
405
401
|
childTrie,
|
|
@@ -416,13 +412,11 @@ export function createContainer(provider) {
|
|
|
416
412
|
return enumValue('v1', resultErr(new GenericError({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR })));
|
|
417
413
|
}
|
|
418
414
|
const params = message.value;
|
|
419
|
-
|
|
420
|
-
if (!realSubId) {
|
|
415
|
+
if (!manager.hasActiveFollow(params.genesisHash)) {
|
|
421
416
|
return enumValue('v1', resultErr(new GenericError({ reason: 'No active follow for this chain' })));
|
|
422
417
|
}
|
|
423
418
|
try {
|
|
424
|
-
const result = await manager.
|
|
425
|
-
realSubId,
|
|
419
|
+
const result = await manager.chainHeadOp(params.genesisHash, 'chainHead_v1_call', [
|
|
426
420
|
params.hash,
|
|
427
421
|
params.function,
|
|
428
422
|
params.callParameters,
|
|
@@ -439,12 +433,11 @@ export function createContainer(provider) {
|
|
|
439
433
|
return enumValue('v1', resultErr(new GenericError({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR })));
|
|
440
434
|
}
|
|
441
435
|
const { genesisHash, hashes } = message.value;
|
|
442
|
-
|
|
443
|
-
if (!realSubId) {
|
|
436
|
+
if (!manager.hasActiveFollow(genesisHash)) {
|
|
444
437
|
return enumValue('v1', resultErr(new GenericError({ reason: 'No active follow for this chain' })));
|
|
445
438
|
}
|
|
446
439
|
try {
|
|
447
|
-
await manager.
|
|
440
|
+
await manager.chainHeadOp(genesisHash, 'chainHead_v1_unpin', [hashes]);
|
|
448
441
|
return enumValue('v1', resultOk(undefined));
|
|
449
442
|
}
|
|
450
443
|
catch (e) {
|
|
@@ -457,12 +450,11 @@ export function createContainer(provider) {
|
|
|
457
450
|
return enumValue('v1', resultErr(new GenericError({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR })));
|
|
458
451
|
}
|
|
459
452
|
const { genesisHash, operationId } = message.value;
|
|
460
|
-
|
|
461
|
-
if (!realSubId) {
|
|
453
|
+
if (!manager.hasActiveFollow(genesisHash)) {
|
|
462
454
|
return enumValue('v1', resultErr(new GenericError({ reason: 'No active follow for this chain' })));
|
|
463
455
|
}
|
|
464
456
|
try {
|
|
465
|
-
await manager.
|
|
457
|
+
await manager.chainHeadOp(genesisHash, 'chainHead_v1_continue', [operationId]);
|
|
466
458
|
return enumValue('v1', resultOk(undefined));
|
|
467
459
|
}
|
|
468
460
|
catch (e) {
|
|
@@ -475,12 +467,11 @@ export function createContainer(provider) {
|
|
|
475
467
|
return enumValue('v1', resultErr(new GenericError({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR })));
|
|
476
468
|
}
|
|
477
469
|
const { genesisHash, operationId } = message.value;
|
|
478
|
-
|
|
479
|
-
if (!realSubId) {
|
|
470
|
+
if (!manager.hasActiveFollow(genesisHash)) {
|
|
480
471
|
return enumValue('v1', resultErr(new GenericError({ reason: 'No active follow for this chain' })));
|
|
481
472
|
}
|
|
482
473
|
try {
|
|
483
|
-
await manager.
|
|
474
|
+
await manager.chainHeadOp(genesisHash, 'chainHead_v1_stopOperation', [operationId]);
|
|
484
475
|
return enumValue('v1', resultOk(undefined));
|
|
485
476
|
}
|
|
486
477
|
catch (e) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novasamatech/host-container",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.5-1",
|
|
5
5
|
"description": "Host container for hosting and managing products within the Polkadot ecosystem.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@noble/hashes": "2.2.0",
|
|
29
29
|
"polkadot-api": ">=2",
|
|
30
|
-
"@
|
|
30
|
+
"@polkadot-api/substrate-client": "^0.7.0",
|
|
31
|
+
"@novasamatech/host-api": "0.7.5-1",
|
|
31
32
|
"nanoid": "5.1.9",
|
|
32
33
|
"neverthrow": "^8.2.0"
|
|
33
34
|
},
|