@novasamatech/statement-store 0.6.17 → 0.6.18

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,5 +1,7 @@
1
1
  import { createClient as createSubstrateClient } from '@polkadot-api/substrate-client';
2
2
  import { createClient as createPolkadotClient } from 'polkadot-api';
3
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
4
+ const noop = () => { };
3
5
  export const createLazyClient = (provider) => {
4
6
  let polkadotClient = null;
5
7
  let substrateClient = null;
@@ -28,12 +30,31 @@ export const createLazyClient = (provider) => {
28
30
  getSubscribeFn() {
29
31
  const c = getSubstrateClient();
30
32
  return (method, params, onMessage, onError) => {
31
- return c._request(method, params, {
32
- onSuccess: (subscriptionId, followSubscription) => {
33
- followSubscription(subscriptionId, { next: onMessage, error: onError });
33
+ let subscriptionId = null;
34
+ let unsubscribeLocal = null;
35
+ const cancelRequest = c._request(method, params, {
36
+ onSuccess: (subId, followSubscription) => {
37
+ subscriptionId = subId;
38
+ unsubscribeLocal = followSubscription(subId, { next: onMessage, error: onError });
34
39
  },
35
40
  onError,
36
41
  });
42
+ // Derive the unsubscribe RPC method from the subscribe method name
43
+ // e.g. statement_subscribeStatement -> statement_unsubscribeStatement
44
+ const unsubscribeMethod = method.replace('subscribe', 'unsubscribe');
45
+ return () => {
46
+ if (unsubscribeLocal) {
47
+ unsubscribeLocal();
48
+ // Send the server-side unsubscribe RPC call
49
+ c._request(unsubscribeMethod, [subscriptionId], {
50
+ onSuccess: noop,
51
+ onError: noop,
52
+ });
53
+ }
54
+ else {
55
+ cancelRequest();
56
+ }
57
+ };
37
58
  };
38
59
  },
39
60
  disconnect() {
@@ -1,20 +1,20 @@
1
+ function decode(payload, codec) {
2
+ try {
3
+ return { status: 'parsed', value: codec.dec(payload) };
4
+ }
5
+ catch {
6
+ return { status: 'failed', value: payload };
7
+ }
8
+ }
1
9
  export function toMessage(statementData, codec) {
2
10
  switch (statementData.tag) {
3
11
  case 'request': {
4
- const decode = (payload) => {
5
- try {
6
- return { status: 'parsed', value: codec.dec(payload) };
7
- }
8
- catch {
9
- return { status: 'failed', value: payload };
10
- }
11
- };
12
12
  return statementData.value.data.map((payload, index) => {
13
13
  return {
14
14
  type: 'request',
15
15
  localId: `${statementData.value.requestId}-${index.toString()}`,
16
16
  requestId: statementData.value.requestId,
17
- payload: decode(payload),
17
+ payload: decode(payload, codec),
18
18
  };
19
19
  });
20
20
  }
@@ -30,6 +30,7 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
30
30
  let subscribers = [];
31
31
  const bufferedMessages = [];
32
32
  let storeUnsub = null;
33
+ let responseStoreUnsub = null;
33
34
  function submitStatementData(channel, topicSessionId, data) {
34
35
  state.expiry = nextExpiry(state.expiry);
35
36
  const expiry = state.expiry;
@@ -53,12 +54,14 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
53
54
  });
54
55
  }
55
56
  function deliverStatementData(statementData) {
56
- if (subscribers.length === 0) {
57
- if (state.phase === 'initialization') {
58
- bufferedMessages.push(statementData);
59
- }
60
- return;
57
+ // Buffer 'request' statements unconditionally so that waitForRequestMessage
58
+ // registered after delivery (race condition) still receives them via subscribe() replay.
59
+ // Buffer everything else during initialization when there are no subscribers yet.
60
+ if (statementData.tag === 'request' || (subscribers.length === 0 && state.phase === 'initialization')) {
61
+ bufferedMessages.push(statementData);
61
62
  }
63
+ if (subscribers.length === 0)
64
+ return;
62
65
  for (const sub of subscribers) {
63
66
  const messages = toMessage(statementData, sub.codec);
64
67
  if (messages.length > 0)
@@ -76,7 +79,7 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
76
79
  .map(decrypted => StatementData.dec(decrypted))
77
80
  .orElse(() => ok(null));
78
81
  }
79
- function processIncomingStatement(statement) {
82
+ function processIncomingStatement(statement, responsesOnly = false) {
80
83
  if (!statement.data)
81
84
  return;
82
85
  const key = toHex(statement.data);
@@ -87,6 +90,8 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
87
90
  if (!statementData)
88
91
  return;
89
92
  if (statementData.tag === 'request') {
93
+ if (responsesOnly)
94
+ return;
90
95
  if (statementData.value.requestId === state.incomingRequest?.requestId)
91
96
  return;
92
97
  state.incomingRequest = { requestId: statementData.value.requestId };
@@ -145,13 +150,25 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
145
150
  }
146
151
  }
147
152
  function ensureStoreSubscription() {
148
- if (storeUnsub)
153
+ if (storeUnsub) {
154
+ console.info('[session] ensureStoreSubscription: already subscribed');
149
155
  return;
156
+ }
157
+ console.info('[session] ensureStoreSubscription: subscribing to', toHex(incomingSessionId));
150
158
  storeUnsub = statementStore.subscribeStatements([incomingSessionId], statements => {
159
+ console.info('[session] subscribeStatements callback fired — statements:', statements.length);
151
160
  for (const statement of statements) {
152
161
  processIncomingStatement(statement);
153
162
  }
154
163
  });
164
+ // Subscribe to outgoing topic to receive peer ACK responses.
165
+ // Only process response-type statements — request-type statements on this topic
166
+ // are our own submissions echoed back and must be ignored.
167
+ responseStoreUnsub = statementStore.subscribeStatements([outgoingSessionId], statements => {
168
+ for (const statement of statements) {
169
+ processIncomingStatement(statement, true);
170
+ }
171
+ });
155
172
  }
156
173
  async function init() {
157
174
  const [ownResult, peerResult] = await Promise.all([
@@ -179,7 +196,7 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
179
196
  const peerRequest = peerDecoded.find(d => d.tag === 'request');
180
197
  const peerResponse = peerDecoded.find(d => d.tag === 'response');
181
198
  if (ownRequest?.tag === 'request') {
182
- const hasResponse = peerResponse?.tag === 'response' && peerResponse.value.requestId === ownRequest.value.requestId;
199
+ const hasResponse = ownResponse?.tag === 'response' && ownResponse.value.requestId === ownRequest.value.requestId;
183
200
  if (!hasResponse) {
184
201
  state.outgoingRequest = {
185
202
  requestId: ownRequest.value.requestId,
@@ -191,7 +208,7 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
191
208
  if (peerRequest?.tag === 'request') {
192
209
  state.incomingRequest = { requestId: peerRequest.value.requestId };
193
210
  state.respondedIncomingRequest =
194
- ownResponse?.tag === 'response' && ownResponse.value.requestId === peerRequest.value.requestId;
211
+ peerResponse?.tag === 'response' && peerResponse.value.requestId === peerRequest.value.requestId;
195
212
  }
196
213
  // Notify app of any unresponded incoming request.
197
214
  // Delivered while phase is still 'initialization' so that deliverStatementData
@@ -274,6 +291,7 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
274
291
  callback: callback,
275
292
  };
276
293
  subscribers.push(sub);
294
+ console.info('[session] subscribe: subscriber count now', subscribers.length);
277
295
  ensureStoreSubscription();
278
296
  // Deliver buffered init messages to this subscriber
279
297
  if (bufferedMessages.length > 0) {
@@ -283,15 +301,25 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
283
301
  }
284
302
  return () => {
285
303
  subscribers = subscribers.filter(s => s !== sub);
286
- if (subscribers.length === 0 && storeUnsub) {
287
- storeUnsub();
288
- storeUnsub = null;
304
+ console.info('[session] unsubscribe: subscriber count now', subscribers.length);
305
+ if (subscribers.length === 0) {
306
+ if (storeUnsub) {
307
+ console.warn('[session] ALL subscribers removed — killing store subscription!');
308
+ storeUnsub();
309
+ storeUnsub = null;
310
+ }
311
+ if (responseStoreUnsub) {
312
+ responseStoreUnsub();
313
+ responseStoreUnsub = null;
314
+ }
289
315
  }
290
316
  };
291
317
  },
292
318
  dispose() {
293
319
  storeUnsub?.();
294
320
  storeUnsub = null;
321
+ responseStoreUnsub?.();
322
+ responseStoreUnsub = null;
295
323
  subscribers = [];
296
324
  for (const [, deferred] of state.pendingDelivery) {
297
325
  deferred.reject(new Error('Session disposed'));
@@ -302,7 +330,6 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
302
330
  void init();
303
331
  return session;
304
332
  }
305
- // ── module-level helpers ──────────────────────────────────────────────────────
306
333
  function mapResponseCode(responseCode) {
307
334
  switch (responseCode) {
308
335
  case 'success':
@@ -177,9 +177,10 @@ describe('session', () => {
177
177
  let callCount = 0;
178
178
  adapter.queryStatements.mockImplementation(() => {
179
179
  callCount++;
180
+ // Outgoing topic contains both our request AND the peer's response
180
181
  if (callCount === 1)
181
- return okAsync([ownRequest]);
182
- return okAsync([peerResponse]);
182
+ return okAsync([ownRequest, peerResponse]);
183
+ return okAsync([]);
183
184
  });
184
185
  const { session } = makeSession(adapter);
185
186
  await flushPromises();
@@ -216,8 +217,9 @@ describe('session', () => {
216
217
  adapter.queryStatements.mockImplementation(() => {
217
218
  callCount++;
218
219
  if (callCount === 1)
219
- return okAsync([ownResponse]);
220
- return okAsync([peerRequest]);
220
+ return okAsync([]);
221
+ // Incoming topic contains both the peer's request AND our response
222
+ return okAsync([peerRequest, ownResponse]);
221
223
  });
222
224
  const { session } = makeSession(adapter);
223
225
  await flushPromises();
@@ -298,10 +300,10 @@ describe('session', () => {
298
300
  it('does NOT auto-send a response when an incoming request arrives', async () => {
299
301
  const requestId = 'no-auto-resp';
300
302
  const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
303
+ const subscribeCallbacks = [];
301
304
  const adapter = makeAdapter();
302
- let subscribeCallback;
303
305
  adapter.subscribeStatements.mockImplementation((_topics, cb) => {
304
- subscribeCallback = cb;
306
+ subscribeCallbacks.push(cb);
305
307
  return vi.fn();
306
308
  });
307
309
  adapter.queryStatements.mockReturnValue(okAsync([]));
@@ -310,22 +312,134 @@ describe('session', () => {
310
312
  const callback = vi.fn();
311
313
  session.subscribe(rawCodec, callback);
312
314
  adapter.submitStatement.mockClear();
313
- subscribeCallback([peerRequest]);
315
+ // Fire on the incoming topic callback (first subscription)
316
+ subscribeCallbacks[0]([peerRequest]);
314
317
  await flushPromises();
315
318
  // Message delivered to app callback but no automatic response submitted
316
319
  expect(callback).toHaveBeenCalled();
317
320
  expect(adapter.submitStatement).not.toHaveBeenCalled();
318
321
  });
322
+ it('delivers peer request to a subscriber that registers after the batch notification (race condition)', async () => {
323
+ // Regression test for PB-439: when peer's request and the ACK response arrive in the
324
+ // same subscribeStatements batch, the request is processed before waitForRequestMessage
325
+ // has a chance to register its subscriber. The fix ensures request statements are always
326
+ // buffered so late subscribers (simulating waitForRequestMessage called in .andThen()
327
+ // after waitForResponseMessage resolves) still receive them.
328
+ const subscribeCallbacks = [];
329
+ const subscribeStatements = vi
330
+ .fn()
331
+ .mockImplementation((_topics, cb) => {
332
+ subscribeCallbacks.push(cb);
333
+ return vi.fn();
334
+ });
335
+ const { session } = makeSession({ subscribeStatements });
336
+ await flushPromises();
337
+ // Register a dummy subscriber to activate the store subscription (simulates
338
+ // any pre-existing subscriber in the session, e.g. the app listening for messages).
339
+ const dummyUnsub = session.subscribe(rawCodec, vi.fn());
340
+ const peerRequestId = 'race-condition-request';
341
+ const peerRequest = makeStatement({
342
+ tag: 'request',
343
+ value: { requestId: peerRequestId, data: [new Uint8Array([42])] },
344
+ });
345
+ // Peer request arrives on the incoming topic (first subscription) while the
346
+ // dummy subscriber is active but waitForRequestMessage hasn't registered its
347
+ // subscriber yet (the race condition scenario).
348
+ subscribeCallbacks[0]([peerRequest]);
349
+ await flushPromises();
350
+ // Now the late subscriber registers (simulates waitForRequestMessage being called
351
+ // in the .andThen() chain after waitForResponseMessage resolves).
352
+ const lateCallback = vi.fn();
353
+ session.subscribe(rawCodec, lateCallback);
354
+ // The late subscriber must receive the buffered peer request, otherwise
355
+ // waitForRequestMessage would hang indefinitely.
356
+ expect(lateCallback).toHaveBeenCalledTimes(1);
357
+ const messages = lateCallback.mock.calls[0][0];
358
+ expect(messages[0]?.type).toBe('request');
359
+ expect(messages[0]?.requestId).toBe(peerRequestId);
360
+ dummyUnsub();
361
+ });
319
362
  it('unsubscribing last subscriber tears down the store subscription', () => {
320
363
  const { session, adapter } = makeSession();
321
364
  const unsub = session.subscribe(rawCodec, vi.fn());
322
- expect(adapter.subscribeStatements).toHaveBeenCalledTimes(1);
365
+ expect(adapter.subscribeStatements).toHaveBeenCalledTimes(2);
323
366
  unsub();
324
367
  // subscribeStatements returns a mock unsubscribe fn — verify it was called
325
368
  // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
326
369
  const storeMockUnsub = adapter.subscribeStatements.mock.results[0].value;
327
370
  expect(storeMockUnsub).toHaveBeenCalled();
328
371
  });
372
+ it('subscribes to outgoing topic for peer responses alongside incoming topic', () => {
373
+ const { session, adapter } = makeSession();
374
+ session.subscribe(rawCodec, vi.fn());
375
+ // Two subscriptions: one for incoming (peer requests), one for outgoing (peer responses)
376
+ expect(adapter.subscribeStatements).toHaveBeenCalledTimes(2);
377
+ });
378
+ it('tears down outgoing subscription when last subscriber leaves', () => {
379
+ const { session, adapter } = makeSession();
380
+ const unsub = session.subscribe(rawCodec, vi.fn());
381
+ unsub();
382
+ // Both unsubscribe functions should be called
383
+ for (const result of adapter.subscribeStatements.mock.results) {
384
+ const mockUnsub = result.value;
385
+ expect(mockUnsub).toHaveBeenCalled();
386
+ }
387
+ });
388
+ it('delivers peer response from outgoing topic subscription to subscribers', async () => {
389
+ const subscribeCallbacks = [];
390
+ const subscribeStatements = vi
391
+ .fn()
392
+ .mockImplementation((_topics, cb) => {
393
+ subscribeCallbacks.push(cb);
394
+ return vi.fn();
395
+ });
396
+ const { session, adapter } = makeSession({ subscribeStatements });
397
+ await flushPromises();
398
+ // Submit a request so the session has an outgoingRequest
399
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
400
+ await flushPromises();
401
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
402
+ const submitted = adapter.submitStatement.mock.calls[0][0];
403
+ const decoded = StatementData.dec(submitted.data);
404
+ const requestId = decoded.tag === 'request' ? decoded.value.requestId : '';
405
+ const callback = vi.fn();
406
+ session.subscribe(rawCodec, callback);
407
+ // Deliver the response via the SECOND subscription callback (outgoing topic)
408
+ // subscribeCallbacks[0] = incoming topic, subscribeCallbacks[1] = outgoing topic
409
+ const responseStatement = makeStatement({
410
+ tag: 'response',
411
+ value: { requestId, responseCode: 'success' },
412
+ });
413
+ subscribeCallbacks[1]([responseStatement]);
414
+ await flushPromises();
415
+ // Subscriber should receive the response
416
+ const allCalls = callback.mock.calls.flat();
417
+ const responseMessages = allCalls.flat().filter((m) => m.type === 'response');
418
+ expect(responseMessages.length).toBeGreaterThan(0);
419
+ });
420
+ it('ignores request-type statements from outgoing topic subscription', async () => {
421
+ const subscribeCallbacks = [];
422
+ const subscribeStatements = vi
423
+ .fn()
424
+ .mockImplementation((_topics, cb) => {
425
+ subscribeCallbacks.push(cb);
426
+ return vi.fn();
427
+ });
428
+ const { session } = makeSession({ subscribeStatements });
429
+ await flushPromises();
430
+ const callback = vi.fn();
431
+ session.subscribe(rawCodec, callback);
432
+ callback.mockClear(); // clear any buffered init messages
433
+ // Deliver a request via the outgoing topic subscription (would be our own echoed back)
434
+ const ownRequest = makeStatement({
435
+ tag: 'request',
436
+ value: { requestId: 'own-req', data: [new Uint8Array([1])] },
437
+ });
438
+ subscribeCallbacks[1]([ownRequest]);
439
+ await flushPromises();
440
+ // Should NOT be delivered to subscriber (filtered by responsesOnly flag)
441
+ expect(callback).not.toHaveBeenCalled();
442
+ });
329
443
  });
330
444
  describe('submitResponseMessage', () => {
331
445
  it('is idempotent — second call does not submit again', async () => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@novasamatech/statement-store",
3
3
  "type": "module",
4
- "version": "0.6.17",
4
+ "version": "0.6.18",
5
5
  "description": "Statement store integration",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {