@novasamatech/host-substrate-chain-connection 0.7.4 → 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.
@@ -1,4 +1,4 @@
1
- import { isResponse } from '@polkadot-api/json-rpc-provider';
1
+ import { isRequest, isResponse } from '@polkadot-api/json-rpc-provider';
2
2
  const isChainMethod = (method) => method.startsWith('chain_');
3
3
  const isSubscribeMethod = (method) => {
4
4
  if (isChainMethod(method))
@@ -8,47 +8,88 @@ const isSubscribeMethod = (method) => {
8
8
  };
9
9
  const isUnsubscribeMethod = (method) => !isChainMethod(method) && method.toLowerCase().includes('unsubscribe');
10
10
  export const withSubscriptionReplay = (provider, onReconnect) => onMessage => {
11
- // request id → request object (sent, awaiting server subscription ID)
12
11
  const pendingSubscriptions = new Map();
13
- // server subscription ID request object (confirmed by server)
12
+ // Confirmed subscriptions keyed by the FIRST subId the consumer received.
13
+ // That key stays stable across reconnects so the consumer's subscription
14
+ // manager (which only ever sees the first subId) keeps routing notifications.
14
15
  const activeSubscriptions = new Map();
16
+ // Reverse index for inbound notification translation.
17
+ const currentToConsumer = new Map();
18
+ const removeSubscription = (consumerSubId) => {
19
+ const sub = activeSubscriptions.get(consumerSubId);
20
+ if (sub === undefined)
21
+ return undefined;
22
+ activeSubscriptions.delete(consumerSubId);
23
+ currentToConsumer.delete(sub.currentSubId);
24
+ // If a reconnect re-send for this entry is mid-flight, drop the pending
25
+ // record so the eventual response doesn't try to update a removed entry.
26
+ const pending = pendingSubscriptions.get(sub.id);
27
+ if (pending?.reconnectFor === consumerSubId)
28
+ pendingSubscriptions.delete(sub.id);
29
+ return sub;
30
+ };
15
31
  const conn = provider(message => {
16
- // Response with a string result means it's a subscription confirmation
17
- if (message.id && isResponse(message) && 'result' in message && typeof message.result === 'string') {
18
- const id = message.id;
19
- const pending = pendingSubscriptions.get(id);
32
+ // Subscribe response (string result) register fresh, or update an
33
+ // existing entry's currentSubId on a re-confirmation after reconnect.
34
+ if (isResponse(message) && message.id != null && 'result' in message && typeof message.result === 'string') {
35
+ const pending = pendingSubscriptions.get(message.id);
20
36
  if (pending !== undefined) {
21
- pendingSubscriptions.delete(id);
22
- activeSubscriptions.set(message.result, { id, payload: pending });
37
+ pendingSubscriptions.delete(message.id);
38
+ const newSubId = message.result;
39
+ if (pending.reconnectFor !== null) {
40
+ const sub = activeSubscriptions.get(pending.reconnectFor);
41
+ if (sub !== undefined) {
42
+ currentToConsumer.delete(sub.currentSubId);
43
+ sub.currentSubId = newSubId;
44
+ currentToConsumer.set(newSubId, pending.reconnectFor);
45
+ }
46
+ // Suppress: the consumer already saw the original response and
47
+ // registered its subscriber under the original subId.
48
+ return;
49
+ }
50
+ activeSubscriptions.set(newSubId, { id: message.id, payload: pending.payload, currentSubId: newSubId });
51
+ currentToConsumer.set(newSubId, newSubId);
52
+ }
53
+ onMessage(message);
54
+ return;
55
+ }
56
+ // Notification: rewrite the server's current subId back to the consumer's
57
+ // stable subId so raw-client's subscription manager routes the event.
58
+ if (isRequest(message)) {
59
+ const params = message.params;
60
+ const incoming = params?.subscription;
61
+ if (typeof incoming === 'string') {
62
+ const consumerSubId = currentToConsumer.get(incoming);
63
+ if (consumerSubId !== undefined && consumerSubId !== incoming) {
64
+ onMessage({ ...message, params: { ...params, subscription: consumerSubId } });
65
+ return;
66
+ }
23
67
  }
24
68
  }
25
69
  onMessage(message);
26
70
  });
27
71
  const unsubReconnect = onReconnect(() => {
28
- const toResend = [...activeSubscriptions.values()];
29
- activeSubscriptions.clear();
30
- for (const { id, payload } of toResend) {
31
- pendingSubscriptions.set(id, payload);
32
- conn.send(payload);
72
+ for (const [consumerSubId, sub] of activeSubscriptions) {
73
+ pendingSubscriptions.set(sub.id, { payload: sub.payload, reconnectFor: consumerSubId });
74
+ conn.send(sub.payload);
33
75
  }
34
76
  });
35
77
  return {
36
78
  send(message) {
37
- const { method, id, params } = message;
38
- if (method) {
79
+ if (isRequest(message)) {
80
+ const { method, id, params } = message;
39
81
  if (isSubscribeMethod(method)) {
40
- if (id !== undefined)
41
- pendingSubscriptions.set(id, message);
82
+ if (id != null)
83
+ pendingSubscriptions.set(id, { payload: message, reconnectFor: null });
42
84
  }
43
85
  else if (isUnsubscribeMethod(method)) {
44
- const subId = params?.[0];
45
- // Note: callers must use the most recently received server-assigned subscription
46
- // ID to successfully unsubscribe. Using a stale ID from a previous connection
47
- // will silently fail. Additionally, if the caller unsubscribes while a subscription
48
- // is in pendingSubscriptions (i.e. after reconnect but before the server assigns a
49
- // new ID), the pending entry is not removed and may be replayed on the next reconnect.
50
- if (subId !== undefined)
51
- activeSubscriptions.delete(subId);
86
+ const consumerSubId = params?.[0];
87
+ const sub = consumerSubId !== undefined ? removeSubscription(consumerSubId) : undefined;
88
+ if (sub !== undefined && sub.currentSubId !== consumerSubId) {
89
+ const rest = (params ?? []).slice(1);
90
+ conn.send({ ...message, params: [sub.currentSubId, ...rest] });
91
+ return;
92
+ }
52
93
  }
53
94
  }
54
95
  conn.send(message);
@@ -56,6 +97,7 @@ export const withSubscriptionReplay = (provider, onReconnect) => onMessage => {
56
97
  disconnect() {
57
98
  pendingSubscriptions.clear();
58
99
  activeSubscriptions.clear();
100
+ currentToConsumer.clear();
59
101
  unsubReconnect();
60
102
  conn.disconnect();
61
103
  },
@@ -98,23 +98,63 @@ describe('withSubscriptionReplay', () => {
98
98
  expect(mock.send).toHaveBeenCalledWith(msg1);
99
99
  expect(mock.send).toHaveBeenCalledWith(msg2);
100
100
  });
101
- it('re-registers subscriptions under new server IDs after reconnect', () => {
101
+ it('translates inbound notifications from the new server subId back to the consumer-facing subId after reconnect', () => {
102
+ const mock = createMockProvider();
103
+ const control = createReconnectControl();
104
+ const onMessage = vi.fn();
105
+ const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(onMessage);
106
+ conn.send(req(1, 'statement_subscribeStatement'));
107
+ mock.simulateMessage(res(1, 'old-sub-id'));
108
+ control.triggerReconnect();
109
+ mock.simulateMessage(res(1, 'new-sub-id'));
110
+ // The post-reconnect re-confirmation must NOT reach the consumer; it would be
111
+ // a duplicate response for a request whose callback was consumed on first connect.
112
+ expect(onMessage).not.toHaveBeenCalledWith(res(1, 'new-sub-id'));
113
+ // Notifications from the server now arrive under the new server subId. The
114
+ // middleware must rewrite them to the consumer's stable subId, otherwise
115
+ // the consumer's subscription manager (which only ever saw 'old-sub-id')
116
+ // would route them nowhere.
117
+ onMessage.mockClear();
118
+ mock.simulateMessage({
119
+ jsonrpc: '2.0',
120
+ method: 'state_storage',
121
+ params: { subscription: 'new-sub-id', result: { changes: [] } },
122
+ });
123
+ expect(onMessage).toHaveBeenCalledWith({
124
+ jsonrpc: '2.0',
125
+ method: 'state_storage',
126
+ params: { subscription: 'old-sub-id', result: { changes: [] } },
127
+ });
128
+ });
129
+ it('translates outbound unsubscribe requests from the consumer-facing subId to the current server subId', () => {
130
+ const mock = createMockProvider();
131
+ const control = createReconnectControl();
132
+ const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
133
+ conn.send(req(1, 'statement_subscribeStatement'));
134
+ mock.simulateMessage(res(1, 'old-sub-id'));
135
+ control.triggerReconnect();
136
+ mock.simulateMessage(res(1, 'new-sub-id'));
137
+ mock.send.mockClear();
138
+ // Consumer only knows 'old-sub-id' (the first response it ever received).
139
+ // Middleware must rewrite the unsubscribe params to use the current server subId.
140
+ conn.send(req(2, 'statement_unsubscribeStatement', ['old-sub-id']));
141
+ expect(mock.send).toHaveBeenCalledWith(expect.objectContaining({ method: 'statement_unsubscribeStatement', params: ['new-sub-id'] }));
142
+ });
143
+ it('drops a subscription on consumer-side unsubscribe so it is not replayed on the next reconnect', () => {
102
144
  const mock = createMockProvider();
103
145
  const control = createReconnectControl();
104
146
  const conn = withSubscriptionReplay(mock.provider, control.onReconnect)(vi.fn());
105
147
  const subscribeMsg = req(1, 'statement_subscribeStatement');
106
148
  conn.send(subscribeMsg);
107
149
  mock.simulateMessage(res(1, 'old-sub-id'));
108
- // First reconnect — replays and moves to pending
109
150
  control.triggerReconnect();
110
- // Server assigns new subscription ID
111
151
  mock.simulateMessage(res(1, 'new-sub-id'));
112
- // Unsubscribing with the old ID should have no effect (old ID is no longer tracked)
152
+ // Consumer unsubscribes using the only subId it knows about.
113
153
  conn.send(req(2, 'statement_unsubscribeStatement', ['old-sub-id']));
114
154
  mock.send.mockClear();
115
- // Second reconnect — subscription is still active (new-sub-id was not removed)
116
155
  control.triggerReconnect();
117
- expect(mock.send).toHaveBeenCalledWith(subscribeMsg);
156
+ // Subscription was unsubscribed; nothing should be re-sent.
157
+ expect(mock.send).not.toHaveBeenCalled();
118
158
  });
119
159
  it('does not replay unsubscribed subscriptions on reconnect', () => {
120
160
  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.4",
4
+ "version": "0.7.5-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.4",
28
+ "@novasamatech/storage-adapter": "0.7.5-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",