@novasamatech/statement-store 0.9.0 → 0.9.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.
Files changed (34) hide show
  1. package/dist/helpers.d.ts +0 -1
  2. package/dist/helpers.js +0 -3
  3. package/dist/index.d.ts +7 -0
  4. package/dist/index.js +3 -0
  5. package/dist/session/codec/decoder.d.ts +68 -0
  6. package/dist/session/codec/decoder.js +96 -0
  7. package/dist/session/codec/decoder.spec.d.ts +1 -0
  8. package/dist/session/codec/decoder.spec.js +161 -0
  9. package/dist/session/codec/envelope.d.ts +55 -0
  10. package/dist/session/codec/envelope.js +115 -0
  11. package/dist/session/codec/envelope.spec.d.ts +1 -0
  12. package/dist/session/codec/envelope.spec.js +114 -0
  13. package/dist/session/codec/incomingTopics.d.ts +46 -0
  14. package/dist/session/codec/incomingTopics.js +69 -0
  15. package/dist/session/codec/outgoingBody.d.ts +46 -0
  16. package/dist/session/codec/outgoingBody.js +64 -0
  17. package/dist/session/core.d.ts +58 -0
  18. package/dist/session/core.js +609 -0
  19. package/dist/session/messageMapper.d.ts +3 -3
  20. package/dist/session/messageMapper.js +8 -8
  21. package/dist/session/multiDeviceSession.d.ts +49 -0
  22. package/dist/session/multiDeviceSession.js +62 -0
  23. package/dist/session/multiDeviceSession.spec.d.ts +6 -0
  24. package/dist/session/multiDeviceSession.spec.js +354 -0
  25. package/dist/session/scale/statementData.d.ts +40 -0
  26. package/dist/session/scale/statementData.js +32 -0
  27. package/dist/session/session.d.ts +15 -5
  28. package/dist/session/session.js +31 -633
  29. package/dist/session/session.spec.js +22 -6
  30. package/dist/session/stateMachine.d.ts +135 -0
  31. package/dist/session/stateMachine.js +203 -0
  32. package/dist/session/stateMachine.spec.d.ts +1 -0
  33. package/dist/session/stateMachine.spec.js +276 -0
  34. package/package.json +4 -3
