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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,38 +1,127 @@
1
- import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
2
1
  import { nanoid } from 'nanoid';
3
2
  import { ResultAsync, err, errAsync, fromPromise, fromThrowable, ok, okAsync } from 'neverthrow';
4
3
  import { toHex } from 'polkadot-api/utils';
4
+ import { Struct, str } from 'scale-ts';
5
+ import { ExpiryTooLowError } from '../adapter/types.js';
5
6
  import { khash, stringToBytes } from '../crypto.js';
6
7
  import { nonNullable, toError } from '../helpers.js';
7
8
  import { createSessionId } from '../model/session.js';
8
9
  import { DecodingError, DecryptionError, UnknownError } from './error.js';
9
10
  import { toMessage } from './messageMapper.js';
11
+ import { nextExpiry } from './priority.js';
10
12
  import { StatementData } from './scale/statementData.js';
11
- const DEFAULT_EXPIRY_DURATION_SECS = 7 * 24 * 60 * 60; // 7 days
12
13
  const DEFAULT_MAX_REQUEST_SIZE = 4096;
13
- export function nextExpiry(current) {
14
- const fresh = createExpiryFromDuration(DEFAULT_EXPIRY_DURATION_SECS);
15
- return fresh > current ? fresh : current + 1n;
14
+ // Rejection reason shared by dispose() and the disposed guards on submit*, so a torn-down session
15
+ // always fails new and in-flight work the same way.
16
+ const SESSION_DISPOSED = 'Session disposed';
17
+ // Bounded retry for transient transport failures (the spec mandates retrying queries
18
+ // and submit_statement on connection failure). The TS adapter doesn't expose connection
19
+ // state, so we approximate with a short fixed backoff and an attempt cap.
20
+ const MAX_INIT_RETRIES = 3;
21
+ const MAX_SUBMIT_RETRIES = 3;
22
+ const RETRY_DELAY_MS = 25;
23
+ /**
24
+ * Fixed per-statement wire overhead reserved before sizing the request payload:
25
+ * topic (32) + channel (32) + expiry (8) + proof signature (64) + signer (32).
26
+ * Mirrors the Android/iOS sessions, which size message batches against
27
+ * `maxStatementSize - overhead` rather than the raw statement limit.
28
+ */
29
+ export const STATEMENT_OVERHEAD = 32 + 32 + 8 + 64 + 32; // 168 bytes
30
+ // Encode/decode a StatementData envelope, surfacing scale-ts throws as a Result.
31
+ const encodeStatementData = fromThrowable(StatementData.enc, toError);
32
+ const decodeStatementData = fromThrowable(StatementData.dec, toError);
33
+ // Best-effort recovery of the requestId from a decrypted-but-undecodable payload. The requestId
34
+ // is the first field after the enum tag, so it usually survives a corrupt message body. Only
35
+ // requests (tag 0) carry an id we should answer; responses (tag 1) and unrecoverable payloads
36
+ // return null and are dropped rather than NACKed.
37
+ const RequestIdPrefix = Struct({ requestId: str });
38
+ const decodeRequestIdPrefix = fromThrowable(
39
+ // slice (a copy), not subarray: scale-ts decodes from the backing buffer start and ignores
40
+ // a view's byteOffset, so a subarray would be read from the wrong position.
41
+ (decrypted) => RequestIdPrefix.dec(decrypted.slice(1)).requestId, () => null);
42
+ function recoverRequestId(decrypted) {
43
+ if (decrypted.length < 1 || decrypted[0] !== 0)
44
+ return null;
45
+ return decodeRequestIdPrefix(decrypted).unwrapOr(null);
46
+ }
47
+ // nanoid() is fixed-length, so the requestId contributes a constant size; any
48
+ // placeholder of that length yields the real encoded size.
49
+ const SIZING_REQUEST_ID = 'x'.repeat(21);
50
+ // Encoded size of the request payload these messages would occupy in a statement's `data`
51
+ // field — the full SCALE envelope (requestId + vector framing), not just the raw bytes. This
52
+ // is what must fit the per-statement budget, matching iOS/Android (which size the full payload).
53
+ function requestPayloadSize(messages) {
54
+ return encodeStatementData({ tag: 'request', value: { requestId: SIZING_REQUEST_ID, data: messages } })
55
+ .map(d => d.length)
56
+ .unwrapOr(Number.MAX_SAFE_INTEGER); // unencodable → treat as "doesn't fit"
57
+ }
58
+ // A response promise paired with its resolver/rejecter. The pre-attached catch
59
+ // keeps a clearOutgoingStatement()/dispose() rejection from surfacing as an
60
+ // unhandled rejection when no caller awaited it via waitForResponseMessage().
61
+ function makeDeferred() {
62
+ let resolve;
63
+ let reject;
64
+ const promise = new Promise((res, rej) => {
65
+ resolve = res;
66
+ reject = rej;
67
+ });
68
+ promise.catch(() => undefined);
69
+ return { resolve, reject, promise };
70
+ }
71
+ // Retry a submit on failure with a short backoff. `shouldRetry` is re-checked before each retry:
72
+ // once the submission is superseded, aborted, or the session is disposed it returns false, so a
73
+ // stale retry can never resurrect an old statement.
74
+ //
75
+ // ExpiryTooLow is treated specially: it is never a chain/statement failure, only a sign our
76
+ // in-memory expiry lagged the channel's on-chain priority. submitStatementData has already resynced
77
+ // us above the reported minimum, so the next attempt submits higher. We therefore retry ExpiryTooLow
78
+ // WITHOUT spending the `attemptsLeft` transient-failure budget — keeping at it until it lands or the
79
+ // submission is superseded — and, once superseded, swallow it as success. The upshot: ExpiryTooLow
80
+ // never surfaces to callers. (Other errors keep the bounded retry and propagate when exhausted.)
81
+ function submitWithRetry(submit, attemptsLeft, shouldRetry) {
82
+ // How to settle once we stop retrying: a no-longer-live submission rejected with ExpiryTooLow
83
+ // simply lost the channel race to a newer, higher-priority statement — benign, so report success.
84
+ const settle = (error) => !shouldRetry() && error instanceof ExpiryTooLowError ? okAsync(undefined) : errAsync(error);
85
+ return submit().orElse(error => {
86
+ const expiryTooLow = error instanceof ExpiryTooLowError;
87
+ if (!shouldRetry() || (!expiryTooLow && attemptsLeft <= 0))
88
+ return settle(error);
89
+ // ExpiryTooLow is always recoverable while live, so it doesn't consume the retry budget.
90
+ const nextAttempts = expiryTooLow ? attemptsLeft : attemptsLeft - 1;
91
+ return ResultAsync.fromSafePromise(new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))).andThen(() => shouldRetry() ? submitWithRetry(submit, nextAttempts, shouldRetry) : settle(error));
92
+ });
16
93
  }
