@novasamatech/statement-store 0.8.7-0 → 0.8.7-2

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,10 +1,13 @@
1
1
  import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
2
- import { ResultAsync, errAsync, ok, okAsync } from 'neverthrow';
3
- import { Bytes, str } from 'scale-ts';
2
+ import { ResultAsync, err, errAsync, ok, okAsync } from 'neverthrow';
3
+ import { Bytes, Struct, str } from 'scale-ts';
4
4
  import { describe, expect, it, vi } from 'vitest';
5
+ import { createInMemoryStatementStore } from '../adapter/inMemory.js';
6
+ import { ExpiryTooLowError } from '../adapter/types.js';
5
7
  import { createAccountId, createLocalSessionAccount, createRemoteSessionAccount } from '../model/sessionAccount.js';
8
+ import { DecodingError, UnknownError } from './error.js';
6
9
  import { StatementData } from './scale/statementData.js';
7
- import { createSession, nextExpiry } from './session.js';
10
+ import { STATEMENT_OVERHEAD, createSession } from './session.js';
8
11
  // Real signature work belongs in statementProver tests; this stub stamps a
9
12
  // non-empty proof so submitted statements are well-formed.
10
13
  const mockProver = {
@@ -47,10 +50,16 @@ function makeStatement(statementData, expiry) {
47
50
  channel: `0x${'00'.repeat(32)}`,
48
51
  };
49
52
  }
