@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.
- package/dist/helpers.d.ts +0 -1
- package/dist/helpers.js +0 -3
- package/dist/index.d.ts +7 -0
- package/dist/index.js +3 -0
- package/dist/session/codec/decoder.d.ts +68 -0
- package/dist/session/codec/decoder.js +96 -0
- package/dist/session/codec/decoder.spec.d.ts +1 -0
- package/dist/session/codec/decoder.spec.js +161 -0
- package/dist/session/codec/envelope.d.ts +55 -0
- package/dist/session/codec/envelope.js +115 -0
- package/dist/session/codec/envelope.spec.d.ts +1 -0
- package/dist/session/codec/envelope.spec.js +114 -0
- package/dist/session/codec/incomingTopics.d.ts +46 -0
- package/dist/session/codec/incomingTopics.js +69 -0
- package/dist/session/codec/outgoingBody.d.ts +46 -0
- package/dist/session/codec/outgoingBody.js +64 -0
- package/dist/session/core.d.ts +58 -0
- package/dist/session/core.js +609 -0
- package/dist/session/messageMapper.d.ts +3 -3
- package/dist/session/messageMapper.js +8 -8
- package/dist/session/multiDeviceSession.d.ts +49 -0
- package/dist/session/multiDeviceSession.js +62 -0
- package/dist/session/multiDeviceSession.spec.d.ts +6 -0
- package/dist/session/multiDeviceSession.spec.js +354 -0
- package/dist/session/scale/statementData.d.ts +40 -0
- package/dist/session/scale/statementData.js +32 -0
- package/dist/session/session.d.ts +15 -5
- package/dist/session/session.js +31 -633
- package/dist/session/session.spec.js +22 -6
- package/dist/session/stateMachine.d.ts +135 -0
- package/dist/session/stateMachine.js +203 -0
- package/dist/session/stateMachine.spec.d.ts +1 -0
- package/dist/session/stateMachine.spec.js +276 -0
- 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
|
|
2
|
-
import type {
|
|
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>(
|
|
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(
|
|
10
|
-
switch (
|
|
9
|
+
export function toMessage(event, codec) {
|
|
10
|
+
switch (event.tag) {
|
|
11
11
|
case 'request': {
|
|
12
|
-
return
|
|
12
|
+
return event.messages.map((payload, index) => {
|
|
13
13
|
return {
|
|
14
14
|
type: 'request',
|
|
15
|
-
localId: `${
|
|
16
|
-
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:
|
|
26
|
-
requestId:
|
|
27
|
-
responseCode:
|
|
25
|
+
localId: event.requestId,
|
|
26
|
+
requestId: event.requestId,
|
|
27
|
+
responseCode: event.responseCode,
|
|
28
28
|
},
|
|
29
29
|
];
|
|
30
30
|
}
|