@novasamatech/statement-store 0.8.6 → 0.8.7-1

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