@novasamatech/statement-store 0.8.12 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/crypto.js +2 -1
- 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/model/session.d.ts +1 -1
- package/dist/model/session.js +1 -1
- package/dist/model/sessionAccount.js +2 -1
- 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/encyption.js +7 -7
- package/dist/session/encyption.spec.d.ts +1 -0
- package/dist/session/encyption.spec.js +43 -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 +34 -1
- package/dist/session/session.d.ts +15 -5
- package/dist/session/session.js +31 -633
- package/dist/session/session.spec.js +24 -7
- 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,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
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@novasamatech/statement-store",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.9.1",
|
|
5
5
|
"description": "Statement store integration",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"repository": {
|
|
@@ -25,13 +25,14 @@
|
|
|
25
25
|
"README.md"
|
|
26
26
|
],
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@novasamatech/scale": "0.
|
|
28
|
+
"@novasamatech/scale": "0.9.1",
|
|
29
29
|
"@novasamatech/sdk-statement": "^0.6.0",
|
|
30
|
-
"@novasamatech/substrate-slot-sr25519-wasm": "0.
|
|
30
|
+
"@novasamatech/substrate-slot-sr25519-wasm": "0.9.1",
|
|
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.31",
|
|
34
34
|
"@polkadot-labs/schnorrkel-wasm": "0.0.9",
|
|
35
|
+
"@noble/curves": "2.2.0",
|
|
35
36
|
"@noble/hashes": "2.2.0",
|
|
36
37
|
"@noble/ciphers": "2.2.0",
|
|
37
38
|
"@scure/sr25519": "2.2.0",
|