@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.
- package/LICENSE +176 -0
- package/dist/adapter/inMemory.d.ts +32 -0
- package/dist/adapter/inMemory.js +56 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -1
- package/dist/session/session.d.ts +7 -0
- package/dist/session/session.js +357 -132
- package/dist/session/session.spec.js +802 -335
- package/dist/session/statementProver.d.ts +3 -0
- package/dist/session/statementProver.js +20 -4
- package/dist/session/statementProver.spec.d.ts +1 -0
- package/dist/session/statementProver.spec.js +71 -0
- package/dist/session/types.d.ts +12 -0
- package/package.json +3 -3
package/dist/session/session.js
CHANGED
|
@@ -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
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 =>
|
|
83
|
-
|
|
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
|
|
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(
|
|
93
|
-
if (
|
|
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
|
-
|
|
233
|
+
const requestId = statementData.value.requestId;
|
|
234
|
+
if (state.incomingRequests.has(requestId))
|
|
97
235
|
return;
|
|
98
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: [
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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.
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
|
339
|
+
const result = await ResultAsync.combine([
|
|
188
340
|
statementStore.queryStatements({ matchAll: [outgoingSessionId] }),
|
|
189
341
|
statementStore.queryStatements({ matchAll: [incomingSessionId] }),
|
|
190
342
|
]);
|
|
191
|
-
if (
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
200
|
-
const peerStatements =
|
|
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
|
-
|
|
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).
|
|
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 =
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
if (payload.status !== 'parsed')
|
|
499
|
+
if (message.payload.status !== 'parsed')
|
|
296
500
|
continue;
|
|
297
|
-
const filtered = filter(payload.value);
|
|
298
|
-
if (filtered) {
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
};
|