eraspace 0.1.0 → 0.2.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.
@@ -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";
4
+ import { EraspaceError, OutcomeUnknownError } from "./errors.ts";
5
5
  import { readConfig, type ClientOptions } from "./config.ts";
6
+ import { requestKey } from "./request-key.ts";
6
7
  import { subKey } from "./sub-key.ts";
7
8
  import { Subscription, type FullResult, 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;
@@ -21,6 +23,11 @@ export class ClientImpl {
21
23
  private socket: Socket | undefined;
22
24
  private channel: Channel | undefined;
23
25
  private joinedOnce = false;
26
+ /** Counts app channel joins. Server subscription ids live only as long as the join that created them. */
27
+ private joins = 0;
28
+ /** Server time minus local time, from the latest join reply. */
29
+ private clockOffset = 0;
30
+ private gone: EraspaceError | undefined;
24
31
  private nextSub = 1;
25
32
  private nextMutation = 1;
26
33
  private subs = new Map<string, Subscription>();
@@ -42,16 +49,13 @@ export class ClientImpl {
42
49
 
43
50
  private ensureSocket(): Socket {
44
51
  if (this.socket) return this.socket;
45
- const socketOpts: Record<string, unknown> = {
46
- params: () => ({ app: this.app }),
47
- timeout: this.opts.timeout ?? 15000,
52
+ const socket = new Socket(this.socketUrl, {
53
+ timeout: this.opts.timeout,
48
54
  ...this.opts.socketOptions,
49
- };
50
- if (this.opts.transport) socketOpts.transport = this.opts.transport;
51
- const socket = new Socket(this.socketUrl, socketOpts as any);
55
+ transport: this.opts.transport,
56
+ });
52
57
  socket.onOpen(() => { this.status.value = "open"; });
53
58
  socket.onClose(() => { this.status.value = "closed"; });
54
- socket.onError(() => { this.status.value = "closed"; });
55
59
  socket.connect();
56
60
  this.socket = socket;
57
61
  return socket;
@@ -60,41 +64,103 @@ export class ClientImpl {
60
64
  private ensureChannel(): Channel {
61
65
  if (this.channel) return this.channel;
62
66
  const socket = this.ensureSocket();
63
- const channel = socket.channel(`app:${this.app}`, { patches: true, watermarks: true });
67
+ const channel = socket.channel(`app:${this.app}`, {});
64
68
  channel.on("data", (m: DataMessage) => this.onData(m));
65
69
  channel.on("error", (m: { id?: string; name?: string; message?: string }) => {
66
70
  const sub = m.id !== undefined ? this.subsById.get(String(m.id)) : undefined;
67
- if (sub) sub.error.value = EraspaceError.from(m);
71
+ if (sub) {
72
+ sub.error.value = EraspaceError.from(m);
73
+ this.retrySub(sub);
74
+ }
68
75
  });
76
+ channel.on("gone", () => this.onGone());
77
+ // Transport loss changes status; reconnect rejoins and resubscribes while cached values stay valid.
78
+ // A join the server rejects is reported to every subscription.
79
+ const failed = (payload: unknown, rejected: boolean) => {
80
+ if (this.gone) return;
81
+ if (payload instanceof EraspaceError && payload.name === "AppGone") { this.onGone(); return; }
82
+ this.status.value = "closed";
83
+ if (rejected) for (const sub of this.subs.values()) sub.error.value = EraspaceError.from(payload, "App channel join failed");
84
+ };
85
+ channel.onError((reason, rejected) => failed(reason, rejected));
86
+ channel.onClose(() => failed(undefined, false));
69
87
  channel.on("deployed", (m: { version?: number }) => this.onDeployed(m?.version));
70
- channel.join().receive("ok", (resp: { self?: string } | undefined) => {
88
+ channel.onJoin((resp) => {
89
+ this.joins++;
90
+ if (this.gone) return;
91
+ this.status.value = "open";
71
92
  if (resp && typeof resp.self === "string") this.self.value = resp.self;
93
+ if (resp && typeof resp.now === "number") this.clockOffset = resp.now - Date.now();
72
94
  // The channel process is new after a reconnect, so every live subscription is sent again.
73
95
  if (this.joinedOnce) this.resubscribeAll();
74
96
  this.joinedOnce = true;
75
97
  });
98
+ channel.join();
76
99
  this.channel = channel;
77
100
  return channel;
78
101
  }
79
102
 
80
- private push<T>(event: string, payload: object): Promise<T> {
103
+ private push<T>(event: string, payload: object | (() => object | null)): Promise<T> {
104
+ if (this.gone) return Promise.reject(this.gone);
81
105
  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
- });
106
+ return channel.push<T>(event, payload, this.opts.timeout ?? 15000);
107
+ }
108
+
109
+ async call(fn: string, args: unknown[], kind: "query" | "action" = "query"): Promise<unknown> {
110
+ const key = this.requestKey();
111
+ try {
112
+ const resp = await this.push<{ value?: unknown }>("call", { fn, args, kind, key });
113
+ return resp?.value;
114
+ } catch (error) {
115
+ if (kind === "action" && error instanceof EraspaceError && ["TimeoutError", "OutcomeUnknownError"].includes(error.name)) {
116
+ throw new OutcomeUnknownError(key, "The action may have performed effects; it cannot be replayed safely");
117
+ }
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ /** Request keys carry server time, so a skewed client clock cannot place them outside the retry window. */
123
+ private requestKey(): string {
124
+ return requestKey(Date.now() + this.clockOffset);
125
+ }
126
+
127
+ private async mutationRequest(fn: string, args: unknown[], key = this.requestKey(), retry = false): Promise<{ value: unknown; commitSeq?: number }> {
128
+ let rekeyed = false;
129
+ for (let attempt = 0; ; attempt++) {
130
+ try {
131
+ return await this.push("call", { fn, args, kind: "mutation", key, retry });
132
+ } catch (error) {
133
+ // The server refused the key before running anything. The join reply has corrected the clock by now.
134
+ if (error instanceof EraspaceError && error.name === "RequestKeyError" && !retry && !rekeyed) {
135
+ rekeyed = true;
136
+ attempt--;
137
+ key = this.requestKey();
138
+ continue;
139
+ }
140
+ const uncertain = error instanceof EraspaceError && ["TimeoutError", "OutcomeUnknownError", "RetryExpiredError"].includes(error.name);
141
+ // A rejected recovery attempt cannot resolve the original request's unknown outcome.
142
+ if (!uncertain && !retry) throw error;
143
+ if (uncertain && attempt === 0 && error.name !== "RetryExpiredError") {
144
+ retry = true;
145
+ continue;
146
+ }
147
+ const message = error instanceof Error ? error.message : "Recovery failed";
148
+ throw new OutcomeUnknownError(key, `${message}. Recover this mutation only with the same request key.`);
149
+ }
150
+ }
89
151
  }
90
152
 
91
- async call(fn: string, args: unknown[]): Promise<any> {
92
- const resp = await this.push<{ value?: unknown }>("call", { fn, args });
93
- return resp?.value;
153
+ async retryMutation(fn: string, args: unknown[], key: string): Promise<unknown> {
154
+ return (await this.mutationRequest(fn, args, key, true)).value;
94
155
  }
95
156
 
96
157
  close(): void {
97
- for (const r of this.rooms.values()) r.channel.leave();
158
+ for (const sub of this.subs.values()) {
159
+ clearTimeout(sub.timer);
160
+ clearTimeout(sub.retryTimer);
161
+ sub.request++;
162
+ }
163
+ for (const r of this.rooms.values()) r.close();
98
164
  this.rooms.clear();
99
165
  this.channel?.leave();
100
166
  this.socket?.disconnect();
@@ -102,12 +168,38 @@ export class ClientImpl {
102
168
  this.socket = undefined;
103
169
  this.subs.clear();
104
170
  this.subsById.clear();
171
+ this.joinedOnce = false;
172
+ this.status.value = "closed";
173
+ }
174
+
175
+ private onGone(): void {
176
+ this.gone = new EraspaceError("AppGone", "This app is no longer serving");
177
+ for (const sub of this.subs.values()) {
178
+ clearTimeout(sub.retryTimer);
179
+ sub.retryTimer = undefined;
180
+ sub.request++;
181
+ sub.error.value = this.gone;
182
+ }
183
+ for (const r of this.rooms.values()) r.close();
184
+ this.channel?.leave();
185
+ this.socket?.disconnect();
105
186
  this.status.value = "closed";
106
187
  }
107
188
 
189
+ private retrySub(sub: Subscription): void {
190
+ if (this.gone || sub.retryTimer || this.subs.get(sub.key) !== sub) return;
191
+ sub.retryTimer = setTimeout(() => {
192
+ sub.retryTimer = undefined;
193
+ if (this.subs.get(sub.key) === sub) this.sendSub(sub);
194
+ }, retryDelay(++sub.retries));
195
+ }
196
+
108
197
  // -- subscriptions --------------------------------------------------------------------------------------------
109
198
 
110
199
  private sendSub(sub: Subscription): void {
200
+ if (this.gone) { sub.error.value = this.gone; return; }
201
+ clearTimeout(sub.retryTimer);
202
+ sub.retryTimer = undefined;
111
203
  const oldId = sub.id;
112
204
  const request = ++sub.request;
113
205
  if (request > 1) {
@@ -115,25 +207,42 @@ export class ClientImpl {
115
207
  sub.id = String(this.nextSub++);
116
208
  this.subsById.set(sub.id, sub);
117
209
  sub.revision = undefined;
118
- this.push("unsub", { id: oldId }).catch(() => {});
210
+ this.unsubscribe(sub, oldId);
119
211
  }
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)),
212
+ const id = sub.id;
213
+ this.push<FullResult>("sub", () => {
214
+ // A newer request or a drop replaced this one before it was sent: withdraw it.
215
+ if (sub.request !== request) return null;
216
+ sub.sent = { id, join: this.joins };
217
+ return { id, fn: sub.fn, args: sub.args, minCommitSeq: Math.max(0, ...sub.layers.map(layer => layer.commitSeq ?? 0)) };
122
218
  }).then(
123
219
  (resp) => {
124
220
  if (this.subsById.get(sub.id) !== sub || sub.request !== request) return;
125
221
  this.setServer(sub, resp.value, resp.commitSeq, resp.revision);
126
222
  },
127
223
  (err: EraspaceError) => {
128
- if (this.subsById.get(sub.id) === sub && sub.request === request) sub.error.value = err;
224
+ if (this.subsById.get(sub.id) === sub && sub.request === request) {
225
+ sub.error.value = err;
226
+ this.retrySub(sub);
227
+ }
129
228
  },
130
229
  );
131
230
  }
132
231
 
232
+ /** Tells the server to drop `id`, unless the server cannot have it: never sent, or sent before a reconnect. */
233
+ private unsubscribe(sub: Subscription, id: string): void {
234
+ if (sub.sent?.id !== id || sub.sent.join !== this.joins) return;
235
+ sub.sent = undefined;
236
+ this.push("unsub", { id }).catch(() => {});
237
+ }
238
+
133
239
  // Messages for one subscription id arrive in order, so only commit watermarks are compared. Revisions
134
240
  // belong to a server cache entry and may restart lower when an identity-dependent query splits.
135
241
  private setServer(sub: Subscription, value: unknown, commitSeq: number, revision: number): void {
136
242
  if (commitSeq < sub.commitSeq) return;
243
+ clearTimeout(sub.retryTimer);
244
+ sub.retryTimer = undefined;
245
+ sub.retries = 0;
137
246
  batch(() => {
138
247
  sub.server = value;
139
248
  sub.hasServer = true;
@@ -146,6 +255,7 @@ export class ClientImpl {
146
255
  }
147
256
 
148
257
  private onData(message: DataMessage): void {
258
+ if (this.gone) return;
149
259
  const sub = this.subsById.get(String(message.id));
150
260
  if (!sub || message.commitSeq < sub.commitSeq) return;
151
261
  if ("patch" in message) {
@@ -167,9 +277,9 @@ export class ClientImpl {
167
277
  for (const sub of this.subs.values()) this.sendSub(sub);
168
278
  }
169
279
 
280
+ // Subscriptions survive a deploy: the server re-runs them with the new code and pushes changed values.
170
281
  private onDeployed(version: number | undefined): void {
171
282
  if (typeof version === "number") this.version.value = version;
172
- this.resubscribeAll();
173
283
  for (const cb of [...this.deployedCbs]) cb(this.version.value);
174
284
  if (this.opts.reloadOnDeploy && typeof location !== "undefined") location.reload();
175
285
  }
@@ -221,9 +331,12 @@ export class ClientImpl {
221
331
  sub.timer = setTimeout(() => {
222
332
  sub.timer = undefined;
223
333
  if (sub.refs > 0 || this.subs.get(sub.key) !== sub) return;
334
+ clearTimeout(sub.retryTimer);
335
+ sub.retryTimer = undefined;
336
+ sub.request++;
224
337
  this.subs.delete(sub.key);
225
338
  this.subsById.delete(sub.id);
226
- this.push("unsub", { id: sub.id }).catch(() => {});
339
+ this.unsubscribe(sub, sub.id);
227
340
  }, ms);
228
341
  }
229
342
 
@@ -245,7 +358,7 @@ export class ClientImpl {
245
358
  // -- mutations ------------------------------------------------------------------------------------------------
246
359
 
247
360
  async mutate(fn: string, args: unknown[], optimistic?: Fn): Promise<any> {
248
- if (!optimistic) return this.call(fn, args);
361
+ if (!optimistic) return (await this.mutationRequest(fn, args)).value;
249
362
  const mid = this.nextMutation++;
250
363
  const touched = new Set<Subscription>();
251
364
  const store = {
@@ -256,7 +369,7 @@ export class ClientImpl {
256
369
  };
257
370
  try {
258
371
  batch(() => optimistic(store, ...args));
259
- const { value, commitSeq } = await this.push<{ value: unknown; commitSeq?: number }>("call", { fn, args });
372
+ const { value, commitSeq } = await this.mutationRequest(fn, args);
260
373
  const timeout = this.opts.optimisticTimeoutMs ?? 2000;
261
374
  for (const sub of touched) {
262
375
  // Only mutations report a commit. Without one, no watermark can cover the layer.
@@ -296,6 +409,10 @@ export class ClientImpl {
296
409
 
297
410
  // -- rooms ----------------------------------------------------------------------------------------------------
298
411
 
412
+ /**
413
+ * Get or create the room for `room`. Like a subscription, it is only held once retained: a render that never
414
+ * commits leaves an unretained room, which is dropped after a grace period.
415
+ */
299
416
  acquireRoom(room: string): RoomConn {
300
417
  const topic = `room:${this.app}:${room}`;
301
418
  let r = this.rooms.get(topic);
@@ -303,20 +420,31 @@ export class ClientImpl {
303
420
  r = new RoomConn(this.ensureSocket(), topic, this.self);
304
421
  this.rooms.set(topic, r);
305
422
  }
306
- if (r.timer) clearTimeout(r.timer);
423
+ if (r.refs === 0) this.scheduleRoomDrop(r, 1000);
424
+ return r;
425
+ }
426
+
427
+ /** False when the room was dropped after it was acquired; acquire it again. */
428
+ retainRoom(r: RoomConn): boolean {
429
+ if (this.rooms.get(r.topic) !== r) return false;
430
+ clearTimeout(r.timer);
307
431
  r.timer = undefined;
308
432
  r.refs++;
309
- return r;
433
+ return true;
310
434
  }
311
435
 
312
436
  releaseRoom(r: RoomConn): void {
313
437
  r.refs = Math.max(0, r.refs - 1);
314
- if (r.refs > 0) return;
315
- if (r.timer) clearTimeout(r.timer);
438
+ if (r.refs === 0) this.scheduleRoomDrop(r, this.opts.unsubscribeDelayMs ?? 50);
439
+ }
440
+
441
+ private scheduleRoomDrop(r: RoomConn, ms: number): void {
442
+ clearTimeout(r.timer);
316
443
  r.timer = setTimeout(() => {
444
+ r.timer = undefined;
317
445
  if (r.refs > 0 || this.rooms.get(r.topic) !== r) return;
318
446
  this.rooms.delete(r.topic);
319
- r.channel.leave();
320
- }, this.opts.unsubscribeDelayMs ?? 50);
447
+ r.close();
448
+ }, ms);
321
449
  }
322
450
  }
@@ -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
+ }