@@ -0,0 +1,609 @@
1
+ /**
2
+ * The session driver: turns the collaborators assembled by `createSession` /
3
+ * `createMultiDeviceSession` into a live {@link Session}.
4
+ *
5
+ * It never branches on single- vs multi-device. Wire format is decided by the injected
6
+ * {@link OutgoingBodyBuilder} and {@link StatementDecoder}; which topics to listen on by
7
+ * the injected {@link IncomingTopics}. Everything here is transport policy that both
8
+ * session kinds share: batching, dedup, the outgoing/incoming request state machine,
9
+ * expiry allocation, retries, and subscriber delivery.
10
+ */
11
+ import { toHex } from '@novasamatech/scale';
12
+ import { nanoid } from 'nanoid';
13
+ import { ResultAsync, err, errAsync, fromPromise, fromThrowable, ok, okAsync } from 'neverthrow';
14
+ import { toError } from '../helpers.js';
15
+ import { isPriorityTooLow, submitWithRetry } from '../submit/retry.js';
16
+ import { submitStatementOnce } from '../submit/submitStatement.js';
17
+ import { DecodingError, DecryptionError, UnknownError } from './error.js';
18
+ import { toMessage } from './messageMapper.js';
19
+ import { incomingRequest, initialSessionState, liveRequestId, transition } from './stateMachine.js';
20
+ /**
21
+ * The Bulletin statement store caps a statement at roughly 500 KiB of total encoded size
22
+ * (proof + channel + topics + expiry + data); 2 KiB leaves margin for the non-data fields.
23
+ * `DataTooLargeError.available` is the chain's authoritative number if this ever drifts.
24
+ *
25
+ * This is deliberately the transport's real capacity rather than an application policy.
26
+ * A too-small budget degrades silently — messages queue instead of batching, with no
27
+ * error — whereas a too-large one fails loudly and recoverably with `DataTooLargeError`.
28
+ * Applications that want a tighter bound (Android's chat uses 100 KiB) should pass their
29
+ * own `maxRequestSize`; base-spec.md leaves the choice to the Application Layer.
30
+ */
31
+ export const DEFAULT_MAX_REQUEST_SIZE = 498 * 1024;
32
+ // Rejection reason shared by dispose() and the disposed guards on submit*, so a torn-down session
33
+ // always fails new and in-flight work the same way.
34
+ const SESSION_DISPOSED = 'Session disposed';
35
+ // Bounded retry for transient transport failures (the spec mandates retrying queries
36
+ // and submit_statement on connection failure). The TS adapter doesn't expose connection
37
+ // state, so we approximate with a short fixed backoff and an attempt cap.
38
+ const MAX_INIT_RETRIES = 3;
39
+ const MAX_SUBMIT_RETRIES = 3;
40
+ const RETRY_DELAY_MS = 25;
41
+ /**
42
+ * Fixed per-statement wire overhead reserved before sizing the request payload:
43
+ * topic (32) + channel (32) + expiry (8) + proof signature (64) + signer (32).
44
+ * Mirrors the Android/iOS sessions, which size message batches against
45
+ * `maxStatementSize - overhead` rather than the raw statement limit.
46
+ */
47
+ export const STATEMENT_OVERHEAD = 32 + 32 + 8 + 64 + 32; // 168 bytes
48
+ // nanoid() is fixed-length, so the requestId contributes a constant size; any
49
+ // placeholder of that length yields the real encoded size.
50
+ const SIZING_REQUEST_ID = 'x'.repeat(21);
51
+ // A statement we could neither verify, decrypt, nor decode far enough to answer.
52
+ const UNDECODABLE_EVENT = { tag: 'undecodable', requestId: null };
53
+ // A response promise paired with its resolver/rejecter. The pre-attached catch
54
+ // keeps a clearOutgoingStatement()/dispose() rejection from surfacing as an
55
+ // unhandled rejection when no caller awaited it via waitForResponseMessage().
56
+ function makeDeferred() {
57
+ let resolve;
58
+ let reject;
59
+ const promise = new Promise((res, rej) => {
60
+ resolve = res;
61
+ reject = rej;
62
+ });
63
+ promise.catch(() => undefined);
64
+ return { resolve, reject, promise };
65
+ }
66
+ export function createSessionCore({ statementStore, prover, allocator, maxRequestSize, bodyBuilder, incomingTopics, decoder, outgoingTopic, peerDevices, }) {
67
+ // Message bytes must fit within the statement limit minus the fixed wire overhead.
68
+ const maxPayloadSize = Math.max(0, maxRequestSize - STATEMENT_OVERHEAD);
69
+ // All batching / dedup / phase decisions are made by the reducer; this file only performs
70
+ // the effects it returns.
71
+ const transitionContext = {
72
+ fits: (messages) => requestPayloadSize(messages) <= maxPayloadSize,
73
+ newRequestId: nanoid,
74
+ };
75
+ let machineState = initialSessionState();
76
+ // Returns the effects performed, so a caller can tell whether the reducer acted on the
77
+ // event at all rather than re-deriving that decision for itself.
78
+ function dispatch(event) {
79
+ const result = transition(machineState, event, transitionContext);
80
+ machineState = result.state;
81
+ runEffects(result.effects);
82
+ return result.effects;
83
+ }
84
+ const pendingDelivery = new Map();
85
+ const seenStatements = new Set();
86
+ let subscribers = [];
87
+ // Reject callbacks for in-flight waitForRequestMessage() promises, so dispose()
88
+ // can settle them instead of leaving them to hang forever.
89
+ const requestWaiters = new Set();
90
+ const bufferedMessages = [];
91
+ // Live topic-set subscription (multi-device rosters change at runtime).
92
+ let topicsUnsub = null;
93
+ let storeUnsub = null;
94
+ let initRetries = 0;
95
+ let initRetryTimer = null;
96
+ let disposed = false;
97
+ // Id of the most recent response we initiated (responses share one channel, so only the
98
+ // latest is live — a retry for an older one must not resurrect it).
99
+ let lastResponseRequestId = null;
100
+ // One attempt, at the allocator's next (strictly increasing) expiry. Adopting the chain's
101
+ // floor after a priority rejection is the caller's job — see `submitWithSessionRetry`.
102
+ function submitBody(body) {
103
+ return submitStatementOnce({
104
+ statementStore,
105
+ prover,
106
+ allocator,
107
+ channel: body.channel,
108
+ topics: body.topics,
109
+ data: body.data,
110
+ });
111
+ }
112
+ // Encoded size of the statement `data` these messages would occupy — built through the
113
+ // real writer, so AEAD expansion and multi-device envelope fan-out are counted rather
114
+ // than estimated. Unbuildable → treat as "doesn't fit".
115
+ function requestPayloadSize(messages) {
116
+ return bodyBuilder
117
+ .buildRequest(SIZING_REQUEST_ID, messages)
118
+ .map(body => body.data.length)
119
+ .unwrapOr(Number.MAX_SAFE_INTEGER);
120
+ }
121
+ function settleTokens(tokens, settle) {
122
+ for (const token of tokens) {
123
+ const deferred = pendingDelivery.get(token);
124
+ if (deferred) {
125
+ settle(deferred);
126
+ pendingDelivery.delete(token);
127
+ }
128
+ }
129
+ }
130
+ function runEffects(effects) {
131
+ for (const effect of effects) {
132
+ switch (effect.type) {
133
+ case 'submitRequest':
134
+ submitRequest(effect.requestId, effect.messages);
135
+ break;
136
+ case 'resolveTokens': {
137
+ const responseMessage = {
138
+ type: 'response',
139
+ localId: effect.requestId,
140
+ requestId: effect.requestId,
141
+ responseCode: effect.responseCode,
142
+ };
143
+ settleTokens(effect.tokens, deferred => deferred.resolve(responseMessage));
144
+ break;
145
+ }
146
+ case 'rejectTokens':
147
+ settleTokens(effect.tokens, deferred => deferred.reject(effect.error));
148
+ break;
149
+ }
150
+ }
151
+ }
152
+ /**
153
+ * The session's submit policy, shared by requests and responses.
154
+ *
155
+ * Priority errors (ExpiryTooLow / AccountFull) mean only that our expiry lagged the chain's
156
+ * floor, so `onPriorityError` adopts the reported minimum and we retry without limit — they
157
+ * never consume the transient-failure budget and never surface to callers. Once `isLive`
158
+ * goes false the submission has been superseded, and a priority rejection is settled as
159
+ * success: it merely lost the channel race to a newer statement. Other errors keep the
160
+ * bounded retry and propagate when exhausted. `isLive` is re-checked before every retry, so
161
+ * a stale retry can never resurrect an old statement.
162
+ */
163
+ function submitWithSessionRetry(body, isLive) {
164
+ return submitWithRetry(() => submitBody(body), {
165
+ attempts: MAX_SUBMIT_RETRIES,
166
+ priorityAttempts: 'unbounded',
167
+ delaysMs: RETRY_DELAY_MS,
168
+ onPriorityError: error => allocator.raiseFloor(error.min),
169
+ shouldRetry: () => !disposed && isLive(),
170
+ });
171
+ }
172
+ function submitRequest(requestId, messages) {
173
+ bodyBuilder
174
+ .buildRequest(requestId, messages)
175
+ .asyncAndThen(body => submitWithSessionRetry(body, () => liveRequestId(machineState) === requestId))
176
+ .mapErr(e => {
177
+ // Genuine failure (priority errors never reach here). No effects means the reducer
178
+ // considered this submission superseded — the newer retransmit carries the waiters.
179
+ if (disposed)
180
+ return;
181
+ if (dispatch({ type: 'requestSubmitFailed', requestId, error: e }).length > 0) {
182
+ console.error('submitRequest failed:', e);
183
+ }
184
+ });
185
+ }
186
+ function deliver(event) {
187
+ // Buffer 'request' events unconditionally so that waitForRequestMessage
188
+ // registered after delivery (race condition) still receives them via subscribe() replay.
189
+ // Buffer everything else during initialization when there are no subscribers yet.
190
+ if (event.tag === 'request' || (subscribers.length === 0 && machineState.phase === 'initialization')) {
191
+ bufferedMessages.push(event);
192
+ }
193
+ if (subscribers.length === 0)
194
+ return;
195
+ for (const sub of subscribers) {
196
+ const messages = toMessage(event, sub.codec);
197
+ if (messages.length > 0)
198
+ sub.callback(messages);
199
+ }
200
+ }
201
+ // Proof or decryption failure leaves the payload (incl. the requestId) unreadable → drop.
202
+ function decodeIncoming(statement, spec) {
203
+ return decoder
204
+ .decodePeer(statement, spec)
205
+ .orElse(() => okAsync({ tag: 'undecodable', requestId: null }));
206
+ }
207
+ // False when the request is already known, so callers skip both delivery and NACKing.
208
+ function trackNewRequest(requestId) {
209
+ if (incomingRequest(machineState, requestId))
210
+ return false;
211
+ dispatch({ type: 'requestReceived', requestId });
212
+ return true;
213
+ }
214
+ function processIncomingStatement(statement, spec) {
215
+ if (!statement.data)
216
+ return;
217
+ const key = toHex(statement.data);
218
+ if (seenStatements.has(key))
219
+ return;
220
+ seenStatements.add(key);
221
+ void decodeIncoming(statement, spec).andTee(event => {
222
+ if (event.tag === 'undecodable') {
223
+ if (event.requestId === null) {
224
+ // Proof/decryption failed, or no requestId was recoverable — nothing to NACK.
225
+ console.warn('statement-store: dropping an undecodable incoming statement (no recoverable requestId)');
226
+ return;
227
+ }
228
+ // Decrypted but malformed — NACK so the sender stops waiting. Only for a genuinely
229
+ // new id: if we already know it, a valid copy is in hand (or was answered), and
230
+ // NACKing now would mask the real response because `responded` is sticky.
231
+ if (!trackNewRequest(event.requestId))
232
+ return;
233
+ void session
234
+ .submitResponseMessage(event.requestId, 'decodingFailed')
235
+ .mapErr(e => console.error('statement-store: failed to NACK an undecodable request:', e));
236
+ return;
237
+ }
238
+ if (event.tag === 'request') {
239
+ if (!trackNewRequest(event.requestId))
240
+ return;
241
+ deliver(event);
242
+ }
243
+ else {
244
+ // Whether the response matches our batch is the reducer's rule to apply, not ours:
245
+ // no effects means it was not ours (or was already answered), so nothing to deliver.
246
+ const effects = dispatch({
247
+ type: 'responseReceived',
248
+ requestId: event.requestId,
249
+ responseCode: event.responseCode,
250
+ });
251
+ if (effects.length > 0)
252
+ deliver(event);
253
+ }
254
+ });
255
+ }
256
+ // Find the spec whose topic a statement arrived on, so the right sender key is used to
257
+ // open it. Single-topic sessions short-circuit — the store already filtered for us.
258
+ function specForStatement(specs, statement) {
259
+ if (specs.length <= 1)
260
+ return specs[0];
261
+ const topics = statement.topics ?? [];
262
+ return specs.find(spec => {
263
+ const specHex = toHex(spec.topic);
264
+ return topics.some(topic => (typeof topic === 'string' ? topic : toHex(topic)) === specHex);
265
+ });
266
+ }
267
+ // ONE subscription covering every incoming topic. A peer's requests AND its responses to
268
+ // our requests both arrive here (the peer publishes everything on its outgoing topic =
269
+ // our incoming topic). We publish on our own outgoing topic, which we don't subscribe to,
270
+ // so our statements are never echoed back.
271
+ function openStoreSubscription(specs) {
272
+ storeUnsub?.();
273
+ storeUnsub =
274
+ specs.length === 0
275
+ ? null
276
+ : statementStore.subscribeStatements({ matchAny: specs.map(spec => spec.topic) }, page => {
277
+ for (const statement of page.statements) {
278
+ const spec = specForStatement(specs, statement);
279
+ if (spec)
280
+ processIncomingStatement(statement, spec);
281
+ }
282
+ });
283
+ }
284
+ function ensureStoreSubscription() {
285
+ if (topicsUnsub)
286
+ return;
287
+ openStoreSubscription(incomingTopics.current());
288
+ // A roster change (peer device added/removed) changes the topic set — re-open the
289
+ // single subscription rather than accumulating one per device.
290
+ topicsUnsub = incomingTopics.subscribe(specs => {
291
+ if (disposed)
292
+ return;
293
+ openStoreSubscription(specs);
294
+ // The same roster drives the outgoing envelope, so its size just changed: messages
295
+ // parked when the budget was tighter may fit now.
296
+ dispatch({ type: 'capacityChanged' });
297
+ });
298
+ }
299
+ // Both handles are cleared together: `ensureStoreSubscription` guards on `topicsUnsub`,
300
+ // so leaving it set while dropping `storeUnsub` would make a later subscribe() a no-op
301
+ // and the session would never listen again.
302
+ function closeStoreSubscription() {
303
+ topicsUnsub?.();
304
+ topicsUnsub = null;
305
+ storeUnsub?.();
306
+ storeUnsub = null;
307
+ }
308
+ // Once a request is answered it no longer needs to be replayed to future subscribers (and a
309
+ // late waitForRequestMessage must not re-receive an already-handled request). Dropping it also
310
+ // keeps bufferedMessages from growing unboundedly with every incoming request.
311
+ function pruneBufferedRequest(requestId) {
312
+ for (let i = bufferedMessages.length - 1; i >= 0; i--) {
313
+ const buffered = bufferedMessages[i];
314
+ if (buffered?.tag === 'request' && buffered.requestId === requestId)
315
+ bufferedMessages.splice(i, 1);
316
+ }
317
+ }
318
+ function rejectAllPending(error) {
319
+ for (const [, deferred] of pendingDelivery) {
320
+ deferred.reject(error);
321
+ }
322
+ pendingDelivery.clear();
323
+ }
324
+ function failInit(error) {
325
+ dispatch({ type: 'initFailed', error });
326
+ rejectAllPending(error);
327
+ }
328
+ async function init() {
329
+ const specs = incomingTopics.current();
330
+ // An empty topic set means nothing to listen to yet (a peer whose devices we have not
331
+ // learned). Skip the query rather than asking the store to match none — the node's
332
+ // reading of an empty `matchAny` is unspecified, and a store that took it as "match
333
+ // everything" would hand us the whole store to decode.
334
+ const result = await ResultAsync.combine([
335
+ statementStore.queryStatements({ matchAll: [outgoingTopic] }),
336
+ specs.length === 0
337
+ ? okAsync([])
338
+ : statementStore.queryStatements({ matchAny: specs.map(spec => spec.topic) }),
339
+ ]);
340
+ if (result.isErr()) {
341
+ if (disposed)
342
+ return;
343
+ // Transient transport failure: retry before failing terminally, preserving the queue.
344
+ // dispose() cancels the handle, or a late retry could re-activate a torn-down session.
345
+ if (initRetries < MAX_INIT_RETRIES) {
346
+ initRetries++;
347
+ initRetryTimer = setTimeout(() => {
348
+ initRetryTimer = null;
349
+ void init();
350
+ }, RETRY_DELAY_MS);
351
+ return;
352
+ }
353
+ failInit(result.error);
354
+ return;
355
+ }
356
+ initRetries = 0;
357
+ const [ownStatements, peerStatements] = result.value;
358
+ // Draw above everything already on our channel. raiseFloor is monotonic, so a statement
359
+ // submitted while init was in flight still keeps the counter ahead of this snapshot.
360
+ allocator.raiseFloor(ownStatements.reduce((max, s) => (s.expiry !== undefined && s.expiry > max ? s.expiry : max), 0n));
361
+ for (const s of [...ownStatements, ...peerStatements]) {
362
+ if (s.data)
363
+ seenStatements.add(toHex(s.data));
364
+ }
365
+ const isReadable = (event) => event.tag !== 'undecodable';
366
+ // Our own statements are read back with `decodeOwn` — a multi-device envelope carries no
367
+ // entry for us, so it is opened via a recipient device we wrapped it for. This is what
368
+ // lets the store, rather than a client-side outbox, hold the outgoing-request state.
369
+ const decodeOwnAll = Promise.all(ownStatements.map(s => decoder.decodeOwn(s, peerDevices()).unwrapOr(UNDECODABLE_EVENT))).then(events => events.filter(isReadable));
370
+ const decodePeerAll = Promise.all(peerStatements.map(s => {
371
+ const spec = specForStatement(specs, s);
372
+ return spec ? decodeIncoming(s, spec).unwrapOr(UNDECODABLE_EVENT) : Promise.resolve(UNDECODABLE_EVENT);
373
+ })).then(events => events.filter(isReadable));
374
+ const [ownDecoded, peerDecoded] = await Promise.all([decodeOwnAll, decodePeerAll]);
375
+ if (disposed)
376
+ return;
377
+ // Both parties publish on their own outgoing topic, so the OUTGOING query returns our
378
+ // requests + OUR responses, and the INCOMING query returns the peer's requests + the
379
+ // PEER's responses. Hence: our request is answered by a PEER response (incoming), and we
380
+ // have answered a peer request iff OUR response (outgoing) carries its id.
381
+ const ownRequest = ownDecoded.find(d => d.tag === 'request');
382
+ const ownResponse = ownDecoded.find(d => d.tag === 'response');
383
+ const peerRequest = peerDecoded.find(d => d.tag === 'request');
384
+ const peerResponse = peerDecoded.find(d => d.tag === 'response');
385
+ if (ownRequest?.tag === 'request') {
386
+ const hasResponse = peerResponse?.tag === 'response' && peerResponse.requestId === ownRequest.requestId;
387
+ if (!hasResponse)
388
+ dispatch({ type: 'outgoingRestored', requestId: ownRequest.requestId, messages: ownRequest.messages });
389
+ }
390
+ if (peerRequest?.tag === 'request') {
391
+ const requestId = peerRequest.requestId;
392
+ const alreadyKnown = incomingRequest(machineState, requestId) !== undefined;
393
+ const responded = ownResponse?.tag === 'response' && ownResponse.requestId === requestId;
394
+ // The reducer ignores an entry a live delivery created during the awaits above (the
395
+ // live one is newer/authoritative), so only notify when this one is genuinely new.
396
+ dispatch({ type: 'incomingRestored', requestId, responded });
397
+ // Notify app of an unresponded incoming request. Delivered while phase is still
398
+ // 'initialization' so `deliver` buffers it for replay if no subscriber is registered yet.
399
+ if (!alreadyKnown && !responded)
400
+ deliver(peerRequest);
401
+ }
402
+ dispatch({ type: 'activated' });
403
+ }
404
+ const session = {
405
+ request(codec, data) {
406
+ return session
407
+ .submitRequestMessage(codec, data)
408
+ .andThen(({ requestId }) => session.waitForResponseMessage(requestId).andThen(({ responseCode }) => mapResponseCode(responseCode)));
409
+ },
410
+ submitRequestMessage(codec, message) {
411
+ if (disposed)
412
+ return errAsync(new Error(SESSION_DISPOSED));
413
+ const encode = fromThrowable(codec.enc, toError);
414
+ const encodedResult = encode(message);
415
+ if (encodedResult.isErr())
416
+ return errAsync(encodedResult.error);
417
+ const encoded = encodedResult.value;
418
+ // Build once to size it, and keep the builder's own error: "cannot wrap without
419
+ // recipient devices" must not surface as "message too big".
420
+ const sized = bodyBuilder.buildRequest(SIZING_REQUEST_ID, [encoded]);
421
+ if (sized.isErr())
422
+ return errAsync(sized.error);
423
+ if (sized.value.data.length > maxPayloadSize)
424
+ return errAsync(new Error('message too big'));
425
+ if (machineState.phase === 'failed') {
426
+ return errAsync(machineState.initError ?? new Error('Session initialization failed'));
427
+ }
428
+ const token = nanoid();
429
+ pendingDelivery.set(token, makeDeferred());
430
+ dispatch({ type: 'messageSubmitted', encoded, token });
431
+ return okAsync({ requestId: token });
432
+ },
433
+ submitResponseMessage(requestId, responseCode) {
434
+ if (disposed)
435
+ return errAsync(new Error(SESSION_DISPOSED));
436
+ const incoming = incomingRequest(machineState, requestId);
437
+ if (!incoming)
438
+ return errAsync(new Error(`No incoming request with id ${requestId}`));
439
+ if (incoming.responded) {
440
+ pruneBufferedRequest(requestId);
441
+ return okAsync(undefined);
442
+ }
443
+ const body = bodyBuilder.buildResponse(requestId, responseCode);
444
+ if (body.isErr())
445
+ return errAsync(body.error);
446
+ const responseBody = body.value;
447
+ // Mark responded up-front so concurrent callers dedupe, but roll back if the
448
+ // submission fails — otherwise the ACK is lost forever (and a peer retransmit
449
+ // with a fresh id could never be answered either).
450
+ dispatch({ type: 'responseSubmitted', requestId });
451
+ lastResponseRequestId = requestId;
452
+ // Responses go on OUR outgoing topic/response-channel (per spec: the responder
453
+ // publishes on SessionId(self, peer)); the requester reads them from its incoming topic.
454
+ // Responses share one channel, so only the newest is live.
455
+ return (submitWithSessionRetry(responseBody, () => lastResponseRequestId === requestId)
456
+ .orElse(error => {
457
+ // Superseded or disposed: keep the request marked answered — re-answering would
458
+ // only clobber the newer response — and absorb the error. NOTE: since the shared
459
+ // channel exposes just the latest response, reliably ACKing several outstanding
460
+ // requests needs a protocol-level fix, tracked separately.
461
+ if (disposed || lastResponseRequestId !== requestId)
462
+ return okAsync(undefined);
463
+ // The live response genuinely failed after exhausting retries — roll back so a later
464
+ // peer retransmit can still be answered, and surface the error.
465
+ dispatch({ type: 'responseSubmitFailed', requestId });
466
+ return errAsync(error);
467
+ })
468
+ // Answered (or absorbed as such): it no longer needs replaying to future subscribers.
469
+ .andTee(() => pruneBufferedRequest(requestId)));
470
+ },
471
+ waitForRequestMessage(codec, filter) {
472
+ const promise = new Promise((resolve, reject) => {
473
+ let settled = false;
474
+ // Initialised to a no-op so a synchronous buffered-replay match during
475
+ // subscribe() can call it without hitting the temporal dead zone; the
476
+ // real unsubscribe is wired in once subscribe() returns.
477
+ let unsubscribe = () => undefined;
478
+ const finish = (run) => {
479
+ if (settled)
480
+ return;
481
+ settled = true;
482
+ requestWaiters.delete(rejectWaiter);
483
+ unsubscribe();
484
+ run();
485
+ };
486
+ const rejectWaiter = (error) => finish(() => reject(error));
487
+ requestWaiters.add(rejectWaiter);
488
+ unsubscribe = session.subscribe(codec, messages => {
489
+ for (const message of messages) {
490
+ if (message.type !== 'request')
491
+ continue;
492
+ if (message.payload.status !== 'parsed')
493
+ continue;
494
+ const filtered = filter(message.payload.value);
495
+ if (filtered !== undefined) {
496
+ finish(() => resolve(filtered));
497
+ return;
498
+ }
499
+ }
500
+ });
501
+ // subscribe() may have matched synchronously (buffered replay) while
502
+ // `unsubscribe` was still the no-op above — tear down the real one now.
503
+ if (settled)
504
+ unsubscribe();
505
+ });
506
+ return fromPromise(promise, toError);
507
+ },
508
+ respondToRequests(codec, handler) {
509
+ return session.subscribe(codec, messages => {
510
+ for (const message of messages) {
511
+ if (message.type !== 'request')
512
+ continue;
513
+ const handled = handler(message);
514
+ const statusResult = handled instanceof ResultAsync ? handled : okAsync(handled);
515
+ void statusResult
516
+ .orElse(() => okAsync('unknown'))
517
+ .andThen(code => session.submitResponseMessage(message.requestId, code))
518
+ .mapErr(e => {
519
+ console.error('respondToRequests: failed to submit response:', e);
520
+ });
521
+ }
522
+ });
523
+ },
524
+ waitForResponseMessage(token) {
525
+ const deferred = pendingDelivery.get(token);
526
+ if (!deferred)
527
+ return errAsync(new Error(`No pending delivery for token ${token}`));
528
+ return fromPromise(deferred.promise, toError);
529
+ },
530
+ subscribe(codec, callback) {
531
+ const sub = {
532
+ codec: codec,
533
+ callback: callback,
534
+ };
535
+ subscribers.push(sub);
536
+ ensureStoreSubscription();
537
+ // Deliver buffered init messages to this subscriber
538
+ if (bufferedMessages.length > 0) {
539
+ const messages = bufferedMessages.flatMap(sd => toMessage(sd, codec));
540
+ if (messages.length > 0)
541
+ callback(messages);
542
+ }
543
+ return () => {
544
+ subscribers = subscribers.filter(s => s !== sub);
545
+ if (subscribers.length === 0)
546
+ closeStoreSubscription();
547
+ };
548
+ },
549
+ clearOutgoingStatement() {
550
+ // Always drop local outgoing state and reject pending waiters up-front, regardless of
551
+ // which path follows. This covers messages queued before the batch went out (e.g.
552
+ // during init, while there is no live batch) and guarantees cleanup even if the
553
+ // superseding submission below fails — the caller still receives any submission error.
554
+ const requestId = liveRequestId(machineState);
555
+ dispatch({ type: 'outgoingCleared' });
556
+ rejectAllPending(new Error('Outgoing batch aborted'));
557
+ if (requestId === null)
558
+ return okAsync(undefined);
559
+ const emptyBody = bodyBuilder.buildRequest(requestId, []);
560
+ if (emptyBody.isErr())
561
+ return errAsync(emptyBody.error);
562
+ // Supersede the live batch with an empty one, which goes out at a STRICTLY higher
563
+ // expiry — the store rejects an equal-or-lower expiry on the same channel, so reusing
564
+ // the last allocated expiry would leave the original request live on-chain. One shot,
565
+ // no retry (clearing is a supersede, not a request that must land); a priority
566
+ // rejection (ExpiryTooLow / AccountFull) means the channel already advanced past us,
567
+ // so the clear already happened → absorb it as success. No retry loop here means no
568
+ // onPriorityError hook, so resync the allocator inline: adopt the chain floor before
569
+ // absorbing, so later submits stay above it.
570
+ return submitBody(emptyBody.value).orElse(error => {
571
+ if (!isPriorityTooLow(error))
572
+ return errAsync(error);
573
+ allocator.raiseFloor(error.min);
574
+ return okAsync(undefined);
575
+ });
576
+ },
577
+ dispose() {
578
+ disposed = true;
579
+ if (initRetryTimer) {
580
+ clearTimeout(initRetryTimer);
581
+ initRetryTimer = null;
582
+ }
583
+ closeStoreSubscription();
584
+ subscribers = [];
585
+ // Drop pending work so no in-flight retry or queue drain acts on a disposed session.
586
+ dispatch({ type: 'outgoingCleared' });
587
+ // Settle any waitForRequestMessage() promises so callers unwind instead of
588
+ // hanging forever. Snapshot first — rejecting mutates the set.
589
+ for (const rejectWaiter of [...requestWaiters])
590
+ rejectWaiter(new Error(SESSION_DISPOSED));
591
+ requestWaiters.clear();
592
+ rejectAllPending(new Error(SESSION_DISPOSED));
593
+ },
594
+ };
595
+ void init();
596
+ return session;
597
+ }
598
+ function mapResponseCode(responseCode) {
599
+ switch (responseCode) {
600
+ case 'success':
601
+ return ok();
602
+ case 'decodingFailed':
603
+ return err(new DecodingError());
604
+ case 'decryptionFailed':
605
+ return err(new DecryptionError());
606
+ case 'unknown':
607
+ return err(new UnknownError());
608
+ }
609
+ }
@@ -1,4 +1,4 @@
1
- import type { Codec, CodecType } from 'scale-ts';
2
- import type { StatementData } from './scale/statementData.js';
1
+ import type { Codec } from 'scale-ts';
2
+ import type { ReadableEvent } from './codec/decoder.js';
3
3
  import type { Message } from './types.js';
