@wibly/sdk 0.1.0

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.
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Predictive (optimistic) rendering helpers.
3
+ *
4
+ * Per chunk B7 build: "predictive.ts — optimistic update helpers for
5
+ * fast UI feedback. Apply a local projection, then reconcile against
6
+ * the next state_diff. Roll back if the server rejected the input."
7
+ *
8
+ * The flow is:
9
+ *
10
+ * 1. The caller invokes `session.submitWithPrediction(input,
11
+ * projection)`. The SDK stamps a fresh `msg_…` id, registers
12
+ * `projection` against that id in the store, and sends the
13
+ * `submit` envelope.
14
+ *
15
+ * 2. The server emits a matching `state_diff` (the side effect of
16
+ * the submit). The SDK removes the projection on receipt — the
17
+ * authoritative state now reflects the change.
18
+ *
19
+ * 3. If the server emits an `error` with `causeMessageId === id`,
20
+ * the SDK rolls back the projection (drops it without applying)
21
+ * and surfaces the error to the caller as `submit_rejected`.
22
+ *
23
+ * The chunk-B7 trap is "Don't try to invent predictive logic outside
24
+ * of `predictive.ts`." Anything that needs to know about pending
25
+ * projections (the React view layer, the testkit's envelope assertion)
26
+ * imports from this module.
27
+ */
28
+
29
+ import type { OptimisticProjection, SessionStore } from './store.js';
30
+
31
+ export type PendingProjectionHandle = {
32
+ /** Envelope id that the projection is tied to. */
33
+ readonly id: string;
34
+ /** Forced rollback (drop the projection without applying it). */
35
+ readonly rollback: () => void;
36
+ /** Forced commit (drop the projection — server confirmed). */
37
+ readonly commit: () => void;
38
+ };
39
+
40
+ export const registerProjection = (
41
+ store: SessionStore,
42
+ id: string,
43
+ projection: OptimisticProjection,
44
+ ): PendingProjectionHandle => {
45
+ store.addProjection(id, projection);
46
+ return {
47
+ id,
48
+ rollback: () => store.removeProjection(id),
49
+ commit: () => store.removeProjection(id),
50
+ };
51
+ };
package/src/react.ts ADDED
@@ -0,0 +1,223 @@
1
+ /**
2
+ * React bindings for the Studio SDK.
3
+ *
4
+ * Per chunk B7 build: "state.ts — reactive state subscription.
5
+ * Returns React hooks that read from a synchronised store: …
6
+ * `useSessionState()`, `useSessionEvent(eventType)`,
7
+ * `useConnectionState()`."
8
+ *
9
+ * The hooks are surface-thin — they use `useSyncExternalStore`
10
+ * against the session's store / event bus and otherwise stay out of
11
+ * the way. The chunk-B7 trap is "Don't try to invent predictive
12
+ * logic outside of `predictive.ts`. Don't let the AI inline state
13
+ * stores per call site." The hooks read from the session-owned
14
+ * store; they never construct their own.
15
+ *
16
+ * The `react` peer-dependency is **optional** (see `package.json`'s
17
+ * `peerDependenciesMeta`). Consumers using the SDK from a non-React
18
+ * shell (a node script, a CLI) can import everything from the SDK
19
+ * root without pulling React into the bundle.
20
+ */
21
+
22
+ import {
23
+ createContext,
24
+ createElement,
25
+ useCallback,
26
+ useContext,
27
+ useEffect,
28
+ useMemo,
29
+ useState,
30
+ useSyncExternalStore,
31
+ type ReactNode,
32
+ } from 'react';
33
+
34
+ import type { EventPayload, LifecyclePayload } from '@wibly/internal-protocol';
35
+
36
+ import type { ConsentDecision, ConsentRequiredPayload } from './consent.js';
37
+ import type { Session, SessionState } from './client.js';
38
+
39
+ const SessionContext = createContext<Session | null>(null);
40
+
41
+ export type SessionProviderProps = {
42
+ readonly session: Session;
43
+ readonly children: ReactNode;
44
+ };
45
+
46
+ /**
47
+ * Provider that exposes a `Session` to the React tree. The host
48
+ * component creates the session via `createSession(...)` and wraps
49
+ * its tree once — every hook below reads from this provider.
50
+ */
51
+ export const SessionProvider = (props: SessionProviderProps): ReactNode =>
52
+ createElement(
53
+ SessionContext.Provider,
54
+ { value: props.session },
55
+ props.children,
56
+ );
57
+
58
+ /**
59
+ * Access the live `Session`. Throws if no provider is mounted (a
60
+ * deliberate fail-fast — the alternative is a silent null that
61
+ * makes every downstream hook misbehave).
62
+ */
63
+ export const useSession = (): Session => {
64
+ const session = useContext(SessionContext);
65
+ if (session === null) {
66
+ throw new Error(
67
+ 'useSession: no <SessionProvider> in the React tree. ' +
68
+ 'Wrap the tree that uses SDK hooks in <SessionProvider session={...}>.',
69
+ );
70
+ }
71
+ return session;
72
+ };
73
+
74
+ /**
75
+ * Subscribe to the session's reactive store. Returns the current
76
+ * `SessionState` snapshot (re-renders on each store change).
77
+ */
78
+ export const useSessionState = (): SessionState => {
79
+ const session = useSession();
80
+ return useSyncExternalStore(
81
+ session.subscribe,
82
+ session.getState,
83
+ session.getState,
84
+ );
85
+ };
86
+
87
+ /** Convenience hook: just the connection state. */
88
+ export const useConnectionState = (): SessionState['connectionState'] => {
89
+ const state = useSessionState();
90
+ return state.connectionState;
91
+ };
92
+
93
+ /**
94
+ * Subscribe to a server-emitted event by `eventType`. The handler
95
+ * fires on every matching `event` frame. Unsubscribes on unmount.
96
+ *
97
+ * Per the chunk-B7 trap "no `useEffect` for data fetching" — this
98
+ * hook only registers / disposes a listener; it never fetches.
99
+ */
100
+ export const useSessionEvent = <P = unknown>(
101
+ eventType: string,
102
+ handler: (payload: EventPayload & { data: P }) => void,
103
+ ): void => {
104
+ const session = useSession();
105
+ const ref = useEventCallback(handler);
106
+ useEffect(() => {
107
+ return session.events.onEvent<P>(eventType, ref);
108
+ }, [session, eventType, ref]);
109
+ };
110
+
111
+ /**
112
+ * Subscribe to a lifecycle transition by name. Same shape as
113
+ * `useSessionEvent` but reading the `lifecycle` channel.
114
+ */
115
+ export const useSessionLifecycle = (
116
+ transition: string,
117
+ handler: (payload: LifecyclePayload) => void,
118
+ ): void => {
119
+ const session = useSession();
120
+ const ref = useEventCallback(handler);
121
+ useEffect(() => {
122
+ if (transition === 'session.opened') {
123
+ return session.lifecycle.onSessionOpened(ref);
124
+ }
125
+ if (transition === 'session.closed') {
126
+ return session.lifecycle.onSessionClosed(ref);
127
+ }
128
+ if (transition.startsWith('phase.entered')) {
129
+ return session.lifecycle.onPhaseEntered(ref);
130
+ }
131
+ if (transition.startsWith('phase.exited')) {
132
+ return session.lifecycle.onPhaseExited(ref);
133
+ }
134
+ if (transition === 'host.reclaimed') {
135
+ return session.lifecycle.onHostReclaimed(ref);
136
+ }
137
+ // Fall back to the raw bus for unknown transitions.
138
+ return session.events.onAnyEvent(() => {
139
+ // Lifecycle frames go through `lifecycle.*`, not `events`.
140
+ // For unknown transitions, the consumer should use
141
+ // `session.lifecycle` directly.
142
+ });
143
+ }, [session, transition, ref]);
144
+ };
145
+
146
+ /**
147
+ * Subscribe to consent-required events as a React hook. Returns a
148
+ * tuple `[payload, decide]`. While `payload` is non-null, a prompt
149
+ * is pending; call `decide('accepted' | 'declined')` to resolve it.
150
+ *
151
+ * Equivalent to wiring `onConsentRequired` on `createSession`, but
152
+ * scoped to a component lifetime. Use this in the Player shell's
153
+ * consent bridge.
154
+ */
155
+ export const useConsentPrompt = (): [
156
+ ConsentRequiredPayload | null,
157
+ (decision: ConsentDecision) => void,
158
+ ] => {
159
+ const session = useSession();
160
+ const [state, setState] = useState<{
161
+ payload: ConsentRequiredPayload | null;
162
+ resolve: ((d: ConsentDecision) => void) | null;
163
+ }>({
164
+ payload: null,
165
+ resolve: null,
166
+ });
167
+
168
+ useSessionEvent<ConsentRequiredPayload>('consent_required', (event) => {
169
+ setState({
170
+ payload: event.data,
171
+ resolve: (decision) => {
172
+ setState({ payload: null, resolve: null });
173
+ if (decision === 'accepted') {
174
+ void session.requestConsent(event.data).then((result) => {
175
+ session.emit({
176
+ eventType: 'consent.decision',
177
+ data: {
178
+ personaId: event.data.personaId,
179
+ scope: event.data.scope,
180
+ decision,
181
+ persistOk: result.ok,
182
+ persistError: result.ok ? null : result.error.kind,
183
+ },
184
+ });
185
+ });
186
+ } else {
187
+ session.emit({
188
+ eventType: 'consent.decision',
189
+ data: {
190
+ personaId: event.data.personaId,
191
+ scope: event.data.scope,
192
+ decision,
193
+ persistOk: false,
194
+ persistError: null,
195
+ },
196
+ });
197
+ }
198
+ },
199
+ });
200
+ });
201
+
202
+ const decide = useCallback(
203
+ (decision: ConsentDecision) => {
204
+ state.resolve?.(decision);
205
+ },
206
+ [state],
207
+ );
208
+
209
+ return [state.payload, decide];
210
+ };
211
+
212
+ /**
213
+ * Stable callback ref — the returned function never changes identity
214
+ * even though it always calls the latest passed-in `handler`. Used
215
+ * by event hooks so the effect doesn't re-subscribe on every render.
216
+ */
217
+ const useEventCallback = <Args extends readonly unknown[], R>(
218
+ handler: (...args: Args) => R,
219
+ ): ((...args: Args) => R) => {
220
+ const ref = useMemo(() => ({ current: handler }), []);
221
+ ref.current = handler;
222
+ return useCallback((...args: Args) => ref.current(...args), [ref]);
223
+ };
package/src/store.ts ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Reactive session store (per chunk B7 build: "state.ts — reactive
3
+ * state subscription. Returns React hooks that read from a
4
+ * synchronised store").
5
+ *
6
+ * The store holds the four pieces of session truth a client cares
7
+ * about: the connection state, the authoritative server projection
8
+ * (the `state` from `snapshot` plus any applied `state_diff` patches),
9
+ * the optimistic projections layered on top (predictive rendering),
10
+ * and the current `appliedSeq`. Consumers subscribe via `subscribe`
11
+ * (callback-style) or via the React hooks in `react.ts` (which use
12
+ * `useSyncExternalStore`).
13
+ *
14
+ * **One store per session.** Per the chunk-B7 trap "Do not let the AI
15
+ * inline state stores per call site. One store per session." The
16
+ * `createSession` factory owns exactly one store and never exposes
17
+ * the constructor; `useSessionState` and friends read from the
18
+ * session-attached store.
19
+ *
20
+ * **State shape.** The protocol is opaque about the projection's
21
+ * shape (`SnapshotPayload.state: unknown`). The store carries the
22
+ * raw value plus a thin per-recipient narrowing helper
23
+ * (`selectSlice`) so a React hook can pull `session`, `host`,
24
+ * `player.public`, `player.private`, or `team` without re-projecting
25
+ * the whole tree on every render. The per-Experience type narrowing
26
+ * lives at the consumer call-site (the SDK is type-agnostic).
27
+ *
28
+ * The store is implementation-detail to the SDK; nothing outside
29
+ * `client.ts` / `react.ts` / `transport.ts` constructs one directly.
30
+ */
31
+
32
+ import type { JsonPatchOp } from '@wibly/internal-protocol';
33
+
34
+ export type ConnectionState =
35
+ | 'idle'
36
+ | 'connecting'
37
+ | 'open'
38
+ | 'reconnecting'
39
+ | 'closed';
40
+
41
+ export type SessionStoreSnapshot = {
42
+ readonly connectionState: ConnectionState;
43
+ readonly state: unknown;
44
+ readonly phaseId: string | null;
45
+ readonly appliedSeq: number;
46
+ /**
47
+ * Identifiers of the optimistic projections currently applied on
48
+ * top of `state`. The actual projection functions are stored
49
+ * separately so the snapshot stays serialisable.
50
+ */
51
+ readonly pendingProjectionIds: readonly string[];
52
+ };
53
+
54
+ export type OptimisticProjection<T = unknown> = (state: T) => T;
55
+
56
+ export type SessionStore = {
57
+ /** Subscribe to store changes. Returns an unsubscribe function. */
58
+ readonly subscribe: (listener: () => void) => () => void;
59
+ /** Current store snapshot. Returns the same reference between mutations. */
60
+ readonly getSnapshot: () => SessionStoreSnapshot;
61
+ /** Internal mutators — not exposed past `createSession`. */
62
+ readonly setConnectionState: (next: ConnectionState) => void;
63
+ readonly applySnapshot: (state: unknown, phaseId: string, baseSeq: number) => void;
64
+ readonly applyDiff: (
65
+ patches: readonly JsonPatchOp[],
66
+ fromSeq: number,
67
+ toSeq: number,
68
+ ) => 'applied' | 'resync_required';
69
+ readonly addProjection: (id: string, fn: OptimisticProjection) => void;
70
+ readonly removeProjection: (id: string) => void;
71
+ readonly hasProjection: (id: string) => boolean;
72
+ /**
73
+ * Render the projected view: `state` with every active projection
74
+ * applied in registration order. Cheap because projections are
75
+ * typically a small handful per session.
76
+ */
77
+ readonly getProjectedState: () => unknown;
78
+ };
79
+
80
+ export const createSessionStore = (): SessionStore => {
81
+ const listeners = new Set<() => void>();
82
+ const projections = new Map<string, OptimisticProjection>();
83
+ let snapshot: SessionStoreSnapshot = {
84
+ connectionState: 'idle',
85
+ state: null,
86
+ phaseId: null,
87
+ appliedSeq: 0,
88
+ pendingProjectionIds: [],
89
+ };
90
+
91
+ const emit = (): void => {
92
+ for (const l of listeners) {
93
+ try {
94
+ l();
95
+ } catch {
96
+ // listeners must not throw; swallow and continue.
97
+ }
98
+ }
99
+ };
100
+
101
+ const refreshSnapshot = (partial: Partial<SessionStoreSnapshot>): void => {
102
+ snapshot = {
103
+ ...snapshot,
104
+ ...partial,
105
+ pendingProjectionIds:
106
+ partial.pendingProjectionIds ?? snapshot.pendingProjectionIds,
107
+ };
108
+ emit();
109
+ };
110
+
111
+ return {
112
+ subscribe: (listener) => {
113
+ listeners.add(listener);
114
+ return () => {
115
+ listeners.delete(listener);
116
+ };
117
+ },
118
+ getSnapshot: () => snapshot,
119
+ setConnectionState: (next) => {
120
+ if (next === snapshot.connectionState) return;
121
+ refreshSnapshot({ connectionState: next });
122
+ },
123
+ applySnapshot: (state, phaseId, baseSeq) => {
124
+ refreshSnapshot({ state, phaseId, appliedSeq: baseSeq });
125
+ },
126
+ applyDiff: (patches, fromSeq, toSeq) => {
127
+ if (fromSeq !== snapshot.appliedSeq) {
128
+ return 'resync_required';
129
+ }
130
+ const next = applyPatches(snapshot.state, patches);
131
+ refreshSnapshot({ state: next, appliedSeq: toSeq });
132
+ return 'applied';
133
+ },
134
+ addProjection: (id, fn) => {
135
+ projections.set(id, fn);
136
+ refreshSnapshot({
137
+ pendingProjectionIds: Array.from(projections.keys()),
138
+ });
139
+ },
140
+ removeProjection: (id) => {
141
+ if (!projections.has(id)) return;
142
+ projections.delete(id);
143
+ refreshSnapshot({
144
+ pendingProjectionIds: Array.from(projections.keys()),
145
+ });
146
+ },
147
+ hasProjection: (id) => projections.has(id),
148
+ getProjectedState: () => {
149
+ let out = snapshot.state;
150
+ for (const fn of projections.values()) {
151
+ try {
152
+ out = fn(out);
153
+ } catch {
154
+ // projection that throws is dropped silently — its
155
+ // confirming submit failed, but a tasteless caller can
156
+ // still inspect server state.
157
+ }
158
+ }
159
+ return out;
160
+ },
161
+ };
162
+ };
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // JSON Patch application
166
+ // ---------------------------------------------------------------------------
167
+
168
+ /**
169
+ * Apply a sequence of JSON-Patch operations to a JSON-shaped value.
170
+ *
171
+ * Supports the five operation kinds declared in `@platform/protocol`
172
+ * (`add` / `remove` / `replace` / `move` / `copy`). Paths use the
173
+ * RFC-6901 escaping (`/` becomes `~1`, `~` becomes `~0`); the empty
174
+ * path `''` targets the root document.
175
+ *
176
+ * Returns a NEW object tree — never mutates the input. The implementation
177
+ * is deliberately small (~100 lines) so the SDK doesn't pull in an
178
+ * external `fast-json-patch` dependency.
179
+ */
180
+ export const applyPatches = (
181
+ root: unknown,
182
+ patches: readonly JsonPatchOp[],
183
+ ): unknown => {
184
+ let next = root;
185
+ for (const op of patches) {
186
+ next = applyOne(next, op);
187
+ }
188
+ return next;
189
+ };
190
+
191
+ const applyOne = (root: unknown, op: JsonPatchOp): unknown => {
192
+ switch (op.op) {
193
+ case 'add':
194
+ return setAt(root, parsePath(op.path), op.value, 'add');
195
+ case 'remove':
196
+ return removeAt(root, parsePath(op.path));
197
+ case 'replace':
198
+ return setAt(root, parsePath(op.path), op.value, 'replace');
199
+ case 'copy': {
200
+ const src = getAt(root, parsePath(op.from));
201
+ return setAt(root, parsePath(op.path), structuredCloneShallow(src), 'add');
202
+ }
203
+ case 'move': {
204
+ const src = getAt(root, parsePath(op.from));
205
+ const removed = removeAt(root, parsePath(op.from));
206
+ return setAt(removed, parsePath(op.path), src, 'add');
207
+ }
208
+ }
209
+ };
210
+
211
+ const parsePath = (path: string): readonly string[] => {
212
+ if (path === '') return [];
213
+ if (!path.startsWith('/')) {
214
+ throw new Error(`json-patch: path must start with '/' (got '${path}')`);
215
+ }
216
+ return path
217
+ .slice(1)
218
+ .split('/')
219
+ .map((seg) => seg.replace(/~1/g, '/').replace(/~0/g, '~'));
220
+ };
221
+
222
+ const getAt = (root: unknown, path: readonly string[]): unknown => {
223
+ let cur: unknown = root;
224
+ for (const seg of path) {
225
+ cur = step(cur, seg);
226
+ }
227
+ return cur;
228
+ };
229
+
230
+ const step = (cur: unknown, seg: string): unknown => {
231
+ if (Array.isArray(cur)) {
232
+ const idx = Number(seg);
233
+ return Number.isInteger(idx) ? cur[idx] : undefined;
234
+ }
235
+ if (cur && typeof cur === 'object') {
236
+ return (cur as Record<string, unknown>)[seg];
237
+ }
238
+ return undefined;
239
+ };
240
+
241
+ const removeAt = (root: unknown, path: readonly string[]): unknown => {
242
+ if (path.length === 0) return null;
243
+ return reduceWithCopy(root, path, (parent, last) => {
244
+ if (Array.isArray(parent)) {
245
+ const idx = Number(last);
246
+ const copy = parent.slice();
247
+ copy.splice(idx, 1);
248
+ return copy;
249
+ }
250
+ if (parent && typeof parent === 'object') {
251
+ const { [last]: _drop, ...rest } = parent as Record<string, unknown>;
252
+ void _drop;
253
+ return rest;
254
+ }
255
+ return parent;
256
+ });
257
+ };
258
+
259
+ const setAt = (
260
+ root: unknown,
261
+ path: readonly string[],
262
+ value: unknown,
263
+ mode: 'add' | 'replace',
264
+ ): unknown => {
265
+ if (path.length === 0) return value;
266
+ return reduceWithCopy(root, path, (parent, last) => {
267
+ if (Array.isArray(parent)) {
268
+ const copy = parent.slice();
269
+ if (last === '-') {
270
+ copy.push(value);
271
+ } else {
272
+ const idx = Number(last);
273
+ if (mode === 'add') copy.splice(idx, 0, value);
274
+ else copy[idx] = value;
275
+ }
276
+ return copy;
277
+ }
278
+ if (parent && typeof parent === 'object') {
279
+ return { ...(parent as Record<string, unknown>), [last]: value };
280
+ }
281
+ // Setting under a primitive parent — create an object with the key.
282
+ return { [last]: value };
283
+ });
284
+ };
285
+
286
+ const reduceWithCopy = (
287
+ root: unknown,
288
+ path: readonly string[],
289
+ mutate: (parent: unknown, last: string) => unknown,
290
+ ): unknown => {
291
+ if (path.length === 0) {
292
+ throw new Error('json-patch: cannot mutate root with empty path');
293
+ }
294
+ const first = path[0];
295
+ if (first === undefined) {
296
+ throw new Error('json-patch: empty path segment');
297
+ }
298
+ const rest = path.slice(1);
299
+ if (rest.length === 0) {
300
+ return mutate(root, first);
301
+ }
302
+ const child = step(root, first);
303
+ const nextChild = reduceWithCopy(child, rest, mutate);
304
+ if (Array.isArray(root)) {
305
+ const copy = root.slice();
306
+ copy[Number(first)] = nextChild;
307
+ return copy;
308
+ }
309
+ if (root && typeof root === 'object') {
310
+ return { ...(root as Record<string, unknown>), [first]: nextChild };
311
+ }
312
+ return { [first]: nextChild };
313
+ };
314
+
315
+ const structuredCloneShallow = (v: unknown): unknown => {
316
+ if (v === null || typeof v !== 'object') return v;
317
+ return structuredClone(v);
318
+ };
package/src/submit.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Submit / emit helpers (per chunk B7 build: "submit.ts — typed
3
+ * submit helpers (session.submit(input)) with idempotent retry on
4
+ * disconnect").
5
+ *
6
+ * `submit` is the workhorse for turn-based input collection. Each
7
+ * call:
8
+ *
9
+ * 1. Generates a stable `msg_…` id (the transport stamps it on the
10
+ * envelope).
11
+ * 2. Optionally layers a predictive projection on the local store.
12
+ * 3. Sends a `submit` frame over the transport.
13
+ * 4. Returns a `PendingSubmit` handle the caller awaits or watches.
14
+ * The handle resolves once the server confirms (with a matching
15
+ * `state_diff` or `error`) — see `client.ts` for the wiring.
16
+ *
17
+ * The Runtime dedupes by envelope `id` (chunk B0). The transport
18
+ * re-queues the same frame on reconnect; the server collapses
19
+ * duplicates server-side.
20
+ *
21
+ * For chunk B7, this module exports the *typed* surface and the
22
+ * `PendingSubmit` shape; `client.ts` wires it up against the live
23
+ * transport.
24
+ */
25
+
26
+ import type { SubmitPayload, EmitPayload } from '@wibly/internal-protocol';
27
+ import type { Result } from '@wibly/internal-shared';
28
+
29
+ import type { SdkError } from './errors.js';
30
+ import type { OptimisticProjection } from './store.js';
31
+
32
+ export type SubmitArgs = {
33
+ readonly phaseId: string;
34
+ readonly inputType: string;
35
+ readonly data: unknown;
36
+ /**
37
+ * Optional predictive projection. The SDK applies the projection
38
+ * to the local store immediately (so the UI updates without
39
+ * waiting for the server's `state_diff`) and removes it on
40
+ * confirmation / rollback.
41
+ */
42
+ readonly predictive?: OptimisticProjection;
43
+ /**
44
+ * How long to wait for the server's confirming `state_diff` /
45
+ * `error` before resolving with `Err({ kind: 'timeout' })`.
46
+ * Default 30s. A submit that times out is NOT rolled back — the
47
+ * server may still emit a confirming frame, in which case the
48
+ * store reconciles. Use `cancel()` to drop a pending submit
49
+ * explicitly.
50
+ */
51
+ readonly timeoutMs?: number;
52
+ };
53
+
54
+ export type EmitArgs = {
55
+ readonly eventType: string;
56
+ readonly data: unknown;
57
+ };
58
+
59
+ export type PendingSubmit = {
60
+ /** The envelope id the SDK generated for this submit. */
61
+ readonly id: string;
62
+ /** Resolves when the server confirms or the timeout fires. */
63
+ readonly promise: Promise<Result<void, SdkError>>;
64
+ /**
65
+ * Drop the predictive projection (if any) and stop waiting for
66
+ * confirmation. Does NOT cancel the server-side processing — the
67
+ * server may still emit a `state_diff` later, which the store
68
+ * reconciles normally.
69
+ */
70
+ readonly cancel: () => void;
71
+ };
72
+
73
+ export const buildSubmitPayload = (
74
+ sessionId: string,
75
+ args: SubmitArgs,
76
+ ): SubmitPayload =>
77
+ ({
78
+ sessionId,
79
+ phaseId: args.phaseId,
80
+ inputType: args.inputType,
81
+ data: args.data,
82
+ }) as SubmitPayload;
83
+
84
+ export const buildEmitPayload = (
85
+ sessionId: string,
86
+ args: EmitArgs,
87
+ ): EmitPayload =>
88
+ ({
89
+ sessionId,
90
+ eventType: args.eventType,
91
+ data: args.data,
92
+ }) as EmitPayload;