@novasamatech/host-container 0.7.4-0 → 0.7.5-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -181,23 +181,25 @@ container.handleThemeSubscribe((_, send, interrupt) => {
181
181
  });
182
182
  ```
183
183
 
184
- ### handleAccountGetRoot
184
+ ### handleGetUserId
185
185
 
186
- Called when a product requests the user's root (primary) account. Show a permission prompt on first call; cache the grant for the session. Return `NotFound` if the user has no DotNS username.
186
+ Called when a product requests the user's primary DotNS username (RFC-0014). Show a disclosure prompt on first call; the host decides what counts as "primary" for the calling product. Return `NotConnected` without prompting if no user is connected; return `PermissionDenied` if the user denies disclosure.
187
187
 
188
188
  ```ts
189
- import { RequestCredentialsErr } from '@novasamatech/host-api';
189
+ import { GetUserIdErr } from '@novasamatech/host-api';
190
190
 
191
- container.handleAccountGetRoot(async (_, { ok, err }) => {
192
- const granted = await promptUserForRootAccountAccess();
193
- if (!granted) {
194
- return err(new RequestCredentialsErr.Rejected());
191
+ container.handleGetUserId(async (_, { ok, err }) => {
192
+ const username = await pickPrimaryUsernameForCallingProduct();
193
+ if (!username) {
194
+ return err(new GetUserIdErr.NotConnected());
195
195
  }
196
- const rootAccount = await getRootAccount();
197
- if (!rootAccount) {
198
- return err(new RequestCredentialsErr.NotConnected());
196
+
197
+ const granted = await promptUserForUsernameDisclosure();
198
+ if (!granted) {
199
+ return err(new GetUserIdErr.PermissionDenied());
199
200
  }
200
- return ok({ publicKey: rootAccount.publicKey, name: rootAccount.name ?? undefined });
201
+
202
+ return ok({ primaryUsername: username });
201
203
  });
202
204
  ```
203
205
 
@@ -225,7 +227,7 @@ container.handleRequestLogin(async (reason, { ok, err }) => {
225
227
  container.handleAccountGet(async ([dotnsId, derivationIndex], { ok, err }) => {
226
228
  const account = await getProductAccount(dotnsId, derivationIndex);
227
229
  if (account) {
228
- return ok({ publicKey: account.publicKey, name: account.name ?? null });
230
+ return ok({ publicKey: account.publicKey });
229
231
  }
230
232
  return err({ tag: 'NotConnected' });
231
233
  });
@@ -1,113 +1,104 @@
1
1
  import type { HexString } from '@novasamatech/host-api';
2
2
  import type { JsonRpcProvider } from 'polkadot-api';
3
- type JsonRpcConnection = ReturnType<JsonRpcProvider>;
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
- getOrCreateChain: (genesisHash: HexString) => ChainEntry | null;
22
- sendRequest: (genesisHash: HexString, method: string, params: unknown[]) => Promise<unknown>;
23
- startFollow: (genesisHash: HexString, withRuntime: boolean, onEvent: (event: unknown) => void) => {
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: (genesisHash: HexString, followId: string) => void;
27
- getChainFollowSubId: (genesisHash: HexString) => string | null;
28
- releaseChain: (genesisHash: HexString) => void;
29
- dispose: () => void;
30
- convertJsonRpcEventToTyped: (event: Record<string, unknown>) => {
31
- tag: "Initialized";
32
- value: {
33
- readonly finalizedBlockHashes: HexString[];
34
- readonly finalizedBlockRuntime: unknown;
35
- };
36
- } | {
37
- tag: "NewBlock";
38
- value: {
39
- readonly blockHash: HexString;
40
- readonly parentBlockHash: HexString;
41
- readonly newRuntime: unknown;
42
- };
43
- } | {
44
- tag: "BestBlockChanged";
45
- value: {
46
- readonly bestBlockHash: HexString;
47
- };
48
- } | {
49
- tag: "Finalized";
50
- value: {
51
- readonly finalizedBlockHashes: HexString[];
52
- readonly prunedBlockHashes: HexString[];
53
- };
54
- } | {
55
- tag: "OperationBodyDone";
56
- value: {
57
- readonly operationId: string;
58
- readonly value: HexString[];
59
- };
60
- } | {
61
- tag: "OperationCallDone";
62
- value: {
63
- readonly operationId: string;
64
- readonly output: HexString;
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
- convertOperationStartedResult: (result: unknown) => {
103
- tag: "Started";
104
- value: {
105
- readonly operationId: string;
106
- };
107
- } | {
108
- tag: "LimitReached";
109
- value: undefined;
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
- convertStorageQueryTypeToJsonRpc: (type: string) => string;
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
- let instanceCounter = 0;
3
- export function createChainConnectionManager(factory) {
4
- const chains = new Map();
5
- const instanceId = instanceCounter++;
6
- let nextId = 0;
7
- function getNextId() {
8
- return `ccm_${instanceId}_${nextId++}`;
9
- }
10
- function getOrCreateChain(genesisHash) {
11
- const existing = chains.get(genesisHash);
12
- if (existing) {
13
- existing.refCount++;
14
- return existing;
15
- }
16
- const provider = factory(genesisHash);
17
- if (!provider)
18
- return null;
19
- const pendingRequests = new Map();
20
- const followSubscriptions = new Map();
21
- const entry = {
22
- connection: null,
23
- pendingRequests,
24
- followSubscriptions,
25
- refCount: 1,
26
- };
27
- entry.connection = provider(message => {
28
- // Response: has 'id' but no 'method'
29
- if ('id' in message && message.id != null && !('method' in message)) {
30
- const pending = pendingRequests.get(String(message.id));
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
- entry.connection.send({
95
- jsonrpc: '2.0',
96
- id: requestId,
97
- method: 'chainHead_v1_follow',
98
- params: [withRuntime],
99
- });
100
- return { followId };
101
- }
102
- function stopFollow(genesisHash, followId) {
103
- const entry = chains.get(genesisHash);
104
- if (!entry)
105
- return;
106
- const follow = entry.followSubscriptions.get(followId);
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 getChainFollowSubId(genesisHash) {
53
+ function activeFollow(genesisHash) {
141
54
  const entry = chains.get(genesisHash);
142
55
  if (!entry)
143
56
  return null;
144
- // Return the first active follow subscription ID for this chain
145
- for (const follow of entry.followSubscriptions.values()) {
146
- if (follow.chainSubId)
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
- function releaseChain(genesisHash) {
152
- const entry = chains.get(genesisHash);
153
- if (!entry)
154
- return;
155
- entry.refCount--;
156
- if (entry.refCount <= 0) {
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
- entry.followSubscriptions.clear();
169
- entry.connection.disconnect();
170
- chains.delete(genesisHash);
171
- }
172
- }
173
- function dispose() {
174
- for (const entry of chains.values()) {
175
- for (const follow of entry.followSubscriptions.values()) {
176
- if (follow.chainSubId) {
177
- const id = getNextId();
178
- entry.connection.send({
179
- jsonrpc: '2.0',
180
- id,
181
- method: 'chainHead_v1_unfollow',
182
- params: [follow.chainSubId],
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
- entry.followSubscriptions.clear();
187
- entry.connection.disconnect();
188
- }
189
- chains.clear();
190
- }
191
- // === JSON RPC ↔ typed conversion helpers ===
192
- function convertJsonRpcEventToTyped(event) {
193
- const eventType = event.event;
194
- switch (eventType) {
195
- case 'initialized':
196
- return enumValue('Initialized', {
197
- finalizedBlockHashes: event.finalizedBlockHashes,
198
- finalizedBlockRuntime: convertRuntime(event.finalizedBlockRuntime),
199
- });
200
- case 'newBlock':
201
- return enumValue('NewBlock', {
202
- blockHash: event.blockHash,
203
- parentBlockHash: event.parentBlockHash,
204
- newRuntime: convertRuntime(event.newRuntime),
205
- });
206
- case 'bestBlockChanged':
207
- return enumValue('BestBlockChanged', {
208
- bestBlockHash: event.bestBlockHash,
209
- });
210
- case 'finalized':
211
- return enumValue('Finalized', {
212
- finalizedBlockHashes: event.finalizedBlockHashes,
213
- prunedBlockHashes: event.prunedBlockHashes,
214
- });
215
- case 'operationBodyDone':
216
- return enumValue('OperationBodyDone', {
217
- operationId: event.operationId,
218
- value: event.value,
219
- });
220
- case 'operationCallDone':
221
- return enumValue('OperationCallDone', {
222
- operationId: event.operationId,
223
- output: event.output,
224
- });
225
- case 'operationStorageItems':
226
- return enumValue('OperationStorageItems', {
227
- operationId: event.operationId,
228
- items: event.items.map(item => ({
229
- key: item.key,
230
- value: item.value ?? null,
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
- if (rt.type === 'invalid') {
275
- return enumValue('Invalid', { error: rt.error });
276
- }
277
- return undefined;
278
- }
279
- function convertOperationStartedResult(result) {
280
- if (typeof result === 'object' && result !== null) {
281
- const r = result;
282
- if (r.result === 'started') {
283
- return enumValue('Started', { operationId: r.operationId });
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
- return enumValue('LimitReached', undefined);
287
- }
288
- function convertStorageQueryTypeToJsonRpc(type) {
289
- const map = {
290
- Value: 'value',
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
+ });
@@ -1,4 +1,4 @@
1
- import { ChatBotRegistrationErr, ChatMessagePostingErr, ChatRoomRegistrationErr, CreateProofErr, CreateTransactionErr, DeriveEntropyErr, DevicePermission, GenericError, LoginErr, NavigateToErr, PaymentBalanceErr, PaymentRequestErr, PaymentStatusErr, PaymentTopUpErr, PreimageSubmitErr, RemotePermission, RequestCredentialsErr, SigningErr, StatementProofErr, StorageErr, createTransport, enumValue, isEnumVariant, resultErr, resultOk, } from '@novasamatech/host-api';
1
+ import { ChatBotRegistrationErr, ChatMessagePostingErr, ChatRoomRegistrationErr, CreateProofErr, CreateTransactionErr, DeriveEntropyErr, DevicePermission, GenericError, GetUserIdErr, LoginErr, NavigateToErr, PaymentBalanceErr, PaymentRequestErr, PaymentStatusErr, PaymentTopUpErr, PreimageSubmitErr, RemotePermission, RequestCredentialsErr, SigningErr, StatementProofErr, StorageErr, createTransport, enumValue, isEnumVariant, resultErr, resultOk, } from '@novasamatech/host-api';
2
2
  import { err, errAsync, ok, okAsync } from 'neverthrow';
3
3
  import { createChainConnectionManager } from './chainConnectionManager.js';
4
4
  const UNSUPPORTED_MESSAGE_FORMAT_ERROR = 'Unsupported message format';
@@ -154,7 +154,7 @@ export function createContainer(provider) {
154
154
  return slot.update(slotHandler);
155
155
  }
156
156
  // account slots
157
- const handleAccountGetRootSlot = makeNotImplementedSlot('host_account_get_root', () => new RequestCredentialsErr.Unknown({ reason: NOT_IMPLEMENTED }));
157
+ const handleGetUserIdSlot = makeNotImplementedSlot('host_get_user_id', () => new GetUserIdErr.Unknown({ reason: NOT_IMPLEMENTED }));
158
158
  const handleRequestLoginSlot = makeNotImplementedSlot('host_request_login', () => new LoginErr.Unknown({ reason: NOT_IMPLEMENTED }));
159
159
  const handleAccountGetSlot = makeNotImplementedSlot('host_account_get', () => new RequestCredentialsErr.Unknown({ reason: NOT_IMPLEMENTED }));
160
160
  const handleAccountGetAliasSlot = makeNotImplementedSlot('host_account_get_alias', () => new RequestCredentialsErr.Unknown({ reason: NOT_IMPLEMENTED }));
@@ -228,8 +228,8 @@ export function createContainer(provider) {
228
228
  handleThemeSubscribe(handler) {
229
229
  return handleV1Subscription(handleThemeSubscribeSlot, handler);
230
230
  },
231
- handleAccountGetRoot(handler) {
232
- return handleV1Request(handleAccountGetRootSlot, () => new RequestCredentialsErr.Unknown({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR }), handler);
231
+ handleGetUserId(handler) {
232
+ return handleV1Request(handleGetUserIdSlot, () => new GetUserIdErr.Unknown({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR }), handler);
233
233
  },
234
234
  handleRequestLogin(handler) {
235
235
  return handleV1Request(handleRequestLoginSlot, () => new LoginErr.Unknown({ reason: UNSUPPORTED_MESSAGE_FORMAT_ERROR }), handler);
@@ -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
- const realSubId = manager.getChainFollowSubId(genesisHash);
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.sendRequest(genesisHash, 'chainHead_v1_header', [realSubId, hash]);
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
- const realSubId = manager.getChainFollowSubId(genesisHash);
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.sendRequest(genesisHash, 'chainHead_v1_body', [realSubId, hash]);
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
- const realSubId = manager.getChainFollowSubId(genesisHash);
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.sendRequest(genesisHash, 'chainHead_v1_storage', [
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
- const realSubId = manager.getChainFollowSubId(params.genesisHash);
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.sendRequest(params.genesisHash, 'chainHead_v1_call', [
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
- const realSubId = manager.getChainFollowSubId(genesisHash);
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.sendRequest(genesisHash, 'chainHead_v1_unpin', [realSubId, hashes]);
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
- const realSubId = manager.getChainFollowSubId(genesisHash);
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.sendRequest(genesisHash, 'chainHead_v1_continue', [realSubId, operationId]);
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
- const realSubId = manager.getChainFollowSubId(genesisHash);
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.sendRequest(genesisHash, 'chainHead_v1_stopOperation', [realSubId, operationId]);
474
+ await manager.chainHeadOp(genesisHash, 'chainHead_v1_stopOperation', [operationId]);
484
475
  return enumValue('v1', resultOk(undefined));
485
476
  }
486
477
  catch (e) {
package/dist/types.d.ts CHANGED
@@ -62,7 +62,7 @@ export type Container = {
62
62
  handleLocalStorageRead: InferHandler<'v1', HostApiProtocol['host_local_storage_read']>;
63
63
  handleLocalStorageWrite: InferHandler<'v1', HostApiProtocol['host_local_storage_write']>;
64
64
  handleLocalStorageClear: InferHandler<'v1', HostApiProtocol['host_local_storage_clear']>;
65
- handleAccountGetRoot: InferHandler<'v1', HostApiProtocol['host_account_get_root']>;
65
+ handleGetUserId: InferHandler<'v1', HostApiProtocol['host_get_user_id']>;
66
66
  handleRequestLogin: InferHandler<'v1', HostApiProtocol['host_request_login']>;
67
67
  handleAccountConnectionStatusSubscribe: InferHandler<'v1', HostApiProtocol['host_account_connection_status_subscribe']>;
68
68
  handleThemeSubscribe: InferHandler<'v1', HostApiProtocol['host_theme_subscribe']>;
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-0",
4
+ "version": "0.7.5-0",
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
- "@novasamatech/host-api": "0.7.4-0",
30
+ "@polkadot-api/substrate-client": "^0.7.0",
31
+ "@novasamatech/host-api": "0.7.5-0",
31
32
  "nanoid": "5.1.9",
32
33
  "neverthrow": "^8.2.0"
33
34
  },