4
- export declare function toMessage<T>(statementData: CodecType<typeof StatementData>, codec: Codec<T>): Message<T>[];
4
+ export declare function toMessage<T>(event: ReadableEvent, codec: Codec<T>): Message<T>[];
@@ -6,14 +6,14 @@ function decode(payload, codec) {
6
6
  return { status: 'failed', value: payload };
7
7
  }
8
8
  }
9
- export function toMessage(statementData, codec) {
10
- switch (statementData.tag) {
9
+ export function toMessage(event, codec) {
10
+ switch (event.tag) {
11
11
  case 'request': {
12
- return statementData.value.data.map((payload, index) => {
12
+ return event.messages.map((payload, index) => {
13
13
  return {
14
14
  type: 'request',
15
- localId: `${statementData.value.requestId}-${index.toString()}`,
16
- requestId: statementData.value.requestId,
15
+ localId: `${event.requestId}-${index.toString()}`,
16
+ requestId: event.requestId,
17
17
  payload: decode(payload, codec),
18
18
  };
19
19
  });
@@ -22,9 +22,9 @@ export function toMessage(statementData, codec) {
22
22
  return [
23
23
  {
24
24
  type: 'response',
25
- localId: statementData.value.requestId,
26
- requestId: statementData.value.requestId,
27
- responseCode: statementData.value.responseCode,
25
+ localId: event.requestId,
26
+ requestId: event.requestId,
27
+ responseCode: event.responseCode,
28
28
  },
29
29
  ];
30
30
  }