@velajs/client 0.1.0 → 0.1.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/presence.js CHANGED
@@ -1,45 +1,47 @@
1
+ //#region src/presence.ts
1
2
  /**
2
- * The built-in roster query name served by `@velajs/vela/live`'s presence
3
- * preset (`PRESENCE_ROSTER_QUERY` on the server side).
4
- */ export const PRESENCE_ROSTER_QUERY = '$presence.roster';
3
+ * The built-in roster query name served by `@velajs/vela/live`'s presence
4
+ * preset (`PRESENCE_ROSTER_QUERY` on the server side).
5
+ */
6
+ const PRESENCE_ROSTER_QUERY = "$presence.roster";
5
7
  /**
6
- * Framework-neutral presence: joins a room's roster (heartbeats over the
7
- * room's live socket) and subscribes to it. Departure is immediate on socket
8
- * close — the heartbeat/TTL pair only covers ungraceful drops.
9
- */ export function createPresence(client, options) {
10
- const intervalMs = options.heartbeatIntervalMs ?? 10_000;
11
- let members;
12
- let timer;
13
- let stopped = false;
14
- const beat = ()=>{
15
- if (stopped) return;
16
- const meta = typeof options.meta === 'function' ? options.meta() : options.meta;
17
- client.presenceBeat(options.room, meta);
18
- };
19
- const tick = ()=>{
20
- timer = undefined;
21
- if (stopped) return;
22
- beat();
23
- timer = setTimeout(tick, intervalMs);
24
- };
25
- const unsubscribe = client.subscribe(PRESENCE_ROSTER_QUERY, {
26
- room: options.room
27
- }, (value)=>{
28
- members = value ?? [];
29
- options.onRoster?.(members);
30
- }, {
31
- room: options.room
32
- });
33
- // First beat rides after the subscription so the socket is being opened.
34
- tick();
35
- return {
36
- roster: ()=>members,
37
- beat,
38
- stop () {
39
- stopped = true;
40
- if (timer) clearTimeout(timer);
41
- timer = undefined;
42
- unsubscribe();
43
- }
44
- };
8
+ * Framework-neutral presence: joins a room's roster (heartbeats over the
9
+ * room's live socket) and subscribes to it. Departure is immediate on socket
10
+ * close — the heartbeat/TTL pair only covers ungraceful drops.
11
+ */
12
+ function createPresence(client, options) {
13
+ const intervalMs = options.heartbeatIntervalMs ?? 1e4;
14
+ let members;
15
+ let timer;
16
+ let stopped = false;
17
+ const beat = () => {
18
+ if (stopped) return;
19
+ const meta = typeof options.meta === "function" ? options.meta() : options.meta;
20
+ client.presenceBeat(options.room, meta);
21
+ };
22
+ const tick = () => {
23
+ timer = void 0;
24
+ if (stopped) return;
25
+ beat();
26
+ timer = setTimeout(tick, intervalMs);
27
+ };
28
+ const unsubscribe = client.subscribe(PRESENCE_ROSTER_QUERY, { room: options.room }, (value) => {
29
+ members = value ?? [];
30
+ options.onRoster?.(members);
31
+ }, { room: options.room });
32
+ tick();
33
+ return {
34
+ roster: () => members,
35
+ beat,
36
+ stop() {
37
+ stopped = true;
38
+ if (timer) clearTimeout(timer);
39
+ timer = void 0;
40
+ unsubscribe();
41
+ }
42
+ };
45
43
  }
44
+ //#endregion
45
+ export { PRESENCE_ROSTER_QUERY, createPresence };
46
+
47
+ //# sourceMappingURL=presence.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"presence.js","names":[],"sources":["../src/presence.ts"],"sourcesContent":["import type { LiveClient } from './live-client';\nimport type { LiveContract, Unsubscribe } from './types';\n\n/**\n * The built-in roster query name served by `@velajs/vela/live`'s presence\n * preset (`PRESENCE_ROSTER_QUERY` on the server side).\n */\nexport const PRESENCE_ROSTER_QUERY = '$presence.roster';\n\n/** One present connection, as the server's roster query returns it. */\nexport interface PresenceMember {\n id: string;\n meta?: unknown;\n lastSeen: number;\n}\n\nexport interface PresenceOptions {\n room: string;\n /** Payload attached to this connection's roster entry (function form re-evaluated per beat). */\n meta?: unknown | (() => unknown);\n /** Keep WELL under the server's TTL (default beat 10 000 ms vs 30 000 ms TTL). */\n heartbeatIntervalMs?: number;\n onRoster?: (members: PresenceMember[]) => void;\n}\n\nexport interface PresenceHandle {\n /** The last received roster (undefined until the first push). */\n roster(): PresenceMember[] | undefined;\n /** Send an immediate heartbeat (e.g. on visibility regain — the React binding wires this). */\n beat(): void;\n stop(): void;\n}\n\n/**\n * Framework-neutral presence: joins a room's roster (heartbeats over the\n * room's live socket) and subscribes to it. Departure is immediate on socket\n * close — the heartbeat/TTL pair only covers ungraceful drops.\n */\nexport function createPresence(\n client: LiveClient<LiveContract>,\n options: PresenceOptions,\n): PresenceHandle {\n const intervalMs = options.heartbeatIntervalMs ?? 10_000;\n let members: PresenceMember[] | undefined;\n let timer: ReturnType<typeof setTimeout> | undefined;\n let stopped = false;\n\n const beat = (): void => {\n if (stopped) return;\n const meta =\n typeof options.meta === 'function' ? (options.meta as () => unknown)() : options.meta;\n client.presenceBeat(options.room, meta);\n };\n\n const tick = (): void => {\n timer = undefined;\n if (stopped) return;\n beat();\n timer = setTimeout(tick, intervalMs);\n };\n\n const unsubscribe: Unsubscribe = client.subscribe(\n PRESENCE_ROSTER_QUERY,\n { room: options.room },\n (value) => {\n members = (value ?? []) as PresenceMember[];\n options.onRoster?.(members);\n },\n { room: options.room },\n );\n\n // First beat rides after the subscription so the socket is being opened.\n tick();\n\n return {\n roster: () => members,\n beat,\n stop() {\n stopped = true;\n if (timer) clearTimeout(timer);\n timer = undefined;\n unsubscribe();\n },\n };\n}\n"],"mappings":";;;;;AAOA,MAAa,wBAAwB;;;;;;AA+BrC,SAAgB,eACd,QACA,SACgB;CAChB,MAAM,aAAa,QAAQ,uBAAuB;CAClD,IAAI;CACJ,IAAI;CACJ,IAAI,UAAU;CAEd,MAAM,aAAmB;EACvB,IAAI,SAAS;EACb,MAAM,OACJ,OAAO,QAAQ,SAAS,aAAc,QAAQ,KAAuB,IAAI,QAAQ;EACnF,OAAO,aAAa,QAAQ,MAAM,IAAI;CACxC;CAEA,MAAM,aAAmB;EACvB,QAAQ,KAAA;EACR,IAAI,SAAS;EACb,KAAK;EACL,QAAQ,WAAW,MAAM,UAAU;CACrC;CAEA,MAAM,cAA2B,OAAO,UACtC,uBACA,EAAE,MAAM,QAAQ,KAAK,IACpB,UAAU;EACT,UAAW,SAAS,CAAC;EACrB,QAAQ,WAAW,OAAO;CAC5B,GACA,EAAE,MAAM,QAAQ,KAAK,CACvB;CAGA,KAAK;CAEL,OAAO;EACL,cAAc;EACd;EACA,OAAO;GACL,UAAU;GACV,IAAI,OAAO,aAAa,KAAK;GAC7B,QAAQ,KAAA;GACR,YAAY;EACd;CACF;AACF"}
package/package.json CHANGED
@@ -1,8 +1,34 @@
1
1
  {
2
2
  "name": "@velajs/client",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Framework-neutral client for Vela live queries: live subscriptions over WebSocket, keyed deltas, cursor-gated optimistic updates, reconnect with resume",
5
+ "keywords": [
6
+ "client",
7
+ "live-queries",
8
+ "optimistic-updates",
9
+ "realtime",
10
+ "vela",
11
+ "websocket"
12
+ ],
13
+ "homepage": "https://github.com/velajs/client#readme",
14
+ "bugs": {
15
+ "url": "https://github.com/velajs/client/issues"
16
+ },
17
+ "license": "MIT",
18
+ "author": "ksh",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/velajs/client.git",
22
+ "directory": "packages/client"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md",
27
+ "LICENSE",
28
+ "CHANGELOG.md"
29
+ ],
5
30
  "type": "module",
31
+ "sideEffects": false,
6
32
  "main": "./dist/index.js",
7
33
  "types": "./dist/index.d.ts",
8
34
  "exports": {
@@ -15,48 +41,22 @@
15
41
  "import": "./dist/presence.js"
16
42
  }
17
43
  },
