@novasamatech/statement-store 0.8.7-1 → 0.8.7-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/session/priority.d.ts +1 -0
- package/dist/session/priority.js +22 -0
- package/dist/session/priority.spec.d.ts +1 -0
- package/dist/session/priority.spec.js +43 -0
- package/dist/session/session.d.ts +0 -1
- package/dist/session/session.js +60 -35
- package/dist/session/session.spec.js +156 -26
- package/package.json +3 -3
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function nextExpiry(current: bigint): bigint;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Statement expiry/priority, u64 = (expiration_epoch << 32) | priority (spec layout).
|
|
3
|
+
* We pin the high word to 0xFFFFFFFF (max → effectively non-expiring, matching iOS &
|
|
4
|
+
* Android) and use the low word as a wall-clock-floored monotonic priority, so channel
|
|
5
|
+
* supersession is driven by the priority regardless of how the store compares the field.
|
|
6
|
+
* Returns a value strictly greater than `current` (i.e. `max(current + 1, now-priority)`).
|
|
7
|
+
*/
|
|
8
|
+
const NEVER_EXPIRE_HIGH = 0xffffffffn;
|
|
9
|
+
/**
|
|
10
|
+
* Priority epoch base: seconds at 2025-11-15T00:00:00Z. The low word is a u32 priority counted FROM
|
|
11
|
+
* this epoch (spec §1), not the raw Unix timestamp. iOS (StatementPriorityFactory.unixOffset) and
|
|
12
|
+
* Android subtract the same offset; omitting it makes the TS low word ~1.76e9 larger than every
|
|
13
|
+
* mobile client's, so any cross-client or shared-channel priority comparison would always rank a
|
|
14
|
+
* TS-written statement above a mobile-written one. Keeping the base aligned removes that landmine.
|
|
15
|
+
*/
|
|
16
|
+
const PRIORITY_EPOCH_OFFSET = 1763164800n;
|
|
17
|
+
export function nextExpiry(current) {
|
|
18
|
+
const nowSecs = BigInt(Math.floor(Date.now() / 1000));
|
|
19
|
+
const priority = nowSecs > PRIORITY_EPOCH_OFFSET ? nowSecs - PRIORITY_EPOCH_OFFSET : 0n;
|
|
20
|
+
const timestampPriority = (NEVER_EXPIRE_HIGH << 32n) | priority;
|
|
21
|
+
return timestampPriority > current ? timestampPriority : current + 1n;
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { nextExpiry } from './priority.js';
|
|
3
|
+
// Statement expiry/priority: u64 = (expiration_epoch << 32) | priority. The high word is pinned
|
|
4
|
+
// to 0xFFFFFFFF (non-expiring) and the low word is a wall-clock-floored monotonic priority that
|
|
5
|
+
// drives channel supersession. (Spec §1; matches iOS/Android.)
|
|
6
|
+
describe('expiry priority', () => {
|
|
7
|
+
it('encodes a non-expiring statement (high word pinned to 0xFFFFFFFF)', () => {
|
|
8
|
+
expect(nextExpiry(0n) >> 32n).toBe(0xffffffffn);
|
|
9
|
+
});
|
|
10
|
+
it('carries a wall-clock priority in the low word', () => {
|
|
11
|
+
const result = nextExpiry(0n);
|
|
12
|
+
expect(result & 0xffffffffn).toBeGreaterThan(0n);
|
|
13
|
+
});
|
|
14
|
+
it('counts the low word from the 2025-11-15 priority epoch (matches iOS/Android)', () => {
|
|
15
|
+
// iOS StatementPriorityFactory: priority = unixSeconds - 1_763_164_800 (the 2025-11-15 base,
|
|
16
|
+
// spec §1). The TS SDK must use the SAME base; otherwise its low word is ~1.76e9 larger than
|
|
17
|
+
// every mobile client's, so any cross-client/shared-channel priority comparison would rank a
|
|
18
|
+
// TS-written statement above a mobile-written one regardless of real time.
|
|
19
|
+
const PRIORITY_EPOCH_OFFSET = 1763164800n;
|
|
20
|
+
vi.useFakeTimers();
|
|
21
|
+
try {
|
|
22
|
+
const fixedMs = 1_780_000_000_000;
|
|
23
|
+
vi.setSystemTime(fixedMs);
|
|
24
|
+
const expected = BigInt(Math.floor(fixedMs / 1000)) - PRIORITY_EPOCH_OFFSET;
|
|
25
|
+
expect(nextExpiry(0n) & 0xffffffffn).toBe(expected);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
it('increments by one when the current value already exceeds the wall-clock priority', () => {
|
|
32
|
+
const high = (0xffffffffn << 32n) | 0xffffffffn; // max u64
|
|
33
|
+
expect(nextExpiry(high)).toBe(high + 1n);
|
|
34
|
+
});
|
|
35
|
+
it('is strictly monotonic across repeated calls', () => {
|
|
36
|
+
let expiry = 0n;
|
|
37
|
+
for (let i = 0; i < 5; i++) {
|
|
38
|
+
const next = nextExpiry(expiry);
|
|
39
|
+
expect(next).toBeGreaterThan(expiry);
|
|
40
|
+
expiry = next;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -32,5 +32,4 @@ export type SessionParams = {
|
|
|
32
32
|
* `maxStatementSize - overhead` rather than the raw statement limit.
|
|
33
33
|
*/
|
|
34
34
|
export declare const STATEMENT_OVERHEAD: number;
|
|
35
|
-
export declare function nextExpiry(current: bigint): bigint;
|
|
36
35
|
export declare function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, sessionKey, maxRequestSize, }: SessionParams): Session;
|
package/dist/session/session.js
CHANGED
|
@@ -8,8 +8,12 @@ import { nonNullable, toError } from '../helpers.js';
|
|
|
8
8
|
import { createSessionId } from '../model/session.js';
|
|
9
9
|
import { DecodingError, DecryptionError, UnknownError } from './error.js';
|
|
10
10
|
import { toMessage } from './messageMapper.js';
|
|
11
|
+
import { nextExpiry } from './priority.js';
|
|
11
12
|
import { StatementData } from './scale/statementData.js';
|
|
12
13
|
const DEFAULT_MAX_REQUEST_SIZE = 4096;
|
|
14
|
+
// Rejection reason shared by dispose() and the disposed guards on submit*, so a torn-down session
|
|
15
|
+
// always fails new and in-flight work the same way.
|
|
16
|
+
const SESSION_DISPOSED = 'Session disposed';
|
|
13
17
|
// Bounded retry for transient transport failures (the spec mandates retrying queries
|
|
14
18
|
// and submit_statement on connection failure). The TS adapter doesn't expose connection
|
|
15
19
|
// state, so we approximate with a short fixed backoff and an attempt cap.
|
|
@@ -23,19 +27,6 @@ const RETRY_DELAY_MS = 25;
|
|
|
23
27
|
* `maxStatementSize - overhead` rather than the raw statement limit.
|
|
24
28
|
*/
|
|
25
29
|
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;
|
|
34
|
-
export function nextExpiry(current) {
|
|
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
30
|
// Encode/decode a StatementData envelope, surfacing scale-ts throws as a Result.
|
|
40
31
|
const encodeStatementData = fromThrowable(StatementData.enc, toError);
|
|
41
32
|
const decodeStatementData = fromThrowable(StatementData.dec, toError);
|
|
@@ -77,15 +68,27 @@ function makeDeferred() {
|
|
|
77
68
|
promise.catch(() => undefined);
|
|
78
69
|
return { resolve, reject, promise };
|
|
79
70
|
}
|
|
80
|
-
// Retry a submit on failure with a short backoff
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
71
|
+
// Retry a submit on failure with a short backoff. `shouldRetry` is re-checked before each retry:
|
|
72
|
+
// once the submission is superseded, aborted, or the session is disposed it returns false, so a
|
|
73
|
+
// stale retry can never resurrect an old statement.
|
|
74
|
+
//
|
|
75
|
+
// ExpiryTooLow is treated specially: it is never a chain/statement failure, only a sign our
|
|
76
|
+
// in-memory expiry lagged the channel's on-chain priority. submitStatementData has already resynced
|
|
77
|
+
// us above the reported minimum, so the next attempt submits higher. We therefore retry ExpiryTooLow
|
|
78
|
+
// WITHOUT spending the `attemptsLeft` transient-failure budget — keeping at it until it lands or the
|
|
79
|
+
// submission is superseded — and, once superseded, swallow it as success. The upshot: ExpiryTooLow
|
|
80
|
+
// never surfaces to callers. (Other errors keep the bounded retry and propagate when exhausted.)
|
|
84
81
|
function submitWithRetry(submit, attemptsLeft, shouldRetry) {
|
|
82
|
+
// How to settle once we stop retrying: a no-longer-live submission rejected with ExpiryTooLow
|
|
83
|
+
// simply lost the channel race to a newer, higher-priority statement — benign, so report success.
|
|
84
|
+
const settle = (error) => !shouldRetry() && error instanceof ExpiryTooLowError ? okAsync(undefined) : errAsync(error);
|
|
85
85
|
return submit().orElse(error => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
86
|
+
const expiryTooLow = error instanceof ExpiryTooLowError;
|
|
87
|
+
if (!shouldRetry() || (!expiryTooLow && attemptsLeft <= 0))
|
|
88
|
+
return settle(error);
|
|
89
|
+
// ExpiryTooLow is always recoverable while live, so it doesn't consume the retry budget.
|
|
90
|
+
const nextAttempts = expiryTooLow ? attemptsLeft : attemptsLeft - 1;
|
|
91
|
+
return ResultAsync.fromSafePromise(new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS))).andThen(() => shouldRetry() ? submitWithRetry(submit, nextAttempts, shouldRetry) : settle(error));
|
|
89
92
|
});
|
|
90
93
|
}
|
|
91
94
|
export function createSession({ localAccount, remoteAccount, statementStore, encryption, prover, sessionKey, maxRequestSize = DEFAULT_MAX_REQUEST_SIZE, }) {
|
|
@@ -155,19 +158,19 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
155
158
|
// newer retransmit, aborted via clearOutgoingStatement, or disposed).
|
|
156
159
|
() => !disposed && state.outgoingRequest?.requestIds.at(-1) === requestId))
|
|
157
160
|
.mapErr(e => {
|
|
158
|
-
|
|
161
|
+
// ExpiryTooLow is handled in submitWithRetry (retried until it lands, swallowed once
|
|
162
|
+
// superseded), so an error reaching here is a different, genuine failure. If this submission
|
|
163
|
+
// was already superseded by a newer retransmit (same tokens) it is not the live request's
|
|
164
|
+
// concern — drop it silently; the newer one carries the waiters. Otherwise the bounded
|
|
165
|
+
// retries are exhausted on the LIVE submission: the request never landed, so fail its
|
|
166
|
+
// waiters rather than let them hang.
|
|
167
|
+
const outgoing = state.outgoingRequest;
|
|
168
|
+
if (disposed || !outgoing || outgoing.requestIds.at(-1) !== requestId)
|
|
159
169
|
return;
|
|
160
170
|
console.error('submitRequest failed:', e);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
}
|
|
171
|
+
settleTokens(outgoing.tokens, deferred => deferred.reject(e));
|
|
172
|
+
state.outgoingRequest = null;
|
|
173
|
+
processMessageQueue();
|
|
171
174
|
});
|
|
172
175
|
}
|
|
173
176
|
function deliverStatementData(statementData) {
|
|
@@ -379,6 +382,8 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
379
382
|
}
|
|
380
383
|
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));
|
|
381
384
|
const [ownDecoded, peerDecoded] = await Promise.all([decodeAll(ownStatements), decodeAll(peerStatements)]);
|
|
385
|
+
if (disposed)
|
|
386
|
+
return;
|
|
382
387
|
// Both parties publish on their own outgoing topic, so the OUTGOING query returns our
|
|
383
388
|
// requests + OUR responses, and the INCOMING query returns the peer's requests + the
|
|
384
389
|
// PEER's responses. Hence: our request is answered by a PEER response (incoming), and we
|
|
@@ -421,6 +426,8 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
421
426
|
.andThen(({ requestId }) => session.waitForResponseMessage(requestId).andThen(({ responseCode }) => mapResponseCode(responseCode)));
|
|
422
427
|
},
|
|
423
428
|
submitRequestMessage(codec, message) {
|
|
429
|
+
if (disposed)
|
|
430
|
+
return errAsync(new Error(SESSION_DISPOSED));
|
|
424
431
|
const encode = fromThrowable(codec.enc, toError);
|
|
425
432
|
const encodedResult = encode(message);
|
|
426
433
|
if (encodedResult.isErr())
|
|
@@ -448,6 +455,8 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
448
455
|
return okAsync({ requestId: token });
|
|
449
456
|
},
|
|
450
457
|
submitResponseMessage(requestId, responseCode) {
|
|
458
|
+
if (disposed)
|
|
459
|
+
return errAsync(new Error(SESSION_DISPOSED));
|
|
451
460
|
const incoming = state.incomingRequests.get(requestId);
|
|
452
461
|
if (!incoming)
|
|
453
462
|
return errAsync(new Error(`No incoming request with id ${requestId}`));
|
|
@@ -471,6 +480,19 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
471
480
|
// Answered: it no longer needs replaying to future subscribers.
|
|
472
481
|
.andTee(() => pruneBufferedRequest(requestId))
|
|
473
482
|
.orElse(error => {
|
|
483
|
+
// ExpiryTooLow is handled in submitWithRetry (swallowed once a newer response supersedes
|
|
484
|
+
// this one on the shared channel), so an error here is a different failure. If this is no
|
|
485
|
+
// longer the latest response (superseded) or the session is disposed, keep the request
|
|
486
|
+
// marked answered — re-answering would only clobber the newer response — and absorb the
|
|
487
|
+
// error. NOTE: the shared response channel still only exposes the latest response to the
|
|
488
|
+
// peer, so reliably ACKing several outstanding requests needs the protocol-level fix
|
|
489
|
+
// tracked separately.
|
|
490
|
+
if (disposed || lastResponseRequestId !== requestId) {
|
|
491
|
+
pruneBufferedRequest(requestId);
|
|
492
|
+
return okAsync(undefined);
|
|
493
|
+
}
|
|
494
|
+
// The live response genuinely failed after exhausting retries — roll back so a later
|
|
495
|
+
// peer retransmit can still be answered, and surface the error.
|
|
474
496
|
incoming.responded = false;
|
|
475
497
|
return errAsync(error);
|
|
476
498
|
}));
|
|
@@ -574,8 +596,11 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
574
596
|
// Supersede the live batch with an empty one. Use submitStatementData so the
|
|
575
597
|
// empty statement goes out at a STRICTLY higher expiry — the store rejects an
|
|
576
598
|
// equal-or-lower expiry on the same channel, so reusing state.expiry would
|
|
577
|
-
// leave the original request live on-chain.
|
|
578
|
-
|
|
599
|
+
// leave the original request live on-chain. Route through submitWithRetry with
|
|
600
|
+
// shouldRetry:()=>false so it inherits the single ExpiryTooLow policy — a rejection
|
|
601
|
+
// means the channel already advanced past us, so the clear already happened → absorb it —
|
|
602
|
+
// without retrying (clearing is a one-shot supersede, not a request that must land).
|
|
603
|
+
return submitWithRetry(() => submitStatementData(createRequestChannel(outgoingSessionId), outgoingSessionId, encoded.value), 0, () => false);
|
|
579
604
|
},
|
|
580
605
|
dispose() {
|
|
581
606
|
disposed = true;
|
|
@@ -592,9 +617,9 @@ export function createSession({ localAccount, remoteAccount, statementStore, enc
|
|
|
592
617
|
// Settle any waitForRequestMessage() promises so callers unwind instead of
|
|
593
618
|
// hanging forever. Snapshot first — rejecting mutates the set.
|
|
594
619
|
for (const rejectWaiter of [...requestWaiters])
|
|
595
|
-
rejectWaiter(new Error(
|
|
620
|
+
rejectWaiter(new Error(SESSION_DISPOSED));
|
|
596
621
|
requestWaiters.clear();
|
|
597
|
-
rejectAllPending(new Error(
|
|
622
|
+
rejectAllPending(new Error(SESSION_DISPOSED));
|
|
598
623
|
},
|
|
599
624
|
};
|
|
600
625
|
void init();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
|
|
2
|
-
import { ResultAsync, errAsync, ok, okAsync } from 'neverthrow';
|
|
2
|
+
import { ResultAsync, err, errAsync, ok, okAsync } from 'neverthrow';
|
|
3
3
|
import { Bytes, Struct, str } from 'scale-ts';
|
|
4
4
|
import { describe, expect, it, vi } from 'vitest';
|
|
5
5
|
import { createInMemoryStatementStore } from '../adapter/inMemory.js';
|
|
@@ -7,7 +7,7 @@ import { ExpiryTooLowError } from '../adapter/types.js';
|
|
|
7
7
|
import { createAccountId, createLocalSessionAccount, createRemoteSessionAccount } from '../model/sessionAccount.js';
|
|
8
8
|
import { DecodingError, UnknownError } from './error.js';
|
|
9
9
|
import { StatementData } from './scale/statementData.js';
|
|
10
|
-
import { STATEMENT_OVERHEAD, createSession
|
|
10
|
+
import { STATEMENT_OVERHEAD, createSession } from './session.js';
|
|
11
11
|
// Real signature work belongs in statementProver tests; this stub stamps a
|
|
12
12
|
// non-empty proof so submitted statements are well-formed.
|
|
13
13
|
const mockProver = {
|
|
@@ -91,6 +91,20 @@ function lastSubmittedRequestId(adapter) {
|
|
|
91
91
|
const decoded = StatementData.dec(lastSubmitted(adapter).data);
|
|
92
92
|
return decoded.tag === 'request' ? decoded.value.requestId : '';
|
|
93
93
|
}
|
|
94
|
+
// A submitStatement mock that defers every submission instead of resolving: each call records a
|
|
95
|
+
// `{ requestId, settle }` entry in `pendings`, letting a test land or reject submissions in a chosen
|
|
96
|
+
// order (used to drive shared-channel supersession races). Works for request and response payloads.
|
|
97
|
+
function deferredSubmit() {
|
|
98
|
+
const pendings = [];
|
|
99
|
+
const submitStatement = vi.fn((stmt) => {
|
|
100
|
+
const decoded = StatementData.dec(stmt.data);
|
|
101
|
+
const requestId = decoded.tag === 'request' || decoded.tag === 'response' ? decoded.value.requestId : '';
|
|
102
|
+
return ResultAsync.fromPromise(new Promise((resolve, reject) => {
|
|
103
|
+
pendings.push({ requestId, settle: r => (r.isOk() ? resolve() : reject(r.error)) });
|
|
104
|
+
}), e => e);
|
|
105
|
+
});
|
|
106
|
+
return { submitStatement, pendings };
|
|
107
|
+
}
|
|
94
108
|
// Encoded size of the request payload (the statement `data` field) for these messages —
|
|
95
109
|
// what the session sizes batches against. Includes the requestId (a fixed-length nanoid)
|
|
96
110
|
// and the SCALE vector framing, so it is larger than the raw message bytes alone.
|
|
@@ -139,30 +153,6 @@ function makeMobile(adapter) {
|
|
|
139
153
|
}
|
|
140
154
|
describe('session', () => {
|
|
141
155
|
const rawCodec = Bytes();
|
|
142
|
-
// Statement expiry/priority: u64 = (expiration_epoch << 32) | priority. The high word is pinned
|
|
143
|
-
// to 0xFFFFFFFF (non-expiring) and the low word is a wall-clock-floored monotonic priority that
|
|
144
|
-
// drives channel supersession. (Spec §1; matches iOS/Android.)
|
|
145
|
-
describe('expiry priority', () => {
|
|
146
|
-
it('encodes a non-expiring statement (high word pinned to 0xFFFFFFFF)', () => {
|
|
147
|
-
expect(nextExpiry(0n) >> 32n).toBe(0xffffffffn);
|
|
148
|
-
});
|
|
149
|
-
it('carries a wall-clock priority in the low word', () => {
|
|
150
|
-
const result = nextExpiry(0n);
|
|
151
|
-
expect(result & 0xffffffffn).toBeGreaterThan(0n);
|
|
152
|
-
});
|
|
153
|
-
it('increments by one when the current value already exceeds the wall-clock priority', () => {
|
|
154
|
-
const high = (0xffffffffn << 32n) | 0xffffffffn; // max u64
|
|
155
|
-
expect(nextExpiry(high)).toBe(high + 1n);
|
|
156
|
-
});
|
|
157
|
-
it('is strictly monotonic across repeated calls', () => {
|
|
158
|
-
let expiry = 0n;
|
|
159
|
-
for (let i = 0; i < 5; i++) {
|
|
160
|
-
const next = nextExpiry(expiry);
|
|
161
|
-
expect(next).toBeGreaterThan(expiry);
|
|
162
|
-
expiry = next;
|
|
163
|
-
}
|
|
164
|
-
});
|
|
165
|
-
});
|
|
166
156
|
// On creation a session queries both of its topics, derives the starting expiry, and buffers
|
|
167
157
|
// anything it finds until it goes active. (Spec §5 initialization.)
|
|
168
158
|
describe('initialization', () => {
|
|
@@ -757,6 +747,40 @@ describe('session', () => {
|
|
|
757
747
|
expect(second.isOk()).toBe(true);
|
|
758
748
|
expect(adapter.submitStatement.mock.calls.length).toBeGreaterThan(submitsBefore);
|
|
759
749
|
}, 3000);
|
|
750
|
+
it('absorbs a superseded response rejected as ExpiryTooLow and keeps the request answered', async () => {
|
|
751
|
+
// Two incoming requests are answered on the SHARED response channel. The response to A is in
|
|
752
|
+
// flight when the response to B takes over the channel; A then lands at a now-lower expiry and
|
|
753
|
+
// the store rejects it (ExpiryTooLow). That supersession is expected: B's response owns the
|
|
754
|
+
// channel, so A's rejection must be absorbed (not surfaced) and A must stay marked answered —
|
|
755
|
+
// re-answering would only clobber B. (Returning ok here is also what stops respondToRequests
|
|
756
|
+
// from logging it as a failed response.)
|
|
757
|
+
const reqA = makeStatement({ tag: 'request', value: { requestId: 'A', data: [] } });
|
|
758
|
+
const { subscribeStatements, callbacks } = capturingSubscribe();
|
|
759
|
+
const { submitStatement, pendings } = deferredSubmit();
|
|
760
|
+
const { session, adapter } = makeSession({ peer: [reqA], subscribeStatements, submitStatement });
|
|
761
|
+
await delay();
|
|
762
|
+
session.subscribe(rawCodec, vi.fn()); // activate the store subscription
|
|
763
|
+
const reqB = makeStatement({ tag: 'request', value: { requestId: 'B', data: [] } });
|
|
764
|
+
callbacks[0]({ statements: [reqB], isComplete: true });
|
|
765
|
+
await delay();
|
|
766
|
+
const resAPromise = session.submitResponseMessage('A', 'success'); // in flight on the shared channel
|
|
767
|
+
const resBPromise = session.submitResponseMessage('B', 'success'); // supersedes A
|
|
768
|
+
await delay(); // both reach submitStatement
|
|
769
|
+
expect(pendings).toHaveLength(2);
|
|
770
|
+
pendings.find(p => p.requestId === 'B').settle(ok(undefined)); // B lands, owns the channel
|
|
771
|
+
pendings.find(p => p.requestId === 'A').settle(err(new ExpiryTooLowError(0n, 0n))); // A lands late, rejected
|
|
772
|
+
const resA = await resAPromise;
|
|
773
|
+
const resB = await resBPromise;
|
|
774
|
+
expect(resB.isOk()).toBe(true);
|
|
775
|
+
expect(resA.isOk()).toBe(true); // superseded rejection absorbed, not surfaced as an error
|
|
776
|
+
// A stays answered: re-answering it must NOT submit again (which would clobber B's response).
|
|
777
|
+
const submitsBefore = adapter.submitStatement.mock.calls.length;
|
|
778
|
+
const reAnswer = await session.submitResponseMessage('A', 'success');
|
|
779
|
+
await delay();
|
|
780
|
+
expect(reAnswer.isOk()).toBe(true);
|
|
781
|
+
expect(adapter.submitStatement.mock.calls.length).toBe(submitsBefore); // deduped → no resubmit
|
|
782
|
+
session.dispose();
|
|
783
|
+
}, 3000);
|
|
760
784
|
});
|
|
761
785
|
// clearOutgoingStatement aborts the in-flight request: it drops local state, rejects waiters, and
|
|
762
786
|
// supersedes the on-chain request with an empty batch at a strictly higher expiry.
|
|
@@ -769,6 +793,22 @@ describe('session', () => {
|
|
|
769
793
|
expect(result.isOk()).toBe(true);
|
|
770
794
|
expect(adapter.submitStatement.mock.calls.length).toBe(before);
|
|
771
795
|
});
|
|
796
|
+
it('absorbs an ExpiryTooLow on the superseding empty batch as success', async () => {
|
|
797
|
+
// clearOutgoingStatement runs a single direct submit (no submitWithRetry). If the empty batch
|
|
798
|
+
// is rejected ExpiryTooLow, the channel already advanced past us — the request is already gone
|
|
799
|
+
// — so the clear has effectively happened. The caller must see success, not the sync artifact.
|
|
800
|
+
let calls = 0;
|
|
801
|
+
const submitStatement = vi.fn((stmt) => ++calls === 1
|
|
802
|
+
? okAsync(undefined) // the request itself lands
|
|
803
|
+
: errAsync(new ExpiryTooLowError(stmt.expiry ?? 0n, (0xffffffffn << 32n) | 9000000000n)));
|
|
804
|
+
const { session } = makeSession({ submitStatement });
|
|
805
|
+
await delay();
|
|
806
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
807
|
+
await delay();
|
|
808
|
+
const cleared = await session.clearOutgoingStatement();
|
|
809
|
+
expect(cleared.isOk()).toBe(true); // ExpiryTooLow suppressed
|
|
810
|
+
session.dispose();
|
|
811
|
+
}, 3000);
|
|
772
812
|
it('submits an empty batch on the same channel at a higher expiry and clears local state', async () => {
|
|
773
813
|
const { session, adapter } = makeSession();
|
|
774
814
|
await delay();
|
|
@@ -928,6 +968,31 @@ describe('session', () => {
|
|
|
928
968
|
expect(retried.expiry ?? 0n).toBeGreaterThan(CHAIN_MIN); // healed past the chain minimum
|
|
929
969
|
session.dispose();
|
|
930
970
|
}, 3000);
|
|
971
|
+
it('keeps retrying a live ExpiryTooLow past the transient-retry cap until it lands', async () => {
|
|
972
|
+
// ExpiryTooLow is a sync artifact, not a chain failure: while the submission is still live the
|
|
973
|
+
// session keeps retrying (resyncing each time) BEYOND MAX_SUBMIT_RETRIES until it lands, and
|
|
974
|
+
// never surfaces ExpiryTooLow to the caller. (A non-ExpiryTooLow error gives up at the cap —
|
|
975
|
+
// see the test below.)
|
|
976
|
+
const CHAIN_MIN = (0xffffffffn << 32n) | 4000000000n;
|
|
977
|
+
let calls = 0;
|
|
978
|
+
const submitStatement = vi.fn((stmt) => ++calls <= 6 ? errAsync(new ExpiryTooLowError(stmt.expiry ?? 0n, CHAIN_MIN)) : okAsync(undefined));
|
|
979
|
+
const { session } = makeSession({ submitStatement });
|
|
980
|
+
await delay();
|
|
981
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
982
|
+
try {
|
|
983
|
+
const submit = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
984
|
+
let waiterRejected = false;
|
|
985
|
+
void session.waitForResponseMessage(submit._unsafeUnwrap().requestId).mapErr(() => (waiterRejected = true));
|
|
986
|
+
await new Promise(resolve => setTimeout(resolve, 300)); // 6 retries × 25ms backoff + slack
|
|
987
|
+
expect(calls).toBeGreaterThanOrEqual(7); // retried well past the 3-attempt cap, then landed
|
|
988
|
+
expect(errorSpy).not.toHaveBeenCalledWith('submitRequest failed:', expect.anything());
|
|
989
|
+
expect(waiterRejected).toBe(false); // ExpiryTooLow never surfaced to the caller
|
|
990
|
+
}
|
|
991
|
+
finally {
|
|
992
|
+
errorSpy.mockRestore();
|
|
993
|
+
session.dispose();
|
|
994
|
+
}
|
|
995
|
+
}, 3000);
|
|
931
996
|
it('rejects the pending waiter once request-submission retries are exhausted', async () => {
|
|
932
997
|
const { session } = makeSession({
|
|
933
998
|
submitStatement: vi.fn().mockReturnValue(errAsync(new Error('store rejected'))),
|
|
@@ -938,6 +1003,40 @@ describe('session', () => {
|
|
|
938
1003
|
const waited = await session.waitForResponseMessage(requestId);
|
|
939
1004
|
expect(waited.isErr()).toBe(true);
|
|
940
1005
|
}, 2000);
|
|
1006
|
+
it('absorbs a superseded older submission rejected as ExpiryTooLow without surfacing an error', async () => {
|
|
1007
|
+
// Two messages batch onto one outgoing request: the first submission (requestId A) is in
|
|
1008
|
+
// flight when the second (requestId B, higher expiry, SAME tokens) is sent. B lands first and
|
|
1009
|
+
// sets the channel priority; A then lands at a now-lower expiry and the store rejects it with
|
|
1010
|
+
// ExpiryTooLow. A is superseded, so its rejection is expected protocol behaviour — it must not
|
|
1011
|
+
// be logged as an error and must not reject the shared waiters (B carries them).
|
|
1012
|
+
const { submitStatement, pendings } = deferredSubmit();
|
|
1013
|
+
const { session, adapter } = makeSession({ submitStatement });
|
|
1014
|
+
await delay();
|
|
1015
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
1016
|
+
try {
|
|
1017
|
+
const first = (await session.submitRequestMessage(rawCodec, new Uint8Array([1])))._unsafeUnwrap();
|
|
1018
|
+
void session.submitRequestMessage(rawCodec, new Uint8Array([2])); // batches onto the same outgoing request
|
|
1019
|
+
await delay(); // let both submissions reach submitStatement
|
|
1020
|
+
expect(pendings).toHaveLength(2);
|
|
1021
|
+
const liveRequestId = lastSubmittedRequestId(adapter); // the newer (B) submission
|
|
1022
|
+
const live = pendings.find(p => p.requestId === liveRequestId);
|
|
1023
|
+
const superseded = pendings.find(p => p.requestId !== liveRequestId);
|
|
1024
|
+
let firstWaiterRejected = false;
|
|
1025
|
+
void session.waitForResponseMessage(first.requestId).mapErr(() => {
|
|
1026
|
+
firstWaiterRejected = true;
|
|
1027
|
+
});
|
|
1028
|
+
live.settle(ok(undefined)); // B lands, claims the channel priority
|
|
1029
|
+
await delay();
|
|
1030
|
+
superseded.settle(err(new ExpiryTooLowError(0n, 0n))); // A lands late and is rejected
|
|
1031
|
+
await delay();
|
|
1032
|
+
expect(errorSpy).not.toHaveBeenCalledWith('submitRequest failed:', expect.anything());
|
|
1033
|
+
expect(firstWaiterRejected).toBe(false); // superseded failure must not reject the shared waiter
|
|
1034
|
+
}
|
|
1035
|
+
finally {
|
|
1036
|
+
errorSpy.mockRestore();
|
|
1037
|
+
session.dispose();
|
|
1038
|
+
}
|
|
1039
|
+
}, 3000);
|
|
941
1040
|
});
|
|
942
1041
|
describe('dispose', () => {
|
|
943
1042
|
it('rejects pending waitForRequestMessage waiters', async () => {
|
|
@@ -957,6 +1056,37 @@ describe('session', () => {
|
|
|
957
1056
|
await new Promise(resolve => setTimeout(resolve, 100)); // retry window elapses
|
|
958
1057
|
expect(queryStatements.mock.calls.length).toBe(callsBeforeDispose); // disposed → no further init queries
|
|
959
1058
|
}, 3000);
|
|
1059
|
+
it('rejects submitRequestMessage after dispose instead of hanging', async () => {
|
|
1060
|
+
const { session, adapter } = makeSession();
|
|
1061
|
+
await delay();
|
|
1062
|
+
session.dispose();
|
|
1063
|
+
const result = await session.submitRequestMessage(rawCodec, new Uint8Array([1]));
|
|
1064
|
+
expect(result.isErr()).toBe(true); // surfaced immediately, not a token left pending forever
|
|
1065
|
+
expect(adapter.submitStatement).not.toHaveBeenCalled();
|
|
1066
|
+
});
|
|
1067
|
+
it('rejects submitResponseMessage after dispose', async () => {
|
|
1068
|
+
const { session } = makeSession();
|
|
1069
|
+
await delay();
|
|
1070
|
+
session.dispose();
|
|
1071
|
+
const result = await session.submitResponseMessage('any-id', 'success');
|
|
1072
|
+
expect(result.isErr()).toBe(true);
|
|
1073
|
+
});
|
|
1074
|
+
it('does not re-activate when disposed while init is in flight', async () => {
|
|
1075
|
+
// dispose() lands during init's query await; init must bail before restoring state / flipping
|
|
1076
|
+
// phase to 'active', otherwise a torn-down session looks alive and accepts new work.
|
|
1077
|
+
let resolveQueries;
|
|
1078
|
+
const gate = new Promise(resolve => (resolveQueries = resolve));
|
|
1079
|
+
const queryStatements = vi.fn(() => ResultAsync.fromSafePromise(gate));
|
|
1080
|
+
const { session, adapter } = makeSession({ queryStatements });
|
|
1081
|
+
const queued = await session.submitRequestMessage(rawCodec, new Uint8Array([1])); // queued during init
|
|
1082
|
+
expect(queued.isOk()).toBe(true);
|
|
1083
|
+
session.dispose(); // dispose mid-init
|
|
1084
|
+
resolveQueries([]); // init resumes — must bail before draining the queue / activating
|
|
1085
|
+
await settle();
|
|
1086
|
+
expect(adapter.submitStatement).not.toHaveBeenCalled(); // no resurrection-driven submit
|
|
1087
|
+
const after = await session.submitRequestMessage(rawCodec, new Uint8Array([2]));
|
|
1088
|
+
expect(after.isErr()).toBe(true); // session stays disposed
|
|
1089
|
+
}, 3000);
|
|
960
1090
|
});
|
|
961
1091
|
// The in-memory adapter replicates the store's observable contract; `fidelity` pins the double's
|
|
962
1092
|
// behaviour, then end-to-end flows run two mirrored sessions (host + mobile) over ONE shared store.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novasamatech/statement-store",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.8.7-
|
|
4
|
+
"version": "0.8.7-2",
|
|
5
5
|
"description": "Statement store integration",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"README.md"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@novasamatech/scale": "0.8.7-
|
|
28
|
+
"@novasamatech/scale": "0.8.7-2",
|
|
29
29
|
"@novasamatech/sdk-statement": "^0.6.0",
|
|
30
|
-
"@novasamatech/substrate-slot-sr25519-wasm": "0.8.7-
|
|
30
|
+
"@novasamatech/substrate-slot-sr25519-wasm": "0.8.7-2",
|
|
31
31
|
"@polkadot-api/substrate-bindings": "^0.20.3",
|
|
32
32
|
"@polkadot-api/substrate-client": "^0.7.0",
|
|
33
33
|
"@polkadot-labs/hdkd-helpers": "^0.0.30",
|