experimental-a2 0.2.0 → 0.4.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.
- package/CHANGELOG.md +169 -0
- package/dist/ai-B4YhEnfw.d.ts +333 -0
- package/dist/ai-server.d.ts +48 -9
- package/dist/ai-server.js +121 -49
- package/dist/ai.d.ts +2 -303
- package/dist/ai.js +231 -86
- package/dist/cli-B3VuxoDe.js +597 -0
- package/dist/cli-bin.d.ts +1 -0
- package/dist/cli-bin.js +5 -0
- package/dist/cli.d.ts +19 -0
- package/dist/cli.js +2 -0
- package/dist/client-BrfDXQ8A.d.ts +155 -0
- package/dist/client-Bt4tAKi9.js +798 -0
- package/dist/client.d.ts +2 -90
- package/dist/client.js +1 -409
- package/dist/{contract-CG_adnu_.js → contract-48bUMgcL.js} +10 -2
- package/dist/{contract-C_3dIIEU.d.ts → contract-jIfaR085.d.ts} +62 -8
- package/dist/devtools-J_jZ2vQf.d.ts +151 -0
- package/dist/devtools-kJJaORn-.js +338 -0
- package/dist/devtools-server.browser.js +1 -1
- package/dist/devtools-server.d.ts +2 -2
- package/dist/devtools-server.js +224 -43
- package/dist/devtools.d.ts +2 -0
- package/dist/devtools.js +2 -0
- package/dist/{errors-BJRMd-h6.js → errors-BQuJpe82.js} +4 -4
- package/dist/{errors-xL_JTXsY.d.ts → errors-W6nwJ-fm.d.ts} +1 -1
- package/dist/http.d.ts +71 -13
- package/dist/http.js +302 -41
- package/dist/{idempotent-replay-BMyHrP0L.js → idempotent-replay-DuqEkYA7.js} +2 -2
- package/dist/index.d.ts +5 -5
- package/dist/index.js +2 -2
- package/dist/{inspection-E7qbD0Xj.js → inspection-DaxB5jM2.js} +2 -1
- package/dist/internal-aEotMzu_.js +209 -0
- package/dist/otel.d.ts +1 -1
- package/dist/platform-B4TnJtWu.js +32 -0
- package/dist/react.d.ts +37 -14
- package/dist/react.js +26 -15
- package/dist/scheduler-qstash.d.ts +78 -0
- package/dist/scheduler-qstash.js +499 -0
- package/dist/scheduler-task-BpzhPnRS.js +54 -0
- package/dist/{recovery-vercel.d.ts → scheduler-vercel.d.ts} +17 -24
- package/dist/scheduler-vercel.js +226 -0
- package/dist/server-CcNnFnoW.js +1405 -0
- package/dist/server-YtPq7hjw.d.ts +260 -0
- package/dist/server.d.ts +4 -155
- package/dist/server.js +2 -2
- package/dist/{log-ldf5g8Cx.d.ts → store-C3sNAaBT.d.ts} +111 -35
- package/dist/{log-yJbXUf72.js → store-N8PXxDAS.js} +1 -1
- package/dist/store-codec-DTG0Ftek.js +8 -0
- package/dist/store-memory.d.ts +11 -0
- package/dist/{log-memory.js → store-memory.js} +127 -24
- package/dist/{log-polling-6COoN60V.js → store-polling-DgrrAE3d.js} +7 -6
- package/dist/{log-postgres.d.ts → store-postgres.d.ts} +6 -6
- package/dist/{log-postgres.js → store-postgres.js} +158 -24
- package/dist/{log-redis.js → store-redis-core-DWqx3F47.js} +294 -156
- package/dist/store-redis-http.d.ts +21 -0
- package/dist/store-redis-http.js +70 -0
- package/dist/store-redis.d.ts +37 -0
- package/dist/store-redis.js +298 -0
- package/dist/{log-sqlite.d.ts → store-sqlite.d.ts} +6 -6
- package/dist/{log-sqlite.js → store-sqlite.js} +116 -22
- package/dist/{telemetry-Cso0qyHQ.d.ts → telemetry-BjYHTfh2.d.ts} +1 -1
- package/dist/testing.browser.d.ts +1 -0
- package/dist/testing.browser.js +4 -0
- package/dist/testing.d.ts +31 -0
- package/dist/testing.js +101 -0
- package/dist/wire-DCUZBUlT.js +222 -0
- package/docs/01-quickstart.mdx +4 -5
- package/docs/concepts/01-contracts.mdx +21 -17
- package/docs/concepts/02-handlers.mdx +7 -7
- package/docs/concepts/03-durability.mdx +26 -29
- package/docs/concepts/04-state.mdx +18 -21
- package/docs/guides/01-timers.mdx +154 -54
- package/docs/guides/02-cancellation.mdx +30 -4
- package/docs/guides/03-react.mdx +20 -21
- package/docs/guides/04-local-first.mdx +1 -1
- package/docs/guides/05-production.mdx +321 -60
- package/docs/guides/06-ai-agents.mdx +249 -49
- package/docs/guides/07-devtools.mdx +137 -12
- package/docs/guides/08-application-data.mdx +1 -1
- package/docs/guides/09-presence.mdx +284 -0
- package/docs/guides/10-transports.mdx +131 -0
- package/docs/index.mdx +22 -46
- package/docs/reference/01-api.mdx +751 -102
- package/docs/reference/02-errors.mdx +10 -5
- package/package.json +23 -6
- package/dist/internal-D6wNxTck.js +0 -36
- package/dist/log-memory.d.ts +0 -11
- package/dist/log-redis.d.ts +0 -31
- package/dist/recovery-vercel.js +0 -119
- package/dist/server-DJgD2YWP.js +0 -877
- package/dist/wire-BVsgR8o9.js +0 -62
package/dist/client.d.ts
CHANGED
|
@@ -1,90 +1,2 @@
|
|
|
1
|
-
import { a as
|
|
2
|
-
|
|
3
|
-
type ConnectionStatus = "idle" | "connecting" | "live" | "closed";
|
|
4
|
-
/**
|
|
5
|
-
* The connection, as a discriminated union — impossible states are
|
|
6
|
-
* unrepresentable: an error only exists while disconnected,
|
|
7
|
-
* `reconnects` only once a connection has been attempted.
|
|
8
|
-
* "Reconnecting…" is `status === 'connecting' && reconnects > 0`.
|
|
9
|
-
*/
|
|
10
|
-
type Connection = {
|
|
11
|
-
status: "idle";
|
|
12
|
-
} | {
|
|
13
|
-
status: "connecting";
|
|
14
|
-
/** Drops of an established stream so far. `0` = first connect. */
|
|
15
|
-
reconnects: number;
|
|
16
|
-
/** Why the last connection ended; `null` on the first connect. */
|
|
17
|
-
error: Error | null;
|
|
18
|
-
} | {
|
|
19
|
-
status: "live";
|
|
20
|
-
reconnects: number;
|
|
21
|
-
} | {
|
|
22
|
-
status: "closed";
|
|
23
|
-
};
|
|
24
|
-
/**
|
|
25
|
-
* What `push` returns: resolves at the server ack (exactly like a
|
|
26
|
-
* plain promise — `await push(...)` gives the acked events), and
|
|
27
|
-
* carries `confirmed` for the later moment when the live stream has
|
|
28
|
-
* delivered the whole batch back and the optimistic overlay entry
|
|
29
|
-
* retired — the view now shows server truth. `confirmed` is lazy:
|
|
30
|
-
* never accessed, never created. A rejected push rejects both.
|
|
31
|
-
*/
|
|
32
|
-
type PushResult<D extends EventDefs> = Promise<ContractEvent<D>[]> & {
|
|
33
|
-
readonly confirmed: Promise<ContractEvent<D>[]>;
|
|
34
|
-
};
|
|
35
|
-
/** One immutable view of the session — stable identity between changes. */
|
|
36
|
-
type SessionSnapshot<D extends EventDefs, S> = {
|
|
37
|
-
/** The live view: server events folded, optimistic pushes applied. */
|
|
38
|
-
state: S;
|
|
39
|
-
/** The observed feed `state` is folded from — server truth plus
|
|
40
|
-
* pending optimistic events (provisional indexes past the frontier). */
|
|
41
|
-
events: ContractEvent<D>[];
|
|
42
|
-
/** The stream frontier: the last server-confirmed index. This is the
|
|
43
|
-
* `lastSeenIndex` cancellation wants. */
|
|
44
|
-
index: number;
|
|
45
|
-
connection: Connection;
|
|
46
|
-
};
|
|
47
|
-
type SessionClient<D extends EventDefs, S> = {
|
|
48
|
-
readonly sessionId: string;
|
|
49
|
-
subscribe(listener: () => void): () => void;
|
|
50
|
-
getSnapshot(): SessionSnapshot<D, S>;
|
|
51
|
-
/**
|
|
52
|
-
* Optimistic append: validates locally against the reducer's event
|
|
53
|
-
* schemas (instant `INVALID_PAYLOAD`, no flicker), applies to the
|
|
54
|
-
* local fold, POSTs, swaps in the ack, rolls back on rejection.
|
|
55
|
-
* Auto-retries only `LOG_UNAVAILABLE`. Resolves with the appended
|
|
56
|
-
* events as the server recorded them.
|
|
57
|
-
*/
|
|
58
|
-
push(...events: AppendInput<D>[]): PushResult<D>;
|
|
59
|
-
/** Open the live stream (idempotent while open). Reconnects with
|
|
60
|
-
* backoff and resumes from the frontier until `close()`. */
|
|
61
|
-
connect(): void;
|
|
62
|
-
/**
|
|
63
|
-
* Stop the live stream. Not terminal: `connect()` starts it again
|
|
64
|
-
* from the current frontier — which is what makes the React
|
|
65
|
-
* StrictMode mount dance (setup → cleanup → setup) work.
|
|
66
|
-
*/
|
|
67
|
-
close(): void;
|
|
68
|
-
};
|
|
69
|
-
type SessionOptions<D extends EventDefs, S> = {
|
|
70
|
-
initialState?: S;
|
|
71
|
-
initialIndex?: number;
|
|
72
|
-
/** Server-rendered history through `initialIndex`. Seeds the event feed. */
|
|
73
|
-
initialEvents?: ContractEvent<D>[];
|
|
74
|
-
};
|
|
75
|
-
type A2Client<D extends EventDefs, S> = {
|
|
76
|
-
session(sessionId: string, options?: SessionOptions<D, S>): SessionClient<D, S>;
|
|
77
|
-
};
|
|
78
|
-
type CreateClientOptions<D extends EventDefs, S> = {
|
|
79
|
-
reducer: Reducer<D, S>;
|
|
80
|
-
/** Base path (or absolute URL) of the route exposing GET/POST. */
|
|
81
|
-
api: string;
|
|
82
|
-
/** Injectable fetch — defaults to the global. */
|
|
83
|
-
fetch?: typeof globalThis.fetch;
|
|
84
|
-
/** How long an idle session keeps its in-memory identity, in
|
|
85
|
-
* milliseconds. Defaults to five minutes; `Infinity` disables GC. */
|
|
86
|
-
gcTime?: number;
|
|
87
|
-
};
|
|
88
|
-
declare function createClient<D extends EventDefs, S>(options: CreateClientOptions<D, S>): A2Client<D, S>;
|
|
89
|
-
//#endregion
|
|
90
|
-
export { A2Client, Connection, ConnectionStatus, CreateClientOptions, PushResult, SessionClient, SessionOptions, SessionSnapshot, createClient };
|
|
1
|
+
import { a as Connection, c as PushResult, d as SessionOptions, f as SessionSnapshot, i as ClientWebSocketConstructor, l as SessionClient, n as ClientApi, o as ConnectionStatus, p as createClient, r as ClientWebSocket, s as CreateClientOptions, t as A2Client, u as SessionClientPresence } from "./client-BrfDXQ8A.js";
|
|
2
|
+
export { A2Client, ClientApi, ClientWebSocket, ClientWebSocketConstructor, Connection, ConnectionStatus, CreateClientOptions, PushResult, SessionClient, SessionClientPresence, SessionOptions, SessionSnapshot, createClient };
|
package/dist/client.js
CHANGED
|
@@ -1,410 +1,2 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { t as A2Error } from "./errors-BJRMd-h6.js";
|
|
3
|
-
import { r as STREAM_TIMINGS } from "./internal-D6wNxTck.js";
|
|
4
|
-
import { i as eventFromWire, o as isWireEvent, t as errorFromWire } from "./wire-BVsgR8o9.js";
|
|
5
|
-
//#region src/client.ts
|
|
6
|
-
/**
|
|
7
|
-
* experimental-a2/client — the framework-agnostic session client.
|
|
8
|
-
*
|
|
9
|
-
* Everything the browser needs to read a session live and push
|
|
10
|
-
* optimistically, with no framework attached: the SSE subscription with
|
|
11
|
-
* frontier resume and reconnection, the optimistic push queue with
|
|
12
|
-
* ack/rollback, and the local fold through the same reducer the server
|
|
13
|
-
* uses. `experimental-a2/react`'s `createReact` is a thin binding over it, and the
|
|
14
|
-
* store contract (`subscribe`/`getSnapshot`) is exactly what
|
|
15
|
-
* `useSyncExternalStore` wants.
|
|
16
|
-
*
|
|
17
|
-
* The client is a replica, never an access path — the routes it talks
|
|
18
|
-
* to authorize every read and write.
|
|
19
|
-
*/
|
|
20
|
-
const PUSH_ATTEMPTS = 3;
|
|
21
|
-
const RECONNECT_BASE_MS = 500;
|
|
22
|
-
const RECONNECT_MAX_MS = 5e3;
|
|
23
|
-
const DEFAULT_GC_TIME_MS = 3e5;
|
|
24
|
-
function createClient(options) {
|
|
25
|
-
const { reducer, api } = options;
|
|
26
|
-
const fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
27
|
-
const gcTime = options.gcTime ?? DEFAULT_GC_TIME_MS;
|
|
28
|
-
if (Number.isNaN(gcTime) || gcTime < 0) throw new RangeError("gcTime must be a non-negative number or Infinity");
|
|
29
|
-
const sessions = /* @__PURE__ */ new Map();
|
|
30
|
-
const scheduleGc = (sessionId, delay = gcTime) => {
|
|
31
|
-
const entry = sessions.get(sessionId);
|
|
32
|
-
if (!entry) return;
|
|
33
|
-
clearTimeout(entry.timer);
|
|
34
|
-
if (gcTime === Infinity) return;
|
|
35
|
-
entry.timer = setTimeout(() => {
|
|
36
|
-
if (sessions.get(sessionId) !== entry) return;
|
|
37
|
-
if (entry.runtime.canEvict()) {
|
|
38
|
-
sessions.delete(sessionId);
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
scheduleGc(sessionId, Math.max(gcTime, 1e3));
|
|
42
|
-
}, delay);
|
|
43
|
-
entry.timer.unref?.();
|
|
44
|
-
};
|
|
45
|
-
const touch = (sessionId) => {
|
|
46
|
-
scheduleGc(sessionId);
|
|
47
|
-
};
|
|
48
|
-
const makeSession = (sessionId, sessionOptions) => {
|
|
49
|
-
let frontier = sessionOptions?.initialIndex ?? 0;
|
|
50
|
-
let foldedState = sessionOptions?.initialState !== void 0 ? sessionOptions.initialState : reducer.initialState;
|
|
51
|
-
const serverEvents = (sessionOptions?.initialEvents ?? []).filter((event) => event.index <= frontier).toSorted((a, b) => a.index - b.index);
|
|
52
|
-
let pending = [];
|
|
53
|
-
const listeners = /* @__PURE__ */ new Set();
|
|
54
|
-
let snapshot = null;
|
|
55
|
-
let status = "idle";
|
|
56
|
-
let reconnects = 0;
|
|
57
|
-
let lastError = null;
|
|
58
|
-
let inFlightPushes = 0;
|
|
59
|
-
let deferredHydration;
|
|
60
|
-
let hydrationNotification;
|
|
61
|
-
const emit = () => {
|
|
62
|
-
for (const listener of [...listeners]) listener();
|
|
63
|
-
};
|
|
64
|
-
const notify = () => {
|
|
65
|
-
snapshot = null;
|
|
66
|
-
emit();
|
|
67
|
-
};
|
|
68
|
-
const notifyHydrated = () => {
|
|
69
|
-
snapshot = null;
|
|
70
|
-
clearTimeout(hydrationNotification);
|
|
71
|
-
hydrationNotification = setTimeout(emit, 0);
|
|
72
|
-
hydrationNotification.unref?.();
|
|
73
|
-
};
|
|
74
|
-
const mergeServerEvents = (events, throughIndex) => {
|
|
75
|
-
if (!events || events.length === 0) return false;
|
|
76
|
-
const byIndex = new Map(serverEvents.map((event) => [event.index, event]));
|
|
77
|
-
let changed = false;
|
|
78
|
-
for (const event of events) {
|
|
79
|
-
if (event.index > throughIndex) continue;
|
|
80
|
-
if (byIndex.get(event.index)?.id === event.id) continue;
|
|
81
|
-
byIndex.set(event.index, event);
|
|
82
|
-
changed = true;
|
|
83
|
-
}
|
|
84
|
-
if (!changed) return false;
|
|
85
|
-
serverEvents.splice(0, serverEvents.length, ...[...byIndex.values()].toSorted((a, b) => a.index - b.index));
|
|
86
|
-
return true;
|
|
87
|
-
};
|
|
88
|
-
const overlayEvents = () => {
|
|
89
|
-
const acked = pending.filter((p) => p.acked).map((p) => p.acked).toSorted((a, b) => a.index - b.index);
|
|
90
|
-
const maxKnown = acked.at(-1)?.index ?? frontier;
|
|
91
|
-
const unacked = pending.filter((p) => !p.acked).map((p, i) => ({
|
|
92
|
-
id: p.id,
|
|
93
|
-
type: p.type,
|
|
94
|
-
payload: p.payload,
|
|
95
|
-
index: maxKnown + 1 + i,
|
|
96
|
-
sessionId,
|
|
97
|
-
createdAt: p.createdAt
|
|
98
|
-
}));
|
|
99
|
-
return [...acked, ...unacked];
|
|
100
|
-
};
|
|
101
|
-
const connection = () => {
|
|
102
|
-
switch (status) {
|
|
103
|
-
case "idle": return { status: "idle" };
|
|
104
|
-
case "closed": return { status: "closed" };
|
|
105
|
-
case "live": return {
|
|
106
|
-
status: "live",
|
|
107
|
-
reconnects
|
|
108
|
-
};
|
|
109
|
-
case "connecting": return {
|
|
110
|
-
status: "connecting",
|
|
111
|
-
reconnects,
|
|
112
|
-
error: lastError
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
const buildSnapshot = () => {
|
|
117
|
-
const overlay = overlayEvents();
|
|
118
|
-
let state = foldedState;
|
|
119
|
-
for (const event of overlay) state = reducer.fold(state, event);
|
|
120
|
-
return {
|
|
121
|
-
state,
|
|
122
|
-
events: [...serverEvents, ...overlay],
|
|
123
|
-
index: frontier,
|
|
124
|
-
connection: connection()
|
|
125
|
-
};
|
|
126
|
-
};
|
|
127
|
-
/** Pushes awaiting stream confirmation — resolved by `ingest` the
|
|
128
|
-
* moment the frontier passes their batch. */
|
|
129
|
-
let confirmWatchers = [];
|
|
130
|
-
/** The single ingest point: every server-confirmed event, in log
|
|
131
|
-
* order, from the stream. */
|
|
132
|
-
const ingest = (event) => {
|
|
133
|
-
if (event.index <= frontier) return;
|
|
134
|
-
frontier = event.index;
|
|
135
|
-
serverEvents.push(event);
|
|
136
|
-
foldedState = reducer.fold(foldedState, event);
|
|
137
|
-
pending = pending.filter((p) => p.id !== event.id);
|
|
138
|
-
if (confirmWatchers.some((w) => w.index <= frontier)) {
|
|
139
|
-
const due = confirmWatchers.filter((w) => w.index <= frontier);
|
|
140
|
-
confirmWatchers = confirmWatchers.filter((w) => w.index > frontier);
|
|
141
|
-
for (const watcher of due) watcher.resolve();
|
|
142
|
-
}
|
|
143
|
-
notify();
|
|
144
|
-
touch(sessionId);
|
|
145
|
-
};
|
|
146
|
-
const validated = (events) => events.map((event) => {
|
|
147
|
-
const schema = Object.hasOwn(reducer.events, event.type) ? reducer.events[event.type] : void 0;
|
|
148
|
-
if (!schema) throw new A2Error("UNKNOWN_EVENT_TYPE", `no event type '${String(event.type)}' in the reducer's vocabulary`);
|
|
149
|
-
const result = validateSync(schema, event.payload, `event '${String(event.type)}'`);
|
|
150
|
-
if (result.issues) throw new A2Error("INVALID_PAYLOAD", `invalid payload for event '${String(event.type)}'`, { details: result.issues });
|
|
151
|
-
return {
|
|
152
|
-
id: event.id ?? crypto.randomUUID(),
|
|
153
|
-
type: event.type,
|
|
154
|
-
payload: result.value
|
|
155
|
-
};
|
|
156
|
-
});
|
|
157
|
-
const post = async (body) => {
|
|
158
|
-
let lastPushError = new A2Error("LOG_UNAVAILABLE", "push failed");
|
|
159
|
-
for (let attempt = 1; attempt <= PUSH_ATTEMPTS; attempt += 1) {
|
|
160
|
-
try {
|
|
161
|
-
const res = await fetchImpl(api, {
|
|
162
|
-
method: "POST",
|
|
163
|
-
headers: { "content-type": "application/json" },
|
|
164
|
-
body: JSON.stringify(body)
|
|
165
|
-
});
|
|
166
|
-
if (res.ok) {
|
|
167
|
-
const rows = await res.json();
|
|
168
|
-
if (!Array.isArray(rows) || !rows.every(isWireEvent)) throw new A2Error("LOG_UNAVAILABLE", "push ack was not a list of events");
|
|
169
|
-
return rows.map((row) => eventFromWire(row));
|
|
170
|
-
}
|
|
171
|
-
lastPushError = errorFromWire(await res.json().catch(() => null)) ?? new A2Error("LOG_UNAVAILABLE", `push failed with ${res.status}`);
|
|
172
|
-
} catch (err) {
|
|
173
|
-
lastPushError = err instanceof A2Error ? err : new A2Error("LOG_UNAVAILABLE", "push request failed", { cause: err });
|
|
174
|
-
}
|
|
175
|
-
if (lastPushError.code !== "LOG_UNAVAILABLE") throw lastPushError;
|
|
176
|
-
if (attempt < PUSH_ATTEMPTS) await new Promise((resolve) => setTimeout(resolve, 250 * attempt));
|
|
177
|
-
}
|
|
178
|
-
throw lastPushError;
|
|
179
|
-
};
|
|
180
|
-
/**
|
|
181
|
-
* Decorate the ack promise into a PushResult. `confirmed` is a
|
|
182
|
-
* lazy getter — materialized on first access, so callers that
|
|
183
|
-
* ignore it can't leak an unhandled rejection.
|
|
184
|
-
*/
|
|
185
|
-
const withConfirmed = (ack) => {
|
|
186
|
-
let confirmed;
|
|
187
|
-
return Object.defineProperty(ack, "confirmed", { get() {
|
|
188
|
-
confirmed ??= ack.then((acked) => new Promise((resolve) => {
|
|
189
|
-
const last = acked.at(-1)?.index ?? 0;
|
|
190
|
-
if (frontier >= last) {
|
|
191
|
-
resolve(acked);
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
confirmWatchers.push({
|
|
195
|
-
index: last,
|
|
196
|
-
resolve: () => resolve(acked)
|
|
197
|
-
});
|
|
198
|
-
}));
|
|
199
|
-
return confirmed;
|
|
200
|
-
} });
|
|
201
|
-
};
|
|
202
|
-
const push = (...events) => {
|
|
203
|
-
touch(sessionId);
|
|
204
|
-
return withConfirmed((async () => {
|
|
205
|
-
if (events.length === 0) throw new TypeError("push requires at least one event");
|
|
206
|
-
const entries = validated(events).map((e) => ({
|
|
207
|
-
id: e.id,
|
|
208
|
-
type: e.type,
|
|
209
|
-
payload: e.payload,
|
|
210
|
-
createdAt: /* @__PURE__ */ new Date()
|
|
211
|
-
}));
|
|
212
|
-
pending.push(...entries);
|
|
213
|
-
inFlightPushes += 1;
|
|
214
|
-
notify();
|
|
215
|
-
try {
|
|
216
|
-
const acked = await post({
|
|
217
|
-
sessionId,
|
|
218
|
-
events: entries.map(({ id, type, payload }) => ({
|
|
219
|
-
id,
|
|
220
|
-
type,
|
|
221
|
-
payload
|
|
222
|
-
}))
|
|
223
|
-
});
|
|
224
|
-
for (const event of acked) {
|
|
225
|
-
const entry = pending.find((p) => p.id === event.id);
|
|
226
|
-
if (entry) entry.acked = event;
|
|
227
|
-
}
|
|
228
|
-
notify();
|
|
229
|
-
return acked;
|
|
230
|
-
} catch (err) {
|
|
231
|
-
const ids = new Set(entries.map((e) => e.id));
|
|
232
|
-
pending = pending.filter((p) => !ids.has(p.id));
|
|
233
|
-
notify();
|
|
234
|
-
throw err;
|
|
235
|
-
} finally {
|
|
236
|
-
inFlightPushes -= 1;
|
|
237
|
-
if (inFlightPushes === 0 && deferredHydration) {
|
|
238
|
-
const next = deferredHydration;
|
|
239
|
-
deferredHydration = void 0;
|
|
240
|
-
runtime.hydrate(next);
|
|
241
|
-
}
|
|
242
|
-
touch(sessionId);
|
|
243
|
-
}
|
|
244
|
-
})());
|
|
245
|
-
};
|
|
246
|
-
let generation = 0;
|
|
247
|
-
let active = false;
|
|
248
|
-
let abort = null;
|
|
249
|
-
const streamUrl = () => {
|
|
250
|
-
const sep = api.includes("?") ? "&" : "?";
|
|
251
|
-
return `${api}${sep}sessionId=${encodeURIComponent(sessionId)}&index=${frontier}`;
|
|
252
|
-
};
|
|
253
|
-
const consume = async (body, onActivity) => {
|
|
254
|
-
const decoder = new TextDecoder();
|
|
255
|
-
const reader = body.getReader();
|
|
256
|
-
let buffer = "";
|
|
257
|
-
try {
|
|
258
|
-
for (;;) {
|
|
259
|
-
const { done, value } = await reader.read();
|
|
260
|
-
if (done) return;
|
|
261
|
-
onActivity();
|
|
262
|
-
buffer += decoder.decode(value, { stream: true });
|
|
263
|
-
for (;;) {
|
|
264
|
-
const boundary = buffer.indexOf("\n\n");
|
|
265
|
-
if (boundary === -1) break;
|
|
266
|
-
const frame = buffer.slice(0, boundary);
|
|
267
|
-
buffer = buffer.slice(boundary + 2);
|
|
268
|
-
const data = frame.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trimStart()).join("\n");
|
|
269
|
-
if (!data) continue;
|
|
270
|
-
const parsed = JSON.parse(data);
|
|
271
|
-
if (isWireEvent(parsed)) ingest(eventFromWire(parsed));
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
} finally {
|
|
275
|
-
reader.cancel().catch(() => {});
|
|
276
|
-
}
|
|
277
|
-
};
|
|
278
|
-
const runStream = async (run) => {
|
|
279
|
-
let backoff = RECONNECT_BASE_MS;
|
|
280
|
-
while (generation === run) {
|
|
281
|
-
const controller = new AbortController();
|
|
282
|
-
abort = controller;
|
|
283
|
-
let stall;
|
|
284
|
-
let stalled = false;
|
|
285
|
-
const armStall = () => {
|
|
286
|
-
clearTimeout(stall);
|
|
287
|
-
stall = setTimeout(() => {
|
|
288
|
-
stalled = true;
|
|
289
|
-
controller.abort();
|
|
290
|
-
}, STREAM_TIMINGS.stallTimeoutMs);
|
|
291
|
-
stall.unref?.();
|
|
292
|
-
};
|
|
293
|
-
let wasLive = false;
|
|
294
|
-
try {
|
|
295
|
-
const res = await fetchImpl(streamUrl(), {
|
|
296
|
-
headers: { accept: "text/event-stream" },
|
|
297
|
-
signal: controller.signal
|
|
298
|
-
});
|
|
299
|
-
if (!res.ok || !res.body) throw new Error(`stream failed with ${res.status}`);
|
|
300
|
-
if (generation !== run) break;
|
|
301
|
-
backoff = RECONNECT_BASE_MS;
|
|
302
|
-
wasLive = true;
|
|
303
|
-
status = "live";
|
|
304
|
-
lastError = null;
|
|
305
|
-
notify();
|
|
306
|
-
armStall();
|
|
307
|
-
await consume(res.body, armStall);
|
|
308
|
-
if (generation === run) lastError = null;
|
|
309
|
-
} catch (err) {
|
|
310
|
-
if (generation === run) lastError = stalled ? /* @__PURE__ */ new Error(`stream stalled: no data for ${STREAM_TIMINGS.stallTimeoutMs}ms`) : err instanceof Error ? err : new Error(String(err));
|
|
311
|
-
} finally {
|
|
312
|
-
clearTimeout(stall);
|
|
313
|
-
}
|
|
314
|
-
if (generation !== run) break;
|
|
315
|
-
if (wasLive) reconnects += 1;
|
|
316
|
-
status = "connecting";
|
|
317
|
-
notify();
|
|
318
|
-
await new Promise((resolve) => {
|
|
319
|
-
let settled = false;
|
|
320
|
-
const finish = () => {
|
|
321
|
-
if (settled) return;
|
|
322
|
-
settled = true;
|
|
323
|
-
clearTimeout(timer);
|
|
324
|
-
resolve();
|
|
325
|
-
};
|
|
326
|
-
const timer = setTimeout(finish, backoff);
|
|
327
|
-
timer.unref?.();
|
|
328
|
-
abort?.signal.addEventListener("abort", finish);
|
|
329
|
-
});
|
|
330
|
-
backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
|
|
331
|
-
}
|
|
332
|
-
};
|
|
333
|
-
const runtime = {
|
|
334
|
-
sessionId,
|
|
335
|
-
subscribe(listener) {
|
|
336
|
-
touch(sessionId);
|
|
337
|
-
listeners.add(listener);
|
|
338
|
-
return () => {
|
|
339
|
-
listeners.delete(listener);
|
|
340
|
-
touch(sessionId);
|
|
341
|
-
};
|
|
342
|
-
},
|
|
343
|
-
getSnapshot() {
|
|
344
|
-
snapshot ??= buildSnapshot();
|
|
345
|
-
return snapshot;
|
|
346
|
-
},
|
|
347
|
-
push,
|
|
348
|
-
connect() {
|
|
349
|
-
touch(sessionId);
|
|
350
|
-
if (active) return;
|
|
351
|
-
active = true;
|
|
352
|
-
status = "connecting";
|
|
353
|
-
notify();
|
|
354
|
-
runStream(generation);
|
|
355
|
-
},
|
|
356
|
-
close() {
|
|
357
|
-
touch(sessionId);
|
|
358
|
-
if (!active) return;
|
|
359
|
-
active = false;
|
|
360
|
-
generation += 1;
|
|
361
|
-
abort?.abort();
|
|
362
|
-
abort = null;
|
|
363
|
-
status = "closed";
|
|
364
|
-
notify();
|
|
365
|
-
},
|
|
366
|
-
hydrate(next) {
|
|
367
|
-
touch(sessionId);
|
|
368
|
-
if (!next) return;
|
|
369
|
-
const nextIndex = next.initialIndex ?? 0;
|
|
370
|
-
if (nextIndex > frontier && inFlightPushes > 0) {
|
|
371
|
-
if (!deferredHydration || nextIndex > (deferredHydration.initialIndex ?? 0)) deferredHydration = next;
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
const historyChanged = mergeServerEvents(next.initialEvents, Math.min(nextIndex, frontier));
|
|
375
|
-
if (next.initialState === void 0 || nextIndex <= frontier) {
|
|
376
|
-
if (historyChanged) notifyHydrated();
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
frontier = nextIndex;
|
|
380
|
-
foldedState = next.initialState;
|
|
381
|
-
mergeServerEvents(next.initialEvents, frontier);
|
|
382
|
-
pending = pending.filter((entry) => !entry.acked || entry.acked.index > frontier);
|
|
383
|
-
if (confirmWatchers.some((watcher) => watcher.index <= frontier)) {
|
|
384
|
-
const due = confirmWatchers.filter((watcher) => watcher.index <= frontier);
|
|
385
|
-
confirmWatchers = confirmWatchers.filter((watcher) => watcher.index > frontier);
|
|
386
|
-
for (const watcher of due) watcher.resolve();
|
|
387
|
-
}
|
|
388
|
-
notifyHydrated();
|
|
389
|
-
},
|
|
390
|
-
canEvict() {
|
|
391
|
-
return listeners.size === 0 && !active && inFlightPushes === 0 && confirmWatchers.length === 0;
|
|
392
|
-
}
|
|
393
|
-
};
|
|
394
|
-
return runtime;
|
|
395
|
-
};
|
|
396
|
-
return { session(sessionId, sessionOptions) {
|
|
397
|
-
const existing = sessions.get(sessionId);
|
|
398
|
-
if (existing) {
|
|
399
|
-
existing.runtime.hydrate(sessionOptions);
|
|
400
|
-
touch(sessionId);
|
|
401
|
-
return existing.runtime;
|
|
402
|
-
}
|
|
403
|
-
const runtime = makeSession(sessionId, sessionOptions);
|
|
404
|
-
sessions.set(sessionId, { runtime });
|
|
405
|
-
touch(sessionId);
|
|
406
|
-
return runtime;
|
|
407
|
-
} };
|
|
408
|
-
}
|
|
409
|
-
//#endregion
|
|
1
|
+
import { t as createClient } from "./client-Bt4tAKi9.js";
|
|
410
2
|
export { createClient };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { n as validateSync, t as assertSyncSchema } from "./validate-XKT4FSNn.js";
|
|
2
2
|
//#region src/reducer.ts
|
|
3
3
|
/** Internal — reducers are created through `contract.reducer(...).fold(...)`. */
|
|
4
|
-
function makeReducerBuilder(events, options) {
|
|
4
|
+
function makeReducerBuilder(events, presence, options) {
|
|
5
5
|
const { name } = options;
|
|
6
6
|
if (typeof name !== "string" || name.length === 0) throw new TypeError("reducer name must be a non-empty string — it identifies the reducer and keys cached snapshots");
|
|
7
7
|
let initialState = options.initialState;
|
|
@@ -15,6 +15,7 @@ function makeReducerBuilder(events, options) {
|
|
|
15
15
|
return {
|
|
16
16
|
name,
|
|
17
17
|
events,
|
|
18
|
+
presence,
|
|
18
19
|
fold,
|
|
19
20
|
initialState,
|
|
20
21
|
stateSchema: options.stateSchema
|
|
@@ -48,12 +49,19 @@ function contract(options) {
|
|
|
48
49
|
assertSyncSchema(schema, `event '${type}'`);
|
|
49
50
|
}
|
|
50
51
|
const events = Object.freeze({ ...defs });
|
|
52
|
+
const presenceDefs = options.presence ?? {};
|
|
53
|
+
for (const [field, schema] of Object.entries(presenceDefs)) {
|
|
54
|
+
if (typeof schema?.["~standard"]?.validate !== "function") throw new TypeError(`contract '${name}': the value for presence field '${field}' is not a Standard Schema (expected an object with '~standard')`);
|
|
55
|
+
assertSyncSchema(schema, `presence field '${field}'`);
|
|
56
|
+
}
|
|
57
|
+
const presence = Object.freeze({ ...presenceDefs });
|
|
51
58
|
return {
|
|
52
59
|
name,
|
|
53
60
|
events,
|
|
61
|
+
presence,
|
|
54
62
|
batch: (...items) => items,
|
|
55
63
|
reducer(reducerOptions) {
|
|
56
|
-
return makeReducerBuilder(events, reducerOptions);
|
|
64
|
+
return makeReducerBuilder(events, presence, reducerOptions);
|
|
57
65
|
}
|
|
58
66
|
};
|
|
59
67
|
}
|
|
@@ -72,11 +72,17 @@ declare namespace StandardSchemaV1 {
|
|
|
72
72
|
* the old name are ignored and the next read refolds from raw events.
|
|
73
73
|
* See docs/concepts/04-state.mdx.
|
|
74
74
|
*/
|
|
75
|
-
type Reducer<D extends EventDefs = EventDefs, S = unknown
|
|
75
|
+
type Reducer<D extends EventDefs = EventDefs, S = unknown, P extends PresenceDefs = Record<never, never>> = {
|
|
76
76
|
/** Identity and cache key. Rename when the fold's logic changes. */
|
|
77
77
|
readonly name: string;
|
|
78
78
|
/** The contract's vocabulary — types every event in the fold. */
|
|
79
79
|
readonly events: Readonly<D>;
|
|
80
|
+
/**
|
|
81
|
+
* The contract's presence vocabulary — carried because the reducer
|
|
82
|
+
* is the client's typed handle on the contract. The fold never sees
|
|
83
|
+
* presence.
|
|
84
|
+
*/
|
|
85
|
+
readonly presence: Readonly<P>;
|
|
80
86
|
readonly fold: (state: S, event: ContractEvent<D>) => S;
|
|
81
87
|
readonly initialState: S;
|
|
82
88
|
/**
|
|
@@ -88,13 +94,15 @@ type Reducer<D extends EventDefs = EventDefs, S = unknown> = {
|
|
|
88
94
|
readonly stateSchema: StandardSchemaV1<unknown, S> | undefined;
|
|
89
95
|
};
|
|
90
96
|
/** The second step of `contract.reducer` — see the method's doc. */
|
|
91
|
-
type ReducerBuilder<D extends EventDefs, S
|
|
92
|
-
fold(fold: (state: S, event: ContractEvent<D>) => S): Reducer<D, S>;
|
|
97
|
+
type ReducerBuilder<D extends EventDefs, S, P extends PresenceDefs = Record<never, never>> = {
|
|
98
|
+
fold(fold: (state: S, event: ContractEvent<D>) => S): Reducer<D, S, P>;
|
|
93
99
|
};
|
|
94
100
|
//#endregion
|
|
95
101
|
//#region src/contract.d.ts
|
|
96
102
|
/** The event vocabulary shape: event name → payload schema. */
|
|
97
103
|
type EventDefs = Record<string, StandardSchemaV1>;
|
|
104
|
+
/** The presence vocabulary shape: field name → value schema. */
|
|
105
|
+
type PresenceDefs = Record<string, StandardSchemaV1>;
|
|
98
106
|
/** One event of a contract, narrowed to type `K`. */
|
|
99
107
|
type ContractEvent<D extends EventDefs, K extends keyof D & string = keyof D & string> = { [T in keyof D & string]: {
|
|
100
108
|
id: string;
|
|
@@ -111,6 +119,44 @@ type AppendInput<D extends EventDefs> = { [T in keyof D & string]: {
|
|
|
111
119
|
id?: string;
|
|
112
120
|
}; }[keyof D & string];
|
|
113
121
|
type EventBatchFactory<D extends EventDefs> = <const E extends readonly AppendInput<D>[]>(...events: E) => E;
|
|
122
|
+
/**
|
|
123
|
+
* One presence update: a participant's changed fields (`null` marks a
|
|
124
|
+
* cleared one), stamped with the sender's stream frontier (`seen`).
|
|
125
|
+
* This is what `stream({ presence: true })` interleaves with events.
|
|
126
|
+
*/
|
|
127
|
+
type PresencePatch<P extends PresenceDefs = PresenceDefs> = {
|
|
128
|
+
participant: string;
|
|
129
|
+
values: { [F in keyof P & string]?: StandardSchemaV1.InferOutput<P[F]> | null; };
|
|
130
|
+
seen: number;
|
|
131
|
+
at: Date;
|
|
132
|
+
};
|
|
133
|
+
/**
|
|
134
|
+
* The replicated ephemeral map: participant → field → latest value,
|
|
135
|
+
* last-writer-wins per field. Never stored in the log; how long a
|
|
136
|
+
* value stays painted is view logic, decided at render time against
|
|
137
|
+
* `at`.
|
|
138
|
+
*/
|
|
139
|
+
type PresenceMap<P extends PresenceDefs = PresenceDefs> = {
|
|
140
|
+
[participant: string]: { [F in keyof P & string]?: {
|
|
141
|
+
value: StandardSchemaV1.InferOutput<P[F]>;
|
|
142
|
+
seen: number;
|
|
143
|
+
at: Date;
|
|
144
|
+
}; };
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* The one item a presence stream yields before live patches: the
|
|
148
|
+
* current pruned map, per-field stamps exact. Discriminate
|
|
149
|
+
* structurally (`'snapshot' in item`).
|
|
150
|
+
*/
|
|
151
|
+
type PresenceSnapshot<P extends PresenceDefs = PresenceDefs> = {
|
|
152
|
+
snapshot: PresenceMap<P>;
|
|
153
|
+
};
|
|
154
|
+
/**
|
|
155
|
+
* Intersect into a surface type to add `Members` only when the
|
|
156
|
+
* contract declares presence fields — on presence-less contracts the
|
|
157
|
+
* members do not exist, a type error rather than a runtime throw.
|
|
158
|
+
*/
|
|
159
|
+
type WithPresence<P extends PresenceDefs, Members> = keyof P extends never ? unknown : Members;
|
|
114
160
|
type ReducerOptions<Seed, S> = {
|
|
115
161
|
/**
|
|
116
162
|
* Identity and snapshot cache key. Rename it when the fold's logic
|
|
@@ -130,11 +176,13 @@ type ReducerOptions<Seed, S> = {
|
|
|
130
176
|
*/
|
|
131
177
|
stateSchema?: StandardSchemaV1<Seed, S>;
|
|
132
178
|
};
|
|
133
|
-
type Contract<D extends EventDefs = EventDefs
|
|
179
|
+
type Contract<D extends EventDefs = EventDefs, P extends PresenceDefs = Record<never, never>> = {
|
|
134
180
|
/** Identity — prefixes storage keys and queue messages. */
|
|
135
181
|
readonly name: string;
|
|
136
182
|
/** The vocabulary. */
|
|
137
183
|
readonly events: Readonly<D>;
|
|
184
|
+
/** The presence vocabulary — empty unless declared. */
|
|
185
|
+
readonly presence: Readonly<P>;
|
|
138
186
|
/** Preserve literal types for a handler-returned event batch. */
|
|
139
187
|
readonly batch: EventBatchFactory<D>;
|
|
140
188
|
/**
|
|
@@ -147,18 +195,24 @@ type Contract<D extends EventDefs = EventDefs> = {
|
|
|
147
195
|
* context-sensitive argument against generics inferred in the same
|
|
148
196
|
* call.)
|
|
149
197
|
*/
|
|
150
|
-
reducer<Seed, S = Seed>(options: ReducerOptions<Seed, S>): ReducerBuilder<D, S>;
|
|
198
|
+
reducer<Seed, S = Seed>(options: ReducerOptions<Seed, S>): ReducerBuilder<D, S, P>;
|
|
151
199
|
};
|
|
152
200
|
/**
|
|
153
201
|
* Define a contract: a name plus the events it understands. Validators
|
|
154
202
|
* must be synchronous — async ones are rejected here, at definition
|
|
155
203
|
* time. The result is a plain, importable, isomorphic value.
|
|
156
204
|
*/
|
|
157
|
-
declare function contract<D extends EventDefs
|
|
205
|
+
declare function contract<D extends EventDefs, P extends PresenceDefs = Record<never, never>>(options: {
|
|
158
206
|
/** Identity — prefixes storage keys and queue messages. */
|
|
159
207
|
name: string;
|
|
160
208
|
/** The vocabulary: event name → payload schema (Standard Schema). */
|
|
161
209
|
events: D;
|
|
162
|
-
|
|
210
|
+
/**
|
|
211
|
+
* Optional second vocabulary: presence field → value schema.
|
|
212
|
+
* Per-participant ephemeral state, replicated last-writer-wins and
|
|
213
|
+
* never stored in the log — reducers cannot see it.
|
|
214
|
+
*/
|
|
215
|
+
presence?: P;
|
|
216
|
+
}): Contract<D, P>;
|
|
163
217
|
//#endregion
|
|
164
|
-
export { EventDefs as a,
|
|
218
|
+
export { EventDefs as a, PresencePatch as c, WithPresence as d, contract as f, StandardSchemaV1 as h, EventBatchFactory as i, PresenceSnapshot as l, ReducerBuilder as m, Contract as n, PresenceDefs as o, Reducer as p, ContractEvent as r, PresenceMap as s, AppendInput as t, ReducerOptions as u };
|