50
- function makeSession(overrides) {
53
+ function makeSession(opts = {}) {
51
54
  const { localAccount, remoteAccount } = makeAccounts();
52
- const { maxRequestSize, ...adapterOverrides } = overrides ?? {};
53
- const adapter = { ...makeAdapter(), ...adapterOverrides };
55
+ const { own = [], peer = [], maxRequestSize, ...adapterOverrides } = opts;
56
+ const adapter = makeAdapter();
57
+ // init() queries the outgoing (own) topic first, then the incoming (peer) topic.
58
+ let queryCall = 0;
59
+ adapter.queryStatements.mockImplementation(() => okAsync(queryCall++ === 0 ? own : peer));
60
+ // Explicit adapter mocks (e.g. a capturing subscribeStatements, or a custom
61
+ // queryStatements) take precedence over the own/peer defaults.
62
+ Object.assign(adapter, adapterOverrides);
54
63
  const session = createSession({
55
64
  localAccount,
56
65
  remoteAccount,
@@ -64,192 +73,371 @@ function makeSession(overrides) {
64
73
  });
65
74
  return { session, adapter };
66
75
  }
76
+ // Capture the callbacks the session registers via subscribeStatements so a test can
77
+ // push statement pages itself. The session subscribes once, to the incoming topic
78
+ // (callbacks[0]), which carries both peer requests and peer responses.
79
+ function capturingSubscribe() {
80
+ const callbacks = [];
81
+ const subscribeStatements = vi.fn((_filter, cb) => {
82
+ callbacks.push(cb);
83
+ return vi.fn();
84
+ });
85
+ return { subscribeStatements, callbacks };
86
+ }
87
+ function lastSubmitted(adapter) {
88
+ return adapter.submitStatement.mock.calls.at(-1)[0];
89
+ }
90
+ function lastSubmittedRequestId(adapter) {
91
+ const decoded = StatementData.dec(lastSubmitted(adapter).data);
92
+ return decoded.tag === 'request' ? decoded.value.requestId : '';
93
+ }
94
+ // A submitStatement mock that defers every submission instead of resolving: each call records a
95
+ // `{ requestId, settle }` entry in `pendings`, letting a test land or reject submissions in a chosen
96
+ // order (used to drive shared-channel supersession races). Works for request and response payloads.
97
+ function deferredSubmit() {
98
+ const pendings = [];
99
+ const submitStatement = vi.fn((stmt) => {
100
+ const decoded = StatementData.dec(stmt.data);
101
+ const requestId = decoded.tag === 'request' || decoded.tag === 'response' ? decoded.value.requestId : '';
102
+ return ResultAsync.fromPromise(new Promise((resolve, reject) => {
103
+ pendings.push({ requestId, settle: r => (r.isOk() ? resolve() : reject(r.error)) });
104
+ }), e => e);
105
+ });
106
+ return { submitStatement, pendings };
107
+ }
108
+ // Encoded size of the request payload (the statement `data` field) for these messages —
109
+ // what the session sizes batches against. Includes the requestId (a fixed-length nanoid)
110
+ // and the SCALE vector framing, so it is larger than the raw message bytes alone.
111
+ function reqPayloadSize(...messages) {
112
+ return StatementData.enc({ tag: 'request', value: { requestId: 'x'.repeat(21), data: messages } }).length;
113
+ }
67
114
  async function delay(ttl = 0) {
68
115
  await new Promise(resolve => setTimeout(resolve, ttl));
69
116
  }
70
- describe('session', () => {
71
- describe('nextExpiry', () => {
72
- it('returns fresh expiry when current is 0', () => {
73
- const result = nextExpiry(0n);
74
- expect(result).toBeGreaterThan(0n);
75
- });
76
- it('returns fresh expiry when current is less than fresh', () => {
77
- const stale = createExpiryFromDuration(1); // 1 second from now, will be smaller than 7-day
78
- const result = nextExpiry(stale);
79
- const fresh = createExpiryFromDuration(7 * 24 * 60 * 60);
80
- expect(result).toBeGreaterThanOrEqual(fresh);
81
- });
82
- it('returns current + 1n when current is already at or above fresh', () => {
83
- const high = createExpiryFromDuration(7 * 24 * 60 * 60 + 999999);
84
- const result = nextExpiry(high);
85
- expect(result).toBe(high + 1n);
86
- });
87
- it('is monotonically increasing across repeated calls', () => {
88
- let expiry = 0n;
89
- for (let i = 0; i < 5; i++) {
90
- const next = nextExpiry(expiry);
91
- expect(next).toBeGreaterThan(expiry);
92
- expiry = next;
93
- }
94
- });
117
+ // Drain microtasks + macrotasks repeatedly so a multi-hop cross-session
118
+ // exchange (submit → deliver → process → submit → …) fully settles.
119
+ async function settle() {
120
+ for (let i = 0; i < 12; i++)
121
+ await delay();
122
+ }
123
+ // Two mirrored sessions sharing one in-memory store. A SHARED session key makes
124
+ // them derive the same SessionId pair (host.outgoing === peer.incoming, and vice
125
+ // versa) a real host/papp pairing. With identity encryption/prover, each side
126
+ // decrypts and verifies the other's statements.
127
+ const SHARED_KEY = new Uint8Array(32).fill(7);
128
+ const localA = createLocalSessionAccount(createAccountId(new Uint8Array(32).fill(1)));
129
+ const remoteA = createRemoteSessionAccount(createAccountId(new Uint8Array(32).fill(1)), new Uint8Array(32).fill(11));
130
+ const localB = createLocalSessionAccount(createAccountId(new Uint8Array(32).fill(2)));
131
+ const remoteB = createRemoteSessionAccount(createAccountId(new Uint8Array(32).fill(2)), new Uint8Array(32).fill(22));
132
+ const RemoteMsg = Struct({ id: str, kind: str, respondingTo: str, body: str });
133
+ const requestMsg = (id) => ({ id, kind: 'request', respondingTo: '', body: id });
134
+ function makeHost(adapter) {
135
+ return createSession({
136
+ localAccount: localA,
137
+ remoteAccount: remoteB,
138
+ statementStore: adapter,
139
+ encryption: mockEncryption(),
140
+ prover: mockProver,
141
+ sessionKey: SHARED_KEY,
142
+ });
143
+ }
144
+ function makeMobile(adapter) {
145
+ return createSession({
146
+ localAccount: localB,
147
+ remoteAccount: remoteA,
148
+ statementStore: adapter,
149
+ encryption: mockEncryption(),
150
+ prover: mockProver,
151
+ sessionKey: SHARED_KEY,
95
152
  });
96
- describe('createSession initialization', () => {
97
- it('queries own and peer statements on creation', async () => {
153
+ }
154
+ describe('session', () => {
155
+ const rawCodec = Bytes();
156
+ // On creation a session queries both of its topics, derives the starting expiry, and buffers
157
+ // anything it finds until it goes active. (Spec §5 initialization.)
158
+ describe('initialization', () => {
159
+ it('queries the outgoing and incoming topics on creation', async () => {
98
160
  const { adapter } = makeSession();
99
161
  await delay();
100
- // Two single-topic matchAll queries — one per channel (outgoing/incoming).
101
- // The topics must differ; otherwise both queries would target the same channel.
162
+ // Two single-topic matchAll queries — one per topic (outgoing/incoming); they must differ.
102
163
  const topics = adapter.queryStatements.mock.calls.map(([f]) => f.matchAll);
103
164
  expect(topics).toHaveLength(2);
104
165
  expect(topics.map(t => t.length)).toEqual([1, 1]);
105
166
  expect(topics[0]).not.toEqual(topics[1]);
106
167
  });
107
- it('expiry is initialized from max own statement expiry', async () => {
168
+ it('seeds the expiry from the highest own statement expiry', async () => {
108
169
  const highExpiry = createExpiryFromDuration(7 * 24 * 60 * 60) + 9999n;
109
170
  const ownRequest = makeStatement({ tag: 'request', value: { requestId: 'r1', data: [] } }, highExpiry);
110
- const adapter = makeAdapter();
111
- let firstCall = true;
112
- adapter.queryStatements.mockImplementation(() => {
113
- if (firstCall) {
114
- firstCall = false;
115
- return okAsync([ownRequest]);
116
- }
117
- return okAsync([]);
118
- });
119
- const { session } = makeSession(adapter);
171
+ const { session, adapter } = makeSession({ own: [ownRequest] });
120
172
  await delay();
121
- // Submit a message to trigger a statement — its expiry must be greater than highExpiry
122
- const rawCodec = Bytes();
173
+ // The next submitted statement must carry an expiry greater than the highest seen.
123
174
  void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
124
175
  await delay();
125
- const submittedStatement = adapter.submitStatement.mock.calls[0]?.[0];
126
- if (submittedStatement) {
127
- expect(submittedStatement.expiry).toBeGreaterThan(highExpiry);
128
- }
176
+ expect(lastSubmitted(adapter).expiry).toBeGreaterThan(highExpiry);
129
177
  });
130
- it('marks own and peer statement data as seen during init', async () => {
178
+ it('buffers an incoming request found during init for a subscriber that registers later', async () => {
131
179
  const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'r2', data: [new Uint8Array([1])] } });
132
- const adapter = makeAdapter();
133
- let callCount = 0;
134
- adapter.queryStatements.mockImplementation(() => {
135
- callCount++;
136
- if (callCount === 2)
137
- return okAsync([peerRequest]);
138
- return okAsync([]);
139
- });
140
- const { session } = makeSession(adapter);
180
+ const { session } = makeSession({ peer: [peerRequest] });
141
181
  await delay();
142
- // Register subscriber AFTER init — buffered incoming request should be delivered
143
182
  const callback = vi.fn();
144
- session.subscribe(Bytes(), callback);
183
+ session.subscribe(rawCodec, callback);
145
184
  expect(callback).toHaveBeenCalled();
146
185
  });
147
- it('transitions to active phase after queries complete', async () => {
186
+ it('queues messages submitted before init completes and sends them once active', async () => {
148
187
  const { session, adapter } = makeSession();
149
- // Before init completes, submitRequestMessage queues the message
188
+ // Submitted while still initializing → queued, not sent yet.
150
189
  void session.submitRequestMessage(str, 'hello');
151
- // Statement should NOT be submitted yet (still initializing)
152
190
  expect(adapter.submitStatement).not.toHaveBeenCalled();
153
191
  await delay();
154
- // After init, queued messages are processed → submitStatement called
192
+ // After init the queue is drained.
155
193
  expect(adapter.submitStatement).toHaveBeenCalled();
156
194
  });
157
- });
158
- describe('session state restoration', () => {
159
- it('restores outgoingRequest when own has request with no peer response', async () => {
160
- const requestId = 'saved-request-id';
161
- const ownRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
162
- const adapter = makeAdapter();
163
- let callCount = 0;
164
- adapter.queryStatements.mockImplementation(() => {
165
- callCount++;
166
- if (callCount === 1)
167
- return okAsync([ownRequest]);
168
- return okAsync([]); // no peer response
195
+ it('does not regress its expiry counter when a response is submitted during init', async () => {
196
+ // A peer request auto-ACKed while init() is still in flight advances both state.expiry and
197
+ // the on-chain channel past the init query snapshot. init() must not reset the counter below
198
+ // that, or the next submit collides at an equal expiry (the single-writer drift).
199
+ let releaseInit;
200
+ const initBarrier = new Promise(resolve => {
201
+ releaseInit = resolve;
202
+ });
203
+ const queryStatements = vi.fn(() => ResultAsync.fromSafePromise(initBarrier.then(() => [])));
204
+ const { subscribeStatements, callbacks } = capturingSubscribe();
205
+ const { session, adapter } = makeSession({ queryStatements, subscribeStatements });
206
+ session.respondToRequests(rawCodec, () => 'success'); // activates the subscription + auto-ACK
207
+ // Two peer requests answered while init() is still pending → two response submits.
208
+ callbacks[0]({
209
+ statements: [makeStatement({ tag: 'request', value: { requestId: 'a', data: [new Uint8Array([1])] } })],
210
+ isComplete: true,
211
+ });
212
+ callbacks[0]({
213
+ statements: [makeStatement({ tag: 'request', value: { requestId: 'b', data: [new Uint8Array([2])] } })],
214
+ isComplete: true,
169
215
  });
170
- const { session } = makeSession(adapter);
171
216
  await delay();
172
- // If outgoingRequest was restored, a new message appends to it
173
- const codec = str;
174
- void session.submitRequestMessage(codec, 'hello');
217
+ const inInitMax = adapter.submitStatement.mock.calls
218
+ .map(c => c[0].expiry ?? 0n)
219
+ .reduce((m, e) => (e > m ? e : m), 0n);
220
+ expect(inInitMax).toBeGreaterThan(0n); // sanity: responses really went out during init
221
+ releaseInit();
222
+ await delay();
223
+ // A response after init completes must use an expiry strictly above the in-init submits.
224
+ callbacks[0]({
225
+ statements: [makeStatement({ tag: 'request', value: { requestId: 'c', data: [new Uint8Array([3])] } })],
226
+ isComplete: true,
227
+ });
228
+ await delay();
229
+ const afterInit = (adapter.submitStatement.mock.calls.at(-1)?.[0]).expiry ?? 0n;
230
+ expect(afterInit).toBeGreaterThan(inInitMax);
231
+ session.dispose();
232
+ });
233
+ });
234
+ // On restart a session rebuilds its in-flight state from the on-chain statements. A request is
235
+ // answered by the PEER's response (read from our incoming topic); we have answered a peer request
236
+ // iff OUR response (on our outgoing topic) carries its id. (Spec §4 response placement + §5.)
237
+ describe('state restoration on restart', () => {
238
+ it('restores the outgoing request when it has no response yet', async () => {
239
+ const ownRequest = makeStatement({ tag: 'request', value: { requestId: 'saved-request-id', data: [] } });
240
+ const { session, adapter } = makeSession({ own: [ownRequest] }); // no peer response
241
+ await delay();
242
+ // If outgoingRequest was restored, a new message appends to it.
243
+ void session.submitRequestMessage(str, 'hello');
175
244
  await delay();
176
245
  expect(adapter.submitStatement).toHaveBeenCalled();
177
246
  });
178
- it('clears outgoingRequest when peer has a matching response', async () => {
179
- const requestId = 'acked-request';
180
- const ownRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
181
- const peerResponse = makeStatement({ tag: 'response', value: { requestId, responseCode: 'success' } });
182
- const adapter = makeAdapter();
183
- let callCount = 0;
184
- adapter.queryStatements.mockImplementation(() => {
185
- callCount++;
186
- // Outgoing topic contains both our request AND the peer's response
187
- if (callCount === 1)
188
- return okAsync([ownRequest, peerResponse]);
189
- return okAsync([]);
190
- });
191
- const { session } = makeSession(adapter);
247
+ it('does not restore the outgoing request once the peer has responded', async () => {
248
+ // Our request is on our outgoing topic; the peer's response to it is on our incoming topic.
249
+ const ownRequest = makeStatement({ tag: 'request', value: { requestId: 'or', data: [new Uint8Array([1])] } });
250
+ const peerResponse = makeStatement({ tag: 'response', value: { requestId: 'or', responseCode: 'success' } });
251
+ const { session, adapter } = makeSession({ own: [ownRequest], peer: [peerResponse] });
192
252
  await delay();
193
- // No pending outgoing request new message creates a brand new request
194
- const codec = str;
195
- void session.submitRequestMessage(codec, 'hi');
253
+ // Answered → not restored as pending a new message starts a fresh batch (data length 1, not 2).
254
+ void session.submitRequestMessage(rawCodec, new Uint8Array([9]));
196
255
  await delay();
197
- // submitStatement called exactly once (for the new message only)
198
- expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
256
+ const decoded = StatementData.dec(lastSubmitted(adapter).data);
257
+ expect(decoded.tag === 'request' && decoded.value.data.length).toBe(1);
258
+ session.dispose();
199
259
  });
200
- it('restores incomingRequest from peer statements', async () => {
260
+ it('restores an unanswered incoming request so it can still be answered', async () => {
201
261
  const requestId = 'peer-request-id';
202
262
  const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
203
- const adapter = makeAdapter();
204
- let callCount = 0;
205
- adapter.queryStatements.mockImplementation(() => {
206
- callCount++;
207
- if (callCount === 2)
208
- return okAsync([peerRequest]);
209
- return okAsync([]);
210
- });
211
- const { session } = makeSession(adapter);
263
+ const { session } = makeSession({ peer: [peerRequest] });
212
264
  await delay();
213
- // Calling submitResponseMessage with restored requestId should succeed
214
265
  const result = await session.submitResponseMessage(requestId, 'success');
215
266
  expect(result.isOk()).toBe(true);
216
267
  });
217
- it('sets respondedIncomingRequest=true when own has a response for the peer request', async () => {
268
+ it('treats an incoming request as already answered when our response is present (no resubmit)', async () => {
218
269
  const requestId = 'peer-request-id';
219
270
  const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
220
271
  const ownResponse = makeStatement({ tag: 'response', value: { requestId, responseCode: 'success' } });
221
- const adapter = makeAdapter();
222
- let callCount = 0;
223
- adapter.queryStatements.mockImplementation(() => {
224
- callCount++;
225
- if (callCount === 1)
226
- return okAsync([]);
227
- // Incoming topic contains both the peer's request AND our response
228
- return okAsync([peerRequest, ownResponse]);
229
- });
230
- const { session } = makeSession(adapter);
272
+ // The peer's request is on our incoming topic; OUR response to it is on our outgoing topic.
273
+ const { session, adapter } = makeSession({ own: [ownResponse], peer: [peerRequest] });
231
274
  await delay();
232
- // Already responded — submitResponseMessage should return ok without submitting again
233
275
  const submitsBefore = adapter.submitStatement.mock.calls.length;
234
276
  const result = await session.submitResponseMessage(requestId, 'success');
235
277
  expect(result.isOk()).toBe(true);
236
278
  expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore); // no new submit
237
279
  });
280
+ it('does not re-deliver an incoming request we already answered', async () => {
281
+ const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'pr', data: [new Uint8Array([1])] } });
282
+ const ownResponse = makeStatement({ tag: 'response', value: { requestId: 'pr', responseCode: 'success' } });
283
+ const { session } = makeSession({ own: [ownResponse], peer: [peerRequest] });
284
+ await delay();
285
+ const cb = vi.fn();
286
+ session.subscribe(rawCodec, cb);
287
+ expect(cb).not.toHaveBeenCalled(); // already responded → not re-delivered for re-processing
288
+ session.dispose();
289
+ });
290
+ });
291
+ // Outgoing messages are batched into one in-flight request; overflow is queued and sent only once
292
+ // the live request is answered. Identical messages are de-duplicated, order is preserved, and the
293
+ // batch is sized against the statement limit minus the fixed wire overhead. (Spec §6.)
294
+ describe('sending requests', () => {
295
+ it('sends a single statement for the first message', async () => {
296
+ const { session, adapter } = makeSession();
297
+ await delay();
298
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
299
+ await delay();
300
+ expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
301
+ });
302
+ it('appends a second message to the in-flight batch and resubmits', async () => {
303
+ const { session, adapter } = makeSession();
304
+ await delay();
305
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
306
+ void session.submitRequestMessage(rawCodec, new Uint8Array([2]));
307
+ await delay();
308
+ // Two submits: first for msg1, second for the msg1+msg2 batch.
309
+ expect(adapter.submitStatement).toHaveBeenCalledTimes(2);
310
+ });
311
+ it('sizes a batch by its full encoded payload, not the raw message bytes', async () => {
312
+ const m1 = rawCodec.enc(new Uint8Array([1, 2, 3]));
313
+ const m2 = rawCodec.enc(new Uint8Array([4, 5, 6]));
314
+ // Budget fits a one-message request but not a two-message one — even though the raw
315
+ // message bytes are tiny, the requestId + SCALE framing tip the second over the limit.
316
+ const maxRequestSize = STATEMENT_OVERHEAD + reqPayloadSize(m1, m2) - 1;
317
+ expect(m1.length + m2.length).toBeLessThan(reqPayloadSize(m1)); // raw sum < a single-message payload
318
+ const { session, adapter } = makeSession({ maxRequestSize });
319
+ await delay();
320
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
321
+ void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6])); // raw sum fits, full payload doesn't
322
+ await delay();
323
+ // The second message is queued, not appended → only the first batch was submitted.
324
+ expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
325
+ });
326
+ it('drains the queue after the in-flight batch is answered', async () => {
327
+ const { subscribeStatements, callbacks } = capturingSubscribe();
328
+ const m1 = rawCodec.enc(new Uint8Array([1, 2, 3]));
329
+ const m2 = rawCodec.enc(new Uint8Array([4, 5, 6]));
330
+ const maxRequestSize = STATEMENT_OVERHEAD + reqPayloadSize(m1, m2) - 1; // m1 fits, m1+m2 doesn't
331
+ const { session, adapter } = makeSession({ maxRequestSize, subscribeStatements });
332
+ await delay();
333
+ session.subscribe(Bytes(), vi.fn()); // ensure the store subscription is active
334
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3])); // sent
335
+ void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6])); // queued (doesn't fit)
336
+ await delay();
337
+ const submitCountBefore = adapter.submitStatement.mock.calls.length;
338
+ // Peer responds to the live request (responses arrive on our incoming topic).
339
+ const responseStatement = makeStatement({
340
+ tag: 'response',
341
+ value: { requestId: lastSubmittedRequestId(adapter), responseCode: 'success' },
342
+ });
343
+ callbacks[0]({ statements: [responseStatement], isComplete: true });
344
+ await delay();
345
+ // The queued message is now submitted.
346
+ expect(adapter.submitStatement.mock.calls.length).toBeGreaterThan(submitCountBefore);
347
+ });
348
+ it('resolves waitForResponseMessage when the batch is answered', async () => {
349
+ const { subscribeStatements, callbacks } = capturingSubscribe();
350
+ const { session, adapter } = makeSession({ subscribeStatements });
351
+ await delay();
352
+ session.subscribe(Bytes(), vi.fn()); // ensure the store subscription is active
353
+ const submitResult = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
354
+ const token = submitResult.unwrapOr({ requestId: '' }).requestId;
355
+ await delay();
356
+ const responsePromise = session.waitForResponseMessage(token);
357
+ callbacks[0]({
358
+ statements: [
359
+ makeStatement({
360
+ tag: 'response',
361
+ value: { requestId: lastSubmittedRequestId(adapter), responseCode: 'success' },
362
+ }),
363
+ ],
364
+ isComplete: true,
365
+ });
366
+ await delay();
367
+ const result = await responsePromise;
368
+ expect(result.isOk()).toBe(true);
369
+ expect(result.unwrapOr({ responseCode: 'unknown' }).responseCode).toBe('success');
370
+ });
371
+ it('does not resend a message that is already in flight (dedup)', async () => {
372
+ const store = createInMemoryStatementStore();
373
+ const session = makeHost(store);
374
+ await settle();
375
+ const msg = new Uint8Array([1, 2, 3]);
376
+ void session.submitRequestMessage(rawCodec, msg);
377
+ await settle();
378
+ const acceptedAfterFirst = store.acceptedStatements().length;
379
+ void session.submitRequestMessage(rawCodec, msg); // identical → must not resubmit
380
+ await settle();
381
+ expect(store.acceptedStatements().length).toBe(acceptedAfterFirst);
382
+ session.dispose();
383
+ });
384
+ it('resolves every caller of a deduplicated message on the single response', async () => {
385
+ const { subscribeStatements, callbacks } = capturingSubscribe();
386
+ const { session, adapter } = makeSession({ subscribeStatements });
387
+ await delay();
388
+ session.subscribe(rawCodec, vi.fn());
389
+ const r1 = await session.submitRequestMessage(rawCodec, new Uint8Array([7, 7]));
390
+ const r2 = await session.submitRequestMessage(rawCodec, new Uint8Array([7, 7])); // duplicate
391
+ const w1 = session.waitForResponseMessage(r1._unsafeUnwrap().requestId);
392
+ const w2 = session.waitForResponseMessage(r2._unsafeUnwrap().requestId);
393
+ await delay();
394
+ callbacks[0]({
395
+ statements: [
396
+ makeStatement({
397
+ tag: 'response',
398
+ value: { requestId: lastSubmittedRequestId(adapter), responseCode: 'success' },
399
+ }),
400
+ ],
401
+ isComplete: true,
402
+ });
403
+ await delay();
404
+ expect((await w1).isOk()).toBe(true);
405
+ expect((await w2).isOk()).toBe(true);
406
+ });
407
+ it('preserves FIFO order: a later fitting message does not overtake queued ones', async () => {
408
+ const m1 = rawCodec.enc(new Uint8Array([1, 2, 3])); // first → in-flight batch
409
+ const mBig = rawCodec.enc(new Uint8Array([4, 5, 6, 7, 8, 9, 10])); // does not fit alongside m1 → queued
410
+ const mSmall = rawCodec.enc(new Uint8Array([9])); // would fit alongside m1, but must queue behind mBig
411
+ const maxRequestSize = STATEMENT_OVERHEAD + reqPayloadSize(m1, mBig) - 1;
412
+ expect(reqPayloadSize(m1, mSmall)).toBeLessThanOrEqual(reqPayloadSize(m1, mBig) - 1); // mSmall alone could fit
413
+ const { session, adapter } = makeSession({ maxRequestSize });
414
+ await delay();
415
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
416
+ void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6, 7, 8, 9, 10]));
417
+ void session.submitRequestMessage(rawCodec, new Uint8Array([9]));
418
+ await delay();
419
+ const decoded = StatementData.dec(lastSubmitted(adapter).data);
420
+ expect(decoded.tag === 'request' && decoded.value.data.length).toBe(1); // still only the first message
421
+ });
422
+ it('rejects a message whose full request payload exceeds the limit', async () => {
423
+ const m = rawCodec.enc(new Uint8Array([1, 2, 3, 4]));
424
+ // The single-message request payload (requestId + framing + the message) is over budget.
425
+ const { session, adapter } = makeSession({ maxRequestSize: STATEMENT_OVERHEAD + reqPayloadSize(m) - 1 });
426
+ await delay();
427
+ adapter.submitStatement.mockClear();
428
+ const result = await session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3, 4]));
429
+ expect(result.isErr()).toBe(true);
430
+ expect(adapter.submitStatement).not.toHaveBeenCalled();
431
+ });
238
432
  });
239
- describe('subscribe', () => {
240
- const rawCodec = Bytes();
241
- it('delivers buffered init messages when subscriber registers after init', async () => {
433
+ // A single subscription on the incoming topic carries both peer requests and peer responses.
434
+ // Requests are buffered so a subscriber registering after delivery still receives them; already
435
+ // seen statements are dropped. (Spec §4 reading + §6 dedup.)
436
+ describe('receiving statements', () => {
437
+ it('delivers a buffered incoming request to a subscriber that registers after init', async () => {
242
438
  const requestId = 'incoming-req';
243
439
  const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1, 2, 3])] } });
244
- const adapter = makeAdapter();
245
- let callCount = 0;
246
- adapter.queryStatements.mockImplementation(() => {
247
- callCount++;
248
- if (callCount === 2)
249
- return okAsync([peerRequest]);
250
- return okAsync([]);
251
- });
252
- const { session } = makeSession(adapter);
440
+ const { session } = makeSession({ peer: [peerRequest] });
253
441
  await delay(); // init completes
254
442
  const callback = vi.fn();
255
443
  session.subscribe(rawCodec, callback);
@@ -258,18 +446,10 @@ describe('session', () => {
258
446
  expect(messages[0]?.type).toBe('request');
259
447
  expect(messages[0]?.requestId).toBe(requestId);
260
448
  });
261
- it('delivers init messages via subscribe when subscriber is registered before init completes', async () => {
449
+ it('delivers a buffered incoming request to a subscriber that registers before init completes', async () => {
262
450
  const requestId = 'early-subscribe';
263
451
  const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
264
- const adapter = makeAdapter();
265
- let callCount = 0;
266
- adapter.queryStatements.mockImplementation(() => {
267
- callCount++;
268
- if (callCount === 2)
269
- return okAsync([peerRequest]);
270
- return okAsync([]);
271
- });
272
- const { session } = makeSession(adapter);
452
+ const { session } = makeSession({ peer: [peerRequest] });
273
453
  const callback = vi.fn();
274
454
  session.subscribe(rawCodec, callback); // before init completes
275
455
  await delay();
@@ -277,278 +457,334 @@ describe('session', () => {
277
457
  const messages2 = callback.mock.calls[0][0];
278
458
  expect(messages2[0]?.requestId).toBe(requestId);
279
459
  });
280
- it('does NOT deliver already-seen statements from subscription', async () => {
460
+ it('does not redeliver an already-seen statement', async () => {
281
461
  const requestId = 'seen-req';
282
462
  const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
283
- const adapter = makeAdapter();
284
- let queryCallCount = 0;
285
- adapter.queryStatements.mockImplementation(() => {
286
- queryCallCount++;
287
- if (queryCallCount === 2)
288
- return okAsync([peerRequest]);
289
- return okAsync([]);
290
- });
291
- let subscribeCallback;
292
- adapter.subscribeStatements.mockImplementation((_filter, cb) => {
293
- subscribeCallback = cb;
294
- return vi.fn();
295
- });
296
- const { session } = makeSession(adapter);
297
- await delay(); // init sees peerRequest, adds to seenStatements
463
+ const { subscribeStatements, callbacks } = capturingSubscribe();
464
+ const { session } = makeSession({ peer: [peerRequest], subscribeStatements });
465
+ await delay(); // init sees peerRequest, adds it to seenStatements
298
466
  const appCallback = vi.fn();
299
467
  session.subscribe(rawCodec, appCallback);
300
- // Simulate subscription delivering the same statement again
301
- subscribeCallback({ statements: [peerRequest], isComplete: true });
468
+ // The store redelivers the same statement on the incoming topic — dedup must drop it.
469
+ callbacks[0]({ statements: [peerRequest], isComplete: true });
302
470
  await delay();
303
- // Should only be called once (from buffered init message), not again from subscription
471
+ // Called only once (from the buffered init message), not again from the subscription.
304
472
  expect(appCallback).toHaveBeenCalledTimes(1);
305
473
  });
306
- it('does NOT auto-send a response when an incoming request arrives', async () => {
307
- const requestId = 'no-auto-resp';
308
- const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [new Uint8Array([1])] } });
309
- const adapter = makeAdapter();
310
- const subscribeCallbacks = [];
311
- adapter.subscribeStatements.mockImplementation((_filter, cb) => {
312
- subscribeCallbacks.push(cb);
313
- return vi.fn();
474
+ it('does not auto-respond when an incoming request arrives', async () => {
475
+ const peerRequest = makeStatement({
476
+ tag: 'request',
477
+ value: { requestId: 'no-auto-resp', data: [new Uint8Array([1])] },
314
478
  });
315
- adapter.queryStatements.mockReturnValue(okAsync([]));
316
- const { session } = makeSession(adapter);
479
+ const { subscribeStatements, callbacks } = capturingSubscribe();
480
+ const { session, adapter } = makeSession({ subscribeStatements });
317
481
  await delay();
318
482
  const callback = vi.fn();
319
483
  session.subscribe(rawCodec, callback);
320
484
  adapter.submitStatement.mockClear();
321
- // Fire on the incoming topic callback (first subscription)
322
- subscribeCallbacks[0]({ statements: [peerRequest], isComplete: true });
485
+ callbacks[0]({ statements: [peerRequest], isComplete: true });
323
486
  await delay();
324
- // Message delivered to app callback but no automatic response submitted
487
+ // Delivered to the app, but no response is submitted automatically.
325
488
  expect(callback).toHaveBeenCalled();
326
489
  expect(adapter.submitStatement).not.toHaveBeenCalled();
327
490
  });
328
- it('delivers peer request to a subscriber that registers after the batch notification (race condition)', async () => {
329
- // Regression test for PB-439: when peer's request and the ACK response arrive in the
330
- // same subscribeStatements batch, the request is processed before waitForRequestMessage
331
- // has a chance to register its subscriber. The fix ensures request statements are always
332
- // buffered so late subscribers (simulating waitForRequestMessage called in .andThen()
333
- // after waitForResponseMessage resolves) still receive them.
334
- const subscribeCallbacks = [];
335
- const subscribeStatements = vi.fn().mockImplementation((_filter, cb) => {
336
- subscribeCallbacks.push(cb);
337
- return vi.fn();
338
- });
491
+ it('delivers a buffered request to a subscriber that registers after the batch notification', async () => {
492
+ // When a request and an ACK arrive in the same batch, the request is processed before a later
493
+ // waitForRequestMessage registers its subscriber. Requests are buffered so the late subscriber
494
+ // still receives them (otherwise waitForRequestMessage would hang).
495
+ const { subscribeStatements, callbacks } = capturingSubscribe();
339
496
  const { session } = makeSession({ subscribeStatements });
340
497
  await delay();
341
- // Register a dummy subscriber to activate the store subscription (simulates
342
- // any pre-existing subscriber in the session, e.g. the app listening for messages).
498
+ // A pre-existing subscriber activates the store subscription.
343
499
  const dummyUnsub = session.subscribe(rawCodec, vi.fn());
344
500
  const peerRequestId = 'race-condition-request';
345
501
  const peerRequest = makeStatement({
346
502
  tag: 'request',
347
503
  value: { requestId: peerRequestId, data: [new Uint8Array([42])] },
348
504
  });
349
- // Peer request arrives on the incoming topic (first subscription) while the
350
- // dummy subscriber is active but waitForRequestMessage hasn't registered its
351
- // subscriber yet (the race condition scenario).
352
- subscribeCallbacks[0]({ statements: [peerRequest], isComplete: true });
505
+ // The request arrives before the late subscriber registers.
506
+ callbacks[0]({ statements: [peerRequest], isComplete: true });
353
507
  await delay();
354
- // Now the late subscriber registers (simulates waitForRequestMessage being called
355
- // in the .andThen() chain after waitForResponseMessage resolves).
356
508
  const lateCallback = vi.fn();
357
509
  session.subscribe(rawCodec, lateCallback);
358
- // The late subscriber must receive the buffered peer request, otherwise
359
- // waitForRequestMessage would hang indefinitely.
360
510
  expect(lateCallback).toHaveBeenCalledTimes(1);
361
511
  const messages = lateCallback.mock.calls[0][0];
362
512
  expect(messages[0]?.type).toBe('request');
363
513
  expect(messages[0]?.requestId).toBe(peerRequestId);
364
514
  dummyUnsub();
365
515
  });
366
- it('unsubscribing last subscriber tears down the store subscription', () => {
367
- const { session, adapter } = makeSession();
368
- const unsub = session.subscribe(rawCodec, vi.fn());
369
- expect(adapter.subscribeStatements).toHaveBeenCalledTimes(2);
370
- unsub();
371
- // subscribeStatements returns a mock unsubscribe fn — verify it was called
372
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
373
- const storeMockUnsub = adapter.subscribeStatements.mock.results[0].value;
374
- expect(storeMockUnsub).toHaveBeenCalled();
375
- });
376
- it('subscribes to outgoing topic for peer responses alongside incoming topic', () => {
377
- const { session, adapter } = makeSession();
516
+ it('resolves waitForRequestMessage from a request already buffered at subscribe time', async () => {
517
+ // The subscribe() replay can invoke the filter synchronously during registration; the
518
+ // unsubscribe handle must already be usable at that point.
519
+ // The inner payload must decode cleanly with rawCodec so the filter actually runs.
520
+ const peerRequest = makeStatement({
521
+ tag: 'request',
522
+ value: { requestId: 'buf', data: [rawCodec.enc(new Uint8Array([7]))] },
523
+ });
524
+ const { session } = makeSession({ peer: [peerRequest] });
525
+ await delay(); // init buffers the peer request
526
+ const result = await session.waitForRequestMessage(rawCodec, () => 'matched');
527
+ expect(result._unsafeUnwrap()).toBe('matched');
528
+ }, 2000);
529
+ it('opens a single subscription on the incoming topic', () => {
530
+ const store = createInMemoryStatementStore();
531
+ const session = makeHost(store);
378
532
  session.subscribe(rawCodec, vi.fn());
379
- // Two subscriptions: one for incoming (peer requests), one for outgoing (peer responses)
380
- expect(adapter.subscribeStatements).toHaveBeenCalledTimes(2);
533
+ // One subscription: the incoming topic carries both peer requests and peer responses.
534
+ expect(store.activeSubscriptions()).toBe(1);
381
535
  });
382
- it('tears down outgoing subscription when last subscriber leaves', () => {
383
- const { session, adapter } = makeSession();
536
+ it('tears down the subscription when the last subscriber leaves', () => {
537
+ const store = createInMemoryStatementStore();
538
+ const session = makeHost(store);
384
539
  const unsub = session.subscribe(rawCodec, vi.fn());
540
+ expect(store.activeSubscriptions()).toBe(1);
385
541
  unsub();
386
- // Both unsubscribe functions should be called
387
- for (const result of adapter.subscribeStatements.mock.results) {
388
- const mockUnsub = result.value;
389
- expect(mockUnsub).toHaveBeenCalled();
390
- }
542
+ expect(store.activeSubscriptions()).toBe(0);
391
543
  });
392
- it('delivers peer response from outgoing topic subscription to subscribers', async () => {
393
- const subscribeCallbacks = [];
394
- const subscribeStatements = vi.fn().mockImplementation((_topics, cb) => {
395
- subscribeCallbacks.push(cb);
396
- return vi.fn();
397
- });
544
+ it('delivers a peer response to subscribers', async () => {
545
+ const { subscribeStatements, callbacks } = capturingSubscribe();
398
546
  const { session, adapter } = makeSession({ subscribeStatements });
399
547
  await delay();
400
- // Submit a request so the session has an outgoingRequest
401
- void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
548
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1])); // creates an outgoing request
402
549
  await delay();
403
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
404
- const submitted = adapter.submitStatement.mock.calls[0][0];
405
- const decoded = StatementData.dec(submitted.data);
406
- const requestId = decoded.tag === 'request' ? decoded.value.requestId : '';
550
+ const requestId = lastSubmittedRequestId(adapter);
407
551
  const callback = vi.fn();
408
552
  session.subscribe(rawCodec, callback);
409
- // Deliver the response via the SECOND subscription callback (outgoing topic)
410
- // subscribeCallbacks[0] = incoming topic, subscribeCallbacks[1] = outgoing topic
411
553
  const responseStatement = makeStatement({
412
554
  tag: 'response',
413
555
  value: { requestId, responseCode: 'success' },
414
556
  });
415
- subscribeCallbacks[1]({ statements: [responseStatement], isComplete: true });
557
+ callbacks[0]({ statements: [responseStatement], isComplete: true });
416
558
  await delay();
417
- // Subscriber should receive the response
418
559
  const allCalls = callback.mock.calls.flat();
419
560
  const responseMessages = allCalls.flat().filter((m) => m.type === 'response');
420
561
  expect(responseMessages.length).toBeGreaterThan(0);
421
562
  });
422
- it('ignores request-type statements from outgoing topic subscription', async () => {
423
- const subscribeCallbacks = [];
424
- const subscribeStatements = vi.fn().mockImplementation((_topics, cb) => {
425
- subscribeCallbacks.push(cb);
426
- return vi.fn();
427
- });
428
- const { session } = makeSession({ subscribeStatements });
429
- await delay();
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]({ statements: [ownRequest], isComplete: true });
439
- await delay();
440
- // Should NOT be delivered to subscriber (filtered by responsesOnly flag)
441
- expect(callback).not.toHaveBeenCalled();
563
+ it('stops replaying a buffered request to new subscribers once it has been answered', async () => {
564
+ const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'q', data: [new Uint8Array([1])] } });
565
+ const { session } = makeSession({ peer: [peerRequest] });
566
+ await delay(); // init buffers the unanswered request for replay
567
+ await session.submitResponseMessage('q', 'success'); // answer it
568
+ // A subscriber registering afterwards must not be handed the already-answered request
569
+ // (and the buffer must not retain it forever).
570
+ const late = vi.fn();
571
+ session.subscribe(rawCodec, late);
572
+ expect(late).not.toHaveBeenCalled();
573
+ session.dispose();
442
574
  });
443
575
  });
444
- describe('submitResponseMessage', () => {
445
- it('is idempotent second call does not submit again', async () => {
576
+ // We answer the peer's requests by publishing a response on OUR outgoing topic/response-channel.
577
+ // submitResponseMessage is the low-level primitive; respondToRequests auto-answers from a handler.
578
+ // (Spec §4 response placement.)
579
+ describe('responding to incoming requests', () => {
580
+ it('is idempotent — a second response does not submit again', async () => {
446
581
  const requestId = 'req-to-respond';
447
582
  const peerRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
448
- const adapter = makeAdapter();
449
- let callCount = 0;
450
- adapter.queryStatements.mockImplementation(() => {
451
- callCount++;
452
- if (callCount === 2)
453
- return okAsync([peerRequest]);
454
- return okAsync([]);
455
- });
456
- const { session } = makeSession(adapter);
583
+ const { session, adapter } = makeSession({ peer: [peerRequest] });
457
584
  await delay();
458
585
  await session.submitResponseMessage(requestId, 'success');
459
586
  const submitsAfterFirst = adapter.submitStatement.mock.calls.length;
460
587
  await session.submitResponseMessage(requestId, 'success'); // second call
461
588
  expect(adapter.submitStatement.mock.calls.length).toBe(submitsAfterFirst);
462
589
  });
463
- it('returns error when requestId does not match incomingRequest', async () => {
590
+ it('errors when the requestId is unknown', async () => {
464
591
  const { session } = makeSession();
465
592
  await delay();
466
593
  const result = await session.submitResponseMessage('wrong-id', 'success');
467
594
  expect(result.isErr()).toBe(true);
468
595
  });
469
- });
470
- describe('message batching', () => {
471
- const rawCodec = Bytes();
472
- it('sends a single statement for the first message', async () => {
473
- const { session, adapter } = makeSession();
596
+ it('NACKs an undecodable incoming request with decodingFailed', async () => {
597
+ // A request (enum tag 0) whose requestId decodes but whose data vector claims more
598
+ // elements than present → the body decode throws, the requestId is still recoverable.
599
+ const idBytes = new TextEncoder().encode('corrupt-rid');
600
+ const corrupted = new Uint8Array([0x00, idBytes.length << 2, ...idBytes, 0xfe, 0xff, 0xff, 0xff]);
601
+ const statement = { ...makeStatement({ tag: 'request', value: { requestId: 'x', data: [] } }), data: corrupted };
602
+ const { subscribeStatements, callbacks } = capturingSubscribe();
603
+ const { session, adapter } = makeSession({ subscribeStatements });
474
604
  await delay();
475
- void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
605
+ session.subscribe(rawCodec, vi.fn()); // activate the subscription
606
+ adapter.submitStatement.mockClear();
607
+ callbacks[0]({ statements: [statement], isComplete: true });
476
608
  await delay();
477
609
  expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
610
+ const decoded = StatementData.dec(lastSubmitted(adapter).data);
611
+ expect(decoded.tag).toBe('response');
612
+ if (decoded.tag === 'response') {
613
+ expect(decoded.value.requestId).toBe('corrupt-rid');
614
+ expect(decoded.value.responseCode).toBe('decodingFailed');
615
+ }
478
616
  });
479
- it('appends second message to existing request (resubmits with new requestId)', async () => {
480
- const { session, adapter } = makeSession();
617
+ it('drops an undecodable incoming statement with no recoverable request id', async () => {
618
+ // An invalid enum tag — nothing decodes, so there is no id to NACK.
619
+ const corrupted = new Uint8Array([0x05, 0x2c, 1, 2, 3]);
620
+ const statement = { ...makeStatement({ tag: 'request', value: { requestId: 'x', data: [] } }), data: corrupted };
621
+ const { subscribeStatements, callbacks } = capturingSubscribe();
622
+ const { session, adapter } = makeSession({ subscribeStatements });
481
623
  await delay();
482
- void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
483
- void session.submitRequestMessage(rawCodec, new Uint8Array([2]));
624
+ session.subscribe(rawCodec, vi.fn());
625
+ adapter.submitStatement.mockClear();
626
+ callbacks[0]({ statements: [statement], isComplete: true });
484
627
  await delay();
485
- // Two submits: first for msg1, second for msg1+msg2 batched
486
- expect(adapter.submitStatement).toHaveBeenCalledTimes(2);
628
+ expect(adapter.submitStatement).not.toHaveBeenCalled();
487
629
  });
488
- it('queues message that exceeds maxRequestSize', async () => {
489
- const { session, adapter } = makeSession({ maxRequestSize: 5 });
630
+ it('does not NACK an undecodable copy of a request it already knows', async () => {
631
+ // A valid request is being handled; a corrupt copy of it must not trigger a premature
632
+ // decodingFailed that would mask the real response (the `responded` flag is sticky).
633
+ const reqId = 'known-req';
634
+ const valid = makeStatement({ tag: 'request', value: { requestId: reqId, data: [new Uint8Array([1])] } });
635
+ const idBytes = new TextEncoder().encode(reqId);
636
+ const corrupt = new Uint8Array([0x00, idBytes.length << 2, ...idBytes, 0xfe, 0xff, 0xff, 0xff]);
637
+ const corruptStatement = {
638
+ ...makeStatement({ tag: 'request', value: { requestId: 'x', data: [] } }),
639
+ data: corrupt,
640
+ };
641
+ const { subscribeStatements, callbacks } = capturingSubscribe();
642
+ const { session, adapter } = makeSession({ subscribeStatements });
490
643
  await delay();
491
- void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3])); // 3 bytes — fits
492
- void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6, 7])); // 4 bytes doesn't fit with existing
644
+ session.subscribe(rawCodec, vi.fn()); // activate the subscription
645
+ callbacks[0]({ statements: [valid], isComplete: true }); // reqId is now a known incoming request
493
646
  await delay();
494
- // Only first message sent; second is queued
495
- expect(adapter.submitStatement).toHaveBeenCalledTimes(1);
647
+ adapter.submitStatement.mockClear();
648
+ callbacks[0]({ statements: [corruptStatement], isComplete: true }); // corrupt copy of the same id
649
+ await delay();
650
+ expect(adapter.submitStatement).not.toHaveBeenCalled(); // no premature NACK
651
+ // The legitimate response still goes through.
652
+ const res = await session.submitResponseMessage(reqId, 'success');
653
+ expect(res.isOk()).toBe(true);
654
+ const decoded = StatementData.dec(lastSubmitted(adapter).data);
655
+ expect(decoded.tag === 'response' && decoded.value.responseCode).toBe('success');
496
656
  });
497
- it('drains message queue after response received', async () => {
498
- let subscribeCallback;
499
- const subscribeStatements = vi.fn().mockImplementation((_filter, cb) => {
500
- subscribeCallback = cb;
501
- return vi.fn();
502
- });
503
- const { session, adapter } = makeSession({ maxRequestSize: 5, subscribeStatements });
657
+ it('publishes the response on the outgoing topic (same topic as our requests)', async () => {
658
+ const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'rid', data: [] } });
659
+ const { session, adapter } = makeSession({ peer: [peerRequest] });
504
660
  await delay();
505
- session.subscribe(Bytes(), vi.fn()); // ensure store subscription is active
506
- void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3])); // sent
507
- void session.submitRequestMessage(rawCodec, new Uint8Array([4, 5, 6])); // queued (doesn't fit)
661
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1])); // outgoing topic
508
662
  await delay();
509
- const submitCountBefore = adapter.submitStatement.mock.calls.length;
510
- // Simulate peer responding to the first request
511
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
512
- const lastSubmittedStatement = adapter.submitStatement.mock.calls[adapter.submitStatement.mock.calls.length - 1][0];
513
- const decoded = StatementData.dec(lastSubmittedStatement.data);
514
- const respondingRequestId = decoded.tag === 'request' ? decoded.value.requestId : '';
515
- const responseStatement = makeStatement({
516
- tag: 'response',
517
- value: { requestId: respondingRequestId, responseCode: 'success' },
518
- });
519
- subscribeCallback({ statements: [responseStatement], isComplete: true });
663
+ const requestTopics = lastSubmitted(adapter).topics;
664
+ adapter.submitStatement.mockClear();
665
+ await session.submitResponseMessage('rid', 'success'); // must also go on the outgoing topic
666
+ const responseTopics = lastSubmitted(adapter).topics;
667
+ expect(responseTopics).toEqual(requestTopics);
668
+ });
669
+ // respondToRequests is exercised end-to-end over a shared in-memory store: a peer issues a real
670
+ // request and the handler's status flows all the way back through request()/mapResponseCode.
671
+ it('acknowledges an incoming request with the status the handler returns', async () => {
672
+ const store = createInMemoryStatementStore();
673
+ const host = makeHost(store);
674
+ const peer = makeMobile(store);
675
+ peer.subscribe(RemoteMsg, () => undefined); // peer must listen to receive the ACK
676
+ host.respondToRequests(RemoteMsg, () => 'success');
677
+ await settle();
678
+ const ack = await peer.request(RemoteMsg, requestMsg('p1'));
679
+ expect(ack.isOk()).toBe(true);
680
+ host.dispose();
681
+ peer.dispose();
682
+ });
683
+ it('maps a decodingFailed status back to the requester', async () => {
684
+ const store = createInMemoryStatementStore();
685
+ const host = makeHost(store);
686
+ const peer = makeMobile(store);
687
+ peer.subscribe(RemoteMsg, () => undefined);
688
+ host.respondToRequests(RemoteMsg, () => okAsync('decodingFailed'));
689
+ await settle();
690
+ const ack = await peer.request(RemoteMsg, requestMsg('p1'));
691
+ expect(ack.isErr()).toBe(true);
692
+ expect(ack._unsafeUnwrapErr()).toBeInstanceOf(DecodingError);
693
+ host.dispose();
694
+ peer.dispose();
695
+ });
696
+ it('answers with unknown when the handler errors', async () => {
697
+ const store = createInMemoryStatementStore();
698
+ const host = makeHost(store);
699
+ const peer = makeMobile(store);
700
+ peer.subscribe(RemoteMsg, () => undefined);
701
+ host.respondToRequests(RemoteMsg, () => errAsync(new Error('handler boom')));
702
+ await settle();
703
+ const ack = await peer.request(RemoteMsg, requestMsg('p1'));
704
+ expect(ack.isErr()).toBe(true);
705
+ expect(ack._unsafeUnwrapErr()).toBeInstanceOf(UnknownError);
706
+ host.dispose();
707
+ peer.dispose();
708
+ });
709
+ it('invokes the handler once per request and never for peer responses', async () => {
710
+ const store = createInMemoryStatementStore();
711
+ const host = makeHost(store);
712
+ const peer = makeMobile(store);
713
+ peer.subscribe(RemoteMsg, () => undefined);
714
+ const handler = vi.fn(() => 'success');
715
+ host.respondToRequests(RemoteMsg, handler);
716
+ await settle();
717
+ await peer.request(RemoteMsg, requestMsg('p1'));
718
+ // The host's own ACK (echoed back on its incoming topic) must not re-trigger the handler.
719
+ expect(handler).toHaveBeenCalledTimes(1);
720
+ host.dispose();
721
+ peer.dispose();
722
+ });
723
+ it('can answer an earlier incoming request after a newer one arrives', async () => {
724
+ const reqA = makeStatement({ tag: 'request', value: { requestId: 'A', data: [] } });
725
+ const { subscribeStatements, callbacks } = capturingSubscribe();
726
+ const { session } = makeSession({ peer: [reqA], subscribeStatements });
520
727
  await delay();
521
- // Queued message should now be submitted
522
- expect(adapter.submitStatement.mock.calls.length).toBeGreaterThan(submitCountBefore);
728
+ session.subscribe(rawCodec, vi.fn()); // activate the store subscription
729
+ const reqB = makeStatement({ tag: 'request', value: { requestId: 'B', data: [] } });
730
+ callbacks[0]({ statements: [reqB], isComplete: true });
731
+ await delay();
732
+ const resA = await session.submitResponseMessage('A', 'success');
733
+ const resB = await session.submitResponseMessage('B', 'success');
734
+ expect(resA.isOk()).toBe(true);
735
+ expect(resB.isOk()).toBe(true);
523
736
  });
524
- it('waitForResponseMessage resolves when response arrives for batch', async () => {
525
- let subscribeCallback;
526
- const subscribeStatements = vi.fn().mockImplementation((_filter, cb) => {
527
- subscribeCallback = cb;
528
- return vi.fn();
529
- });
530
- const { session, adapter } = makeSession({ subscribeStatements });
737
+ it('remains answerable after response submission retries are exhausted', async () => {
738
+ const peerRequest = makeStatement({ tag: 'request', value: { requestId: 'rid', data: [] } });
739
+ const { session, adapter } = makeSession({ peer: [peerRequest] });
531
740
  await delay();
532
- session.subscribe(Bytes(), vi.fn()); // ensure store subscription is active
533
- const submitResult = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
534
- const token = submitResult.unwrapOr({ requestId: '' }).requestId;
741
+ adapter.submitStatement.mockReturnValue(errAsync(new Error('store rejected')));
742
+ const first = await session.submitResponseMessage('rid', 'success'); // all retries fail → err + rollback
743
+ expect(first.isErr()).toBe(true);
744
+ adapter.submitStatement.mockReturnValue(okAsync(undefined)); // store recovers
745
+ const submitsBefore = adapter.submitStatement.mock.calls.length;
746
+ const second = await session.submitResponseMessage('rid', 'success'); // retryable → submits and succeeds
747
+ expect(second.isOk()).toBe(true);
748
+ expect(adapter.submitStatement.mock.calls.length).toBeGreaterThan(submitsBefore);
749
+ }, 3000);
750
+ it('absorbs a superseded response rejected as ExpiryTooLow and keeps the request answered', async () => {
751
+ // Two incoming requests are answered on the SHARED response channel. The response to A is in
752
+ // flight when the response to B takes over the channel; A then lands at a now-lower expiry and
753
+ // the store rejects it (ExpiryTooLow). That supersession is expected: B's response owns the
754
+ // channel, so A's rejection must be absorbed (not surfaced) and A must stay marked answered —
755
+ // re-answering would only clobber B. (Returning ok here is also what stops respondToRequests
756
+ // from logging it as a failed response.)
757
+ const reqA = makeStatement({ tag: 'request', value: { requestId: 'A', data: [] } });
758
+ const { subscribeStatements, callbacks } = capturingSubscribe();
759
+ const { submitStatement, pendings } = deferredSubmit();
760
+ const { session, adapter } = makeSession({ peer: [reqA], subscribeStatements, submitStatement });
535
761
  await delay();
536
- const responsePromise = session.waitForResponseMessage(token);
537
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
538
- const lastStatement = adapter.submitStatement.mock.calls[adapter.submitStatement.mock.calls.length - 1][0];
539
- const decoded = StatementData.dec(lastStatement.data);
540
- const respondingId = decoded.tag === 'request' ? decoded.value.requestId : '';
541
- subscribeCallback({
542
- statements: [makeStatement({ tag: 'response', value: { requestId: respondingId, responseCode: 'success' } })],
543
- isComplete: true,
544
- });
762
+ session.subscribe(rawCodec, vi.fn()); // activate the store subscription
763
+ const reqB = makeStatement({ tag: 'request', value: { requestId: 'B', data: [] } });
764
+ callbacks[0]({ statements: [reqB], isComplete: true });
545
765
  await delay();
546
- const result = await responsePromise;
547
- expect(result.isOk()).toBe(true);
548
- expect(result.unwrapOr({ responseCode: 'unknown' }).responseCode).toBe('success');
549
- });
766
+ const resAPromise = session.submitResponseMessage('A', 'success'); // in flight on the shared channel
767
+ const resBPromise = session.submitResponseMessage('B', 'success'); // supersedes A
768
+ await delay(); // both reach submitStatement
769
+ expect(pendings).toHaveLength(2);
770
+ pendings.find(p => p.requestId === 'B').settle(ok(undefined)); // B lands, owns the channel
771
+ pendings.find(p => p.requestId === 'A').settle(err(new ExpiryTooLowError(0n, 0n))); // A lands late, rejected
772
+ const resA = await resAPromise;
773
+ const resB = await resBPromise;
774
+ expect(resB.isOk()).toBe(true);
775
+ expect(resA.isOk()).toBe(true); // superseded rejection absorbed, not surfaced as an error
776
+ // A stays answered: re-answering it must NOT submit again (which would clobber B's response).
777
+ const submitsBefore = adapter.submitStatement.mock.calls.length;
778
+ const reAnswer = await session.submitResponseMessage('A', 'success');
779
+ await delay();
780
+ expect(reAnswer.isOk()).toBe(true);
781
+ expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore); // deduped → no resubmit
782
+ session.dispose();
783
+ }, 3000);
550
784
  });
551
- describe('clearOutgoingStatement', () => {
785
+ // clearOutgoingStatement aborts the in-flight request: it drops local state, rejects waiters, and
786
+ // supersedes the on-chain request with an empty batch at a strictly higher expiry.
787
+ describe('aborting the outgoing request', () => {
552
788
  it('is a no-op when there is no outgoing request', async () => {
553
789
  const { session, adapter } = makeSession();
554
790
  await delay();
@@ -557,10 +793,25 @@ describe('session', () => {
557
793
  expect(result.isOk()).toBe(true);
558
794
  expect(adapter.submitStatement.mock.calls.length).toBe(before);
559
795
  });
560
- it('submits an empty request batch on the same channel at >= the live expiry and clears state', async () => {
796
+ it('absorbs an ExpiryTooLow on the superseding empty batch as success', async () => {
797
+ // clearOutgoingStatement runs a single direct submit (no submitWithRetry). If the empty batch
798
+ // is rejected ExpiryTooLow, the channel already advanced past us — the request is already gone
799
+ // — so the clear has effectively happened. The caller must see success, not the sync artifact.
800
+ let calls = 0;
801
+ const submitStatement = vi.fn((stmt) => ++calls === 1
802
+ ? okAsync(undefined) // the request itself lands
803
+ : errAsync(new ExpiryTooLowError(stmt.expiry ?? 0n, (0xffffffffn << 32n) | 9000000000n)));
804
+ const { session } = makeSession({ submitStatement });
805
+ await delay();
806
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
807
+ await delay();
808
+ const cleared = await session.clearOutgoingStatement();
809
+ expect(cleared.isOk()).toBe(true); // ExpiryTooLow suppressed
810
+ session.dispose();
811
+ }, 3000);
812
+ it('submits an empty batch on the same channel at a higher expiry and clears local state', async () => {
561
813
  const { session, adapter } = makeSession();
562
814
  await delay();
563
- const rawCodec = Bytes();
564
815
  void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
565
816
  await delay();
566
817
  const liveCall = adapter.submitStatement.mock.calls.at(-1)?.[0];
@@ -577,7 +828,7 @@ describe('session', () => {
577
828
  expect(clearDecoded.value.data).toEqual([]);
578
829
  expect(clearCall.channel).toBe(liveCall.channel);
579
830
  expect(clearCall.expiry).toBeGreaterThanOrEqual(liveCall.expiry);
580
- // Outgoing state is cleared: the next message starts a brand-new batch (data length 1, not 2).
831
+ // State is cleared: the next message starts a brand-new batch (data length 1, not 2).
581
832
  void session.submitRequestMessage(rawCodec, new Uint8Array([4]));
582
833
  await delay();
583
834
  const afterClear = adapter.submitStatement.mock.calls.at(-1)?.[0];
@@ -585,10 +836,29 @@ describe('session', () => {
585
836
  if (afterDecoded.tag === 'request')
586
837
  expect(afterDecoded.value.data.length).toBe(1);
587
838
  });
839
+ it('evicts the live on-chain request by superseding it at a strictly higher expiry', async () => {
840
+ // The store rejects an equal-expiry write on the same channel, so the empty batch must go out
841
+ // at a strictly higher expiry to actually evict the live request.
842
+ const store = createInMemoryStatementStore();
843
+ const session = makeHost(store);
844
+ await settle();
845
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
846
+ await settle();
847
+ const result = await session.clearOutgoingStatement();
848
+ expect(result.isOk()).toBe(true);
849
+ await settle();
850
+ const requests = store
851
+ .currentStatements()
852
+ .map(s => StatementData.dec(s.data))
853
+ .filter(d => d.tag === 'request');
854
+ expect(requests.some(d => d.tag === 'request' && d.value.data.length > 0)).toBe(false);
855
+ expect(requests.some(d => d.tag === 'request' && d.value.data.length === 0)).toBe(true);
856
+ session.dispose();
857
+ });
588
858
  it('rejects the pending response waiter so callers unwind', async () => {
589
859
  const { session } = makeSession();
590
860
  await delay();
591
- const submit = await session.submitRequestMessage(Bytes(), new Uint8Array([9]));
861
+ const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([9]));
592
862
  expect(submit.isOk()).toBe(true);
593
863
  const requestId = submit._unsafeUnwrap().requestId;
594
864
  const waiter = session.waitForResponseMessage(requestId);
@@ -596,10 +866,9 @@ describe('session', () => {
596
866
  const waited = await waiter;
597
867
  expect(waited.isErr()).toBe(true);
598
868
  });
599
- it('clears local state and rejects waiters even when the superseding submission fails', async () => {
869
+ it('clears local state and rejects waiters even when the supersede submission fails', async () => {
600
870
  const { session, adapter } = makeSession();
601
871
  await delay();
602
- const rawCodec = Bytes();
603
872
  const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([1, 2, 3]));
604
873
  const requestId = submit._unsafeUnwrap().requestId;
605
874
  const waiter = session.waitForResponseMessage(requestId);
@@ -609,7 +878,7 @@ describe('session', () => {
609
878
  // The pending waiter is rejected despite the failed submission.
610
879
  const waited = await waiter;
611
880
  expect(waited.isErr()).toBe(true);
612
- // Local state is cleared: the next message starts a brand-new batch (data length 1, not 2).
881
+ // State is cleared: the next message starts a brand-new batch (data length 1, not 2).
613
882
  adapter.submitStatement.mockReturnValue(okAsync(undefined));
614
883
  void session.submitRequestMessage(rawCodec, new Uint8Array([4]));
615
884
  await delay();
@@ -618,14 +887,14 @@ describe('session', () => {
618
887
  if (afterDecoded.tag === 'request')
619
888
  expect(afterDecoded.value.data.length).toBe(1);
620
889
  });
621
- it('cancels messages queued before the batch is submitted (init still pending)', async () => {
622
- // queryStatements never resolves, so init() stays pending and the message
623
- // sits in the queue with outgoingRequest still null.
890
+ it('cancels messages queued before the batch was submitted (init still pending)', async () => {
891
+ // queryStatements never resolves, so init() stays pending and the message sits in the queue
892
+ // with outgoingRequest still null.
624
893
  const neverResolves = vi
625
894
  .fn()
626
895
  .mockReturnValue(new ResultAsync(new Promise(() => undefined)));
627
896
  const { session, adapter } = makeSession({ queryStatements: neverResolves });
628
- const submit = await session.submitRequestMessage(Bytes(), new Uint8Array([7]));
897
+ const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([7]));
629
898
  const requestId = submit._unsafeUnwrap().requestId;
630
899
  const waiter = session.waitForResponseMessage(requestId);
631
900
  const submitsBefore = adapter.submitStatement.mock.calls.length;
@@ -637,5 +906,333 @@ describe('session', () => {
637
906
  // No empty batch is submitted since there was no live on-chain request yet.
638
907
  expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore);
639
908
  });
909
+ it('does not resurrect the aborted request when a submit retry is pending', async () => {
910
+ let calls = 0;
911
+ const submitStatement = vi.fn(() => {
912
+ calls++;
913
+ return calls === 1 ? errAsync(new Error('transient')) : okAsync(undefined); // first request submit fails → retry scheduled
914
+ });
915
+ const { session, adapter } = makeSession({ submitStatement });
916
+ await delay();
917
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1])); // submit #1 fails, schedules a retry
918
+ await session.clearOutgoingStatement(); // abort: drops outgoing state + empty supersede (submit #2)
919
+ const callsAfterAbort = adapter.submitStatement.mock.calls.length;
920
+ await new Promise(resolve => setTimeout(resolve, 100)); // let the (now-stale) retry window elapse
921
+ expect(adapter.submitStatement.mock.calls.length).toBe(callsAfterAbort); // retry must NOT re-send the request
922
+ session.dispose();
923
+ }, 3000);
924
+ });
925
+ // The spec mandates retrying queries (init) and submit_statement on connection failure. The
926
+ // session retries transient failures with a bounded backoff. (Spec §5.)
927
+ describe('resilience (transient-failure retries)', () => {
928
+ it('retries initialization after a transient query failure', async () => {
929
+ let attempts = 0;
930
+ const queryStatements = vi.fn(() => {
931
+ attempts++;
932
+ return attempts <= 2 ? errAsync(new Error('transient')) : okAsync([]); // first init attempt fails
933
+ });
934
+ const { session, adapter } = makeSession({ queryStatements });
935
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1])); // queued during init
936
+ await new Promise(resolve => setTimeout(resolve, 200)); // allow re-init + activation
937
+ expect(adapter.submitStatement).toHaveBeenCalled(); // queued message sent after successful re-init
938
+ session.dispose();
939
+ }, 3000);
940
+ it('retries a request submission that transiently fails', async () => {
941
+ let calls = 0;
942
+ const submitStatement = vi.fn(() => {
943
+ calls++;
944
+ return calls === 1 ? errAsync(new Error('transient')) : okAsync(undefined);
945
+ });
946
+ const { session, adapter } = makeSession({ submitStatement });
947
+ await delay();
948
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
949
+ await new Promise(resolve => setTimeout(resolve, 150)); // allow retry
950
+ expect(adapter.submitStatement.mock.calls.length).toBeGreaterThanOrEqual(2); // 1 failure + ≥1 retry
951
+ session.dispose();
952
+ }, 3000);
953
+ it('resyncs its expiry above the chain minimum after an ExpiryTooLow rejection', async () => {
954
+ // The in-memory expiry counter has drifted behind the channel's real priority (prior run /
955
+ // other writer / propagation lag). The chain reports the minimum; the retry must clear it.
956
+ const CHAIN_MIN = (0xffffffffn << 32n) | 4000000000n; // well above the wall-clock priority
957
+ let calls = 0;
958
+ const submitStatement = vi.fn((stmt) => {
959
+ calls++;
960
+ return calls === 1 ? errAsync(new ExpiryTooLowError(stmt.expiry ?? 0n, CHAIN_MIN)) : okAsync(undefined);
961
+ });
962
+ const { session, adapter } = makeSession({ submitStatement });
963
+ await delay();
964
+ void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
965
+ await new Promise(resolve => setTimeout(resolve, 100)); // allow the retry (25ms backoff)
966
+ expect(adapter.submitStatement.mock.calls.length).toBeGreaterThanOrEqual(2);
967
+ const retried = adapter.submitStatement.mock.calls.at(-1)?.[0];
968
+ expect(retried.expiry ?? 0n).toBeGreaterThan(CHAIN_MIN); // healed past the chain minimum
969
+ session.dispose();
970
+ }, 3000);
971
+ it('keeps retrying a live ExpiryTooLow past the transient-retry cap until it lands', async () => {
972
+ // ExpiryTooLow is a sync artifact, not a chain failure: while the submission is still live the
973
+ // session keeps retrying (resyncing each time) BEYOND MAX_SUBMIT_RETRIES until it lands, and
974
+ // never surfaces ExpiryTooLow to the caller. (A non-ExpiryTooLow error gives up at the cap —
975
+ // see the test below.)
976
+ const CHAIN_MIN = (0xffffffffn << 32n) | 4000000000n;
977
+ let calls = 0;
978
+ const submitStatement = vi.fn((stmt) => ++calls <= 6 ? errAsync(new ExpiryTooLowError(stmt.expiry ?? 0n, CHAIN_MIN)) : okAsync(undefined));
979
+ const { session } = makeSession({ submitStatement });
980
+ await delay();
981
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
982
+ try {
983
+ const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
984
+ let waiterRejected = false;
985
+ void session.waitForResponseMessage(submit._unsafeUnwrap().requestId).mapErr(() => (waiterRejected = true));
986
+ await new Promise(resolve => setTimeout(resolve, 300)); // 6 retries × 25ms backoff + slack
987
+ expect(calls).toBeGreaterThanOrEqual(7); // retried well past the 3-attempt cap, then landed
988
+ expect(errorSpy).not.toHaveBeenCalledWith('submitRequest failed:', expect.anything());
989
+ expect(waiterRejected).toBe(false); // ExpiryTooLow never surfaced to the caller
990
+ }
991
+ finally {
992
+ errorSpy.mockRestore();
993
+ session.dispose();
994
+ }
995
+ }, 3000);
996
+ it('rejects the pending waiter once request-submission retries are exhausted', async () => {
997
+ const { session } = makeSession({
998
+ submitStatement: vi.fn().mockReturnValue(errAsync(new Error('store rejected'))),
999
+ });
1000
+ await delay();
1001
+ const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
1002
+ const requestId = submit._unsafeUnwrap().requestId;
1003
+ const waited = await session.waitForResponseMessage(requestId);
1004
+ expect(waited.isErr()).toBe(true);
1005
+ }, 2000);
1006
+ it('absorbs a superseded older submission rejected as ExpiryTooLow without surfacing an error', async () => {
1007
+ // Two messages batch onto one outgoing request: the first submission (requestId A) is in
1008
+ // flight when the second (requestId B, higher expiry, SAME tokens) is sent. B lands first and
1009
+ // sets the channel priority; A then lands at a now-lower expiry and the store rejects it with
1010
+ // ExpiryTooLow. A is superseded, so its rejection is expected protocol behaviour — it must not
1011
+ // be logged as an error and must not reject the shared waiters (B carries them).
1012
+ const { submitStatement, pendings } = deferredSubmit();
1013
+ const { session, adapter } = makeSession({ submitStatement });
1014
+ await delay();
1015
+ const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
1016
+ try {
1017
+ const first = (await session.submitRequestMessage(rawCodec, new Uint8Array([1])))._unsafeUnwrap();
1018
+ void session.submitRequestMessage(rawCodec, new Uint8Array([2])); // batches onto the same outgoing request
1019
+ await delay(); // let both submissions reach submitStatement
1020
+ expect(pendings).toHaveLength(2);
1021
+ const liveRequestId = lastSubmittedRequestId(adapter); // the newer (B) submission
1022
+ const live = pendings.find(p => p.requestId === liveRequestId);
1023
+ const superseded = pendings.find(p => p.requestId !== liveRequestId);
1024
+ let firstWaiterRejected = false;
1025
+ void session.waitForResponseMessage(first.requestId).mapErr(() => {
1026
+ firstWaiterRejected = true;
1027
+ });
1028
+ live.settle(ok(undefined)); // B lands, claims the channel priority
1029
+ await delay();
1030
+ superseded.settle(err(new ExpiryTooLowError(0n, 0n))); // A lands late and is rejected
1031
+ await delay();
1032
+ expect(errorSpy).not.toHaveBeenCalledWith('submitRequest failed:', expect.anything());
1033
+ expect(firstWaiterRejected).toBe(false); // superseded failure must not reject the shared waiter
1034
+ }
1035
+ finally {
1036
+ errorSpy.mockRestore();
1037
+ session.dispose();
1038
+ }
1039
+ }, 3000);
1040
+ });
1041
+ describe('dispose', () => {
1042
+ it('rejects pending waitForRequestMessage waiters', async () => {
1043
+ const { session } = makeSession();
1044
+ await delay();
1045
+ const waiter = session.waitForRequestMessage(rawCodec, () => 'x');
1046
+ session.dispose();
1047
+ const result = await waiter;
1048
+ expect(result.isErr()).toBe(true);
1049
+ }, 2000);
1050
+ it('cancels a pending init retry (no further queries)', async () => {
1051
+ const queryStatements = vi.fn(() => errAsync(new Error('store down'))); // init always fails → schedules retry
1052
+ const { session } = makeSession({ queryStatements });
1053
+ await delay(); // first init attempt completes (2 queries) and schedules a retry
1054
+ const callsBeforeDispose = queryStatements.mock.calls.length;
1055
+ session.dispose();
1056
+ await new Promise(resolve => setTimeout(resolve, 100)); // retry window elapses
1057
+ expect(queryStatements.mock.calls.length).toBe(callsBeforeDispose); // disposed → no further init queries
1058
+ }, 3000);
1059
+ it('rejects submitRequestMessage after dispose instead of hanging', async () => {
1060
+ const { session, adapter } = makeSession();
1061
+ await delay();
1062
+ session.dispose();
1063
+ const result = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
1064
+ expect(result.isErr()).toBe(true); // surfaced immediately, not a token left pending forever
1065
+ expect(adapter.submitStatement).not.toHaveBeenCalled();
1066
+ });
1067
+ it('rejects submitResponseMessage after dispose', async () => {
1068
+ const { session } = makeSession();
1069
+ await delay();
1070
+ session.dispose();
1071
+ const result = await session.submitResponseMessage('any-id', 'success');
1072
+ expect(result.isErr()).toBe(true);
1073
+ });
1074
+ it('does not re-activate when disposed while init is in flight', async () => {
1075
+ // dispose() lands during init's query await; init must bail before restoring state / flipping
1076
+ // phase to 'active', otherwise a torn-down session looks alive and accepts new work.
1077
+ let resolveQueries;
1078
+ const gate = new Promise(resolve => (resolveQueries = resolve));
1079
+ const queryStatements = vi.fn(() => ResultAsync.fromSafePromise(gate));
1080
+ const { session, adapter } = makeSession({ queryStatements });
1081
+ const queued = await session.submitRequestMessage(rawCodec, new Uint8Array([1])); // queued during init
1082
+ expect(queued.isOk()).toBe(true);
1083
+ session.dispose(); // dispose mid-init
1084
+ resolveQueries([]); // init resumes — must bail before draining the queue / activating
1085
+ await settle();
1086
+ expect(adapter.submitStatement).not.toHaveBeenCalled(); // no resurrection-driven submit
1087
+ const after = await session.submitRequestMessage(rawCodec, new Uint8Array([2]));
1088
+ expect(after.isErr()).toBe(true); // session stays disposed
1089
+ }, 3000);
1090
+ });
1091
+ // The in-memory adapter replicates the store's observable contract; `fidelity` pins the double's
1092
+ // behaviour, then end-to-end flows run two mirrored sessions (host + mobile) over ONE shared store.
1093
+ describe('in-memory statement store', () => {
1094
+ const hex = (fill) => `0x${fill.toString(16).padStart(2, '0').repeat(32)}`;
1095
+ function makeSignedStatement(channel, expiry, topic, data) {
1096
+ return {
1097
+ channel: channel,
1098
+ expiry,
1099
+ topics: [topic],
1100
+ data,
1101
+ proof: { type: 'sr25519', value: { signature: `0x${'00'.repeat(64)}`, signer: `0x${'00'.repeat(32)}` } },
1102
+ };
1103
+ }
1104
+ describe('fidelity', () => {
1105
+ it('accepts a new statement and returns it from a matching query', async () => {
1106
+ const store = createInMemoryStatementStore();
1107
+ await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1])));
1108
+ const found = (await store.queryStatements({ matchAll: [new Uint8Array(32).fill(1)] }))._unsafeUnwrap();
1109
+ expect(found).toHaveLength(1);
1110
+ expect(found[0]?.expiry).toBe(10n);
1111
+ });
1112
+ it('replaces a same-channel statement only with a strictly higher expiry', async () => {
1113
+ const store = createInMemoryStatementStore();
1114
+ await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1])));
1115
+ const higher = await store.submitStatement(makeSignedStatement(hex(0xaa), 11n, hex(0x01), new Uint8Array([2])));
1116
+ expect(higher.isOk()).toBe(true);
1117
+ expect(store.currentStatements()).toHaveLength(1);
1118
+ expect(store.currentStatements()[0]?.expiry).toBe(11n);
1119
+ });
1120
+ it('rejects a same-channel statement with equal or lower expiry (ExpiryTooLowError)', async () => {
1121
+ const store = createInMemoryStatementStore();
1122
+ await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1])));
1123
+ const equal = await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([9])));
1124
+ const lower = await store.submitStatement(makeSignedStatement(hex(0xaa), 5n, hex(0x01), new Uint8Array([9])));
1125
+ expect(equal.isErr()).toBe(true);
1126
+ expect(equal._unsafeUnwrapErr()).toBeInstanceOf(ExpiryTooLowError);
1127
+ expect(lower.isErr()).toBe(true);
1128
+ // The original statement is untouched.
1129
+ expect(store.currentStatements()[0]?.data).toEqual(new Uint8Array([1]));
1130
+ });
1131
+ it('treats a byte-identical resubmission as known (ok, no duplicate)', async () => {
1132
+ const store = createInMemoryStatementStore();
1133
+ const stmt = makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1]));
1134
+ await store.submitStatement(stmt);
1135
+ const again = await store.submitStatement(stmt);
1136
+ expect(again.isOk()).toBe(true);
1137
+ expect(store.currentStatements()).toHaveLength(1);
1138
+ });
1139
+ it('coexists statements on different channels sharing a topic', async () => {
1140
+ const store = createInMemoryStatementStore();
1141
+ await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1])));
1142
+ await store.submitStatement(makeSignedStatement(hex(0xbb), 10n, hex(0x01), new Uint8Array([2])));
1143
+ const found = (await store.queryStatements({ matchAll: [new Uint8Array(32).fill(1)] }))._unsafeUnwrap();
1144
+ expect(found).toHaveLength(2);
1145
+ });
1146
+ it('streams only post-subscription matching statements to live subscribers', async () => {
1147
+ const store = createInMemoryStatementStore();
1148
+ await store.submitStatement(makeSignedStatement(hex(0xaa), 10n, hex(0x01), new Uint8Array([1])));
1149
+ const pages = [];
1150
+ store.subscribeStatements({ matchAll: [new Uint8Array(32).fill(1)] }, page => pages.push(page));
1151
+ // Pre-existing statement is NOT replayed; a new matching one is delivered.
1152
+ expect(pages).toHaveLength(0);
1153
+ await store.submitStatement(makeSignedStatement(hex(0xaa), 11n, hex(0x01), new Uint8Array([2])));
1154
+ expect(pages).toHaveLength(1);
1155
+ expect(pages[0]?.statements[0]?.expiry).toBe(11n);
1156
+ // A non-matching topic is not delivered.
1157
+ await store.submitStatement(makeSignedStatement(hex(0xcc), 10n, hex(0x02), new Uint8Array([3])));
1158
+ expect(pages).toHaveLength(1);
1159
+ });
1160
+ });
1161
+ describe('end-to-end flows (host ↔ mobile over a shared store)', () => {
1162
+ it('completes a request → ACK → reply → ACK round trip', async () => {
1163
+ const store = createInMemoryStatementStore();
1164
+ const host = makeHost(store);
1165
+ const mobile = makeMobile(store);
1166
+ // Mobile acknowledges every incoming request and, on seeing the host's
1167
+ // request, sends its application reply back as a new request.
1168
+ mobile.respondToRequests(RemoteMsg, () => 'success');
1169
+ const mobileGotRequest = mobile.waitForRequestMessage(RemoteMsg, msg => msg.kind === 'request' ? msg : undefined);
1170
+ // Host acknowledges the mobile's reply.
1171
+ host.respondToRequests(RemoteMsg, () => 'success');
1172
+ await settle();
1173
+ const hostAck = host.request(RemoteMsg, { id: 'h1', kind: 'request', respondingTo: '', body: 'sign this' });
1174
+ const mobileReplyAck = mobileGotRequest.andThen(req => mobile.request(RemoteMsg, { id: 'm1', kind: 'reply', respondingTo: req.id, body: 'signature' }));
1175
+ const hostReply = host.waitForRequestMessage(RemoteMsg, msg => msg.kind === 'reply' && msg.respondingTo === 'h1' ? msg.body : undefined);
1176
+ await settle();
1177
+ expect((await hostAck).isOk()).toBe(true); // mobile ACKed the host request
1178
+ expect((await mobileReplyAck).isOk()).toBe(true); // host ACKed the mobile reply
1179
+ expect((await hostReply)._unsafeUnwrap()).toBe('signature'); // host received the reply
1180
+ host.dispose();
1181
+ mobile.dispose();
1182
+ });
1183
+ it('answers an incoming request that went unanswered until a restart', async () => {
1184
+ const store = createInMemoryStatementStore();
1185
+ const host = makeHost(store);
1186
+ // Host must be listening to receive the eventual ACK.
1187
+ host.respondToRequests(RemoteMsg, () => 'success');
1188
+ // Mobile receives the request but never responds (no responder registered).
1189
+ let mobile = makeMobile(store);
1190
+ mobile.subscribe(RemoteMsg, () => undefined); // activate the store subscription, but do not ACK
1191
+ await settle();
1192
+ const hostAck = host.request(RemoteMsg, { id: 'h1', kind: 'request', respondingTo: '', body: 'sign this' });
1193
+ await settle();
1194
+ const beforeRestart = await Promise.race([
1195
+ Promise.resolve(hostAck).then(() => 'resolved'),
1196
+ new Promise(resolve => setTimeout(() => resolve('pending'), 20)),
1197
+ ]);
1198
+ expect(beforeRestart).toBe('pending'); // unanswered while the responder is absent
1199
+ // Restart: a fresh mobile session on the same store rediscovers the
1200
+ // unanswered request via init() and now answers it.
1201
+ mobile.dispose();
1202
+ mobile = makeMobile(store);
1203
+ mobile.respondToRequests(RemoteMsg, () => 'success');
1204
+ await settle();
1205
+ expect((await hostAck).isOk()).toBe(true);
1206
+ host.dispose();
1207
+ mobile.dispose();
1208
+ });
1209
+ it('delivers the mobile reply to the host independently of the request ACK', async () => {
1210
+ // The application reply (waitForRequestMessage) and the transport ACK (request →
1211
+ // waitForResponseMessage) are independent channels. Here the mobile sends ONLY the reply
1212
+ // and never ACKs the host request — the host must still receive the reply while its request
1213
+ // ACK stays outstanding.
1214
+ const store = createInMemoryStatementStore();
1215
+ const host = makeHost(store);
1216
+ const mobile = makeMobile(store);
1217
+ const mobileGotRequest = mobile.waitForRequestMessage(RemoteMsg, msg => msg.kind === 'request' ? msg : undefined);
1218
+ void mobileGotRequest.andThen(() =>
1219
+ // Fire-and-forget the reply (do not wait for the host to ACK it).
1220
+ mobile.submitRequestMessage(RemoteMsg, { id: 'm1', kind: 'reply', respondingTo: 'h1', body: 'signature' }));
1221
+ await settle();
1222
+ const hostReply = host.waitForRequestMessage(RemoteMsg, msg => msg.kind === 'reply' && msg.respondingTo === 'h1' ? msg.body : undefined);
1223
+ const hostAck = host.request(RemoteMsg, { id: 'h1', kind: 'request', respondingTo: '', body: 'sign this' });
1224
+ await settle();
1225
+ // The reply is delivered…
1226
+ expect((await hostReply)._unsafeUnwrap()).toBe('signature');
1227
+ // …while the transport ACK is still outstanding (mobile never sent it).
1228
+ const ackState = await Promise.race([
1229
+ Promise.resolve(hostAck).then(() => 'resolved'),
1230
+ new Promise(resolve => setTimeout(() => resolve('pending'), 20)),
1231
+ ]);
1232
+ expect(ackState).toBe('pending');
1233
+ host.dispose();
1234
+ mobile.dispose();
1235
+ });
1236
+ });
640
1237
  });
641
1238
  });