18
- "files": [
19
- "dist",
20
- "README.md",
21
- "LICENSE",
22
- "CHANGELOG.md"
23
- ],
24
- "sideEffects": false,
25
- "keywords": [
26
- "vela",
27
- "live-queries",
28
- "realtime",
29
- "websocket",
30
- "optimistic-updates",
31
- "client"
32
- ],
33
- "author": "ksh",
34
- "license": "MIT",
35
- "repository": {
36
- "type": "git",
37
- "url": "git+https://github.com/velajs/client.git",
38
- "directory": "packages/client"
39
- },
40
- "homepage": "https://github.com/velajs/client#readme",
41
- "bugs": {
42
- "url": "https://github.com/velajs/client/issues"
43
- },
44
- "engines": {
45
- "node": ">=20"
46
- },
47
44
  "dependencies": {
48
45
  "@velajs/live-protocol": "^1.0.0"
49
46
  },
50
47
  "devDependencies": {
51
- "@swc/cli": "^0.8.1",
52
48
  "@swc/core": "^1.15.43",
53
- "typescript": "^6.0.3",
49
+ "@velajs/vela": "^1.17.0",
50
+ "tsdown": "^0.22.4",
51
+ "typescript": "^7.0.2",
54
52
  "unplugin-swc": "^1.5.9",
55
- "vitest": "^4.1.9",
56
- "@velajs/vela": "^1.17.0"
53
+ "vitest": "^4.1.10"
54
+ },
55
+ "engines": {
56
+ "node": ">=24"
57
57
  },
