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

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