eraspace 0.1.0 → 0.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eraspace",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "eraspace: build, deploy and manage eraspace capsules",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -8,7 +8,7 @@
8
8
  "cli",
9
9
  "realtime",
10
10
  "preact",
11
- "beam"
11
+ "v8"
12
12
  ],
13
13
  "license": "MIT",
14
14
  "bin": {
@@ -24,10 +24,8 @@
24
24
  "@preact/signals": "2.11.3",
25
25
  "@tailwindcss/node": "4.3.3",
26
26
  "@tailwindcss/oxide": "4.3.3",
27
- "@types/phoenix": "1.6.7",
28
27
  "esbuild": "0.28.2",
29
28
  "oxc-parser": "0.154.0",
30
- "phoenix": "1.8.15",
31
29
  "preact": "11.0.1",
32
30
  "preact-iso": "2.12.2",
33
31
  "tailwindcss": "4.3.3",
@@ -1,11 +1,13 @@
1
1
  import { computed, type ReadonlySignal } from "@preact/signals";
2
- import { useEffect, useMemo, useRef } from "preact/hooks";
2
+ import { useEffect, useMemo, useRef, useState } from "preact/hooks";
3
3
  import { createStorageClient } from "./storage.ts";
4
- import type { ClientOptions } from "./config.ts";
4
+ import { useUpload, type UseUploadOptions } from "./files.ts";
5
+ import { readConfig, type ClientOptions } from "./config.ts";
5
6
  import { subKey, stableStringify } from "./sub-key.ts";
6
7
  import type { Subscription } from "./subscription.ts";
7
8
  import type { RoomConn } from "./room.ts";
8
9
  import type { EraspaceClient, Room } from "./types.ts";
10
+ import { EraspaceError } from "./errors.ts";
9
11
  import { ClientImpl } from "./connection.ts";
10
12
  import { Pager } from "./pagination.ts";
11
13
 
@@ -21,8 +23,10 @@ type Fn = (...args: any[]) => any;
21
23
  */
22
24
  export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C> {
23
25
  let impl: ClientImpl | undefined;
24
- // Lazy so a module-level createClient() costs nothing until first use.
26
+ // Lazy so a module-level createClient() costs nothing until first use, except in a browser page that reloads on
27
+ // deploy (the platform shell's default), where the client connects at once to hear deploys.
25
28
  const get = () => (impl ??= new ClientImpl(options));
29
+ if (typeof location !== "undefined" && (options.reloadOnDeploy ?? readConfig().app)) get().watchDeploys();
26
30
 
27
31
  function useSubscription(name: string, args: unknown[]): Subscription {
28
32
  const c = get();
@@ -39,7 +43,8 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
39
43
  return useMemo(() => computed(() => {
40
44
  const err = sub.error.value;
41
45
  const value: unknown = sub.value.value;
42
- if (err && value === undefined) throw err;
46
+ // Retried failures keep showing the last value; a gone app cannot recover.
47
+ if (err && (value === undefined || err.name === "AppGone")) throw err;
43
48
  return value;
44
49
  }), [sub]);
45
50
  }
@@ -61,7 +66,7 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
61
66
  }
62
67
 
63
68
  function useAction(name: string) {
64
- return useMemo(() => (...args: unknown[]) => get().call(name, args), [name]);
69
+ return useMemo(() => (...args: unknown[]) => get().call(name, args, "action"), [name]);
65
70
  }
66
71
 
67
72
  function usePaginatedQuery(name: string, args: unknown[], opts?: { initialNumItems?: number }) {
@@ -85,8 +90,17 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
85
90
 
86
91
  function useRoomConn(room: string, meta: Record<string, unknown> | undefined): RoomConn {
87
92
  const c = get();
88
- const r = useMemo(() => c.acquireRoom(room), [room]);
89
- useEffect(() => () => c.releaseRoom(r), [r]);
93
+ const [generation, renew] = useState(0);
94
+ // Acquiring during render holds nothing: only a committed effect retains the room.
95
+ const r = useMemo(() => c.acquireRoom(room), [room, generation]);
96
+ useEffect(() => {
97
+ if (!c.retainRoom(r)) {
98
+ // Dropped between render and commit.
99
+ renew((n) => n + 1);
100
+ return;
101
+ }
102
+ return () => c.releaseRoom(r);
103
+ }, [r]);
90
104
  const metaKey = meta === undefined ? undefined : stableStringify(meta);
91
105
  useEffect(() => {
92
106
  if (meta !== undefined) r.setMeta(meta);
@@ -104,8 +118,9 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
104
118
  presence: r.members.value,
105
119
  self: r.self.value,
106
120
  state: r.state.value,
121
+ error: r.error.value,
107
122
  broadcast: (event: string, payload?: unknown) => {
108
- r.channel.push("broadcast", { event, payload: payload ?? null });
123
+ void r.channel.push("broadcast", { event, payload: payload ?? null }).catch(error => { r.error.value = EraspaceError.from(error); });
109
124
  },
110
125
  on: (event: string, cb: (payload: any, from: string | undefined) => void) => {
111
126
  let set = r.listeners.get(event);
@@ -114,14 +129,15 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
114
129
  return () => set!.delete(cb);
115
130
  },
116
131
  setState: (key: string, value: unknown) => {
117
- r.state.value = { ...r.state.peek(), [key]: value };
118
- r.channel.push("state:set", { key, value: value ?? null });
132
+ r.setState(key, value);
119
133
  },
120
134
  };
121
135
  }
122
136
 
137
+ const storage = createStorageClient(() => get().base);
123
138
  const client = {
124
- storage: createStorageClient(() => get().base),
139
+ storage,
140
+ useUpload: (opts?: UseUploadOptions) => useUpload(storage, opts),
125
141
  useQuery,
126
142
  useQuerySignal,
127
143
  useQuerySelector,
@@ -131,8 +147,9 @@ export function createClient<C>(options: ClientOptions = {}): EraspaceClient<C>
131
147
  usePresence,
132
148
  useRoom,
133
149
  query: (name: string, ...args: unknown[]) => get().call(name, args),
134
- mutation: (name: string, ...args: unknown[]) => get().call(name, args),
135
- action: (name: string, ...args: unknown[]) => get().call(name, args),
150
+ mutation: (name: string, ...args: unknown[]) => get().mutate(name, args),
151
+ retryMutation: (name: string, args: unknown[], requestKey: string) => get().retryMutation(name, args, requestKey),
152
+ action: (name: string, ...args: unknown[]) => get().call(name, args, "action"),
136
153
  subscribe: (name: string, args: unknown[], onValue: Fn, onError?: Fn) => get().subscribe(name, args, onValue, onError),
137
154
  get app() {
138
155
  return get().app;
@@ -1,9 +1,23 @@
1
+ import type { SocketOptions, WebSocketConstructor } from "./transport.ts";
2
+
3
+ /** A query result the shell embedded, as a `sub` reply would carry it. */
4
+ export interface InitialResult {
5
+ fn: string;
6
+ args: unknown[];
7
+ value: unknown;
8
+ commitSeq: number;
9
+ revision: number;
10
+ entry: string;
11
+ }
12
+
1
13
  /** `window.__ERASPACE__`, injected by the platform's HTML shell (SPEC §6). */
2
14
  export interface EraspaceConfig {
3
15
  app: string;
4
16
  base: string;
5
17
  version: number;
6
18
  socket: string;
19
+ /** Current results of the queries the app's pages usually open first. */
20
+ initial?: InitialResult[];
7
21
  }
8
22
 
9
23
  export interface ClientOptions {
@@ -12,7 +26,11 @@ export interface ClientOptions {
12
26
  socket?: string;
13
27
  base?: string;
14
28
  version?: number;
15
- /** Reload the page when the app is redeployed (after re-subscribing). Default `false`. */
29
+ /**
30
+ * Reload the page when the app is redeployed, so a deploy reaches open tabs without a manual refresh. Default
31
+ * `true` for the shell's own app on pages served by the platform shell (`window.__ERASPACE__` is set), otherwise
32
+ * `false`. When enabled in a browser, the client connects at creation rather than on first use.
33
+ */
16
34
  reloadOnDeploy?: boolean;
17
35
  /** Push timeout in ms. Default 15000. */
18
36
  timeout?: number;
@@ -21,9 +39,9 @@ export interface ClientOptions {
21
39
  /** Delay before refreshing a subscription still waiting for a mutation commit. Default 2000 ms. */
22
40
  optimisticTimeoutMs?: number;
23
41
  /** WebSocket constructor (tests, non-browser runtimes). */
24
- transport?: unknown;
25
- /** Extra Phoenix socket options. */
26
- socketOptions?: Record<string, unknown>;
42
+ transport?: WebSocketConstructor;
43
+ /** Reconnect and rejoin delay overrides. */
44
+ socketOptions?: Pick<SocketOptions, "reconnectAfterMs" | "rejoinAfterMs">;
27
45
  }
28
46
 
29
47
  declare global {
@@ -1,11 +1,13 @@
1
- import { Socket, type Channel } from "phoenix";
1
+ import { Socket, type Channel } from "./transport.ts";
2
2
  import { signal, effect, batch, type Signal } from "@preact/signals";
3
3
  import { applyPatch } from "./result-diff.ts";
4
- import { EraspaceError } from "./errors.ts";
5
- import { readConfig, type ClientOptions } from "./config.ts";
6
- import { subKey } from "./sub-key.ts";
7
- import { Subscription, type FullResult, type DataMessage } from "./subscription.ts";
4
+ import { EraspaceError, OutcomeUnknownError } from "./errors.ts";
5
+ import { readConfig, type ClientOptions, type InitialResult } from "./config.ts";
6
+ import { requestKey } from "./request-key.ts";
7
+ import { canonicalArgs, subKey } from "./sub-key.ts";
8
+ import { Subscription, type FullResult, type UnchangedResult, type DataMessage } from "./subscription.ts";
8
9
  import { RoomConn } from "./room.ts";
10
+ import { retryDelay } from "./retry.ts";
9
11
  import type { Status } from "./types.ts";
10
12
 
11
13
  type Fn = (...args: any[]) => any;
@@ -16,17 +18,25 @@ export class ClientImpl {
16
18
  readonly self = signal("");
17
19
  readonly status = signal<Status>("connecting");
18
20
  readonly version: Signal<number>;
21
+ readonly reloadOnDeploy: boolean;
19
22
  private opts: ClientOptions;
20
23
  private socketUrl: string;
21
24
  private socket: Socket | undefined;
22
25
  private channel: Channel | undefined;
23
26
  private joinedOnce = false;
27
+ /** Counts app channel joins. Server subscription ids live only as long as the join that created them. */
28
+ private joins = 0;
29
+ /** Server time minus local time, from the latest join reply. */
30
+ private clockOffset = 0;
31
+ private gone: EraspaceError | undefined;
24
32
  private nextSub = 1;
25
33
  private nextMutation = 1;
26
34
  private subs = new Map<string, Subscription>();
27
35
  private subsById = new Map<string, Subscription>();
28
36
  private rooms = new Map<string, RoomConn>();
29
37
  private deployedCbs = new Set<(v: number) => void>();
38
+ /** Results the shell embedded, by subscription key, each used by the first subscription to it. */
39
+ private initial = new Map<string, InitialResult>();
30
40
 
31
41
  constructor(opts: ClientOptions) {
32
42
  const cfg = readConfig();
@@ -35,23 +45,31 @@ export class ClientImpl {
35
45
  if (!this.app) throw new Error("eraspace: createClient() needs an app (window.__ERASPACE__ is missing)");
36
46
  this.base = opts.base ?? cfg.base ?? `/${this.app}/`;
37
47
  this.socketUrl = opts.socket ?? cfg.socket ?? "/socket";
38
- this.version = signal(opts.version ?? cfg.version ?? 0);
48
+ // The shell's version and reload default describe the page's own app, not another app it talks to.
49
+ const ownApp = !opts.app || opts.app === cfg.app;
50
+ this.version = signal(opts.version ?? (ownApp ? cfg.version : undefined) ?? 0);
51
+ this.reloadOnDeploy = opts.reloadOnDeploy ?? (ownApp && Boolean(cfg.app));
52
+ if (ownApp && Array.isArray(cfg.initial)) {
53
+ for (const r of cfg.initial) if (typeof r?.fn === "string" && Array.isArray(r.args)) this.initial.set(subKey(r.fn, r.args), r);
54
+ }
55
+ }
56
+
57
+ /** Join the app topic now when deploys reload the page, so a page that has not queried yet still hears them. */
58
+ watchDeploys(): void {
59
+ if (this.reloadOnDeploy && !this.gone) this.ensureChannel();
39
60
  }
40
61
 
41
62
  // -- connection -----------------------------------------------------------------------------------------------
42
63
 
43
64
  private ensureSocket(): Socket {
44
65
  if (this.socket) return this.socket;
45
- const socketOpts: Record<string, unknown> = {
46
- params: () => ({ app: this.app }),
47
- timeout: this.opts.timeout ?? 15000,
66
+ const socket = new Socket(this.socketUrl, {
67
+ timeout: this.opts.timeout,
48
68
  ...this.opts.socketOptions,
49
- };
50
- if (this.opts.transport) socketOpts.transport = this.opts.transport;
51
- const socket = new Socket(this.socketUrl, socketOpts as any);
69
+ transport: this.opts.transport,
70
+ });
52
71
  socket.onOpen(() => { this.status.value = "open"; });
53
72
  socket.onClose(() => { this.status.value = "closed"; });
54
- socket.onError(() => { this.status.value = "closed"; });
55
73
  socket.connect();
56
74
  this.socket = socket;
57
75
  return socket;
@@ -60,41 +78,105 @@ export class ClientImpl {
60
78
  private ensureChannel(): Channel {
61
79
  if (this.channel) return this.channel;
62
80
  const socket = this.ensureSocket();
63
- const channel = socket.channel(`app:${this.app}`, { patches: true, watermarks: true });
81
+ const channel = socket.channel(`app:${this.app}`, {});
64
82
  channel.on("data", (m: DataMessage) => this.onData(m));
65
83
  channel.on("error", (m: { id?: string; name?: string; message?: string }) => {
66
84
  const sub = m.id !== undefined ? this.subsById.get(String(m.id)) : undefined;
67
- if (sub) sub.error.value = EraspaceError.from(m);
85
+ if (sub) {
86
+ sub.error.value = EraspaceError.from(m);
87
+ this.retrySub(sub);
88
+ }
68
89
  });
90
+ channel.on("gone", () => this.onGone());
91
+ // Transport loss changes status; reconnect rejoins and resubscribes while cached values stay valid.
92
+ // A join the server rejects is reported to every subscription.
93
+ const failed = (payload: unknown, rejected: boolean) => {
94
+ if (this.gone) return;
95
+ if (payload instanceof EraspaceError && payload.name === "AppGone") { this.onGone(); return; }
96
+ this.status.value = "closed";
97
+ if (rejected) for (const sub of this.subs.values()) sub.error.value = EraspaceError.from(payload, "App channel join failed");
98
+ };
99
+ channel.onError((reason, rejected) => failed(reason, rejected));
100
+ channel.onClose(() => failed(undefined, false));
69
101
  channel.on("deployed", (m: { version?: number }) => this.onDeployed(m?.version));
70
- channel.join().receive("ok", (resp: { self?: string } | undefined) => {
102
+ channel.onJoin((resp) => {
103
+ this.joins++;
104
+ if (this.gone) return;
105
+ this.status.value = "open";
71
106
  if (resp && typeof resp.self === "string") this.self.value = resp.self;
107
+ if (resp && typeof resp.now === "number") this.clockOffset = resp.now - Date.now();
108
+ if (resp && typeof resp.version === "number") this.onJoinVersion(resp.version);
72
109
  // The channel process is new after a reconnect, so every live subscription is sent again.
73
110
  if (this.joinedOnce) this.resubscribeAll();
74
111
  this.joinedOnce = true;
75
112
  });
113
+ channel.join();
76
114
  this.channel = channel;
77
115
  return channel;
78
116
  }
79
117
 
80
- private push<T>(event: string, payload: object): Promise<T> {
118
+ private push<T>(event: string, payload: object | (() => object | null)): Promise<T> {
119
+ if (this.gone) return Promise.reject(this.gone);
81
120
  const channel = this.ensureChannel();
82
- return new Promise<T>((resolve, reject) => {
83
- channel
84
- .push(event, payload, this.opts.timeout ?? 15000)
85
- .receive("ok", (resp: T) => resolve(resp))
86
- .receive("error", (resp: unknown) => reject(EraspaceError.from(resp)))
87
- .receive("timeout", () => reject(new EraspaceError("TimeoutError", `${event} timed out`)));
88
- });
121
+ return channel.push<T>(event, payload, this.opts.timeout ?? 15000);
122
+ }
123
+
124
+ async call(fn: string, args: unknown[], kind: "query" | "action" = "query"): Promise<unknown> {
125
+ const key = this.requestKey();
126
+ try {
127
+ // Sorted object keys let equal query arguments share the server's cached result.
128
+ const resp = await this.push<{ value?: unknown }>("call", { fn, args: kind === "query" ? canonicalArgs(args) : args, kind, key });
129
+ return resp?.value;
130
+ } catch (error) {
131
+ if (kind === "action" && error instanceof EraspaceError && ["TimeoutError", "OutcomeUnknownError"].includes(error.name)) {
132
+ throw new OutcomeUnknownError(key, "The action may have performed effects; it cannot be replayed safely");
133
+ }
134
+ throw error;
135
+ }
89
136
  }
90
137
 
91
- async call(fn: string, args: unknown[]): Promise<any> {
92
- const resp = await this.push<{ value?: unknown }>("call", { fn, args });
93
- return resp?.value;
138
+ /** Request keys carry server time, so a skewed client clock cannot place them outside the retry window. */
139
+ private requestKey(): string {
140
+ return requestKey(Date.now() + this.clockOffset);
141
+ }
142
+
143
+ private async mutationRequest(fn: string, args: unknown[], key = this.requestKey(), retry = false): Promise<{ value: unknown; commitSeq?: number }> {
144
+ let rekeyed = false;
145
+ for (let attempt = 0; ; attempt++) {
146
+ try {
147
+ return await this.push("call", { fn, args, kind: "mutation", key, retry });
148
+ } catch (error) {
149
+ // The server refused the key before running anything. The join reply has corrected the clock by now.
150
+ if (error instanceof EraspaceError && error.name === "RequestKeyError" && !retry && !rekeyed) {
151
+ rekeyed = true;
152
+ attempt--;
153
+ key = this.requestKey();
154
+ continue;
155
+ }
156
+ const uncertain = error instanceof EraspaceError && ["TimeoutError", "OutcomeUnknownError", "RetryExpiredError"].includes(error.name);
157
+ // A rejected recovery attempt cannot resolve the original request's unknown outcome.
158
+ if (!uncertain && !retry) throw error;
159
+ if (uncertain && attempt === 0 && error.name !== "RetryExpiredError") {
160
+ retry = true;
161
+ continue;
162
+ }
163
+ const message = error instanceof Error ? error.message : "Recovery failed";
164
+ throw new OutcomeUnknownError(key, `${message}. Recover this mutation only with the same request key.`);
165
+ }
166
+ }
167
+ }
168
+
169
+ async retryMutation(fn: string, args: unknown[], key: string): Promise<unknown> {
170
+ return (await this.mutationRequest(fn, args, key, true)).value;
94
171
  }
95
172
 
96
173
  close(): void {
97
- for (const r of this.rooms.values()) r.channel.leave();
174
+ for (const sub of this.subs.values()) {
175
+ clearTimeout(sub.timer);
176
+ clearTimeout(sub.retryTimer);
177
+ sub.request++;
178
+ }
179
+ for (const r of this.rooms.values()) r.close();
98
180
  this.rooms.clear();
99
181
  this.channel?.leave();
100
182
  this.socket?.disconnect();
@@ -102,38 +184,92 @@ export class ClientImpl {
102
184
  this.socket = undefined;
103
185
  this.subs.clear();
104
186
  this.subsById.clear();
187
+ this.joinedOnce = false;
105
188
  this.status.value = "closed";
106
189
  }
107
190
 
191
+ private onGone(): void {
192
+ this.gone = new EraspaceError("AppGone", "This app is no longer serving");
193
+ for (const sub of this.subs.values()) {
194
+ clearTimeout(sub.retryTimer);
195
+ sub.retryTimer = undefined;
196
+ sub.request++;
197
+ sub.error.value = this.gone;
198
+ }
199
+ for (const r of this.rooms.values()) r.close();
200
+ this.channel?.leave();
201
+ this.socket?.disconnect();
202
+ this.status.value = "closed";
203
+ }
204
+
205
+ private retrySub(sub: Subscription): void {
206
+ if (this.gone || sub.retryTimer || this.subs.get(sub.key) !== sub) return;
207
+ sub.retryTimer = setTimeout(() => {
208
+ sub.retryTimer = undefined;
209
+ if (this.subs.get(sub.key) === sub) this.sendSub(sub);
210
+ }, retryDelay(++sub.retries));
211
+ }
212
+
108
213
  // -- subscriptions --------------------------------------------------------------------------------------------
109
214
 
110
215
  private sendSub(sub: Subscription): void {
216
+ if (this.gone) { sub.error.value = this.gone; return; }
217
+ clearTimeout(sub.retryTimer);
218
+ sub.retryTimer = undefined;
111
219
  const oldId = sub.id;
112
220
  const request = ++sub.request;
221
+ // The server replies without the value when its cache entry still holds the revision we have.
222
+ const known = sub.hasServer && sub.entry !== undefined && sub.revision !== undefined ? { entry: sub.entry, revision: sub.revision } : undefined;
113
223
  if (request > 1) {
114
224
  this.subsById.delete(oldId);
115
225
  sub.id = String(this.nextSub++);
116
226
  this.subsById.set(sub.id, sub);
117
227
  sub.revision = undefined;
118
- this.push("unsub", { id: oldId }).catch(() => {});
228
+ this.unsubscribe(sub, oldId);
119
229
  }
120
- this.push<FullResult>("sub", { id: sub.id, fn: sub.fn, args: sub.args,
121
- minCommitSeq: Math.max(0, ...sub.layers.map(layer => layer.commitSeq ?? 0)),
230
+ const id = sub.id;
231
+ this.push<FullResult | UnchangedResult>("sub", () => {
232
+ // A newer request or a drop replaced this one before it was sent: withdraw it.
233
+ if (sub.request !== request) return null;
234
+ sub.sent = { id, join: this.joins };
235
+ const minCommitSeq = Math.max(0, ...sub.layers.map(layer => layer.commitSeq ?? 0));
236
+ return known ? { id, fn: sub.fn, args: sub.args, minCommitSeq, known } : { id, fn: sub.fn, args: sub.args, minCommitSeq };
122
237
  }).then(
123
238
  (resp) => {
124
239
  if (this.subsById.get(sub.id) !== sub || sub.request !== request) return;
125
- this.setServer(sub, resp.value, resp.commitSeq, resp.revision);
240
+ if ("unchanged" in resp && resp.unchanged === true) {
241
+ // Only a known revision is answered this way, and the value cannot have changed since it was sent.
242
+ if (known && sub.hasServer) this.setServer(sub, sub.server, resp.commitSeq, resp.revision);
243
+ else this.sendSub(sub);
244
+ return;
245
+ }
246
+ const full = resp as FullResult;
247
+ sub.entry = full.entry;
248
+ this.setServer(sub, full.value, full.commitSeq, full.revision);
126
249
  },
127
250
  (err: EraspaceError) => {
128
- if (this.subsById.get(sub.id) === sub && sub.request === request) sub.error.value = err;
251
+ if (this.subsById.get(sub.id) === sub && sub.request === request) {
252
+ sub.error.value = err;
253
+ this.retrySub(sub);
254
+ }
129
255
  },
130
256
  );
131
257
  }
132
258
 
259
+ /** Tells the server to drop `id`, unless the server cannot have it: never sent, or sent before a reconnect. */
260
+ private unsubscribe(sub: Subscription, id: string): void {
261
+ if (sub.sent?.id !== id || sub.sent.join !== this.joins) return;
262
+ sub.sent = undefined;
263
+ this.push("unsub", { id }).catch(() => {});
264
+ }
265
+
133
266
  // Messages for one subscription id arrive in order, so only commit watermarks are compared. Revisions
134
267
  // belong to a server cache entry and may restart lower when an identity-dependent query splits.
135
268
  private setServer(sub: Subscription, value: unknown, commitSeq: number, revision: number): void {
136
269
  if (commitSeq < sub.commitSeq) return;
270
+ clearTimeout(sub.retryTimer);
271
+ sub.retryTimer = undefined;
272
+ sub.retries = 0;
137
273
  batch(() => {
138
274
  sub.server = value;
139
275
  sub.hasServer = true;
@@ -146,6 +282,7 @@ export class ClientImpl {
146
282
  }
147
283
 
148
284
  private onData(message: DataMessage): void {
285
+ if (this.gone) return;
149
286
  const sub = this.subsById.get(String(message.id));
150
287
  if (!sub || message.commitSeq < sub.commitSeq) return;
151
288
  if ("patch" in message) {
@@ -159,6 +296,8 @@ export class ClientImpl {
159
296
  this.sendSub(sub);
160
297
  }
161
298
  } else {
299
+ // Recovered subscriptions arrive as full pushes that name their new cache entry.
300
+ if (typeof message.entry === "string") sub.entry = message.entry;
162
301
  this.setServer(sub, message.value, message.commitSeq, message.revision);
163
302
  }
164
303
  }
@@ -167,11 +306,18 @@ export class ClientImpl {
167
306
  for (const sub of this.subs.values()) this.sendSub(sub);
168
307
  }
169
308
 
309
+ // Subscriptions survive a deploy: the server re-runs them with the new code and pushes changed values.
170
310
  private onDeployed(version: number | undefined): void {
171
311
  if (typeof version === "number") this.version.value = version;
172
- this.resubscribeAll();
173
312
  for (const cb of [...this.deployedCbs]) cb(this.version.value);
174
- if (this.opts.reloadOnDeploy && typeof location !== "undefined") location.reload();
313
+ if (this.reloadOnDeploy && typeof location !== "undefined") location.reload();
314
+ }
315
+
316
+ // A deploy announced while the socket was down, or before the first join, shows up as a newer join version.
317
+ // Without a known page version there is nothing to compare, so the first one seen becomes the baseline.
318
+ private onJoinVersion(version: number): void {
319
+ if (this.version.value === 0) this.version.value = version;
320
+ else if (version > this.version.value) this.onDeployed(version);
175
321
  }
176
322
 
177
323
  onDeployedCb(cb: (v: number) => void): () => void {
@@ -187,6 +333,14 @@ export class ClientImpl {
187
333
  sub = new Subscription(String(this.nextSub++), fn, args, key);
188
334
  this.subs.set(key, sub);
189
335
  this.subsById.set(sub.id, sub);
336
+ // A result embedded in the page shows at once; the server confirms it with `unchanged` when the first
337
+ // `sub` sends it as `known`, or replies with the current value.
338
+ const seed = this.initial.get(key);
339
+ if (seed) {
340
+ this.initial.delete(key);
341
+ sub.entry = seed.entry;
342
+ this.setServer(sub, seed.value, seed.commitSeq, seed.revision);
343
+ }
190
344
  this.sendSub(sub);
191
345
  }
192
346
  if (sub.refs === 0) this.scheduleDrop(sub, 1000);
@@ -221,9 +375,12 @@ export class ClientImpl {
221
375
  sub.timer = setTimeout(() => {
222
376
  sub.timer = undefined;
223
377
  if (sub.refs > 0 || this.subs.get(sub.key) !== sub) return;
378
+ clearTimeout(sub.retryTimer);
379
+ sub.retryTimer = undefined;
380
+ sub.request++;
224
381
  this.subs.delete(sub.key);
225
382
  this.subsById.delete(sub.id);
226
- this.push("unsub", { id: sub.id }).catch(() => {});
383
+ this.unsubscribe(sub, sub.id);
227
384
  }, ms);
228
385
  }
229
386
 
@@ -245,7 +402,7 @@ export class ClientImpl {
245
402
  // -- mutations ------------------------------------------------------------------------------------------------
246
403
 
247
404
  async mutate(fn: string, args: unknown[], optimistic?: Fn): Promise<any> {
248
- if (!optimistic) return this.call(fn, args);
405
+ if (!optimistic) return (await this.mutationRequest(fn, args)).value;
249
406
  const mid = this.nextMutation++;
250
407
  const touched = new Set<Subscription>();
251
408
  const store = {
@@ -256,7 +413,7 @@ export class ClientImpl {
256
413
  };
257
414
  try {
258
415
  batch(() => optimistic(store, ...args));
259
- const { value, commitSeq } = await this.push<{ value: unknown; commitSeq?: number }>("call", { fn, args });
416
+ const { value, commitSeq } = await this.mutationRequest(fn, args);
260
417
  const timeout = this.opts.optimisticTimeoutMs ?? 2000;
261
418
  for (const sub of touched) {
262
419
  // Only mutations report a commit. Without one, no watermark can cover the layer.
@@ -269,9 +426,13 @@ export class ClientImpl {
269
426
  l.commitSeq = commitSeq;
270
427
  }
271
428
  if (sub.commitSeq >= commitSeq) this.dropLayers(sub, mid);
272
- else setTimeout(() => {
273
- if (sub.layers.some((l) => l.mid === mid) && this.subs.get(sub.key) === sub) this.sendSub(sub);
274
- }, timeout);
429
+ else {
430
+ // The server sends unchanged values' newer watermarks only on request.
431
+ this.watch(sub, commitSeq);
432
+ setTimeout(() => {
433
+ if (sub.layers.some((l) => l.mid === mid) && this.subs.get(sub.key) === sub) this.sendSub(sub);
434
+ }, timeout);
435
+ }
275
436
  }
276
437
  return value;
277
438
  } catch (e) {
@@ -280,6 +441,13 @@ export class ClientImpl {
280
441
  }
281
442
  }
282
443
 
444
+ /** Asks for a watermark covering `commitSeq` on the subscription's current server id. */
445
+ private watch(sub: Subscription, commitSeq: number): void {
446
+ const id = sub.id;
447
+ if (sub.sent?.id !== id || sub.sent.join !== this.joins) return;
448
+ this.push("watch", { id, commitSeq }).catch(() => {});
449
+ }
450
+
283
451
  private addLayer(mid: number, name: string, args: unknown[], apply: Fn, touched: Set<Subscription>): void {
284
452
  const sub = this.subs.get(subKey(name, args));
285
453
  if (!sub) return;
@@ -296,6 +464,10 @@ export class ClientImpl {
296
464
 
297
465
  // -- rooms ----------------------------------------------------------------------------------------------------
298
466
 
467
+ /**
468
+ * Get or create the room for `room`. Like a subscription, it is only held once retained: a render that never
469
+ * commits leaves an unretained room, which is dropped after a grace period.
470
+ */
299
471
  acquireRoom(room: string): RoomConn {
300
472
  const topic = `room:${this.app}:${room}`;
301
473
  let r = this.rooms.get(topic);
@@ -303,20 +475,31 @@ export class ClientImpl {
303
475
  r = new RoomConn(this.ensureSocket(), topic, this.self);
304
476
  this.rooms.set(topic, r);
305
477
  }
306
- if (r.timer) clearTimeout(r.timer);
478
+ if (r.refs === 0) this.scheduleRoomDrop(r, 1000);
479
+ return r;
480
+ }
481
+
482
+ /** False when the room was dropped after it was acquired; acquire it again. */
483
+ retainRoom(r: RoomConn): boolean {
484
+ if (this.rooms.get(r.topic) !== r) return false;
485
+ clearTimeout(r.timer);
307
486
  r.timer = undefined;
308
487
  r.refs++;
309
- return r;
488
+ return true;
310
489
  }
311
490
 
312
491
  releaseRoom(r: RoomConn): void {
313
492
  r.refs = Math.max(0, r.refs - 1);
314
- if (r.refs > 0) return;
315
- if (r.timer) clearTimeout(r.timer);
493
+ if (r.refs === 0) this.scheduleRoomDrop(r, this.opts.unsubscribeDelayMs ?? 50);
494
+ }
495
+
496
+ private scheduleRoomDrop(r: RoomConn, ms: number): void {
497
+ clearTimeout(r.timer);
316
498
  r.timer = setTimeout(() => {
499
+ r.timer = undefined;
317
500
  if (r.refs > 0 || this.rooms.get(r.topic) !== r) return;
318
501
  this.rooms.delete(r.topic);
319
- r.channel.leave();
320
- }, this.opts.unsubscribeDelayMs ?? 50);
502
+ r.close();
503
+ }, ms);
321
504
  }
322
505
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * An error raised by a server handler (or the transport). `name` is the server error name, for example `Error`,
3
- * `TypeError`, or `TimeoutError` for a call that got no reply in time.
3
+ * `TypeError`, or `OutcomeUnknownError` when a mutation or action could have applied.
4
4
  */
5
5
  export class EraspaceError extends Error {
6
6
  readonly data?: unknown;
@@ -26,3 +26,12 @@ export class EraspaceError extends Error {
26
26
  export function isEraspaceError(e: unknown): e is EraspaceError {
27
27
  return e instanceof EraspaceError;
28
28
  }
29
+
30
+ /** The mutation may have committed. Recover with `client.retryMutation(name, args, error.requestKey)`. */
31
+ export class OutcomeUnknownError extends EraspaceError {
32
+ readonly requestKey: string;
33
+ constructor(requestKey: string, message = "The request's outcome is unknown") {
34
+ super("OutcomeUnknownError", message);
35
+ this.requestKey = requestKey;
36
+ }
37
+ }