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