@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
|
@@ -6,9 +6,10 @@ import { describe, expect, it, vi } from 'vitest';
|
|
|
6
6
|
import { createInMemoryStatementStore } from '../adapter/inMemory.js';
|
|
7
7
|
import { AccountFullError, ExpiryTooLowError } from '../adapter/types.js';
|
|
8
8
|
import { createAccountId, createLocalSessionAccount, createRemoteSessionAccount } from '../model/sessionAccount.js';
|
|
9
|
+
import { STATEMENT_OVERHEAD } from './core.js';
|
|
9
10
|
import { DecodingError, UnknownError } from './error.js';
|
|
10
11
|
import { StatementData } from './scale/statementData.js';
|
|
11
|
-
import {
|
|
12
|
+
import { createSession } from './session.js';
|
|
12
13
|
// Real signature work belongs in statementProver tests; this stub stamps a
|
|
13
14
|
// non-empty proof so submitted statements are well-formed.
|
|
14
15
|
const mockProver = {
|
|
@@ -160,11 +161,14 @@ describe('session', () => {
|
|
|
160
161
|
it('queries the outgoing and incoming topics on creation', async () => {
|
|
161
162
|
const { adapter } = makeSession();
|
|
162
163
|
await delay();
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
expect(
|
|
164
|
+
// Outgoing is our single publish topic (matchAll); incoming is matchAny because a
|
|
165
|
+
// multi-device session listens on one topic per peer device. Both carry exactly one
|
|
166
|
+
// topic here, and they must differ.
|
|
167
|
+
const filters = adapter.queryStatements.mock.calls.map(([f]) => f);
|
|
168
|
+
expect(filters).toHaveLength(2);
|
|
169
|
+
expect(filters[0].matchAll).toHaveLength(1);
|
|
170
|
+
expect(filters[1].matchAny).toHaveLength(1);
|
|
171
|
+
expect(filters[0].matchAll).not.toEqual(filters[1].matchAny);
|
|
168
172
|
});
|
|
169
173
|
it('seeds the expiry from the highest own statement expiry', async () => {
|
|
170
174
|
const highExpiry = createExpiryFromDuration(7 * 24 * 60 * 60) + 9999n;
|
|
@@ -533,6 +537,18 @@ describe('session', () => {
|
|
|
533
537
|
// One subscription: the incoming topic carries both peer requests and peer responses.
|
|
534
538
|
expect(store.activeSubscriptions()).toBe(1);
|
|
535
539
|
});
|
|
540
|
+
it('reopens the subscription when a subscriber returns after the last one left', async () => {
|
|
541
|
+
const { subscribeStatements } = capturingSubscribe();
|
|
542
|
+
const { session } = makeSession({ subscribeStatements });
|
|
543
|
+
await delay();
|
|
544
|
+
const unsubscribe = session.subscribe(rawCodec, vi.fn());
|
|
545
|
+
expect(subscribeStatements).toHaveBeenCalledTimes(1);
|
|
546
|
+
unsubscribe();
|
|
547
|
+
// The topic-set watcher and the store subscription must be torn down together —
|
|
548
|
+
// otherwise the session silently never listens again.
|
|
549
|
+
session.subscribe(rawCodec, vi.fn());
|
|
550
|
+
expect(subscribeStatements).toHaveBeenCalledTimes(2);
|
|
551
|
+
});
|
|
536
552
|
it('tears down the subscription when the last subscriber leaves', () => {
|
|
537
553
|
const store = createInMemoryStatementStore();
|
|
538
554
|
const session = makeHost(store);
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session's transport decision logic as a pure reducer: {@link transition} maps a
|
|
3
|
+
* {@link SessionState} and a {@link SessionEvent} to the next state plus the
|
|
4
|
+
* {@link SessionEffect}s the driver must perform. It performs no I/O, mutates nothing it is
|
|
5
|
+
* given, and reads no clock — sizing and id generation arrive in {@link TransitionContext},
|
|
6
|
+
* so tests make both deterministic.
|
|
7
|
+
*
|
|
8
|
+
* The state is what base-spec.md §"Session State" defines: the phase, the outgoing request
|
|
9
|
+
* plus the queue behind it, and the incoming requests.
|
|
10
|
+
*
|
|
11
|
+
* Deliberate deviation from base-spec.md: `incomingRequests` is a map rather than the
|
|
12
|
+
* spec's single `IncomingRequest(A, B)`. The spec's model assumes the Application Layer
|
|
13
|
+
* answers synchronously; this SDK lets it answer whenever, so an older request has to stay
|
|
14
|
+
* answerable after a newer one arrives. It is local bookkeeping only — the wire is
|
|
15
|
+
* unaffected, since the shared response channel still exposes just the latest response.
|
|
16
|
+
*/
|
|
17
|
+
import type { ResponseStatus } from './scale/statementData.js';
|
|
18
|
+
type QueuedMessage = {
|
|
19
|
+
encoded: Uint8Array;
|
|
20
|
+
tokens: string[];
|
|
21
|
+
};
|
|
22
|
+
type OutgoingRequest = {
|
|
23
|
+
requestIds: string[];
|
|
24
|
+
messages: Uint8Array[];
|
|
25
|
+
tokens: string[];
|
|
26
|
+
};
|
|
27
|
+
type IncomingRequest = {
|
|
28
|
+
responded: boolean;
|
|
29
|
+
};
|
|
30
|
+
export type SessionState = {
|
|
31
|
+
phase: 'initialization' | 'active' | 'failed';
|
|
32
|
+
initError: Error | null;
|
|
33
|
+
outgoingRequest: OutgoingRequest | null;
|
|
34
|
+
messageQueue: QueuedMessage[];
|
|
35
|
+
incomingRequests: Map<string, IncomingRequest>;
|
|
36
|
+
};
|
|
37
|
+
export type SessionEvent =
|
|
38
|
+
/** The application layer wants a message sent. */
|
|
39
|
+
{
|
|
40
|
+
type: 'messageSubmitted';
|
|
41
|
+
encoded: Uint8Array;
|
|
42
|
+
token: string;
|
|
43
|
+
}
|
|
44
|
+
/** A peer request we had not seen before. */
|
|
45
|
+
| {
|
|
46
|
+
type: 'requestReceived';
|
|
47
|
+
requestId: string;
|
|
48
|
+
}
|
|
49
|
+
/** A peer response arrived on our outgoing batch. */
|
|
50
|
+
| {
|
|
51
|
+
type: 'responseReceived';
|
|
52
|
+
requestId: string;
|
|
53
|
+
responseCode: ResponseStatus;
|
|
54
|
+
}
|
|
55
|
+
/** A request submission exhausted its retries. */
|
|
56
|
+
| {
|
|
57
|
+
type: 'requestSubmitFailed';
|
|
58
|
+
requestId: string;
|
|
59
|
+
error: Error;
|
|
60
|
+
}
|
|
61
|
+
/** We answered an incoming request (marked before the submit, so concurrent callers dedupe). */
|
|
62
|
+
| {
|
|
63
|
+
type: 'responseSubmitted';
|
|
64
|
+
requestId: string;
|
|
65
|
+
}
|
|
66
|
+
/** That answer failed after retries — let a later peer retransmit be answered. */
|
|
67
|
+
| {
|
|
68
|
+
type: 'responseSubmitFailed';
|
|
69
|
+
requestId: string;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The statement budget may now hold a different number of messages — the peer's device
|
|
73
|
+
* roster changed, so the envelope grew or shrank. Re-evaluate what the queue can ship.
|
|
74
|
+
*/
|
|
75
|
+
| {
|
|
76
|
+
type: 'capacityChanged';
|
|
77
|
+
}
|
|
78
|
+
/** Drop the live batch and everything queued behind it. */
|
|
79
|
+
| {
|
|
80
|
+
type: 'outgoingCleared';
|
|
81
|
+
}
|
|
82
|
+
/** An unacknowledged batch was found in the store during initialization. */
|
|
83
|
+
| {
|
|
84
|
+
type: 'outgoingRestored';
|
|
85
|
+
requestId: string;
|
|
86
|
+
messages: Uint8Array[];
|
|
87
|
+
}
|
|
88
|
+
/** An incoming request was found in the store during initialization. */
|
|
89
|
+
| {
|
|
90
|
+
type: 'incomingRestored';
|
|
91
|
+
requestId: string;
|
|
92
|
+
responded: boolean;
|
|
93
|
+
}
|
|
94
|
+
/** Initialization finished: go active and ship whatever the queue held. */
|
|
95
|
+
| {
|
|
96
|
+
type: 'activated';
|
|
97
|
+
}
|
|
98
|
+
/** Initialization failed terminally. */
|
|
99
|
+
| {
|
|
100
|
+
type: 'initFailed';
|
|
101
|
+
error: Error;
|
|
102
|
+
};
|
|
103
|
+
/** Work the driver must carry out. The reducer performs none of it. */
|
|
104
|
+
export type SessionEffect = {
|
|
105
|
+
type: 'submitRequest';
|
|
106
|
+
requestId: string;
|
|
107
|
+
messages: Uint8Array[];
|
|
108
|
+
} | {
|
|
109
|
+
type: 'resolveTokens';
|
|
110
|
+
tokens: string[];
|
|
111
|
+
requestId: string;
|
|
112
|
+
responseCode: ResponseStatus;
|
|
113
|
+
} | {
|
|
114
|
+
type: 'rejectTokens';
|
|
115
|
+
tokens: string[];
|
|
116
|
+
error: Error;
|
|
117
|
+
};
|
|
118
|
+
export type TransitionContext = {
|
|
119
|
+
/** Whether these messages fit one statement — the body builder is the size oracle. */
|
|
120
|
+
fits: (messages: Uint8Array[]) => boolean;
|
|
121
|
+
newRequestId: () => string;
|
|
122
|
+
};
|
|
123
|
+
export type Transition = {
|
|
124
|
+
state: SessionState;
|
|
125
|
+
effects: SessionEffect[];
|
|
126
|
+
};
|
|
127
|
+
export declare function initialSessionState(): SessionState;
|
|
128
|
+
export declare function incomingRequest(state: SessionState, requestId: string): IncomingRequest | undefined;
|
|
129
|
+
/**
|
|
130
|
+
* The newest submission of the live batch: the id still worth retrying, and the one whose
|
|
131
|
+
* on-chain statement must be superseded to clear the batch. Null when nothing is in flight.
|
|
132
|
+
*/
|
|
133
|
+
export declare function liveRequestId(state: SessionState): string | null;
|
|
134
|
+
export declare function transition(state: SessionState, event: SessionEvent, ctx: TransitionContext): Transition;
|
|
135
|
+
export {};
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session's transport decision logic as a pure reducer: {@link transition} maps a
|
|
3
|
+
* {@link SessionState} and a {@link SessionEvent} to the next state plus the
|
|
4
|
+
* {@link SessionEffect}s the driver must perform. It performs no I/O, mutates nothing it is
|
|
5
|
+
* given, and reads no clock — sizing and id generation arrive in {@link TransitionContext},
|
|
6
|
+
* so tests make both deterministic.
|
|
7
|
+
*
|
|
8
|
+
* The state is what base-spec.md §"Session State" defines: the phase, the outgoing request
|
|
9
|
+
* plus the queue behind it, and the incoming requests.
|
|
10
|
+
*
|
|
11
|
+
* Deliberate deviation from base-spec.md: `incomingRequests` is a map rather than the
|
|
12
|
+
* spec's single `IncomingRequest(A, B)`. The spec's model assumes the Application Layer
|
|
13
|
+
* answers synchronously; this SDK lets it answer whenever, so an older request has to stay
|
|
14
|
+
* answerable after a newer one arrives. It is local bookkeeping only — the wire is
|
|
15
|
+
* unaffected, since the shared response channel still exposes just the latest response.
|
|
16
|
+
*/
|
|
17
|
+
import { toHex } from '@novasamatech/scale';
|
|
18
|
+
export function initialSessionState() {
|
|
19
|
+
return {
|
|
20
|
+
phase: 'initialization',
|
|
21
|
+
initError: null,
|
|
22
|
+
outgoingRequest: null,
|
|
23
|
+
messageQueue: [],
|
|
24
|
+
incomingRequests: new Map(),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
// ── selectors ────────────────────────────────────────────────────────────────
|
|
28
|
+
// Reads the driver needs that are not transitions.
|
|
29
|
+
export function incomingRequest(state, requestId) {
|
|
30
|
+
return state.incomingRequests.get(requestId);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The newest submission of the live batch: the id still worth retrying, and the one whose
|
|
34
|
+
* on-chain statement must be superseded to clear the batch. Null when nothing is in flight.
|
|
35
|
+
*/
|
|
36
|
+
export function liveRequestId(state) {
|
|
37
|
+
return state.outgoingRequest?.requestIds.at(-1) ?? null;
|
|
38
|
+
}
|
|
39
|
+
// ── internals ────────────────────────────────────────────────────────────────
|
|
40
|
+
const nothing = (state) => ({ state, effects: [] });
|
|
41
|
+
function withIncoming(state, requestId, value) {
|
|
42
|
+
return { ...state, incomingRequests: new Map(state.incomingRequests).set(requestId, value) };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Attach `token` to an identical message already in flight or queued, so the caller
|
|
46
|
+
* resolves on that message's response instead of the bytes going out twice.
|
|
47
|
+
* Returns null when nothing matches.
|
|
48
|
+
*/
|
|
49
|
+
function attachToDuplicate(state, encoded, token) {
|
|
50
|
+
const encodedHex = toHex(encoded);
|
|
51
|
+
const sameBytes = (m) => m.length === encoded.length && toHex(m) === encodedHex;
|
|
52
|
+
const outgoing = state.outgoingRequest;
|
|
53
|
+
if (outgoing?.messages.some(sameBytes)) {
|
|
54
|
+
return { ...state, outgoingRequest: { ...outgoing, tokens: [...outgoing.tokens, token] } };
|
|
55
|
+
}
|
|
56
|
+
// Only the first match takes the token; a later identical entry must not duplicate it.
|
|
57
|
+
const index = state.messageQueue.findIndex(entry => sameBytes(entry.encoded));
|
|
58
|
+
if (index === -1)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
...state,
|
|
62
|
+
messageQueue: state.messageQueue.map((entry, i) => i === index ? { ...entry, tokens: [...entry.tokens, token] } : entry),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Start a batch, extend the live one, or park the message behind it. */
|
|
66
|
+
function admit(state, encoded, tokens, ctx) {
|
|
67
|
+
const outgoing = state.outgoingRequest;
|
|
68
|
+
if (outgoing === null) {
|
|
69
|
+
const requestId = ctx.newRequestId();
|
|
70
|
+
const messages = [encoded];
|
|
71
|
+
return {
|
|
72
|
+
state: { ...state, outgoingRequest: { requestIds: [requestId], messages, tokens: [...tokens] } },
|
|
73
|
+
effects: [{ type: 'submitRequest', requestId, messages: [...messages] }],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const messages = [...outgoing.messages, encoded];
|
|
77
|
+
if (ctx.fits(messages)) {
|
|
78
|
+
const requestId = ctx.newRequestId();
|
|
79
|
+
return {
|
|
80
|
+
state: {
|
|
81
|
+
...state,
|
|
82
|
+
outgoingRequest: {
|
|
83
|
+
requestIds: [...outgoing.requestIds, requestId],
|
|
84
|
+
messages,
|
|
85
|
+
tokens: [...outgoing.tokens, ...tokens],
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
// The statement store keeps one statement per channel, so every submission must
|
|
89
|
+
// carry the FULL unacknowledged batch — an offline peer only ever sees the survivor.
|
|
90
|
+
effects: [{ type: 'submitRequest', requestId, messages: [...messages] }],
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
return { state: { ...state, messageQueue: [...state.messageQueue, { encoded, tokens }] }, effects: [] };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Move queue heads into the batch for as long as the budget allows. FIFO: a later message
|
|
97
|
+
* never overtakes one already waiting.
|
|
98
|
+
*/
|
|
99
|
+
function drain(state, ctx) {
|
|
100
|
+
let current = state;
|
|
101
|
+
const effects = [];
|
|
102
|
+
while (current.messageQueue.length > 0) {
|
|
103
|
+
const [head, ...rest] = current.messageQueue;
|
|
104
|
+
if (!head)
|
|
105
|
+
break;
|
|
106
|
+
// Recomputed per iteration; `admit` grows the batch as it goes.
|
|
107
|
+
if (current.outgoingRequest !== null && !ctx.fits([...current.outgoingRequest.messages, head.encoded]))
|
|
108
|
+
break;
|
|
109
|
+
const admitted = admit({ ...current, messageQueue: rest }, head.encoded, head.tokens, ctx);
|
|
110
|
+
current = admitted.state;
|
|
111
|
+
effects.push(...admitted.effects);
|
|
112
|
+
}
|
|
113
|
+
return { state: current, effects };
|
|
114
|
+
}
|
|
115
|
+
// ── reducer ──────────────────────────────────────────────────────────────────
|
|
116
|
+
export function transition(state, event, ctx) {
|
|
117
|
+
switch (event.type) {
|
|
118
|
+
case 'messageSubmitted': {
|
|
119
|
+
const deduped = attachToDuplicate(state, event.encoded, event.token);
|
|
120
|
+
if (deduped)
|
|
121
|
+
return nothing(deduped);
|
|
122
|
+
// FIFO: never let a later (fitting) message overtake queued ones, and never submit
|
|
123
|
+
// before initialization has established the expiry floor.
|
|
124
|
+
if (state.phase === 'initialization' || state.messageQueue.length > 0) {
|
|
125
|
+
return nothing({
|
|
126
|
+
...state,
|
|
127
|
+
messageQueue: [...state.messageQueue, { encoded: event.encoded, tokens: [event.token] }],
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return admit(state, event.encoded, [event.token], ctx);
|
|
131
|
+
}
|
|
132
|
+
case 'requestReceived': {
|
|
133
|
+
if (state.incomingRequests.has(event.requestId))
|
|
134
|
+
return nothing(state);
|
|
135
|
+
return nothing(withIncoming(state, event.requestId, { responded: false }));
|
|
136
|
+
}
|
|
137
|
+
case 'responseReceived': {
|
|
138
|
+
const outgoing = state.outgoingRequest;
|
|
139
|
+
// Any id the batch was ever submitted under counts — an early response to a
|
|
140
|
+
// superseded retransmit still answers the same messages.
|
|
141
|
+
if (!outgoing?.requestIds.includes(event.requestId))
|
|
142
|
+
return nothing(state);
|
|
143
|
+
const drained = drain({ ...state, outgoingRequest: null }, ctx);
|
|
144
|
+
return {
|
|
145
|
+
state: drained.state,
|
|
146
|
+
effects: [
|
|
147
|
+
{
|
|
148
|
+
type: 'resolveTokens',
|
|
149
|
+
tokens: outgoing.tokens,
|
|
150
|
+
requestId: event.requestId,
|
|
151
|
+
responseCode: event.responseCode,
|
|
152
|
+
},
|
|
153
|
+
...drained.effects,
|
|
154
|
+
],
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
case 'requestSubmitFailed': {
|
|
158
|
+
const outgoing = state.outgoingRequest;
|
|
159
|
+
// Superseded by a newer retransmit carrying the same tokens — that one owns the
|
|
160
|
+
// waiters now, so this failure is not the live batch's concern.
|
|
161
|
+
if (!outgoing || outgoing.requestIds.at(-1) !== event.requestId)
|
|
162
|
+
return nothing(state);
|
|
163
|
+
const drained = drain({ ...state, outgoingRequest: null }, ctx);
|
|
164
|
+
return {
|
|
165
|
+
state: drained.state,
|
|
166
|
+
effects: [{ type: 'rejectTokens', tokens: outgoing.tokens, error: event.error }, ...drained.effects],
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
case 'responseSubmitted': {
|
|
170
|
+
const incoming = state.incomingRequests.get(event.requestId);
|
|
171
|
+
if (!incoming)
|
|
172
|
+
return nothing(state);
|
|
173
|
+
return nothing(withIncoming(state, event.requestId, { responded: true }));
|
|
174
|
+
}
|
|
175
|
+
case 'responseSubmitFailed': {
|
|
176
|
+
const incoming = state.incomingRequests.get(event.requestId);
|
|
177
|
+
if (!incoming)
|
|
178
|
+
return nothing(state);
|
|
179
|
+
return nothing(withIncoming(state, event.requestId, { responded: false }));
|
|
180
|
+
}
|
|
181
|
+
case 'capacityChanged':
|
|
182
|
+
// Never before initialization has established the expiry floor; `activated` drains.
|
|
183
|
+
return state.phase === 'active' ? drain(state, ctx) : nothing(state);
|
|
184
|
+
case 'outgoingCleared':
|
|
185
|
+
return nothing({ ...state, outgoingRequest: null, messageQueue: [] });
|
|
186
|
+
case 'outgoingRestored':
|
|
187
|
+
return nothing({
|
|
188
|
+
...state,
|
|
189
|
+
// Tokens from a previous run cannot be restored — nobody is awaiting them.
|
|
190
|
+
outgoingRequest: { requestIds: [event.requestId], messages: event.messages, tokens: [] },
|
|
191
|
+
});
|
|
192
|
+
case 'incomingRestored': {
|
|
193
|
+
// Don't clobber an entry a live delivery created while init was still awaiting.
|
|
194
|
+
if (state.incomingRequests.has(event.requestId))
|
|
195
|
+
return nothing(state);
|
|
196
|
+
return nothing(withIncoming(state, event.requestId, { responded: event.responded }));
|
|
197
|
+
}
|
|
198
|
+
case 'activated':
|
|
199
|
+
return drain({ ...state, phase: 'active' }, ctx);
|
|
200
|
+
case 'initFailed':
|
|
201
|
+
return nothing({ ...state, phase: 'failed', initError: event.error, messageQueue: [] });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { incomingRequest, initialSessionState, liveRequestId, transition } from './stateMachine.js';
|
|
3
|
+
const msg = (text) => new TextEncoder().encode(text);
|
|
4
|
+
/**
|
|
5
|
+
* `fits` counts messages rather than bytes, so a test can say "two per statement" and read
|
|
6
|
+
* as the batching rule it exercises. Ids are sequential for the same reason.
|
|
7
|
+
*/
|
|
8
|
+
function makeContext({ capacity = Infinity } = {}) {
|
|
9
|
+
let next = 0;
|
|
10
|
+
return {
|
|
11
|
+
fits: messages => messages.length <= capacity,
|
|
12
|
+
newRequestId: () => `r${(++next).toString()}`,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/** Fold a sequence of events, keeping only the effects of the LAST one. */
|
|
16
|
+
function run(events, opts = {}) {
|
|
17
|
+
const ctx = makeContext(opts);
|
|
18
|
+
let state = opts.from ?? initialSessionState();
|
|
19
|
+
let effects = [];
|
|
20
|
+
for (const event of events) {
|
|
21
|
+
const result = transition(state, event, ctx);
|
|
22
|
+
state = result.state;
|
|
23
|
+
effects = result.effects;
|
|
24
|
+
}
|
|
25
|
+
return { state, effects };
|
|
26
|
+
}
|
|
27
|
+
const send = (text, token) => ({
|
|
28
|
+
type: 'messageSubmitted',
|
|
29
|
+
encoded: msg(text),
|
|
30
|
+
token,
|
|
31
|
+
});
|
|
32
|
+
const ACTIVATE = { type: 'activated' };
|
|
33
|
+
const submits = (effects) => effects.filter(e => e.type === 'submitRequest');
|
|
34
|
+
describe('session state machine', () => {
|
|
35
|
+
it('never mutates the state it is given', () => {
|
|
36
|
+
const before = initialSessionState();
|
|
37
|
+
const snapshot = { ...before, messageQueue: [...before.messageQueue] };
|
|
38
|
+
transition(before, send('a', 't1'), makeContext());
|
|
39
|
+
expect(before.messageQueue).toEqual(snapshot.messageQueue);
|
|
40
|
+
expect(before.outgoingRequest).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
describe('phase', () => {
|
|
43
|
+
it('queues instead of submitting while initializing', () => {
|
|
44
|
+
const { state, effects } = run([send('a', 't1')]);
|
|
45
|
+
expect(state.phase).toBe('initialization');
|
|
46
|
+
expect(effects).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
it('drains what initialization queued once active', () => {
|
|
49
|
+
const { effects } = run([send('a', 't1'), send('b', 't2'), ACTIVATE]);
|
|
50
|
+
// Both ride one batch: the second extends the first, so the latest submission
|
|
51
|
+
// carries everything unacknowledged.
|
|
52
|
+
expect(submits(effects).at(-1)).toMatchObject({ messages: [msg('a'), msg('b')] });
|
|
53
|
+
});
|
|
54
|
+
it('records a terminal initialization failure', () => {
|
|
55
|
+
const error = new Error('init failed');
|
|
56
|
+
const { state } = run([send('a', 't1'), { type: 'initFailed', error }]);
|
|
57
|
+
expect(state.phase).toBe('failed');
|
|
58
|
+
expect(state.initError).toBe(error);
|
|
59
|
+
expect(state.messageQueue).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
describe('batching', () => {
|
|
63
|
+
it('submits the first message immediately', () => {
|
|
64
|
+
const { effects } = run([ACTIVATE, send('a', 't1')]);
|
|
65
|
+
expect(effects).toEqual([{ type: 'submitRequest', requestId: 'r1', messages: [msg('a')] }]);
|
|
66
|
+
});
|
|
67
|
+
// The store keeps one statement per channel, so each submission must carry every
|
|
68
|
+
// message the peer has not acknowledged — not just the newest one.
|
|
69
|
+
it('resubmits the whole unacknowledged batch when extending it', () => {
|
|
70
|
+
const { effects } = run([ACTIVATE, send('a', 't1'), send('b', 't2')]);
|
|
71
|
+
expect(effects).toEqual([{ type: 'submitRequest', requestId: 'r2', messages: [msg('a'), msg('b')] }]);
|
|
72
|
+
});
|
|
73
|
+
it('queues a message that does not fit the live batch', () => {
|
|
74
|
+
const { state, effects } = run([ACTIVATE, send('a', 't1'), send('b', 't2')], { capacity: 1 });
|
|
75
|
+
expect(effects).toEqual([]);
|
|
76
|
+
expect(state.messageQueue).toHaveLength(1);
|
|
77
|
+
});
|
|
78
|
+
it('keeps FIFO: a later fitting message never overtakes a queued one', () => {
|
|
79
|
+
const events = [ACTIVATE, send('a', 't1'), send('b', 't2'), send('c', 't3'), send('d', 't4')];
|
|
80
|
+
// Batch holds a+b; c and d both wait, even though d alone would fit.
|
|
81
|
+
const { state } = run(events, { capacity: 2 });
|
|
82
|
+
expect(state.messageQueue.map(e => e.tokens)).toEqual([['t3'], ['t4']]);
|
|
83
|
+
const { effects } = run([...events, { type: 'responseReceived', requestId: 'r2', responseCode: 'success' }], {
|
|
84
|
+
capacity: 2,
|
|
85
|
+
});
|
|
86
|
+
expect(submits(effects).at(-1)).toMatchObject({ messages: [msg('c'), msg('d')] });
|
|
87
|
+
});
|
|
88
|
+
it('snapshots the messages of each submission', () => {
|
|
89
|
+
const ctx = makeContext();
|
|
90
|
+
let state = transition(initialSessionState(), ACTIVATE, ctx).state;
|
|
91
|
+
const first = transition(state, send('a', 't1'), ctx);
|
|
92
|
+
state = first.state;
|
|
93
|
+
transition(state, send('b', 't2'), ctx);
|
|
94
|
+
// The earlier effect must not have grown along with the batch.
|
|
95
|
+
expect(first.effects).toEqual([{ type: 'submitRequest', requestId: 'r1', messages: [msg('a')] }]);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('deduplication', () => {
|
|
99
|
+
it('attaches a duplicate of an in-flight message instead of resending', () => {
|
|
100
|
+
const events = [ACTIVATE, send('a', 't1'), send('a', 't2')];
|
|
101
|
+
expect(run(events).effects).toEqual([]);
|
|
102
|
+
// Both tokens resolve on the one response.
|
|
103
|
+
const { effects } = run([...events, { type: 'responseReceived', requestId: 'r1', responseCode: 'success' }]);
|
|
104
|
+
expect(effects[0]).toMatchObject({ type: 'resolveTokens', tokens: ['t1', 't2'] });
|
|
105
|
+
});
|
|
106
|
+
it('attaches a duplicate of a queued message', () => {
|
|
107
|
+
const events = [ACTIVATE, send('a', 't1'), send('b', 't2'), send('b', 't3')];
|
|
108
|
+
const { state } = run(events, { capacity: 1 });
|
|
109
|
+
expect(state.messageQueue).toHaveLength(1);
|
|
110
|
+
expect(state.messageQueue[0].tokens).toEqual(['t2', 't3']);
|
|
111
|
+
});
|
|
112
|
+
it('gives the token to only one of two identical queued entries', () => {
|
|
113
|
+
// Two distinct queue entries can hold the same bytes when the first was queued
|
|
114
|
+
// before the second's dedup check could see it.
|
|
115
|
+
const seeded = {
|
|
116
|
+
...initialSessionState(),
|
|
117
|
+
phase: 'active',
|
|
118
|
+
outgoingRequest: { requestIds: ['r0'], messages: [msg('x')], tokens: ['t0'] },
|
|
119
|
+
messageQueue: [
|
|
120
|
+
{ encoded: msg('dup'), tokens: ['t1'] },
|
|
121
|
+
{ encoded: msg('dup'), tokens: ['t2'] },
|
|
122
|
+
],
|
|
123
|
+
};
|
|
124
|
+
const { state } = run([send('dup', 't3')], { capacity: 1, from: seeded });
|
|
125
|
+
expect(state.messageQueue.map(e => e.tokens)).toEqual([['t1', 't3'], ['t2']]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe('responses', () => {
|
|
129
|
+
it('resolves the batch tokens and drains the queue', () => {
|
|
130
|
+
const { effects } = run([
|
|
131
|
+
ACTIVATE,
|
|
132
|
+
send('a', 't1'),
|
|
133
|
+
send('b', 't2'),
|
|
134
|
+
{ type: 'responseReceived', requestId: 'r1', responseCode: 'success' },
|
|
135
|
+
], { capacity: 1 });
|
|
136
|
+
expect(effects[0]).toEqual({ type: 'resolveTokens', tokens: ['t1'], requestId: 'r1', responseCode: 'success' });
|
|
137
|
+
expect(submits(effects)).toHaveLength(1);
|
|
138
|
+
});
|
|
139
|
+
// A retransmit gives the batch a new id, but a response to any earlier id still
|
|
140
|
+
// answers the same messages and must not be dropped.
|
|
141
|
+
it('accepts a response to a superseded retransmit id', () => {
|
|
142
|
+
const { effects } = run([
|
|
143
|
+
ACTIVATE,
|
|
144
|
+
send('a', 't1'),
|
|
145
|
+
send('b', 't2'),
|
|
146
|
+
{ type: 'responseReceived', requestId: 'r1', responseCode: 'success' },
|
|
147
|
+
]);
|
|
148
|
+
expect(effects[0]).toMatchObject({ type: 'resolveTokens', tokens: ['t1', 't2'] });
|
|
149
|
+
});
|
|
150
|
+
it('ignores a response for an unknown request', () => {
|
|
151
|
+
const { effects } = run([
|
|
152
|
+
ACTIVATE,
|
|
153
|
+
send('a', 't1'),
|
|
154
|
+
{ type: 'responseReceived', requestId: 'nope', responseCode: 'success' },
|
|
155
|
+
]);
|
|
156
|
+
expect(effects).toEqual([]);
|
|
157
|
+
});
|
|
158
|
+
it('ignores a second response for an already-answered batch', () => {
|
|
159
|
+
const answered = { type: 'responseReceived', requestId: 'r1', responseCode: 'success' };
|
|
160
|
+
const { effects } = run([ACTIVATE, send('a', 't1'), answered, answered]);
|
|
161
|
+
expect(effects).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
describe('request submit failure', () => {
|
|
165
|
+
it('rejects the waiters of the live batch and drains', () => {
|
|
166
|
+
const error = new Error('store rejected');
|
|
167
|
+
const { effects } = run([ACTIVATE, send('a', 't1'), send('b', 't2'), { type: 'requestSubmitFailed', requestId: 'r1', error }], { capacity: 1 });
|
|
168
|
+
expect(effects[0]).toEqual({ type: 'rejectTokens', tokens: ['t1'], error });
|
|
169
|
+
expect(submits(effects)).toHaveLength(1);
|
|
170
|
+
});
|
|
171
|
+
// The newer retransmit carries the same tokens, so an older submission's failure is
|
|
172
|
+
// not the live batch's problem.
|
|
173
|
+
it('ignores the failure of a superseded submission', () => {
|
|
174
|
+
const { effects } = run([
|
|
175
|
+
ACTIVATE,
|
|
176
|
+
send('a', 't1'),
|
|
177
|
+
send('b', 't2'),
|
|
178
|
+
{ type: 'requestSubmitFailed', requestId: 'r1', error: new Error('stale') },
|
|
179
|
+
]);
|
|
180
|
+
expect(effects).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
it('tracks which submission is live', () => {
|
|
183
|
+
expect(liveRequestId(run([ACTIVATE, send('a', 't1')]).state)).toBe('r1');
|
|
184
|
+
// A retransmit takes over: only the newest id is still worth retrying.
|
|
185
|
+
expect(liveRequestId(run([ACTIVATE, send('a', 't1'), send('b', 't2')]).state)).toBe('r2');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
describe('capacity changes', () => {
|
|
189
|
+
it('ships what the queue can now hold when the budget grows', () => {
|
|
190
|
+
// Queued at capacity 1, then re-evaluated once two fit.
|
|
191
|
+
const ctx = makeContext();
|
|
192
|
+
let capacity = 1;
|
|
193
|
+
const sizing = { ...ctx, fits: messages => messages.length <= capacity };
|
|
194
|
+
let state = transition(initialSessionState(), ACTIVATE, sizing).state;
|
|
195
|
+
state = transition(state, send('a', 't1'), sizing).state;
|
|
196
|
+
state = transition(state, send('b', 't2'), sizing).state;
|
|
197
|
+
expect(state.messageQueue).toHaveLength(1);
|
|
198
|
+
capacity = 2;
|
|
199
|
+
const result = transition(state, { type: 'capacityChanged' }, sizing);
|
|
200
|
+
expect(submits(result.effects).at(-1)).toMatchObject({ messages: [msg('a'), msg('b')] });
|
|
201
|
+
expect(result.state.messageQueue).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
it('does not ship anything before initialization completes', () => {
|
|
204
|
+
const { effects, state } = run([send('a', 't1'), { type: 'capacityChanged' }]);
|
|
205
|
+
expect(effects).toEqual([]);
|
|
206
|
+
expect(state.messageQueue).toHaveLength(1);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
describe('clearing the outgoing batch', () => {
|
|
210
|
+
it('reports the id to supersede and drops the batch and queue', () => {
|
|
211
|
+
const built = run([ACTIVATE, send('a', 't1'), send('b', 't2')], { capacity: 1 });
|
|
212
|
+
expect(liveRequestId(built.state)).toBe('r1');
|
|
213
|
+
const { state } = run([{ type: 'outgoingCleared' }], { capacity: 1, from: built.state });
|
|
214
|
+
expect(state.outgoingRequest).toBeNull();
|
|
215
|
+
expect(state.messageQueue).toEqual([]);
|
|
216
|
+
expect(liveRequestId(state)).toBeNull();
|
|
217
|
+
});
|
|
218
|
+
it('reports nothing to supersede when nothing is in flight', () => {
|
|
219
|
+
expect(liveRequestId(initialSessionState())).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
describe('restore', () => {
|
|
223
|
+
it('adopts an unacknowledged batch found in the store', () => {
|
|
224
|
+
const { effects } = run([
|
|
225
|
+
{ type: 'outgoingRestored', requestId: 'old', messages: [msg('a')] },
|
|
226
|
+
ACTIVATE,
|
|
227
|
+
send('b', 't1'),
|
|
228
|
+
]);
|
|
229
|
+
// A new message extends the restored batch rather than replacing it.
|
|
230
|
+
expect(effects).toEqual([{ type: 'submitRequest', requestId: 'r1', messages: [msg('a'), msg('b')] }]);
|
|
231
|
+
});
|
|
232
|
+
it('answers a restored batch by its original id, resolving no tokens', () => {
|
|
233
|
+
const { effects } = run([
|
|
234
|
+
{ type: 'outgoingRestored', requestId: 'old', messages: [msg('a')] },
|
|
235
|
+
ACTIVATE,
|
|
236
|
+
{ type: 'responseReceived', requestId: 'old', responseCode: 'success' },
|
|
237
|
+
]);
|
|
238
|
+
expect(effects).toEqual([{ type: 'resolveTokens', tokens: [], requestId: 'old', responseCode: 'success' }]);
|
|
239
|
+
});
|
|
240
|
+
it('does not clobber an incoming request a live delivery already tracked', () => {
|
|
241
|
+
const { state } = run([
|
|
242
|
+
{ type: 'requestReceived', requestId: 'req' },
|
|
243
|
+
{ type: 'incomingRestored', requestId: 'req', responded: true },
|
|
244
|
+
]);
|
|
245
|
+
expect(incomingRequest(state, 'req')).toEqual({ responded: false });
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
describe('incoming requests', () => {
|
|
249
|
+
it('tracks a request once', () => {
|
|
250
|
+
const { state } = run([{ type: 'requestReceived', requestId: 'req' }]);
|
|
251
|
+
expect(incomingRequest(state, 'req')).toEqual({ responded: false });
|
|
252
|
+
expect(incomingRequest(state, 'nope')).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
// An async responder must still be able to answer an older request after a newer one
|
|
255
|
+
// arrives — the reason this is a map and not the spec's single value.
|
|
256
|
+
it('keeps an older request answerable after a newer one arrives', () => {
|
|
257
|
+
const { state } = run([
|
|
258
|
+
{ type: 'requestReceived', requestId: 'a' },
|
|
259
|
+
{ type: 'requestReceived', requestId: 'b' },
|
|
260
|
+
]);
|
|
261
|
+
expect(incomingRequest(state, 'a')).toEqual({ responded: false });
|
|
262
|
+
expect(incomingRequest(state, 'b')).toEqual({ responded: false });
|
|
263
|
+
});
|
|
264
|
+
it('marks a request answered, and rolls back when the answer fails', () => {
|
|
265
|
+
const tracked = { type: 'requestReceived', requestId: 'req' };
|
|
266
|
+
const answered = run([tracked, { type: 'responseSubmitted', requestId: 'req' }]);
|
|
267
|
+
expect(incomingRequest(answered.state, 'req')).toEqual({ responded: true });
|
|
268
|
+
const rolledBack = run([{ type: 'responseSubmitFailed', requestId: 'req' }], { from: answered.state });
|
|
269
|
+
expect(incomingRequest(rolledBack.state, 'req')).toEqual({ responded: false });
|
|
270
|
+
});
|
|
271
|
+
it('ignores answer bookkeeping for an untracked request', () => {
|
|
272
|
+
const { state } = run([{ type: 'responseSubmitted', requestId: 'ghost' }]);
|
|
273
|
+
expect(incomingRequest(state, 'ghost')).toBeUndefined();
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
});
|