@novasamatech/host-substrate-chain-connection 0.7.6 → 0.7.8-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.
@@ -106,15 +106,31 @@ export const createChainConnection = ({ resolve, clientOptions, createProvider,
106
106
  },
107
107
  getProvider(chain) {
108
108
  return getSyncProvider(onResult => {
109
+ let teardownCalled = false;
110
+ let pendingUnlock = null;
111
+ // Idempotent: subsequent calls (e.g. teardown after disconnect) are no-ops.
112
+ const releaseUnlock = () => {
113
+ const unlock = pendingUnlock;
114
+ pendingUnlock = null;
115
+ unlock?.();
116
+ };
109
117
  rawAcquire(chain)
110
118
  .then(({ pooled, unlock }) => {
111
- onResult((onMessage, _onHalt) => pooled.provider.branch(unlock)(onMessage));
119
+ if (teardownCalled) {
120
+ unlock();
121
+ onResult(null);
122
+ return;
123
+ }
124
+ pendingUnlock = unlock;
125
+ onResult((onMessage, _onHalt) => pooled.provider.branch(releaseUnlock)(onMessage));
112
126
  })
113
127
  .catch(() => {
114
128
  onResult(null);
115
129
  });
130
+ // Covers teardown without disconnect, and teardown before acquire resolves.
116
131
  return () => {
117
- /* empty */
132
+ teardownCalled = true;
133
+ releaseUnlock();
118
134
  };
119
135
  });
120
136
  },
@@ -239,14 +239,19 @@ describe('createChainConnection', () => {
239
239
  expect(chainA.resume).toHaveBeenCalledTimes(1);
240
240
  unlock();
241
241
  });
242
- it('skips providers that do not expose pause/resume', async () => {
243
- const { connection } = createTestConnection();
244
- const { unlock } = await connection.lockApi(testChain('a'));
245
- expect(() => {
246
- connection.pauseAll();
247
- connection.resumeAll();
248
- }).not.toThrow();
249
- unlock();
242
+ it('skips non-pausable providers without preventing pause on the rest', async () => {
243
+ // Mixed pool: 'a' is non-pausable, 'b' is pausable. The pausable one must
244
+ // still receive pause/resume even though the other is silently skipped.
245
+ const pausable = createPausableMockProvider();
246
+ const connection = createChainConnection({
247
+ createProvider: c => (c.genesisHash === 'b' ? pausable.provider : createMockProvider().provider),
248
+ });
249
+ await connection.lockApi(testChain('a'));
250
+ await connection.lockApi(testChain('b'));
251
+ connection.pauseAll();
252
+ connection.resumeAll();
253
+ expect(pausable.pause).toHaveBeenCalledTimes(1);
254
+ expect(pausable.resume).toHaveBeenCalledTimes(1);
250
255
  });
251
256
  it('does not call pause on providers for chains that have been destroyed', async () => {
252
257
  const chainA = createPausableMockProvider();
@@ -39,7 +39,10 @@ export const createMetadataCache = (options) => {
39
39
  setMetadata(key, metadata) {
40
40
  const k = cacheKey(chainId, key);
41
41
  memory.set(k, metadata);
42
- storage?.write(k, bytesToBase64(metadata));
42
+ // setMetadata is fire-and-forget by contract; log persist failures.
43
+ storage?.write(k, bytesToBase64(metadata)).orTee(error => {
44
+ console.error(`[metadataCache] failed to persist metadata for ${k}:`, error);
45
+ });
43
46
  },
44
47
  };
45
48
  },
@@ -3,8 +3,8 @@ import { describe, expect, it, vi } from 'vitest';
3
3
  import { createMetadataCache } from './metadataCache.js';
4
4
  const createMockStorage = () => ({
5
5
  read: vi.fn(),
6
- write: vi.fn(),
7
- clear: vi.fn(),
6
+ write: vi.fn(() => okAsync(undefined)),
7
+ clear: vi.fn(() => okAsync(undefined)),
8
8
  // eslint-disable-next-line @typescript-eslint/no-empty-function
9
9
  subscribe: vi.fn(() => () => { }),
10
10
  });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,106 @@
1
+ import { getSyncProvider } from '@polkadot-api/json-rpc-provider-proxy';
2
+ import { describe, expect, it, vi } from 'vitest';
3
+ import { createPauseController } from './pauseController.js';
4
+ const flushMicroAndTimers = async () => {
5
+ await vi.advanceTimersByTimeAsync(0);
6
+ };
7
+ const req = (id, method, params = []) => ({
8
+ jsonrpc: '2.0',
9
+ id,
10
+ method,
11
+ params,
12
+ });
13
+ const followNotif = (subscription, result) => ({
14
+ jsonrpc: '2.0',
15
+ method: 'chainHead_v1_followEvent',
16
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
17
+ params: { subscription, result },
18
+ });
19
+ describe('pauseController × getSyncProvider integration', () => {
20
+ it('routes follow events to the new listener after pause+resume+refollow (best blocks must keep flowing)', async () => {
21
+ vi.useFakeTimers();
22
+ try {
23
+ const invocations = [];
24
+ const base = (onMessage, onHalt) => {
25
+ const send = vi.fn();
26
+ const disconnect = vi.fn();
27
+ invocations.push({
28
+ onMessage: onMessage,
29
+ onHalt: onHalt,
30
+ send,
31
+ disconnect,
32
+ });
33
+ return { send, disconnect };
34
+ };
35
+ const pc = createPauseController();
36
+ const inner = pc.middleware(base);
37
+ // Wrap the pausable inner in getSyncProvider so we get the same proxy /
38
+ // synthetic-Stop machinery the WS provider gets via getWsProvider.
39
+ const provider = getSyncProvider(onReady => {
40
+ onReady((onMsg, onHalt) => inner(onMsg, onHalt));
41
+ return () => {
42
+ /* no teardown for the scheduling step */
43
+ };
44
+ });
45
+ const events = [];
46
+ const conn = provider(msg => events.push(msg));
47
+ // getSyncProvider schedules input via setTimeout(0); flush it so the
48
+ // base receives onMessage/onHalt and the proxy reaches Connected.
49
+ await flushMicroAndTimers();
50
+ expect(invocations).toHaveLength(1);
51
+ // Initial follow: consumer sends chainHead_v1_follow, the base "chain"
52
+ // assigns sub-id-1, and a bestBlockChanged event arrives.
53
+ conn.send(req(1, 'chainHead_v1_follow', [true]));
54
+ expect(invocations[0].send).toHaveBeenCalledWith(req(1, 'chainHead_v1_follow', [true]));
55
+ invocations[0].onMessage({ jsonrpc: '2.0', id: 1, result: 'sub-id-1' });
56
+ invocations[0].onMessage(followNotif('sub-id-1', { event: 'bestBlockChanged', bestBlockHash: '0xaa' }));
57
+ expect(events).toContainEqual({ jsonrpc: '2.0', id: 1, result: 'sub-id-1' });
58
+ expect(events).toContainEqual(followNotif('sub-id-1', { event: 'bestBlockChanged', bestBlockHash: '0xaa' }));
59
+ // === pause ===
60
+ // pauseController.pause() disconnects the live socket and manually fires
61
+ // onHalt({type:'paused'}). The proxy reacts by synthesizing a stop
62
+ // notification for every active chainHead — which is what surfaces to
63
+ // the consumer as "the follow died, please refollow".
64
+ events.length = 0;
65
+ pc.pause();
66
+ const syntheticStop = events.find(m =>
67
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
68
+ m.method === 'chainHead_v1_follow' &&
69
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
70
+ m.params?.subscription === 'sub-id-1' &&
71
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
72
+ m.params?.result?.event === 'stop');
73
+ expect(syntheticStop).toBeDefined();
74
+ expect(invocations[0].disconnect).toHaveBeenCalledTimes(1);
75
+ // Consumer's substrate-client refollows synchronously inside the stop
76
+ // handler. The new chainHead_v1_follow goes into the proxy's pending
77
+ // queue while the base is paused.
78
+ conn.send(req(2, 'chainHead_v1_follow', [true]));
79
+ expect(invocations[0].send).not.toHaveBeenCalledWith(req(2, 'chainHead_v1_follow', [true]));
80
+ // === resume ===
81
+ pc.resume();
82
+ // After resume, the base is re-invoked (new "connection") and the
83
+ // queued send is flushed. proxy.start() has its own setTimeout backoff;
84
+ // flush all timers so the new connection is established.
85
+ await vi.advanceTimersByTimeAsync(10_000);
86
+ expect(invocations.length).toBeGreaterThanOrEqual(2);
87
+ const fresh = invocations[invocations.length - 1];
88
+ // The buffered chainHead_v1_follow must hit the fresh base.
89
+ expect(fresh.send).toHaveBeenCalledWith(req(2, 'chainHead_v1_follow', [true]));
90
+ // Server assigns a NEW subId on the new connection.
91
+ fresh.onMessage({ jsonrpc: '2.0', id: 2, result: 'sub-id-2' });
92
+ // bestBlockChanged on sub-id-2 must reach the consumer — this is the
93
+ // assertion that fails if pause/resume drops best-block flow.
94
+ events.length = 0;
95
+ fresh.onMessage(followNotif('sub-id-2', { event: 'bestBlockChanged', bestBlockHash: '0xcc' }));
96
+ expect(events).toContainEqual(followNotif('sub-id-2', { event: 'bestBlockChanged', bestBlockHash: '0xcc' }));
97
+ // And no stale sub-id-1 events should have leaked through after refollow.
98
+ expect(events.some(m =>
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ m.method === 'chainHead_v1_follow' && m.params?.subscription === 'sub-id-1')).toBe(false);
101
+ }
102
+ finally {
103
+ vi.useRealTimers();
104
+ }
105
+ });
106
+ });
@@ -69,6 +69,15 @@ export const withSubscriptionReplay = (provider, onReconnect) => onMessage => {
69
69
  onMessage(message);
70
70
  });
71
71
  const unsubReconnect = onReconnect(() => {
72
+ // Replay unconfirmed subscribes first: their `reconnectFor === null`, so
73
+ // they're disjoint from the entries inserted by the active-replay loop
74
+ // below (which all carry a non-null `reconnectFor`).
75
+ for (const [, pending] of pendingSubscriptions) {
76
+ if (pending.reconnectFor === null)
77
+ conn.send(pending.payload);
78
+ }
79
+ // Replay confirmed subscriptions: the server returns a fresh subId, which
80
+ // we map back to the consumer's stable subId.
72
81
  for (const [consumerSubId, sub] of activeSubscriptions) {
73
82
  pendingSubscriptions.set(sub.id, { payload: sub.payload, reconnectFor: consumerSubId });
74
83
  conn.send(sub.payload);
@@ -61,15 +61,16 @@ describe('withSubscriptionReplay', () => {
61
61
  control.triggerReconnect();
62
62
  expect(mock.send).not.toHaveBeenCalled();
63
63
  });
64
- it('does not replay pending subscriptions (no server response yet) on reconnect', () => {
64
+ it('replays pending subscriptions (no server response yet) on reconnect', () => {
65
65
  const mock = createMockProvider();
66
66
  const control = createReconnectControl();
67
67
  const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
68
- conn.send(req(1, 'statement_subscribeStatement'));
68
+ const subscribeMsg = req(1, 'statement_subscribeStatement');
69
+ conn.send(subscribeMsg);
69
70
  // intentionally no simulateMessage — subscription not confirmed by server
70
71
  mock.send.mockClear();
71
72
  control.triggerReconnect();
72
- expect(mock.send).not.toHaveBeenCalled();
73
+ expect(mock.send).toHaveBeenCalledWith(subscribeMsg);
73
74
  });
74
75
  it('replays active subscriptions on reconnect', () => {
75
76
  const mock = createMockProvider();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novasamatech/host-substrate-chain-connection",
3
3
  "type": "module",
4
- "version": "0.7.6",
4
+ "version": "0.7.8-0",
5
5
  "description": "Chain connection pool with ref counting and provider branching for Polkadot API",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -25,7 +25,7 @@
25
25
  "README.md"
26
26
  ],
27
27
  "dependencies": {
28
- "@novasamatech/storage-adapter": "0.7.6",
28
+ "@novasamatech/storage-adapter": "0.7.8-0",
29
29
  "@polkadot-api/ws-provider": "^0.9.0",
30
30
  "@polkadot-api/json-rpc-provider": "^0.2.0",
31
31
  "@polkadot-api/json-rpc-provider-proxy": "^0.4.0",