@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.
Files changed (41) hide show
  1. package/dist/crypto.js +2 -1
  2. package/dist/helpers.d.ts +0 -1
  3. package/dist/helpers.js +0 -3
  4. package/dist/index.d.ts +7 -0
  5. package/dist/index.js +3 -0
  6. package/dist/model/session.d.ts +1 -1
  7. package/dist/model/session.js +1 -1
  8. package/dist/model/sessionAccount.js +2 -1
  9. package/dist/session/codec/decoder.d.ts +68 -0
  10. package/dist/session/codec/decoder.js +96 -0
  11. package/dist/session/codec/decoder.spec.d.ts +1 -0
  12. package/dist/session/codec/decoder.spec.js +161 -0
  13. package/dist/session/codec/envelope.d.ts +55 -0
  14. package/dist/session/codec/envelope.js +115 -0
  15. package/dist/session/codec/envelope.spec.d.ts +1 -0
  16. package/dist/session/codec/envelope.spec.js +114 -0
  17. package/dist/session/codec/incomingTopics.d.ts +46 -0
  18. package/dist/session/codec/incomingTopics.js +69 -0
  19. package/dist/session/codec/outgoingBody.d.ts +46 -0
  20. package/dist/session/codec/outgoingBody.js +64 -0
  21. package/dist/session/core.d.ts +58 -0
  22. package/dist/session/core.js +609 -0
  23. package/dist/session/encyption.js +7 -7
  24. package/dist/session/encyption.spec.d.ts +1 -0
  25. package/dist/session/encyption.spec.js +43 -0
  26. package/dist/session/messageMapper.d.ts +3 -3
  27. package/dist/session/messageMapper.js +8 -8
  28. package/dist/session/multiDeviceSession.d.ts +49 -0
  29. package/dist/session/multiDeviceSession.js +62 -0
  30. package/dist/session/multiDeviceSession.spec.d.ts +6 -0
  31. package/dist/session/multiDeviceSession.spec.js +354 -0
  32. package/dist/session/scale/statementData.d.ts +40 -0
  33. package/dist/session/scale/statementData.js +34 -1
  34. package/dist/session/session.d.ts +15 -5
  35. package/dist/session/session.js +31 -633
  36. package/dist/session/session.spec.js +24 -7
  37. package/dist/session/stateMachine.d.ts +135 -0
  38. package/dist/session/stateMachine.js +203 -0
  39. package/dist/session/stateMachine.spec.d.ts +1 -0
  40. package/dist/session/stateMachine.spec.js +276 -0
  41. package/package.json +4 -3
@@ -1,13 +1,15 @@
1
+ import { Bytes } from '@novasamatech/scale';
1
2
  import { createExpiryFromDuration } from '@novasamatech/sdk-statement';
2
3
  import { ResultAsync, err, errAsync, ok, okAsync } from 'neverthrow';
3
- import { Bytes, Struct, str } from 'scale-ts';
4
+ import { Struct, str } from 'scale-ts';
4
5
  import { describe, expect, it, vi } from 'vitest';
5
6
  import { createInMemoryStatementStore } from '../adapter/inMemory.js';
6
7
  import { AccountFullError, ExpiryTooLowError } from '../adapter/types.js';
7
8
  import { createAccountId, createLocalSessionAccount, createRemoteSessionAccount } from '../model/sessionAccount.js';
9
+ import { STATEMENT_OVERHEAD } from './core.js';
8
10
  import { DecodingError, UnknownError } from './error.js';
9
11
  import { StatementData } from './scale/statementData.js';
10
- import { STATEMENT_OVERHEAD, createSession } from './session.js';
12
+ import { createSession } from './session.js';
11
13
  // Real signature work belongs in statementProver tests; this stub stamps a
12
14
  // non-empty proof so submitted statements are well-formed.
13
15
  const mockProver = {
@@ -159,11 +161,14 @@ describe('session', () => {
159
161
  it('queries the outgoing and incoming topics on creation', async () => {
160
162
  const { adapter } = makeSession();
161
163
  await delay();
162
- // Two single-topic matchAll queries one per topic (outgoing/incoming); they must differ.
163
- const topics = adapter.queryStatements.mock.calls.map(([f]) => f.matchAll);
164
- expect(topics).toHaveLength(2);
165
- expect(topics.map(t => t.length)).toEqual([1, 1]);
166
- expect(topics[0]).not.toEqual(topics[1]);
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);
167
172
  });
168
173
  it('seeds the expiry from the highest own statement expiry', async () => {
169
174
  const highExpiry = createExpiryFromDuration(7 * 24 * 60 * 60) + 9999n;
@@ -532,6 +537,18 @@ describe('session', () => {
532
537
  // One subscription: the incoming topic carries both peer requests and peer responses.
533
538
  expect(store.activeSubscriptions()).toBe(1);
534
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
+ });
535
552
  it('tears down the subscription when the last subscriber leaves', () => {
536
553
  const store = createInMemoryStatementStore();
537
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 {};