statewire 0.23.0 → 0.24.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/client/core.d.ts +4 -3
- package/dist/client/core.d.ts.map +1 -1
- package/dist/client/core.js +56 -45
- package/dist/client/core.js.map +1 -1
- package/dist/client/session.d.ts +56 -12
- package/dist/client/session.d.ts.map +1 -1
- package/dist/client/session.js +632 -260
- package/dist/client/session.js.map +1 -1
- package/dist/client/sessionStore.d.ts +1 -0
- package/dist/client/sessionStore.d.ts.map +1 -1
- package/dist/client/sessionStore.js +1 -0
- package/dist/client/sessionStore.js.map +1 -1
- package/dist/host/host.d.ts +6 -5
- package/dist/host/host.d.ts.map +1 -1
- package/dist/host/host.js.map +1 -1
- package/dist/indexedDb.d.ts +15 -0
- package/dist/indexedDb.d.ts.map +1 -0
- package/dist/indexedDb.js +171 -0
- package/dist/indexedDb.js.map +1 -0
- package/dist/statewire.d.ts +117 -59
- package/dist/statewire.d.ts.map +1 -1
- package/dist/statewire.js +13 -25
- package/dist/statewire.js.map +1 -1
- package/dist/storage.d.ts +42 -0
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +387 -125
- package/dist/storage.js.map +1 -1
- package/dist/transports/remote.d.ts.map +1 -1
- package/dist/transports/remote.js +7 -9
- package/dist/transports/remote.js.map +1 -1
- package/dist/types.d.ts +6 -7
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/package.json +2 -1
- package/src/client/core.ts +81 -64
- package/src/client/session.ts +886 -424
- package/src/client/sessionStore.ts +2 -0
- package/src/host/host.ts +10 -3
- package/src/indexedDb.ts +167 -0
- package/src/statewire.ts +133 -86
- package/src/storage.ts +548 -112
- package/src/transports/remote.ts +13 -14
- package/src/types.ts +6 -7
|
@@ -12,6 +12,7 @@ export const SessionStorageContext = createContext<
|
|
|
12
12
|
export const createMemorySessionStore = (): StatewireClient.SessionStorage => {
|
|
13
13
|
let record: StatewireClient.SessionRecord | null = null;
|
|
14
14
|
return {
|
|
15
|
+
durable: false,
|
|
15
16
|
load: () => record,
|
|
16
17
|
save: (next) => {
|
|
17
18
|
record = next;
|
|
@@ -28,6 +29,7 @@ export const LinkContext = createContext<{
|
|
|
28
29
|
canOvertake?:
|
|
29
30
|
| Readonly<Record<string, StatewireClient.CanOvertake>>
|
|
30
31
|
| undefined;
|
|
32
|
+
onSettled?: StatewireClient.OnSettled | undefined;
|
|
31
33
|
}>({});
|
|
32
34
|
|
|
33
35
|
export const DocumentStorageContext = createContext<
|
package/src/host/host.ts
CHANGED
|
@@ -151,7 +151,9 @@ export type CommandsInvocation = {
|
|
|
151
151
|
readonly id?: string;
|
|
152
152
|
readonly clientId?: string;
|
|
153
153
|
/** Present while the queued command is held. */
|
|
154
|
-
readonly
|
|
154
|
+
readonly held?: true;
|
|
155
|
+
/** Client-only data persisted with an outbox entry. */
|
|
156
|
+
readonly localMeta?: unknown;
|
|
155
157
|
};
|
|
156
158
|
|
|
157
159
|
export type CommandsOutcome =
|
|
@@ -161,12 +163,17 @@ export type CommandsOutcome =
|
|
|
161
163
|
export type CommandsListener = {
|
|
162
164
|
onStart: (inv: CommandsInvocation) => void;
|
|
163
165
|
onPosted?: (inv: CommandsInvocation) => void;
|
|
164
|
-
/** The invocation's seq or
|
|
166
|
+
/** The invocation's seq, hold or local meta changed in place. */
|
|
165
167
|
onChange?: (inv: CommandsInvocation) => void;
|
|
166
168
|
onApplied: (inv: CommandsInvocation) => void;
|
|
167
169
|
/** Canonical state now covers the invocation's effect — drop any overlay. */
|
|
168
170
|
onCovered?: (inv: CommandsInvocation) => void;
|
|
169
|
-
|
|
171
|
+
/** `durable`: the session persisted the settled update and delivers it itself. */
|
|
172
|
+
onSettle: (
|
|
173
|
+
inv: CommandsInvocation,
|
|
174
|
+
outcome: CommandsOutcome,
|
|
175
|
+
durable?: boolean,
|
|
176
|
+
) => void;
|
|
170
177
|
};
|
|
171
178
|
|
|
172
179
|
export type CommandsSubmission = {
|
package/src/indexedDb.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { resource, useResource } from "@assistant-ui/tap";
|
|
3
|
+
import { KeyValueSessionStorage } from "./storage";
|
|
4
|
+
import type { StatewireClient } from "./statewire";
|
|
5
|
+
import { randomId } from "./util/randomId";
|
|
6
|
+
|
|
7
|
+
const STORE = "sessions";
|
|
8
|
+
|
|
9
|
+
const request = <T>(req: IDBRequest<T>) =>
|
|
10
|
+
new Promise<T>((resolve, reject) => {
|
|
11
|
+
req.onsuccess = () => resolve(req.result);
|
|
12
|
+
req.onerror = () => reject(req.error);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
const backends = new Map<string, KeyValueSessionStorage.Backend>();
|
|
16
|
+
|
|
17
|
+
/** A key-value backend over one IndexedDB database; writes broadcast to other runtimes. */
|
|
18
|
+
export const indexedDbBackend = (
|
|
19
|
+
database: string,
|
|
20
|
+
): KeyValueSessionStorage.Backend => {
|
|
21
|
+
const existing = backends.get(database);
|
|
22
|
+
if (existing) return existing;
|
|
23
|
+
if (typeof indexedDB === "undefined")
|
|
24
|
+
throw new Error("statewire: IndexedDbSession requires indexedDB");
|
|
25
|
+
let opened: Promise<IDBDatabase> | undefined;
|
|
26
|
+
const db = () =>
|
|
27
|
+
(opened ??= (() => {
|
|
28
|
+
const open = indexedDB.open(database, 1);
|
|
29
|
+
open.onupgradeneeded = () => open.result.createObjectStore(STORE);
|
|
30
|
+
return request(open);
|
|
31
|
+
})());
|
|
32
|
+
// Writes resolve once their transaction commits.
|
|
33
|
+
const run = async <T>(
|
|
34
|
+
mode: IDBTransactionMode,
|
|
35
|
+
fn: (store: IDBObjectStore) => IDBRequest<T>,
|
|
36
|
+
) => {
|
|
37
|
+
const tx = (await db()).transaction(STORE, mode);
|
|
38
|
+
const committed =
|
|
39
|
+
mode === "readonly"
|
|
40
|
+
? undefined
|
|
41
|
+
: new Promise<void>((resolve, reject) => {
|
|
42
|
+
tx.oncomplete = () => resolve();
|
|
43
|
+
tx.onerror = () => reject(tx.error);
|
|
44
|
+
tx.onabort = () => reject(tx.error);
|
|
45
|
+
});
|
|
46
|
+
const result = await request(fn(tx.objectStore(STORE)));
|
|
47
|
+
await committed;
|
|
48
|
+
return result;
|
|
49
|
+
};
|
|
50
|
+
const channel = new BroadcastChannel(`statewire-idb:${database}`);
|
|
51
|
+
const announce = (key: string) => channel.postMessage(key);
|
|
52
|
+
const backend: KeyValueSessionStorage.Backend = {
|
|
53
|
+
getItem: async (key) =>
|
|
54
|
+
((await run("readonly", (s) => s.get(key))) as string | undefined) ??
|
|
55
|
+
null,
|
|
56
|
+
setItem: async (key, value) => {
|
|
57
|
+
await run("readwrite", (s) => s.put(value, key));
|
|
58
|
+
announce(key);
|
|
59
|
+
},
|
|
60
|
+
removeItem: async (key) => {
|
|
61
|
+
await run("readwrite", (s) => s.delete(key));
|
|
62
|
+
announce(key);
|
|
63
|
+
},
|
|
64
|
+
getAllKeys: async () =>
|
|
65
|
+
(await run("readonly", (s) => s.getAllKeys())) as string[],
|
|
66
|
+
withUnused: (key, action) =>
|
|
67
|
+
navigator.locks.request(
|
|
68
|
+
`statewire-active:${database}:${key}`,
|
|
69
|
+
{ ifAvailable: true },
|
|
70
|
+
(lock) =>
|
|
71
|
+
lock === null
|
|
72
|
+
? undefined
|
|
73
|
+
: navigator.locks.request(
|
|
74
|
+
`statewire-session:${database}:${key}`,
|
|
75
|
+
{ ifAvailable: true },
|
|
76
|
+
(inner) => (inner === null ? undefined : action()),
|
|
77
|
+
),
|
|
78
|
+
),
|
|
79
|
+
subscribe: (listener) => {
|
|
80
|
+
const own = new BroadcastChannel(`statewire-idb:${database}`);
|
|
81
|
+
own.onmessage = (event: MessageEvent<string>) => listener(event.data);
|
|
82
|
+
return () => own.close();
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
backends.set(database, backend);
|
|
86
|
+
return backend;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const holdLock = (name: string, mode: LockMode = "exclusive") => {
|
|
90
|
+
let release: (() => void) | undefined;
|
|
91
|
+
let disposed = false;
|
|
92
|
+
void navigator.locks
|
|
93
|
+
.request(name, { mode }, () => {
|
|
94
|
+
if (disposed) return;
|
|
95
|
+
return new Promise<void>((resolve) => {
|
|
96
|
+
release = resolve;
|
|
97
|
+
});
|
|
98
|
+
})
|
|
99
|
+
.catch(() => {});
|
|
100
|
+
return () => {
|
|
101
|
+
disposed = true;
|
|
102
|
+
release?.();
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const useIndexedDbSession = ({
|
|
107
|
+
database = "statewire",
|
|
108
|
+
key,
|
|
109
|
+
namespace = "statewire-session:",
|
|
110
|
+
...options
|
|
111
|
+
}: IndexedDbSession.Options): StatewireClient.SessionStorage => {
|
|
112
|
+
if (typeof navigator === "undefined" || navigator.locks === undefined)
|
|
113
|
+
throw new Error(
|
|
114
|
+
"statewire: IndexedDbSession requires the Web Locks API (navigator.locks)",
|
|
115
|
+
);
|
|
116
|
+
const backend = indexedDbBackend(database);
|
|
117
|
+
const storageKey = namespace + key;
|
|
118
|
+
const [tabId] = useState(randomId);
|
|
119
|
+
useEffect(() => holdLock(`statewire-tab:${tabId}`), [tabId]);
|
|
120
|
+
useEffect(
|
|
121
|
+
() => holdLock(`statewire-active:${database}:${storageKey}`, "shared"),
|
|
122
|
+
[database, storageKey],
|
|
123
|
+
);
|
|
124
|
+
const storage = useResource(
|
|
125
|
+
KeyValueSessionStorage({ ...options, backend, key, namespace }),
|
|
126
|
+
);
|
|
127
|
+
return useMemo(
|
|
128
|
+
(): StatewireClient.SessionStorage => ({
|
|
129
|
+
...storage,
|
|
130
|
+
lock: (fn) =>
|
|
131
|
+
navigator.locks.request(
|
|
132
|
+
`statewire-session:${database}:${storageKey}`,
|
|
133
|
+
async () => fn(),
|
|
134
|
+
),
|
|
135
|
+
notifyLock: (fn) =>
|
|
136
|
+
navigator.locks.request(
|
|
137
|
+
`statewire-notify:${database}:${storageKey}`,
|
|
138
|
+
fn,
|
|
139
|
+
),
|
|
140
|
+
owner: {
|
|
141
|
+
id: tabId,
|
|
142
|
+
whenGone: (id, signal) =>
|
|
143
|
+
navigator.locks.request(`statewire-tab:${id}`, { signal }, () => {}),
|
|
144
|
+
},
|
|
145
|
+
subscribe: (listener) =>
|
|
146
|
+
backend.subscribe!((changed) => {
|
|
147
|
+
if (changed === storageKey) listener();
|
|
148
|
+
}),
|
|
149
|
+
}),
|
|
150
|
+
[storage, backend, database, storageKey, tabId],
|
|
151
|
+
);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Shared session storage on IndexedDB: sharers serialize through Web Locks and hear each other over a BroadcastChannel. */
|
|
155
|
+
export const IndexedDbSession = resource(useIndexedDbSession);
|
|
156
|
+
|
|
157
|
+
export namespace IndexedDbSession {
|
|
158
|
+
export type Options = Omit<
|
|
159
|
+
KeyValueSessionStorage.Options,
|
|
160
|
+
"backend" | "namespace"
|
|
161
|
+
> & {
|
|
162
|
+
/** IndexedDB database name; default "statewire". */
|
|
163
|
+
database?: string;
|
|
164
|
+
/** Default "statewire-session:". */
|
|
165
|
+
namespace?: string;
|
|
166
|
+
};
|
|
167
|
+
}
|
package/src/statewire.ts
CHANGED
|
@@ -46,6 +46,7 @@ const useSessionTransport = ({
|
|
|
46
46
|
canOvertake?:
|
|
47
47
|
| Readonly<Record<string, StatewireClient.CanOvertake>>
|
|
48
48
|
| undefined;
|
|
49
|
+
onSettled?: StatewireClient.OnSettled | undefined;
|
|
49
50
|
};
|
|
50
51
|
}): Statewire<unknown> =>
|
|
51
52
|
useContextProvider(SessionStorageContext, session, () =>
|
|
@@ -97,6 +98,8 @@ export const useStatewire = <
|
|
|
97
98
|
keyPrefixes: options.keyPrefixes,
|
|
98
99
|
}),
|
|
99
100
|
};
|
|
101
|
+
const handlesUpdates = options.onCommandChange !== undefined;
|
|
102
|
+
const coreRef = useRef<ReturnType<typeof useStatewireCore> | null>(null);
|
|
100
103
|
const link = useMemo(
|
|
101
104
|
() => ({
|
|
102
105
|
...(options.isNew === undefined ? {} : { isNew: options.isNew }),
|
|
@@ -104,8 +107,14 @@ export const useStatewire = <
|
|
|
104
107
|
...(options.canOvertake === undefined
|
|
105
108
|
? {}
|
|
106
109
|
: { canOvertake: options.canOvertake }),
|
|
110
|
+
...(handlesUpdates && {
|
|
111
|
+
onSettled: (update: Statewire.CommandUpdate) =>
|
|
112
|
+
latest.current.onCommandChange?.(update as never, {
|
|
113
|
+
state: coreRef.current?.getState() as never,
|
|
114
|
+
}),
|
|
115
|
+
}),
|
|
107
116
|
}),
|
|
108
|
-
[options.isNew, options.context, options.canOvertake],
|
|
117
|
+
[options.isNew, options.context, options.canOvertake, handlesUpdates],
|
|
109
118
|
);
|
|
110
119
|
const value = useResource(
|
|
111
120
|
SessionTransport({
|
|
@@ -125,6 +134,7 @@ export const useStatewire = <
|
|
|
125
134
|
localContext(protocol, context);
|
|
126
135
|
}, [options.context, localContext]);
|
|
127
136
|
const core = useStatewireCore(() => latest.current, value);
|
|
137
|
+
coreRef.current = core;
|
|
128
138
|
useEffect(() => core.rematerialize(), [core, options.materializeKey]);
|
|
129
139
|
const [subscribe] = useState(
|
|
130
140
|
() => (listener: () => void) =>
|
|
@@ -137,11 +147,6 @@ export const useStatewire = <
|
|
|
137
147
|
core.getState,
|
|
138
148
|
) as TState;
|
|
139
149
|
const isBusy = useSyncExternalStore(subscribe, core.getBusy, core.getBusy);
|
|
140
|
-
const failed = useSyncExternalStore(
|
|
141
|
-
subscribe,
|
|
142
|
-
core.getFailed,
|
|
143
|
-
core.getFailed,
|
|
144
|
-
);
|
|
145
150
|
const outboxEntries = useSyncExternalStore(
|
|
146
151
|
subscribe,
|
|
147
152
|
core.getOutbox,
|
|
@@ -153,7 +158,6 @@ export const useStatewire = <
|
|
|
153
158
|
selection,
|
|
154
159
|
invoke: transportInvoke,
|
|
155
160
|
setContext: transportSetContext,
|
|
156
|
-
failures: transportFailures,
|
|
157
161
|
outbox: transportOutbox,
|
|
158
162
|
} = value;
|
|
159
163
|
|
|
@@ -183,12 +187,6 @@ export const useStatewire = <
|
|
|
183
187
|
);
|
|
184
188
|
|
|
185
189
|
return useMemo(() => {
|
|
186
|
-
const failures = (): NonNullable<typeof transportFailures> => {
|
|
187
|
-
if (!transportFailures) {
|
|
188
|
-
throw new Error("statewire: this transport has no failed-send lane");
|
|
189
|
-
}
|
|
190
|
-
return transportFailures;
|
|
191
|
-
};
|
|
192
190
|
const outbox = (): NonNullable<typeof transportOutbox> => {
|
|
193
191
|
if (!transportOutbox) {
|
|
194
192
|
throw new Error("statewire: this transport has no outbox");
|
|
@@ -205,16 +203,13 @@ export const useStatewire = <
|
|
|
205
203
|
connection,
|
|
206
204
|
commands: core.commands as Statewire.CommandsProxy<TCommands>,
|
|
207
205
|
isBusy,
|
|
208
|
-
failed,
|
|
209
|
-
retryFailed: (entry: StatewireClient.FailedCommand) =>
|
|
210
|
-
failures().retry(entry),
|
|
211
|
-
discardFailed: (entry: StatewireClient.FailedCommand) =>
|
|
212
|
-
failures().discard(entry),
|
|
213
206
|
outbox: {
|
|
214
207
|
entries: outboxEntries,
|
|
215
|
-
hold: (
|
|
216
|
-
release: (
|
|
217
|
-
remove: (
|
|
208
|
+
hold: (id) => outbox().hold(id),
|
|
209
|
+
release: (id) => outbox().release(id),
|
|
210
|
+
remove: (id) => outbox().remove(id),
|
|
211
|
+
update: (id, fn) => outbox().update(id, fn),
|
|
212
|
+
insert: (entry) => outbox().insert(entry),
|
|
218
213
|
},
|
|
219
214
|
};
|
|
220
215
|
}, [
|
|
@@ -222,13 +217,11 @@ export const useStatewire = <
|
|
|
222
217
|
transportOutbox,
|
|
223
218
|
state,
|
|
224
219
|
isBusy,
|
|
225
|
-
failed,
|
|
226
220
|
connection,
|
|
227
221
|
core,
|
|
228
222
|
source,
|
|
229
223
|
selection,
|
|
230
224
|
invoke,
|
|
231
|
-
transportFailures,
|
|
232
225
|
setContext,
|
|
233
226
|
]);
|
|
234
227
|
};
|
|
@@ -286,18 +279,6 @@ export class StatewireClient<
|
|
|
286
279
|
return this.root.getValue().isBusy;
|
|
287
280
|
}
|
|
288
281
|
|
|
289
|
-
get failed(): readonly StatewireClient.FailedCommand[] {
|
|
290
|
-
return this.root.getValue().failed;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
retryFailed(entry: StatewireClient.FailedCommand): Promise<unknown> {
|
|
294
|
-
return this.root.getValue().retryFailed(entry);
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
discardFailed(entry: StatewireClient.FailedCommand): void {
|
|
298
|
-
this.root.getValue().discardFailed(entry);
|
|
299
|
-
}
|
|
300
|
-
|
|
301
282
|
get outbox(): StatewireClient.Outbox {
|
|
302
283
|
return this.root.getValue().outbox;
|
|
303
284
|
}
|
|
@@ -345,17 +326,19 @@ export namespace StatewireClient {
|
|
|
345
326
|
state: NonNullable<TState>,
|
|
346
327
|
ctx: { changes: Statewire.Changes },
|
|
347
328
|
) => void;
|
|
329
|
+
/**
|
|
330
|
+
* queued, posted and applied updates are best-effort and in memory. A
|
|
331
|
+
* durable session persists each `settled` update before the command leaves
|
|
332
|
+
* the record and delivers it to one sharer at a time: at-least-once under
|
|
333
|
+
* the same `id`, acked once the returned promise resolves; a pending
|
|
334
|
+
* delivery blocks later ones for the same session.
|
|
335
|
+
*/
|
|
348
336
|
onCommandChange?: (
|
|
349
337
|
update: Statewire.CommandUpdate<TCommands>,
|
|
350
338
|
ctx: { state: NonNullable<TState> },
|
|
351
|
-
) =>
|
|
339
|
+
) => unknown;
|
|
352
340
|
onConnectionChange?: (connection: Connection) => void;
|
|
353
341
|
onError?: (error: ConnectionError) => void;
|
|
354
|
-
/** Drops a retained failed entry once a canonical snapshot shows its effect landed; checked against each new snapshot. */
|
|
355
|
-
failureCovered?: (
|
|
356
|
-
state: NonNullable<TState>,
|
|
357
|
-
entry: FailedCommand,
|
|
358
|
-
) => boolean;
|
|
359
342
|
};
|
|
360
343
|
|
|
361
344
|
export type Helpers<
|
|
@@ -379,25 +362,40 @@ export namespace StatewireClient {
|
|
|
379
362
|
commands: Statewire.CommandsProxy<TCommands>;
|
|
380
363
|
/** Negation of the server's broadcast idle level (a transport without idle is always busy). */
|
|
381
364
|
isBusy: boolean;
|
|
382
|
-
/**
|
|
383
|
-
failed: readonly FailedCommand[];
|
|
384
|
-
/** Re-enqueues the entry's command on fresh clocks and removes the entry. */
|
|
385
|
-
retryFailed: (entry: FailedCommand) => Promise<unknown>;
|
|
386
|
-
/** Removes the entry. */
|
|
387
|
-
discardFailed: (entry: FailedCommand) => void;
|
|
388
|
-
/** Commands queued without a seq yet; a transport without a session has an empty outbox whose methods throw. */
|
|
365
|
+
/** Unsettled commands in queue order; a transport without a session has an empty outbox whose methods throw. */
|
|
389
366
|
outbox: Outbox;
|
|
390
367
|
};
|
|
391
368
|
|
|
392
|
-
/** The
|
|
369
|
+
/** The unsettled commands across sharers, and the operations on them; every verb throws `outbox: no entry <id>` or `outbox: <id> is in flight`. */
|
|
393
370
|
export type Outbox = {
|
|
394
371
|
readonly entries: readonly OutboxEntry[];
|
|
395
|
-
/**
|
|
396
|
-
hold: (
|
|
397
|
-
/**
|
|
398
|
-
release: (
|
|
399
|
-
/** Drops
|
|
400
|
-
remove: (
|
|
372
|
+
/** queued → held: no flush stamps it until released. */
|
|
373
|
+
hold: (id: string) => void;
|
|
374
|
+
/** held → queued; settles with the command's outcome. */
|
|
375
|
+
release: (id: string) => Promise<unknown>;
|
|
376
|
+
/** Drops a queued or held entry; its pending send rejects `{ fate: "aborted", cause: "removed" }`. */
|
|
377
|
+
remove: (id: string) => void;
|
|
378
|
+
/** Patches the entry under the storage lock; `fn` gets the current entry. */
|
|
379
|
+
update: (id: string, fn: (entry: OutboxEntry) => OutboxPatch) => void;
|
|
380
|
+
/** Adds a command no send awaits, at the lane's tail or next to `after`/`before`; returns its id. */
|
|
381
|
+
insert: (entry: OutboxInsert) => string;
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
export type OutboxPatch = {
|
|
385
|
+
held?: boolean;
|
|
386
|
+
localMeta?: unknown;
|
|
387
|
+
command?: { params: readonly unknown[] };
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
export type OutboxInsert = {
|
|
391
|
+
/** The `settled` update this re-inserts: the insert acks it in the same write, so a redelivery never inserts twice. */
|
|
392
|
+
from?: string;
|
|
393
|
+
protocol: string;
|
|
394
|
+
command: { method: string; params: readonly unknown[] };
|
|
395
|
+
held?: boolean;
|
|
396
|
+
localMeta?: unknown;
|
|
397
|
+
after?: string;
|
|
398
|
+
before?: string;
|
|
401
399
|
};
|
|
402
400
|
|
|
403
401
|
/** Connection lifecycle and whether its current state warrants a problem indicator. */
|
|
@@ -448,17 +446,20 @@ export namespace StatewireClient {
|
|
|
448
446
|
) => void;
|
|
449
447
|
|
|
450
448
|
export type OptimisticContext = {
|
|
449
|
+
/** Enqueue time. */
|
|
451
450
|
ts: number;
|
|
452
451
|
/** Absent while the command waits in the outbox. */
|
|
453
452
|
seq?: number;
|
|
454
453
|
clientId?: string;
|
|
455
|
-
|
|
456
|
-
|
|
454
|
+
state: OutboxState;
|
|
455
|
+
localMeta: unknown;
|
|
457
456
|
};
|
|
458
457
|
|
|
459
458
|
export type InvokeOptions = {
|
|
460
|
-
/** Enqueue the command
|
|
461
|
-
hold?:
|
|
459
|
+
/** Enqueue the command held. */
|
|
460
|
+
hold?: boolean;
|
|
461
|
+
/** Client-only data persisted with the entry, never sent. */
|
|
462
|
+
localMeta?: unknown;
|
|
462
463
|
};
|
|
463
464
|
|
|
464
465
|
/**
|
|
@@ -469,7 +470,7 @@ export namespace StatewireClient {
|
|
|
469
470
|
*/
|
|
470
471
|
export type FlushReason = "enqueue" | "reconnect" | "reload" | "release";
|
|
471
472
|
|
|
472
|
-
/** Runs in the sharer about to stamp, under the storage lock, once per reason;
|
|
473
|
+
/** Runs in the sharer about to stamp, under the storage lock, once per reason; outbox verbs on these entries apply synchronously before stamping. */
|
|
473
474
|
export type OnFlush = (flush: {
|
|
474
475
|
commands: readonly OutboxEntry[];
|
|
475
476
|
reason: FlushReason;
|
|
@@ -508,7 +509,7 @@ export namespace StatewireClient {
|
|
|
508
509
|
| { fate: "expired"; cause: "server" }
|
|
509
510
|
| {
|
|
510
511
|
fate: "aborted";
|
|
511
|
-
cause: "unmount" | "stream-gone" | "session-lost" | "removed";
|
|
512
|
+
cause: "unmount" | "stream-gone" | "session-lost" | "removed" | "quota";
|
|
512
513
|
predecessorLost?: true;
|
|
513
514
|
}
|
|
514
515
|
| {
|
|
@@ -516,10 +517,15 @@ export namespace StatewireClient {
|
|
|
516
517
|
cause: "unmount" | "stream-gone" | "session-lost";
|
|
517
518
|
};
|
|
518
519
|
|
|
519
|
-
/**
|
|
520
|
+
/**
|
|
521
|
+
* Temporary and discouraged; removed in a later release. A proven record gap
|
|
522
|
+
* settles the lane's commands `{ fate: "lost", cause: "session-lost" }` and
|
|
523
|
+
* drops its outbox. Returning "requeue" instead re-enqueues this client's
|
|
524
|
+
* lost stamps (fresh ids, at the tail) and keeps the outbox.
|
|
525
|
+
*/
|
|
520
526
|
export type OnSessionLost = (
|
|
521
527
|
pending: ReadonlyArray<{
|
|
522
|
-
id
|
|
528
|
+
id: string;
|
|
523
529
|
method: string;
|
|
524
530
|
params: readonly unknown[];
|
|
525
531
|
ts: number;
|
|
@@ -528,19 +534,18 @@ export namespace StatewireClient {
|
|
|
528
534
|
}>,
|
|
529
535
|
) => "requeue" | "drop";
|
|
530
536
|
|
|
531
|
-
/**
|
|
532
|
-
export type
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
readonly ts: number;
|
|
540
|
-
readonly seq: number;
|
|
541
|
-
};
|
|
537
|
+
/** Reports a deprecated code path firing, so its use can be measured before removal. */
|
|
538
|
+
export type OnDeprecatedPath = (event: {
|
|
539
|
+
kind: "session-lost-requeue";
|
|
540
|
+
count: number;
|
|
541
|
+
}) => void;
|
|
542
|
+
|
|
543
|
+
/** Delivers one durable `settled` update; acked once the returned promise resolves. */
|
|
544
|
+
export type OnSettled = (update: Statewire.CommandUpdate) => unknown;
|
|
542
545
|
|
|
543
|
-
|
|
546
|
+
export type OutboxState = "queued" | "held" | "sending";
|
|
547
|
+
|
|
548
|
+
/** A queued command as persisted. */
|
|
544
549
|
export type OutboxCommand = {
|
|
545
550
|
id: string;
|
|
546
551
|
produced?: {
|
|
@@ -549,30 +554,41 @@ export namespace StatewireClient {
|
|
|
549
554
|
};
|
|
550
555
|
method: string;
|
|
551
556
|
params: unknown[];
|
|
557
|
+
/** Encoding of non-JSON params. */
|
|
552
558
|
meta?: unknown;
|
|
559
|
+
/** Enqueue time. */
|
|
553
560
|
ts: number;
|
|
554
561
|
/** Present while held. */
|
|
555
|
-
|
|
562
|
+
held?: true;
|
|
563
|
+
localMeta?: unknown;
|
|
556
564
|
/** Its hold was released; the next flush reports reason "release". */
|
|
557
565
|
released?: true;
|
|
558
566
|
/** The enqueuing sharer's `SessionStorage.owner.id` (shared storage only). */
|
|
559
567
|
by?: string;
|
|
560
568
|
};
|
|
561
569
|
|
|
562
|
-
/**
|
|
570
|
+
/** An unsettled command, in queue order. */
|
|
563
571
|
export type OutboxEntry = {
|
|
564
572
|
readonly id: string;
|
|
565
573
|
readonly protocol: string;
|
|
566
|
-
readonly command: {
|
|
567
|
-
|
|
568
|
-
|
|
574
|
+
readonly command: {
|
|
575
|
+
readonly method: string;
|
|
576
|
+
readonly params: readonly unknown[];
|
|
577
|
+
};
|
|
578
|
+
/** Enqueue time. */
|
|
579
|
+
readonly ts: number;
|
|
580
|
+
readonly state: OutboxState;
|
|
581
|
+
/** Client-only, persisted, never sent. */
|
|
582
|
+
readonly localMeta: unknown;
|
|
583
|
+
/** False when the entry lives in page memory only. */
|
|
584
|
+
readonly durable: boolean;
|
|
569
585
|
};
|
|
570
586
|
|
|
571
|
-
/** Whether `entry` may move ahead of `ahead`, a held entry or one waiting behind
|
|
587
|
+
/** Whether `entry` may move ahead of `ahead`, a held entry or one waiting behind one. */
|
|
572
588
|
export type CanOvertake = (entry: OutboxEntry, ahead: OutboxEntry) => boolean;
|
|
573
589
|
|
|
574
590
|
/** A stamped command as persisted while un-durable: its lane seq, the sharer that stamped it, plus the wire statement. */
|
|
575
|
-
export type SessionCommand = Omit<OutboxCommand, "
|
|
591
|
+
export type SessionCommand = Omit<OutboxCommand, "held" | "released"> & {
|
|
576
592
|
seq: number;
|
|
577
593
|
/** The stamping sharer's `SessionStorage.owner.id` (shared storage only). */
|
|
578
594
|
owner?: string;
|
|
@@ -583,12 +599,26 @@ export namespace StatewireClient {
|
|
|
583
599
|
nextSeq: number;
|
|
584
600
|
commands: readonly SessionCommand[];
|
|
585
601
|
outbox?: readonly OutboxCommand[];
|
|
586
|
-
/** Entries another sharer
|
|
587
|
-
removed?: readonly { id: string; by: string }[];
|
|
602
|
+
/** Entries another sharer settled unsent, kept until their enqueuer (`by`) rejects them or is gone (shared storage only); no failure means removed. */
|
|
603
|
+
removed?: readonly { id: string; by: string; failure?: SendFailure }[];
|
|
588
604
|
/** Terminal verdicts parked for the sharer that minted the seq (shared storage only). */
|
|
589
605
|
verdicts?: readonly SessionVerdict[];
|
|
590
|
-
/**
|
|
591
|
-
|
|
606
|
+
/** Terminal `settled` updates awaiting their `onCommandChange` ack. */
|
|
607
|
+
inbox?: readonly StoredSettled[];
|
|
608
|
+
};
|
|
609
|
+
|
|
610
|
+
/** A terminal update as persisted: the stored command plus its outcome. */
|
|
611
|
+
export type StoredSettled = {
|
|
612
|
+
id: string;
|
|
613
|
+
method: string;
|
|
614
|
+
params: unknown[];
|
|
615
|
+
meta?: unknown;
|
|
616
|
+
ts: number;
|
|
617
|
+
seq?: number;
|
|
618
|
+
localMeta?: unknown;
|
|
619
|
+
/** The accepted result, encoded as a one-element param list. */
|
|
620
|
+
result?: { value: unknown[]; meta?: unknown };
|
|
621
|
+
failure?: SendFailure;
|
|
592
622
|
};
|
|
593
623
|
|
|
594
624
|
/** The persisted session: the client identity plus one lane per durable protocol. */
|
|
@@ -611,6 +641,10 @@ export namespace StatewireClient {
|
|
|
611
641
|
|
|
612
642
|
/** Session storage seam with optional asynchronous hydration and persistence. */
|
|
613
643
|
export type SessionStorage = {
|
|
644
|
+
/** False when the record lives in page memory only. */
|
|
645
|
+
durable?: boolean;
|
|
646
|
+
/** Re-reads the stored record; awaited under `lock` when present. */
|
|
647
|
+
read?: () => Promise<SessionRecord | null>;
|
|
614
648
|
/** Resolves when a synchronous load can return the hydrated record. */
|
|
615
649
|
ready?: Promise<void>;
|
|
616
650
|
/** Resolves after all queued saves are persisted; awaited before sending statements. */
|
|
@@ -631,6 +665,17 @@ export namespace StatewireClient {
|
|
|
631
665
|
id: string;
|
|
632
666
|
whenGone: (id: string, signal: AbortSignal) => Promise<void>;
|
|
633
667
|
};
|
|
668
|
+
/** Exclusive across sharers while a durable `settled` update is delivered; required alongside `lock`. */
|
|
669
|
+
notifyLock?: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
670
|
+
/** Reports failure notices the session dropped. */
|
|
671
|
+
onEvict?: (event: Omit<EvictEvent, "key">) => void;
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
export type EvictEvent = {
|
|
675
|
+
key: string;
|
|
676
|
+
reason: "expired" | "overflow" | "quota" | "inbox";
|
|
677
|
+
/** The undelivered `settled` updates that went with it. */
|
|
678
|
+
dropped: readonly Statewire.CommandUpdate[];
|
|
634
679
|
};
|
|
635
680
|
|
|
636
681
|
/** Static headers, or a resolver invoked per request/attach; `staleAuth` is true right after an auth rejection. */
|
|
@@ -648,6 +693,7 @@ export namespace StatewireClient {
|
|
|
648
693
|
headers?: HeadersOption;
|
|
649
694
|
fetch?: typeof globalThis.fetch;
|
|
650
695
|
onSessionLost?: OnSessionLost;
|
|
696
|
+
onDeprecatedPath?: OnDeprecatedPath;
|
|
651
697
|
onFlush?: OnFlush;
|
|
652
698
|
/** Per protocol: lets an outbox entry move ahead of held or waiting ones; default never. */
|
|
653
699
|
canOvertake?: Readonly<Record<string, CanOvertake>>;
|
|
@@ -669,6 +715,7 @@ export namespace StatewireClient {
|
|
|
669
715
|
| (() => Record<string, string> | Promise<Record<string, string>>);
|
|
670
716
|
webSocket?: new (url: string, protocols?: string | string[]) => WebSocket;
|
|
671
717
|
onSessionLost?: OnSessionLost;
|
|
718
|
+
onDeprecatedPath?: OnDeprecatedPath;
|
|
672
719
|
onFlush?: OnFlush;
|
|
673
720
|
/** Per protocol: lets an outbox entry move ahead of held or waiting ones; default never. */
|
|
674
721
|
canOvertake?: Readonly<Record<string, CanOvertake>>;
|