@novasamatech/statement-store 0.6.16 → 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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
}
|
package/dist/session/session.js
CHANGED
|
@@ -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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
287
|
-
|
|
288
|
-
storeUnsub
|
|
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([
|
|
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([
|
|
220
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 () => {
|