@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.
- package/dist/adapter/inMemory.d.ts +32 -0
- package/dist/adapter/inMemory.js +56 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -1
- package/dist/session/session.d.ts +7 -0
- package/dist/session/session.js +357 -132
- package/dist/session/session.spec.js +802 -335
- package/dist/session/statementProver.d.ts +3 -0
- package/dist/session/statementProver.js +20 -4
- package/dist/session/statementProver.spec.d.ts +1 -0
- package/dist/session/statementProver.spec.js +71 -0
- package/dist/session/types.d.ts +12 -0
- package/package.json +3 -3
|
@@ -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(
|
|
53
|
+
function makeSession(opts = {}) {
|
|
51
54
|
const { localAccount, remoteAccount } = makeAccounts();
|
|
52
|
-
const { maxRequestSize, ...adapterOverrides } =
|
|
53
|
-
const adapter =
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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('
|
|
77
|
-
const
|
|
78
|
-
|
|
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('
|
|
83
|
-
const high =
|
|
84
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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('
|
|
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 =
|
|
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
|
-
//
|
|
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
|
-
|
|
126
|
-
if (submittedStatement) {
|
|
127
|
-
expect(submittedStatement.expiry).toBeGreaterThan(highExpiry);
|
|
128
|
-
}
|
|
186
|
+
expect(lastSubmitted(adapter).expiry).toBeGreaterThan(highExpiry);
|
|
129
187
|
});
|
|
130
|
-
it('
|
|
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
|
|
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(
|
|
193
|
+
session.subscribe(rawCodec, callback);
|
|
145
194
|
expect(callback).toHaveBeenCalled();
|
|
146
195
|
});
|
|
147
|
-
it('
|
|
196
|
+
it('queues messages submitted before init completes and sends them once active', async () => {
|
|
148
197
|
const { session, adapter } = makeSession();
|
|
149
|
-
//
|
|
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
|
|
202
|
+
// After init the queue is drained.
|
|
155
203
|
expect(adapter.submitStatement).toHaveBeenCalled();
|
|
156
204
|
});
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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('
|
|
179
|
-
|
|
180
|
-
const ownRequest = makeStatement({ tag: 'request', value: { requestId, data: [] } });
|
|
181
|
-
const peerResponse = makeStatement({ tag: 'response', value: { requestId, responseCode: 'success' } });
|
|
182
|
-
const adapter =
|
|
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
|
-
//
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
expect(
|
|
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
|
|
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
|
|
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('
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
//
|
|
301
|
-
|
|
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
|
-
//
|
|
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
|
|
307
|
-
const
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
316
|
-
const { session } = makeSession(
|
|
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
|
-
|
|
322
|
-
subscribeCallbacks[0]({ statements: [peerRequest], isComplete: true });
|
|
495
|
+
callbacks[0]({ statements: [peerRequest], isComplete: true });
|
|
323
496
|
await delay();
|
|
324
|
-
//
|
|
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
|
|
329
|
-
//
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
350
|
-
|
|
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('
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
//
|
|
380
|
-
expect(
|
|
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
|
|
383
|
-
const
|
|
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
|
-
|
|
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
|
|
393
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
445
|
-
|
|
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 =
|
|
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('
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
const
|
|
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
|
-
|
|
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('
|
|
480
|
-
|
|
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
|
-
|
|
483
|
-
|
|
634
|
+
session.subscribe(rawCodec, vi.fn());
|
|
635
|
+
adapter.submitStatement.mockClear();
|
|
636
|
+
callbacks[0]({ statements: [statement], isComplete: true });
|
|
484
637
|
await delay();
|
|
485
|
-
|
|
486
|
-
expect(adapter.submitStatement).toHaveBeenCalledTimes(2);
|
|
638
|
+
expect(adapter.submitStatement).not.toHaveBeenCalled();
|
|
487
639
|
});
|
|
488
|
-
it('
|
|
489
|
-
|
|
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
|
-
|
|
492
|
-
|
|
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
|
-
|
|
495
|
-
|
|
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
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
-
|
|
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
|
-
|
|
522
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
const
|
|
534
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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
|
|
547
|
-
|
|
548
|
-
expect(
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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(
|
|
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
|
|
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
|
-
//
|
|
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
|
|
622
|
-
// queryStatements never resolves, so init() stays pending and the message
|
|
623
|
-
//
|
|
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(
|
|
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
|
});
|