58
58
  "scripts": {
59
- "build": "rm -rf dist && swc src -d dist --strip-leading-paths && tsc --emitDeclarationOnly",
59
+ "build": "tsdown",
60
60
  "test": "vitest run",
61
61
  "typecheck": "tsc --noEmit"
62
62
  }
@@ -1,43 +0,0 @@
1
- import type { SubscriptionState } from './subscription';
2
- import type { ConnectionStatus, ReconnectOptions, WebSocketFactory } from './types';
3
- export interface ConnectionDeps {
4
- makeSocket: WebSocketFactory;
5
- authToken?: () => string | undefined | Promise<string | undefined>;
6
- heartbeatIntervalMs: number;
7
- reconnect?: ReconnectOptions;
8
- onStatusChange: () => void;
9
- }
10
- /**
11
- * One socket per room (vela's node transport auto-joins the `:id` route
12
- * param's room; Cloudflare is one-DO≈one-room), multiplexing every live
13
- * subscription for that room. Owns: connect/reconnect (decorrelated jitter),
14
- * resubscribe-with-cursor on open, app-level ping keepalive (self-rescheduling
15
- * setTimeout — never setInterval), and inbound `$live` frame routing into the
16
- * pure frame reducer.
17
- */
18
- export declare class RoomConnection {
19
- private readonly url;
20
- private readonly deps;
21
- status: ConnectionStatus;
22
- private socket?;
23
- private readonly bySub;
24
- private readonly reconnectState;
25
- private reconnectTimer?;
26
- private heartbeatTimer?;
27
- private closedByUser;
28
- private generation;
29
- constructor(url: string, deps: ConnectionDeps);
30
- register(state: SubscriptionState): void;
31
- unregister(state: SubscriptionState): void;
32
- sendPresence(room: string, meta?: unknown): void;
33
- close(): void;
34
- private ensureConnected;
35
- private connect;
36
- private handleMessage;
37
- private sendSub;
38
- private sendFrame;
39
- private scheduleReconnect;
40
- private scheduleHeartbeat;
41
- private clearTimers;
42
- private setStatus;
43
- }
@@ -1,215 +0,0 @@
1
- import { LIVE_PROTOCOL, encodeLiveEnvelope, isServerLiveFrame, readLiveEnvelope } from "@velajs/live-protocol";
2
- import { applyServerFrame } from "./frame-reducer.js";
3
- import { nextReconnectDelay, resetReconnect } from "./reconnect.js";
4
- import { notify } from "./subscription.js";
5
- const OPEN = 1;
6
- /**
7
- * One socket per room (vela's node transport auto-joins the `:id` route
8
- * param's room; Cloudflare is one-DO≈one-room), multiplexing every live
9
- * subscription for that room. Owns: connect/reconnect (decorrelated jitter),
10
- * resubscribe-with-cursor on open, app-level ping keepalive (self-rescheduling
11
- * setTimeout — never setInterval), and inbound `$live` frame routing into the
12
- * pure frame reducer.
13
- */ export class RoomConnection {
14
- url;
15
- deps;
16
- status = 'idle';
17
- socket;
18
- bySub = new Map();
19
- reconnectState = {};
20
- reconnectTimer;
21
- heartbeatTimer;
22
- closedByUser = false;
23
- generation = 0;
24
- constructor(url, deps){
25
- this.url = url;
26
- this.deps = deps;
27
- }
28
- register(state) {
29
- this.bySub.set(state.sub, state);
30
- if (this.socket?.readyState === OPEN) {
31
- this.sendSub(state);
32
- } else {
33
- this.ensureConnected();
34
- }
35
- }
36
- unregister(state) {
37
- this.bySub.delete(state.sub);
38
- this.sendFrame({
39
- t: 'unsub',
40
- sub: state.sub
41
- });
42
- }
43
- sendPresence(room, meta) {
44
- this.ensureConnected();
45
- this.sendFrame({
46
- t: 'presence',
47
- room,
48
- meta
49
- });
50
- }
51
- close() {
52
- this.closedByUser = true;
53
- this.clearTimers();
54
- this.socket?.close(1000, 'client closed');
55
- this.socket = undefined;
56
- this.setStatus('closed');
57
- }
58
- ensureConnected() {
59
- if (this.closedByUser) return;
60
- if (this.socket && this.socket.readyState <= OPEN) return; // CONNECTING or OPEN
61
- void this.connect();
62
- }
63
- async connect() {
64
- this.setStatus('connecting');
65
- const generation = ++this.generation;
66
- let token;
67
- try {
68
- token = await this.deps.authToken?.();
69
- } catch {
70
- // A failing token provider is treated as a connection failure: back off
71
- // and retry — the next attempt re-invokes it (rotation-friendly).
72
- if (generation === this.generation) this.scheduleReconnect();
73
- return;
74
- }
75
- if (generation !== this.generation || this.closedByUser) return;
76
- const url = token === undefined ? this.url : `${this.url}${this.url.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`;
77
- let socket;
78
- try {
79
- socket = this.deps.makeSocket(url);
80
- } catch {
81
- this.scheduleReconnect();
82
- return;
83
- }
84
- this.socket = socket;
85
- socket.onopen = ()=>{
86
- if (generation !== this.generation) return;
87
- resetReconnect(this.reconnectState);
88
- this.setStatus('connected');
89
- // Resubscribe everything with resume watermarks — the server answers
90
- // each with data (re-run), resume (untouched), or a cold snapshot.
91
- for (const state of this.bySub.values())this.sendSub(state);
92
- this.scheduleHeartbeat();
93
- };
94
- socket.onmessage = (event)=>{
95
- if (typeof event.data !== 'string') return;
96
- this.handleMessage(event.data);
97
- };
98
- socket.onclose = ()=>{
99
- if (generation !== this.generation) return;
100
- this.clearTimers();
101
- this.socket = undefined;
102
- if (this.closedByUser) return;
103
- this.setStatus('offline');
104
- this.scheduleReconnect();
105
- };
106
- socket.onerror = ()=>{
107
- // onclose follows; nothing to do here.
108
- };
109
- }
110
- handleMessage(raw) {
111
- let envelope;
112
- try {
113
- envelope = JSON.parse(raw);
114
- } catch {
115
- return;
116
- }
117
- const frame = readLiveEnvelope(envelope);
118
- if (frame === undefined || !isServerLiveFrame(frame)) return; // classic gateway events / pong / unknown frames
119
- const sub = 'sub' in frame ? frame.sub : undefined;
120
- const state = sub === undefined ? undefined : this.bySub.get(sub);
121
- if (!state) return;
122
- const effect = applyServerFrame(state, frame);
123
- switch(effect){
124
- case 'notify':
125
- notify(state);
126
- return;
127
- case 'error':
128
- if (frame.t === 'error') {
129
- for (const callback of state.errorCallbacks){
130
- callback({
131
- code: frame.code,
132
- message: frame.message,
133
- fatal: frame.fatal
134
- });
135
- }
136
- }
137
- return;
138
- case 'resubscribe':
139
- // Unusable cache (epoch fork mid-delta / unmergeable delta): start
140
- // cold. Same wire id ⇒ unsub first so the server replaces the record.
141
- state.hasBase = false;
142
- state.serverBase = undefined;
143
- state.serverCursor = undefined;
144
- state.serverEpoch = undefined;
145
- this.sendFrame({
146
- t: 'unsub',
147
- sub: state.sub
148
- });
149
- this.sendSub(state);
150
- return;
151
- case 'none':
152
- return;
153
- }
154
- }
155
- sendSub(state) {
156
- const frame = {
157
- t: 'sub',
158
- sub: state.sub,
159
- query: state.query,
160
- ...state.args === undefined ? {} : {
161
- args: state.args
162
- },
163
- ...state.serverCursor !== undefined && state.serverEpoch !== undefined ? {
164
- sinceCursor: state.serverCursor,
165
- sinceEpoch: state.serverEpoch
166
- } : {},
167
- ...state.key === undefined ? {} : {
168
- key: state.key
169
- },
170
- v: LIVE_PROTOCOL
171
- };
172
- this.sendFrame(frame);
173
- }
174
- sendFrame(frame) {
175
- if (this.socket?.readyState !== OPEN) return; // onopen resends subscriptions
176
- try {
177
- this.socket.send(encodeLiveEnvelope(frame));
178
- } catch {
179
- // socket died between the readyState check and send — onclose recovers
180
- }
181
- }
182
- scheduleReconnect() {
183
- if (this.closedByUser || this.reconnectTimer) return;
184
- const delay = nextReconnectDelay(this.reconnectState, this.deps.reconnect?.baseMs, this.deps.reconnect?.capMs);
185
- this.reconnectTimer = setTimeout(()=>{
186
- this.reconnectTimer = undefined;
187
- void this.connect();
188
- }, delay);
189
- }
190
- scheduleHeartbeat() {
191
- if (this.heartbeatTimer) return;
192
- const tick = ()=>{
193
- this.heartbeatTimer = undefined;
194
- if (this.socket?.readyState !== OPEN) return;
195
- try {
196
- this.socket.send('{"event":"ping"}');
197
- } catch {
198
- return;
199
- }
200
- this.heartbeatTimer = setTimeout(tick, this.deps.heartbeatIntervalMs);
201
- };
202
- this.heartbeatTimer = setTimeout(tick, this.deps.heartbeatIntervalMs);
203
- }
204
- clearTimers() {
205
- if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
206
- if (this.heartbeatTimer) clearTimeout(this.heartbeatTimer);
207
- this.reconnectTimer = undefined;
208
- this.heartbeatTimer = undefined;
209
- }
210
- setStatus(status) {
211
- if (this.status === status) return;
212
- this.status = status;
213
- this.deps.onStatusChange();
214
- }
215
- }
package/dist/errors.d.ts DELETED
@@ -1,18 +0,0 @@
1
- /**
2
- * The client's error surface — three tiers:
3
- * - connection-level: surfaced through `onConnectionStatus('offline')`;
4
- * - subscription-level: `error` frames → `SubscribeOptions.onError`;
5
- * - mutation-level: `mutate()` rejects with a {@link VelaLiveError}.
6
- *
7
- * Follows lunora's zero-dep catalog pattern (one error class, a small code
8
- * union, structural guards) inlined rather than published separately.
9
- */
10
- export declare class VelaLiveError extends Error {
11
- readonly code: string;
12
- readonly status?: number | undefined;
13
- constructor(code: string, message: string, status?: number | undefined);
14
- }
15
- export declare const isVelaLiveError: (value: unknown) => value is VelaLiveError;
16
- export declare const getErrorCode: (value: unknown) => string | undefined;
17
- /** Decode an HTTP error response into a VelaLiveError (vela's `{ error | message }` shapes tolerated). */
18
- export declare const toMutationError: (response: Response) => Promise<VelaLiveError>;
package/dist/errors.js DELETED
@@ -1,36 +0,0 @@
1
- /**
2
- * The client's error surface — three tiers:
3
- * - connection-level: surfaced through `onConnectionStatus('offline')`;
4
- * - subscription-level: `error` frames → `SubscribeOptions.onError`;
5
- * - mutation-level: `mutate()` rejects with a {@link VelaLiveError}.
6
- *
7
- * Follows lunora's zero-dep catalog pattern (one error class, a small code
8
- * union, structural guards) inlined rather than published separately.
9
- */ export class VelaLiveError extends Error {
10
- code;
11
- status;
12
- constructor(code, message, status){
13
- super(message), this.code = code, this.status = status;
14
- this.name = 'VelaLiveError';
15
- }
16
- }
17
- export const isVelaLiveError = (value)=>value instanceof VelaLiveError || typeof value === 'object' && value !== null && value.name === 'VelaLiveError' && typeof value.code === 'string';
18
- export const getErrorCode = (value)=>isVelaLiveError(value) ? value.code : undefined;
19
- /** Decode an HTTP error response into a VelaLiveError (vela's `{ error | message }` shapes tolerated). */ export const toMutationError = async (response)=>{
20
- let code = `http_${response.status}`;
21
- let message = response.statusText || `mutation failed with status ${response.status}`;
22
- try {
23
- const body = await response.json();
24
- if (typeof body.error === 'object' && body.error !== null) {
25
- code = body.error.code ?? code;
26
- message = body.error.message ?? message;
27
- } else if (typeof body.error === 'string') {
28
- message = body.error;
29
- } else if (typeof body.message === 'string') {
30
- message = body.message;
31
- }
32
- } catch {
33
- // non-JSON body — keep the status-derived error
34
- }
35
- return new VelaLiveError(code, message, response.status);
36
- };
@@ -1,17 +0,0 @@
1
- import type { ServerLiveFrame } from '@velajs/live-protocol';
2
- import type { SubscriptionState } from './subscription';
3
- /**
4
- * What the connection layer must do after a frame was applied:
5
- * - `notify` — the displayed value changed, fire callbacks;
6
- * - `resubscribe` — the cache is unusable (epoch fork mid-delta, unmergeable
7
- * delta); send a fresh cold `sub` for this state;
8
- * - `error` — an error frame; route to error callbacks;
9
- * - `none` — bookkeeping only.
10
- */
11
- export type FrameEffect = 'none' | 'notify' | 'resubscribe' | 'error';
12
- /**
13
- * The pure per-subscription frame state machine (adapts lunora's
14
- * `handleDataMessage`/`handleResumeMessage`/`handleSettledMessage`).
15
- * Deliberately socket-free so the protocol semantics are unit-testable.
16
- */
17
- export declare function applyServerFrame(state: SubscriptionState, frame: ServerLiveFrame): FrameEffect;
@@ -1,66 +0,0 @@
1
- import { DEFAULT_KEY_FIELD, applyListDelta } from "@velajs/live-protocol";
2
- import { dropConfirmedLayers } from "./optimistic.js";
3
- import { refold } from "./subscription.js";
4
- /**
5
- * The pure per-subscription frame state machine (adapts lunora's
6
- * `handleDataMessage`/`handleResumeMessage`/`handleSettledMessage`).
7
- * Deliberately socket-free so the protocol semantics are unit-testable.
8
- */ export function applyServerFrame(state, frame) {
9
- switch(frame.t){
10
- case 'ack':
11
- state.acked = true;
12
- return 'none';
13
- case 'error':
14
- return 'error';
15
- case 'resume':
16
- {
17
- // Nothing relevant changed while away: keep the cached value, advance
18
- // the watermark. Confirmed layers the new cursor covers still drop.
19
- state.serverCursor = frame.cursor;
20
- state.serverEpoch = frame.epoch;
21
- const dropped = dropConfirmedLayers(state, frame.cursor, frame.epoch);
22
- return dropped && refold(state) ? 'notify' : 'none';
23
- }
24
- case 'settled':
25
- {
26
- // Byte-identical re-run: no payload, but the cursor advance is what
27
- // drops optimistic layers for writes that didn't change this query.
28
- advanceWatermark(state, frame.cursor, frame.epoch);
29
- const dropped = dropConfirmedLayers(state, frame.cursor, frame.epoch);
30
- return dropped && refold(state) ? 'notify' : 'none';
31
- }
32
- case 'data':
33
- {
34
- if (epochForked(state, frame.epoch)) {
35
- // New timeline: every optimistic gate is void. The snapshot itself is
36
- // authoritative, so apply it as a cold first frame.
37
- state.layers = [];
38
- }
39
- state.serverBase = frame.snapshot;
40
- state.hasBase = true;
41
- advanceWatermark(state, frame.cursor, frame.epoch);
42
- dropConfirmedLayers(state, frame.cursor, frame.epoch);
43
- refold(state);
44
- return 'notify';
45
- }
46
- case 'delta':
47
- {
48
- // A delta diffs against the baseline the server believes we confirmed —
49
- // across an epoch fork or without a base that belief is wrong by
50
- // construction: start over.
51
- if (epochForked(state, frame.epoch) || !state.hasBase) return 'resubscribe';
52
- const merged = applyListDelta(state.serverBase, frame.ops, state.key ?? DEFAULT_KEY_FIELD);
53
- if (merged === undefined) return 'resubscribe';
54
- state.serverBase = merged;
55
- advanceWatermark(state, frame.cursor, frame.epoch);
56
- dropConfirmedLayers(state, frame.cursor, frame.epoch);
57
- refold(state);
58
- return 'notify';
59
- }
60
- }
61
- }
62
- const epochForked = (state, epoch)=>epoch !== undefined && state.serverEpoch !== undefined && epoch !== state.serverEpoch;
63
- function advanceWatermark(state, cursor, epoch) {
64
- if (cursor !== undefined) state.serverCursor = cursor;
65
- if (epoch !== undefined) state.serverEpoch = epoch;
66
- }
@@ -1,44 +0,0 @@
1
- import type { ArgsOf, ConnectionStatus, HydrationEntry, LiveClientOptions, LiveContract, MutateOptions, ResultOf, SubscribeOptions, Unsubscribe } from './types';
2
- /**
3
- * The framework-neutral Vela live client.
4
- *
5
- * ```ts
6
- * const client = new LiveClient<AppLive>({ url: 'https://api.example.com' });
7
- * const stop = client.subscribe('todos.list', { listId: 'l1' }, (todos) => render(todos));
8
- * await client.mutate('/todos', { text: 'hi' }, {
9
- * optimistic: { query: 'todos.list', args: { listId: 'l1' }, apply: (t = []) => [...t, temp] },
10
- * });
11
- * ```
12
- *
13
- * Identical `(query, args, room)` subscriptions share one wire registration;
14
- * reconnects resubscribe with the last cursor+epoch so untouched
15
- * subscriptions resume without a re-run; optimistic updates are rebaseable
16
- * layers gated on the mutation's `Vela-Commit-Cursor` response header.
17
- */
18
- export declare class LiveClient<C extends LiveContract = LiveContract> {
19
- private readonly options;
20
- private readonly registry;
21
- private readonly connections;
22
- private readonly statusListeners;
23
- private lastStatus;
24
- constructor(options: LiveClientOptions);
25
- subscribe<Q extends keyof C & string>(query: Q, args: ArgsOf<C, Q>, callback: (value: ResultOf<C, Q> | undefined) => void, subscribeOptions?: SubscribeOptions): Unsubscribe;
26
- /** The current cached (folded) value — referentially stable between notifications. */
27
- peek<Q extends keyof C & string>(query: Q, args: ArgsOf<C, Q>, room?: string): ResultOf<C, Q> | undefined;
28
- /**
29
- * Fire an HTTP mutation. Optimistic targets paint immediately; the layer
30
- * drops when a subscription frame's cursor passes the response's
31
- * `Vela-Commit-Cursor` (missing header ⇒ one-shot optimism); a rejection
32
- * rolls back. Resolves with the parsed JSON body (undefined when empty).
33
- */
34
- mutate<R = unknown>(path: string, body?: unknown, mutateOptions?: MutateOptions): Promise<R>;
35
- /** Presence heartbeat for a room (see `@velajs/client/presence` for the managed preset). */
36
- presenceBeat(room: string, meta?: unknown): void;
37
- /** Seed subscription state before connecting (SSR hydration). Subscribes then resume from the seeded cursor. */
38
- hydrate(entries: HydrationEntry[]): void;
39
- connectionStatus(): ConnectionStatus;
40
- onConnectionStatus(listener: (status: ConnectionStatus) => void): Unsubscribe;
41
- close(): void;
42
- private makeStore;
43
- private connectionFor;
44
- }