17
94
  export function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, sessionKey, maxRequestSize = DEFAULT_MAX_REQUEST_SIZE, }) {
18
95
  const outgoingSessionId = createSessionId(sessionKey, localAccount, remoteAccount);
19
96
  const incomingSessionId = createSessionId(sessionKey, remoteAccount, localAccount);
97
+ // Message bytes must fit within the statement limit minus the fixed wire overhead.
98
+ const maxPayloadSize = Math.max(0, maxRequestSize - STATEMENT_OVERHEAD);
20
99
  const state = {
21
100
  phase: 'initialization',
22
101
  initError: null,
23
102
  expiry: 0n,
24
103
  outgoingRequest: null,
25
- incomingRequest: null,
26
- respondedIncomingRequest: false,
104
+ incomingRequests: new Map(),
27
105
  messageQueue: [],
28
106
  pendingDelivery: new Map(),
29
107
  seenStatements: new Set(),
30
108
  };
31
109
  let subscribers = [];
110
+ // Reject callbacks for in-flight waitForRequestMessage() promises, so dispose()
111
+ // can settle them instead of leaving them to hang forever.
112
+ const requestWaiters = new Set();
32
113
  const bufferedMessages = [];
33
114
  let storeUnsub = null;
34
- let responseStoreUnsub = null;
35
- function submitStatementAt(expiry, channel, topicSessionId, data) {
115
+ let initRetries = 0;
116
+ let initRetryTimer = null;
117
+ let disposed = false;
118
+ // Id of the most recent response we initiated (responses share one channel, so only the
119
+ // latest is live — a retry for an older one must not resurrect it).
120
+ let lastResponseRequestId = null;
121
+ // Submit on `channel`/`topicSessionId` at the next (strictly increasing) expiry.
122
+ function submitStatementData(channel, topicSessionId, data) {
123
+ state.expiry = nextExpiry(state.expiry);
124
+ const expiry = state.expiry;
36
125
  return encryption
37
126
  .encrypt(data)
38
127
  .map(encrypted => ({
@@ -42,18 +131,46 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
42
131
  data: encrypted,
43
132
  }))
44
133
  .asyncAndThen(prover.generateMessageProof)
45
- .andThen(statementStore.submitStatement);
134
+ .andThen(statementStore.submitStatement)
135
+ .orElse(error => {
136
+ // The chain is the source of truth for a channel's priority. If our in-memory counter
137
+ // drifted behind it (a prior run, another writer, or propagation lag), resync to the
138
+ // reported minimum so the retry — and every later submit — clears it.
139
+ if (error instanceof ExpiryTooLowError && error.min > state.expiry)
140
+ state.expiry = error.min;
141
+ return errAsync(error);
142
+ });
46
143
  }
47
- function submitStatementData(channel, topicSessionId, data) {
48
- state.expiry = nextExpiry(state.expiry);
49
- return submitStatementAt(state.expiry, channel, topicSessionId, data);
144
+ // Settle and remove the pending-delivery entries for the given tokens.
145
+ function settleTokens(tokens, settle) {
146
+ for (const token of tokens) {
147
+ const deferred = state.pendingDelivery.get(token);
148
+ if (deferred) {
149
+ settle(deferred);
150
+ state.pendingDelivery.delete(token);
151
+ }
152
+ }
50
153
  }
51
154
  function encodeAndSubmitRequest(requestId, messages) {
52
- const encode = fromThrowable(StatementData.enc, toError);
53
- encode({ tag: 'request', value: { requestId, data: messages } })
54
- .asyncAndThen(data => submitStatementData(createRequestChannel(outgoingSessionId), outgoingSessionId, data))
155
+ encodeStatementData({ tag: 'request', value: { requestId, data: messages } })
156
+ .asyncAndThen(data => submitWithRetry(() => submitStatementData(createRequestChannel(outgoingSessionId), outgoingSessionId, data), MAX_SUBMIT_RETRIES,
157
+ // Only keep retrying while this is still the live submission (not superseded by a
158
+ // newer retransmit, aborted via clearOutgoingStatement, or disposed).
159
+ () => !disposed && state.outgoingRequest?.requestIds.at(-1) === requestId))
55
160
  .mapErr(e => {
161
+ // ExpiryTooLow is handled in submitWithRetry (retried until it lands, swallowed once
162
+ // superseded), so an error reaching here is a different, genuine failure. If this submission
163
+ // was already superseded by a newer retransmit (same tokens) it is not the live request's
164
+ // concern — drop it silently; the newer one carries the waiters. Otherwise the bounded
165
+ // retries are exhausted on the LIVE submission: the request never landed, so fail its
166
+ // waiters rather than let them hang.
167
+ const outgoing = state.outgoingRequest;
168
+ if (disposed || !outgoing || outgoing.requestIds.at(-1) !== requestId)
169
+ return;
56
170
  console.error('submitRequest failed:', e);
171
+ settleTokens(outgoing.tokens, deferred => deferred.reject(e));
172
+ state.outgoingRequest = null;
173
+ processMessageQueue();
57
174
  });
58
175
  }
59
176
  function deliverStatementData(statementData) {
@@ -73,36 +190,58 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
73
190
  }
74
191
  function tryDecodeStatement(statement) {
75
192
  if (!statement.data)
76
- return okAsync(null);
193
+ return okAsync({ kind: 'undecodable', requestId: null });
77
194
  const data = statement.data;
78
- return prover
195
+ return (prover
79
196
  .verifyMessageProof(statement)
80
197
  .andThen(verified => (verified ? ok() : err(new Error('Invalid proof'))))
81
198
  .andThen(() => encryption.decrypt(data))
82
- .map(decrypted => StatementData.dec(decrypted))
83
- .orElse(() => ok(null));
199
+ .map(decrypted => {
200
+ const decoded = decodeStatementData(decrypted);
201
+ return decoded.isOk()
202
+ ? { kind: 'decoded', data: decoded.value }
203
+ : { kind: 'undecodable', requestId: recoverRequestId(decrypted) };
204
+ })
205
+ // Proof or decryption failure: the payload (incl. the requestId) is unreadable → drop.
206
+ .orElse(() => okAsync({ kind: 'undecodable', requestId: null })));
84
207
  }
85
- function processIncomingStatement(statement, responsesOnly = false) {
208
+ function processIncomingStatement(statement) {
86
209
  if (!statement.data)
87
210
  return;
88
211
  const key = toHex(statement.data);
89
212
  if (state.seenStatements.has(key))
90
213
  return;
91
214
  state.seenStatements.add(key);
92
- tryDecodeStatement(statement).andTee(statementData => {
93
- if (!statementData)
215
+ void tryDecodeStatement(statement).andTee(outcome => {
216
+ if (outcome.kind === 'undecodable') {
217
+ if (outcome.requestId === null) {
218
+ // Proof/decryption failed, or no requestId was recoverable — nothing to NACK.
219
+ console.warn('statement-store: dropping an undecodable incoming statement (no recoverable requestId)');
220
+ return;
221
+ }
222
+ // Only NACK a genuinely new id. If we already know this request, a valid copy is being
223
+ // handled (or was already answered) — NACKing now would mask the real response, since the
224
+ // `responded` flag is sticky.
225
+ if (state.incomingRequests.has(outcome.requestId))
226
+ return;
227
+ // Decrypted but the message body is malformed — NACK so the sender stops waiting.
228
+ state.incomingRequests.set(outcome.requestId, { responded: false });
229
+ void session
230
+ .submitResponseMessage(outcome.requestId, 'decodingFailed')
231
+ .mapErr(e => console.error('statement-store: failed to NACK an undecodable request:', e));
94
232
  return;
233
+ }
234
+ const statementData = outcome.data;
95
235
  if (statementData.tag === 'request') {
96
- if (responsesOnly)
97
- return;
98
- if (statementData.value.requestId === state.incomingRequest?.requestId)
236
+ const requestId = statementData.value.requestId;
237
+ if (state.incomingRequests.has(requestId))
99
238
  return;
100
- state.incomingRequest = { requestId: statementData.value.requestId };
101
- state.respondedIncomingRequest = false;
239
+ state.incomingRequests.set(requestId, { responded: false });
102
240
  deliverStatementData(statementData);
103
241
  }
104
242
  else if (statementData.tag === 'response') {
105
- if (!state.outgoingRequest?.requestIds.includes(statementData.value.requestId))
243
+ const outgoing = state.outgoingRequest;
244
+ if (!outgoing?.requestIds.includes(statementData.value.requestId))
106
245
  return;
107
246
  const responseMessage = {
108
247
  type: 'response',
@@ -110,66 +249,82 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
110
249
  requestId: statementData.value.requestId,
111
250
  responseCode: statementData.value.responseCode,
112
251
  };
113
- for (const token of state.outgoingRequest.tokens) {
114
- const deferred = state.pendingDelivery.get(token);
115
- if (deferred) {
116
- deferred.resolve(responseMessage);
117
- state.pendingDelivery.delete(token);
118
- }
119
- }
252
+ settleTokens(outgoing.tokens, deferred => deferred.resolve(responseMessage));
120
253
  state.outgoingRequest = null;
121
254
  deliverStatementData(statementData);
122
255
  processMessageQueue();
123
256
  }
124
257
  });
125
258
  }
126
- function processNewMessage(encoded, token) {
259
+ // Returns true if `encoded` matches a message already in flight or queued, after
260
+ // attaching `token` to it so the caller resolves on that message's response
261
+ // instead of the bytes being submitted a second time.
262
+ function attachToDuplicate(encoded, token) {
263
+ const encodedHex = toHex(encoded);
264
+ const sameBytes = (m) => m.length === encoded.length && toHex(m) === encodedHex;
265
+ const outgoing = state.outgoingRequest;
266
+ if (outgoing && outgoing.messages.some(sameBytes)) {
267
+ outgoing.tokens.push(token);
268
+ return true;
269
+ }
270
+ const queued = state.messageQueue.find(entry => sameBytes(entry.encoded));
271
+ if (queued) {
272
+ queued.tokens.push(token);
273
+ return true;
274
+ }
275
+ return false;
276
+ }
277
+ function processNewMessage(encoded, tokens) {
127
278
  if (state.outgoingRequest === null) {
128
279
  const requestId = nanoid();
129
- state.outgoingRequest = { requestIds: [requestId], messages: [encoded], tokens: [token] };
280
+ state.outgoingRequest = { requestIds: [requestId], messages: [encoded], tokens: [...tokens] };
130
281
  encodeAndSubmitRequest(requestId, state.outgoingRequest.messages);
131
282
  }
283
+ else if (requestPayloadSize([...state.outgoingRequest.messages, encoded]) <= maxPayloadSize) {
284
+ state.outgoingRequest.messages.push(encoded);
285
+ state.outgoingRequest.tokens.push(...tokens);
286
+ const newRequestId = nanoid();
287
+ state.outgoingRequest.requestIds.push(newRequestId);
288
+ encodeAndSubmitRequest(newRequestId, state.outgoingRequest.messages);
289
+ }
132
290
  else {
133
- const currentTotal = state.outgoingRequest.messages.reduce((s, m) => s + m.length, 0);
134
- if (currentTotal + encoded.length <= maxRequestSize) {
135
- state.outgoingRequest.messages.push(encoded);
136
- state.outgoingRequest.tokens.push(token);
137
- const newRequestId = nanoid();
138
- state.outgoingRequest.requestIds.push(newRequestId);
139
- encodeAndSubmitRequest(newRequestId, state.outgoingRequest.messages);
140
- }
141
- else {
142
- state.messageQueue.push({ encoded, token });
143
- }
291
+ state.messageQueue.push({ encoded, tokens });
144
292
  }
145
293
  }
146
294
  function processMessageQueue() {
147
295
  while (state.messageQueue.length > 0) {
148
296
  const head = state.messageQueue[0];
149
297
  // Recompute per iteration; `processNewMessage` mutates outgoingRequest.messages in place.
150
- const currentTotal = state.outgoingRequest?.messages.reduce((s, m) => s + m.length, 0) ?? 0;
151
- if (state.outgoingRequest !== null && currentTotal + head.encoded.length > maxRequestSize)
298
+ if (state.outgoingRequest !== null &&
299
+ requestPayloadSize([...state.outgoingRequest.messages, head.encoded]) > maxPayloadSize) {
152
300
  break;
301
+ }
153
302
  state.messageQueue.shift();
154
- processNewMessage(head.encoded, head.token);
303
+ processNewMessage(head.encoded, head.tokens);
155
304
  }
156
305
  }
157
306
  function ensureStoreSubscription() {
158
307
  if (storeUnsub)
159
308
  return;
309
+ // A single subscription on the incoming topic carries BOTH the peer's requests
310
+ // and the peer's responses to our requests (the peer publishes everything on its
311
+ // outgoing topic = our incoming topic). We publish on the outgoing topic, which
312
+ // we don't subscribe to, so our own statements are never echoed back.
160
313
  storeUnsub = statementStore.subscribeStatements({ matchAll: [incomingSessionId] }, page => {
161
314
  for (const statement of page.statements) {
162
315
  processIncomingStatement(statement);
163
316
  }
164
317
  });
165
- // Subscribe to outgoing topic to receive peer ACK responses.
166
- // Only process response-type statements request-type statements on this topic
167
- // are our own submissions echoed back and must be ignored.
168
- responseStoreUnsub = statementStore.subscribeStatements({ matchAll: [outgoingSessionId] }, ({ statements }) => {
169
- for (const statement of statements) {
170
- processIncomingStatement(statement, true);
171
- }
172
- });
318
+ }
319
+ // Once a request is answered it no longer needs to be replayed to future subscribers (and a
320
+ // late waitForRequestMessage must not re-receive an already-handled request). Dropping it also
321
+ // keeps bufferedMessages from growing unboundedly with every incoming request.
322
+ function pruneBufferedRequest(requestId) {
323
+ for (let i = bufferedMessages.length - 1; i >= 0; i--) {
324
+ const sd = bufferedMessages[i];
325
+ if (sd && sd.tag === 'request' && sd.value.requestId === requestId)
326
+ bufferedMessages.splice(i, 1);
327
+ }
173
328
  }
174
329
  function rejectAllPending(error) {
175
330
  for (const [, deferred] of state.pendingDelivery) {
@@ -184,38 +339,61 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
184
339
  rejectAllPending(error);
185
340
  }
186
341
  async function init() {
187
- const [ownResult, peerResult] = await Promise.all([
342
+ const result = await ResultAsync.combine([
188
343
  statementStore.queryStatements({ matchAll: [outgoingSessionId] }),
189
344
  statementStore.queryStatements({ matchAll: [incomingSessionId] }),
190
345
  ]);
191
- if (ownResult.isErr()) {
192
- failInit(ownResult.error);
193
- return;
194
- }
195
- if (peerResult.isErr()) {
196
- failInit(peerResult.error);
346
+ if (result.isErr()) {
347
+ if (disposed)
348
+ return;
349
+ // Transient transport failure: retry init (preserving the message queue) before
350
+ // giving up. Only after the cap is reached do we fail terminally.
351
+ if (initRetries < MAX_INIT_RETRIES) {
352
+ initRetries++;
353
+ // Store the handle so dispose() can cancel it — otherwise a disposed session keeps
354
+ // querying and can re-activate itself if a late retry succeeds.
355
+ initRetryTimer = setTimeout(() => {
356
+ initRetryTimer = null;
357
+ void init();
358
+ }, RETRY_DELAY_MS);
359
+ return;
360
+ }
361
+ failInit(result.error);
197
362
  return;
198
363
  }
199
- const ownStatements = ownResult.value;
200
- const peerStatements = peerResult.value;
364
+ initRetries = 0;
365
+ const [ownStatements, peerStatements] = result.value;
201
366
  let maxExpiry = 0n;
202
367
  for (const s of ownStatements) {
203
368
  if (s.expiry !== undefined && s.expiry > maxExpiry)
204
369
  maxExpiry = s.expiry;
205
370
  }
206
- state.expiry = nextExpiry(maxExpiry);
371
+ // Never regress the counter. The query is a snapshot taken when init() began; a statement
372
+ // submitted while init was in flight (e.g. an auto-ACK for a peer request that arrived during
373
+ // the query) has already advanced both state.expiry and the on-chain channel past that
374
+ // snapshot. Overwriting unconditionally would drop the counter below the on-chain priority,
375
+ // making the next submit collide at an equal expiry.
376
+ const seeded = nextExpiry(maxExpiry);
377
+ if (seeded > state.expiry)
378
+ state.expiry = seeded;
207
379
  for (const s of [...ownStatements, ...peerStatements]) {
208
380
  if (s.data)
209
381
  state.seenStatements.add(toHex(s.data));
210
382
  }
211
- const decodeAll = (statements) => Promise.all(statements.map(s => tryDecodeStatement(s).match(v => v, () => null))).then(r => r.filter(nonNullable));
383
+ const decodeAll = (statements) => Promise.all(statements.map(s => tryDecodeStatement(s).unwrapOr({ kind: 'undecodable', requestId: null }))).then(outcomes => outcomes.map(o => (o.kind === 'decoded' ? o.data : null)).filter(nonNullable));
212
384
  const [ownDecoded, peerDecoded] = await Promise.all([decodeAll(ownStatements), decodeAll(peerStatements)]);
385
+ if (disposed)
386
+ return;
387
+ // Both parties publish on their own outgoing topic, so the OUTGOING query returns our
388
+ // requests + OUR responses, and the INCOMING query returns the peer's requests + the
389
+ // PEER's responses. Hence: our request is answered by a PEER response (incoming), and we
390
+ // have answered a peer request iff OUR response (outgoing) carries its id.
213
391
  const ownRequest = ownDecoded.find(d => d.tag === 'request');
214
392
  const ownResponse = ownDecoded.find(d => d.tag === 'response');
215
393
  const peerRequest = peerDecoded.find(d => d.tag === 'request');
216
394
  const peerResponse = peerDecoded.find(d => d.tag === 'response');
217
395
  if (ownRequest?.tag === 'request') {
218
- const hasResponse = ownResponse?.tag === 'response' && ownResponse.value.requestId === ownRequest.value.requestId;
396
+ const hasResponse = peerResponse?.tag === 'response' && peerResponse.value.requestId === ownRequest.value.requestId;
219
397
  if (!hasResponse) {
220
398
  state.outgoingRequest = {
221
399
  requestIds: [ownRequest.value.requestId],
@@ -225,15 +403,18 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
225
403
  }
226
404
  }
227
405
  if (peerRequest?.tag === 'request') {
228
- state.incomingRequest = { requestId: peerRequest.value.requestId };
229
- state.respondedIncomingRequest =
230
- peerResponse?.tag === 'response' && peerResponse.value.requestId === peerRequest.value.requestId;
231
- }
232
- // Notify app of any unresponded incoming request.
233
- // Delivered while phase is still 'initialization' so that deliverStatementData
234
- // buffers the message for replay if no subscriber is registered yet.
235
- if (peerRequest && state.incomingRequest && !state.respondedIncomingRequest) {
236
- deliverStatementData(peerRequest);
406
+ const requestId = peerRequest.value.requestId;
407
+ // Don't clobber an entry a live delivery may have created during the awaits
408
+ // above (the live one is newer/authoritative).
409
+ if (!state.incomingRequests.has(requestId)) {
410
+ const responded = ownResponse?.tag === 'response' && ownResponse.value.requestId === requestId;
411
+ state.incomingRequests.set(requestId, { responded });
412
+ // Notify app of an unresponded incoming request. Delivered while phase is
413
+ // still 'initialization' so deliverStatementData buffers it for replay if
414
+ // no subscriber is registered yet.
415
+ if (!responded)
416
+ deliverStatementData(peerRequest);
417
+ }
237
418
  }
238
419
  state.phase = 'active';
239
420
  processMessageQueue();
@@ -245,66 +426,130 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
245
426
  .andThen(({ requestId }) => session.waitForResponseMessage(requestId).andThen(({ responseCode }) => mapResponseCode(responseCode)));
246
427
  },
247
428
  submitRequestMessage(codec, message) {
429
+ if (disposed)
430
+ return errAsync(new Error(SESSION_DISPOSED));
248
431
  const encode = fromThrowable(codec.enc, toError);
249
432
  const encodedResult = encode(message);
250
433
  if (encodedResult.isErr())
251
434
  return errAsync(encodedResult.error);
252
435
  const encoded = encodedResult.value;
253
- if (encoded.length > maxRequestSize)
436
+ if (requestPayloadSize([encoded]) > maxPayloadSize)
254
437
  return errAsync(new Error('message too big'));
255
438
  if (state.phase === 'failed') {
256
439
  return errAsync(state.initError ?? new Error('Session initialization failed'));
257
440
  }
258
441
  const token = nanoid();
259
- let resolveFn;
260
- let rejectFn;
261
- const promise = new Promise((res, rej) => {
262
- resolveFn = res;
263
- rejectFn = rej;
264
- });
265
- state.pendingDelivery.set(token, { resolve: resolveFn, reject: rejectFn, promise });
266
- // Ensure a rejection from clearOutgoingStatement()/dispose() is always handled,
267
- // even when no caller attached via waitForResponseMessage(); the real waiter
268
- // still receives the rejection through its own handler.
269
- promise.catch(() => undefined);
270
- if (state.phase === 'initialization') {
271
- state.messageQueue.push({ encoded, token });
272
- }
273
- else {
274
- processNewMessage(encoded, token);
442
+ state.pendingDelivery.set(token, makeDeferred());
443
+ // Dedup: an identical message already in flight or queued is not re-sent — the
444
+ // new caller is attached to it and resolves on the same response.
445
+ if (!attachToDuplicate(encoded, token)) {
446
+ // FIFO: never let a later (fitting) message overtake queued ones; only append
447
+ // to the live batch when nothing is waiting behind it.
448
+ if (state.phase === 'initialization' || state.messageQueue.length > 0) {
449
+ state.messageQueue.push({ encoded, tokens: [token] });
450
+ }
451
+ else {
452
+ processNewMessage(encoded, [token]);
453
+ }
275
454
  }
276
455
  return okAsync({ requestId: token });
277
456
  },
278
457
  submitResponseMessage(requestId, responseCode) {
279
- if (state.respondedIncomingRequest)
280
- return okAsync(undefined);
281
- if (state.incomingRequest?.requestId !== requestId) {
458
+ if (disposed)
459
+ return errAsync(new Error(SESSION_DISPOSED));
460
+ const incoming = state.incomingRequests.get(requestId);
461
+ if (!incoming)
282
462
  return errAsync(new Error(`No incoming request with id ${requestId}`));
463
+ if (incoming.responded) {
464
+ pruneBufferedRequest(requestId);
465
+ return okAsync(undefined);
283
466
  }
284
- state.respondedIncomingRequest = true;
285
- const encode = fromThrowable(StatementData.enc, toError);
286
- return encode({ tag: 'response', value: { requestId, responseCode } }).asyncAndThen(data => submitStatementData(createResponseChannel(incomingSessionId), incomingSessionId, data));
467
+ const encoded = encodeStatementData({ tag: 'response', value: { requestId, responseCode } });
468
+ if (encoded.isErr())
469
+ return errAsync(encoded.error);
470
+ // Mark responded up-front so concurrent callers dedupe, but roll back if the
471
+ // submission fails — otherwise the ACK is lost forever (and a peer retransmit
472
+ // with a fresh id could never be answered either).
473
+ incoming.responded = true;
474
+ lastResponseRequestId = requestId;
475
+ // Responses go on OUR outgoing topic/response-channel (per spec: the responder
476
+ // publishes on SessionId(self, peer)); the requester reads them from its incoming topic.
477
+ return (submitWithRetry(() => submitStatementData(createResponseChannel(outgoingSessionId), outgoingSessionId, encoded.value), MAX_SUBMIT_RETRIES,
478
+ // Stop retrying once a newer response supersedes this one (shared response channel) or disposed.
479
+ () => !disposed && lastResponseRequestId === requestId)
480
+ // Answered: it no longer needs replaying to future subscribers.
481
+ .andTee(() => pruneBufferedRequest(requestId))
482
+ .orElse(error => {
483
+ // ExpiryTooLow is handled in submitWithRetry (swallowed once a newer response supersedes
484
+ // this one on the shared channel), so an error here is a different failure. If this is no
485
+ // longer the latest response (superseded) or the session is disposed, keep the request
486
+ // marked answered — re-answering would only clobber the newer response — and absorb the
487
+ // error. NOTE: the shared response channel still only exposes the latest response to the
488
+ // peer, so reliably ACKing several outstanding requests needs the protocol-level fix
489
+ // tracked separately.
490
+ if (disposed || lastResponseRequestId !== requestId) {
491
+ pruneBufferedRequest(requestId);
492
+ return okAsync(undefined);
493
+ }
494
+ // The live response genuinely failed after exhausting retries — roll back so a later
495
+ // peer retransmit can still be answered, and surface the error.
496
+ incoming.responded = false;
497
+ return errAsync(error);
498
+ }));
287
499
  },
288
500
  waitForRequestMessage(codec, filter) {
289
- const promise = new Promise(resolve => {
290
- const unsubscribe = session.subscribe(codec, messages => {
501
+ const promise = new Promise((resolve, reject) => {
502
+ let settled = false;
503
+ // Initialised to a no-op so a synchronous buffered-replay match during
504
+ // subscribe() can call it without hitting the temporal dead zone; the
505
+ // real unsubscribe is wired in once subscribe() returns.
506
+ let unsubscribe = () => undefined;
507
+ const finish = (run) => {
508
+ if (settled)
509
+ return;
510
+ settled = true;
511
+ requestWaiters.delete(rejectWaiter);
512
+ unsubscribe();
513
+ run();
514
+ };
515
+ const rejectWaiter = (error) => finish(() => reject(error));
516
+ requestWaiters.add(rejectWaiter);
517
+ unsubscribe = session.subscribe(codec, messages => {
291
518
  for (const message of messages) {
292
519
  if (message.type !== 'request')
293
520
  continue;
294
- const payload = message.payload;
295
- if (payload.status !== 'parsed')
521
+ if (message.payload.status !== 'parsed')
296
522
  continue;
297
- const filtered = filter(payload.value);
298
- if (filtered) {
299
- unsubscribe();
300
- resolve(filtered);
301
- break;
523
+ const filtered = filter(message.payload.value);
524
+ if (filtered !== undefined) {
525
+ finish(() => resolve(filtered));
526
+ return;
302
527
  }
303
528
  }
304
529
  });
530
+ // subscribe() may have matched synchronously (buffered replay) while
531
+ // `unsubscribe` was still the no-op above — tear down the real one now.
532
+ if (settled)
533
+ unsubscribe();
305
534
  });
306
535
  return fromPromise(promise, toError);
307
536
  },
537
+ respondToRequests(codec, handler) {
538
+ return session.subscribe(codec, messages => {
539
+ for (const message of messages) {
540
+ if (message.type !== 'request')
541
+ continue;
542
+ const handled = handler(message);
543
+ const statusResult = handled instanceof ResultAsync ? handled : okAsync(handled);
544
+ void statusResult
545
+ .orElse(() => okAsync('unknown'))
546
+ .andThen(code => session.submitResponseMessage(message.requestId, code))
547
+ .mapErr(e => {
548
+ console.error('respondToRequests: failed to submit response:', e);
549
+ });
550
+ }
551
+ });
552
+ },
308
553
  waitForResponseMessage(token) {
309
554
  const deferred = state.pendingDelivery.get(token);
310
555
  if (!deferred)
@@ -326,24 +571,14 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
326
571
  }
327
572
  return () => {
328
573
  subscribers = subscribers.filter(s => s !== sub);
329
- if (subscribers.length === 0) {
330
- if (storeUnsub) {
331
- storeUnsub();
332
- storeUnsub = null;
333
- }
334
- if (responseStoreUnsub) {
335
- responseStoreUnsub();
336
- responseStoreUnsub = null;
337
- }
574
+ if (subscribers.length === 0 && storeUnsub) {
575
+ storeUnsub();
576
+ storeUnsub = null;
338
577
  }
339
578
  };
340
579
  },
341
580
  clearOutgoingStatement() {
342
581
  const outgoing = state.outgoingRequest;
343
- // Reuse the current expiry (do NOT call nextExpiry): the live batch was last
344
- // submitted at state.expiry, so an empty statement at the same expiry on the
345
- // same channel supersedes it. The store rejects only a strictly lower expiry.
346
- const expiry = state.expiry;
347
582
  // Always drop local outgoing state and reject pending waiters up-front,
348
583
  // regardless of which path follows. This covers messages queued before the
349
584
  // batch went out (e.g. during init, while outgoingRequest is still null) and
@@ -355,21 +590,36 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
355
590
  if (outgoing === null)
356
591
  return okAsync(undefined);
357
592
  const requestId = outgoing.requestIds[outgoing.requestIds.length - 1];
358
- const encoded = fromThrowable(StatementData.enc, toError)({
359
- tag: 'request',
360
- value: { requestId, data: [] },
361
- });
593
+ const encoded = encodeStatementData({ tag: 'request', value: { requestId, data: [] } });
362
594
  if (encoded.isErr())
363
595
  return errAsync(encoded.error);
364
- return submitStatementAt(expiry, createRequestChannel(outgoingSessionId), outgoingSessionId, encoded.value);
596
+ // Supersede the live batch with an empty one. Use submitStatementData so the
597
+ // empty statement goes out at a STRICTLY higher expiry — the store rejects an
598
+ // equal-or-lower expiry on the same channel, so reusing state.expiry would
599
+ // leave the original request live on-chain. Route through submitWithRetry with
600
+ // shouldRetry:()=>false so it inherits the single ExpiryTooLow policy — a rejection
601
+ // means the channel already advanced past us, so the clear already happened → absorb it —
602
+ // without retrying (clearing is a one-shot supersede, not a request that must land).
603
+ return submitWithRetry(() => submitStatementData(createRequestChannel(outgoingSessionId), outgoingSessionId, encoded.value), 0, () => false);
365
604
  },
366
605
  dispose() {
606
+ disposed = true;
607
+ if (initRetryTimer) {
608
+ clearTimeout(initRetryTimer);
609
+ initRetryTimer = null;
610
+ }
367
611
  storeUnsub?.();
368
612
  storeUnsub = null;
369
- responseStoreUnsub?.();
370
- responseStoreUnsub = null;
371
613
  subscribers = [];
372
- rejectAllPending(new Error('Session disposed'));
614
+ // Drop pending work so no in-flight retry or queue drain acts on a disposed session.
615
+ state.outgoingRequest = null;
616
+ state.messageQueue = [];
617
+ // Settle any waitForRequestMessage() promises so callers unwind instead of
618
+ // hanging forever. Snapshot first — rejecting mutates the set.
619
+ for (const rejectWaiter of [...requestWaiters])
620
+ rejectWaiter(new Error(SESSION_DISPOSED));
621
+ requestWaiters.clear();
622
+ rejectAllPending(new Error(SESSION_DISPOSED));
373
623
  },
374
624
  };
375
625
  void init();