@pramen/server 0.0.57 → 0.0.58

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.
@@ -38,10 +38,11 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
38
38
  /** Live subscriptions per socket — held IN MEMORY, not in the WS attachment. The
39
39
  * attachment is capped at ~2 KB by workerd, and 64 subs (each with arbitrary input
40
40
  * JSON + a read-set + digest) blow past that well before MAX_SUBSCRIPTIONS. The
41
- * tradeoff: this map is lost on DO hibernation/eviction, so a woken socket has no
42
- * entry and is treated as having no active subscriptions acceptable because the
43
- * client replays its subscriptions on (re)connect. Keyed by the WebSocket object;
44
- * cleaned up in webSocketClose. */
41
+ * tradeoff: this map is lost on DO hibernation/eviction. That is NOT self-healing
42
+ * a hibernated socket stays OPEN, so the client sees no close, never replays, and
43
+ * every push to it is silently dropped forever. `subscribed` on the attachment is the
44
+ * one bit that survives to detect it; `subsFor` turns that into a close, and the close
45
+ * into a replay. Keyed by the WebSocket object; cleaned up in webSocketClose. */
45
46
  private readonly subsBySocket;
46
47
  constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp);
47
48
  private ensureMigrated;
@@ -96,6 +97,13 @@ export declare class PramenDOBase extends DurableObject<DoEnv> {
96
97
  * replays them on reconnect. */
97
98
  private getSubs;
98
99
  private setSubs;
100
+ /** This socket's subscriptions, or `null` if they were lost to hibernation.
101
+ *
102
+ * The two states the in-memory map cannot tell apart: a socket that has subscribed to
103
+ * nothing, and a socket whose subscriptions the map lost. Both read as an empty list;
104
+ * only the first is harmless. The attachment's `subscribed` bit is what survives
105
+ * hibernation, so it is what separates them. */
106
+ private subsFor;
99
107
  private send;
100
108
  }
101
109
  /** Produce the concrete, app-bound Durable Object class. A DO is constructed by the
@@ -34,6 +34,9 @@ const MAX_SUBSCRIPTIONS = 64;
34
34
  /** WebSocket close code for an auth failure (RFC 6455 leaves 4000-4999 to the app;
35
35
  * 4401 mirrors HTTP 401). Sent when a socket's token has expired since upgrade. */
36
36
  const WS_CLOSE_UNAUTHORIZED = 4401;
37
+ /** Application close code for "this socket's subscriptions did not survive hibernation".
38
+ * Sent so the client reconnects and replays them — which it already does on any close. */
39
+ const WS_CLOSE_RESUBSCRIBE = 4410;
37
40
  export class PramenDOBase extends DurableObject {
38
41
  app;
39
42
  acl;
@@ -59,10 +62,11 @@ export class PramenDOBase extends DurableObject {
59
62
  /** Live subscriptions per socket — held IN MEMORY, not in the WS attachment. The
60
63
  * attachment is capped at ~2 KB by workerd, and 64 subs (each with arbitrary input
61
64
  * JSON + a read-set + digest) blow past that well before MAX_SUBSCRIPTIONS. The
62
- * tradeoff: this map is lost on DO hibernation/eviction, so a woken socket has no
63
- * entry and is treated as having no active subscriptions acceptable because the
64
- * client replays its subscriptions on (re)connect. Keyed by the WebSocket object;
65
- * cleaned up in webSocketClose. */
65
+ * tradeoff: this map is lost on DO hibernation/eviction. That is NOT self-healing
66
+ * a hibernated socket stays OPEN, so the client sees no close, never replays, and
67
+ * every push to it is silently dropped forever. `subscribed` on the attachment is the
68
+ * one bit that survives to detect it; `subsFor` turns that into a close, and the close
69
+ * into a replay. Keyed by the WebSocket object; cleaned up in webSocketClose. */
66
70
  subsBySocket = new Map();
67
71
  constructor(ctx, env, app) {
68
72
  super(ctx, env);
@@ -291,6 +295,21 @@ export class PramenDOBase extends DurableObject {
291
295
  // frame and close 4401 so the client re-auths. Synthetic identities carry no exp.
292
296
  if (this.isExpired(att.identity))
293
297
  return this.rejectExpired(ws, msg.id);
298
+ // A woken socket whose subscriptions the map lost cannot be repaired one frame at a
299
+ // time: every id an `unsubscribe` or a re-`subscribe` names refers to a subscription
300
+ // this instance has never seen, and letting one through would repopulate the map —
301
+ // making the socket look healthy while the rest of its subscriptions stay zombies.
302
+ // Close it instead and let the client replay the whole set. A one-shot `call`
303
+ // depends on none of that, so it is answered normally.
304
+ if (msg.type !== "call" && att.subscribed && !this.subsBySocket.has(ws)) {
305
+ try {
306
+ ws.close(WS_CLOSE_RESUBSCRIBE, "resubscribe");
307
+ }
308
+ catch {
309
+ /* already closing */
310
+ }
311
+ return;
312
+ }
294
313
  await this.ensureMigrated();
295
314
  switch (msg.type) {
296
315
  case "subscribe":
@@ -380,7 +399,20 @@ export class PramenDOBase extends DurableObject {
380
399
  this.subsBySocket.delete(ws);
381
400
  continue;
382
401
  }
383
- const subs = this.getSubs(ws);
402
+ const subs = this.subsFor(ws, att);
403
+ // Lost to hibernation. Closing is the fix, not a fallback: the client replays
404
+ // every subscription on `open`, and re-running them there returns the state this
405
+ // broadcast was carrying anyway. Pushing on would push to nobody.
406
+ if (subs === null) {
407
+ try {
408
+ ws.close(WS_CLOSE_RESUBSCRIBE, "resubscribe");
409
+ }
410
+ catch {
411
+ /* already closing */
412
+ }
413
+ this.subsBySocket.delete(ws);
414
+ continue;
415
+ }
384
416
  let dirty = false;
385
417
  for (const sub of subs) {
386
418
  if (!sub.tables.some((t) => written.has(t)))
@@ -604,6 +636,27 @@ export class PramenDOBase extends DurableObject {
604
636
  }
605
637
  setSubs(ws, subs) {
606
638
  this.subsBySocket.set(ws, subs);
639
+ // Keep the durable marker in step, and only when it actually flips — an attachment
640
+ // write per subscription update would be churn for nothing.
641
+ const att = this.getAttachment(ws);
642
+ const subscribed = subs.length > 0;
643
+ if ((att.subscribed ?? false) !== subscribed)
644
+ this.setAttachment(ws, { ...att, subscribed });
645
+ }
646
+ /** This socket's subscriptions, or `null` if they were lost to hibernation.
647
+ *
648
+ * The two states the in-memory map cannot tell apart: a socket that has subscribed to
649
+ * nothing, and a socket whose subscriptions the map lost. Both read as an empty list;
650
+ * only the first is harmless. The attachment's `subscribed` bit is what survives
651
+ * hibernation, so it is what separates them. */
652
+ subsFor(ws, att) {
653
+ const subs = this.subsBySocket.get(ws);
654
+ // An ABSENT entry is the signal, not an empty one: the upgrade seeds every socket
655
+ // with `[]`, so "no entry" can only mean this instance never saw this socket — it
656
+ // was accepted by an instance that has since been evicted.
657
+ if (subs)
658
+ return subs;
659
+ return att.subscribed ? null : [];
607
660
  }
608
661
  send(ws, msg) {
609
662
  ws.send(JSON.stringify(msg));
@@ -29,7 +29,10 @@ export type ServerMsg = {
29
29
  id: string;
30
30
  error: string;
31
31
  };
32
- /** A live subscription, persisted on the socket so it survives DO hibernation. */
32
+ /** A live subscription. Held in the DO's memory, NOT on the socket the attachment is
33
+ * capped at ~2 KB and a full set of these blows past it. Only a one-bit `subscribed`
34
+ * marker rides the attachment, which is enough for the DO to notice the loss and close
35
+ * the socket so the client replays. */
33
36
  export interface Subscription {
34
37
  id: string;
35
38
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pramen/server",
3
- "version": "0.0.57",
3
+ "version": "0.0.58",
4
4
  "description": "pramen server runtime \u2014 schema, ACL, ORM, live queries, files, and the createPramen(app) factory for Cloudflare Workers + Durable Objects.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -48,6 +48,11 @@ interface SocketAttachment {
48
48
  /** Partition fixed at connect time (read from x-pramen-partition at upgrade);
49
49
  * survives hibernation via the attachment, like `tenant`. */
50
50
  partition: string;
51
+ /** Whether this socket had any live subscription. One bit, not the list — the list is
52
+ * far too big for the attachment, but this is enough to tell "subscribed to nothing"
53
+ * apart from "subscriptions lost to hibernation", which otherwise look identical and
54
+ * silently kill every push on the socket. See `subsFor`. */
55
+ subscribed?: boolean;
51
56
  }
52
57
 
53
58
  export interface DoEnv {
@@ -71,6 +76,10 @@ const MAX_SUBSCRIPTIONS = 64;
71
76
  * 4401 mirrors HTTP 401). Sent when a socket's token has expired since upgrade. */
72
77
  const WS_CLOSE_UNAUTHORIZED = 4401;
73
78
 
79
+ /** Application close code for "this socket's subscriptions did not survive hibernation".
80
+ * Sent so the client reconnects and replays them — which it already does on any close. */
81
+ const WS_CLOSE_RESUBSCRIBE = 4410;
82
+
74
83
  export class PramenDOBase extends DurableObject<DoEnv> {
75
84
  private readonly app: PramenApp;
76
85
  private readonly acl: CompiledAcl;
@@ -96,10 +105,11 @@ export class PramenDOBase extends DurableObject<DoEnv> {
96
105
  /** Live subscriptions per socket — held IN MEMORY, not in the WS attachment. The
97
106
  * attachment is capped at ~2 KB by workerd, and 64 subs (each with arbitrary input
98
107
  * JSON + a read-set + digest) blow past that well before MAX_SUBSCRIPTIONS. The
99
- * tradeoff: this map is lost on DO hibernation/eviction, so a woken socket has no
100
- * entry and is treated as having no active subscriptions acceptable because the
101
- * client replays its subscriptions on (re)connect. Keyed by the WebSocket object;
102
- * cleaned up in webSocketClose. */
108
+ * tradeoff: this map is lost on DO hibernation/eviction. That is NOT self-healing
109
+ * a hibernated socket stays OPEN, so the client sees no close, never replays, and
110
+ * every push to it is silently dropped forever. `subscribed` on the attachment is the
111
+ * one bit that survives to detect it; `subsFor` turns that into a close, and the close
112
+ * into a replay. Keyed by the WebSocket object; cleaned up in webSocketClose. */
103
113
  private readonly subsBySocket = new Map<WebSocket, Subscription[]>();
104
114
 
105
115
  constructor(ctx: DurableObjectState, env: DoEnv, app: PramenApp) {
@@ -355,6 +365,21 @@ export class PramenDOBase extends DurableObject<DoEnv> {
355
365
  // frame and close 4401 so the client re-auths. Synthetic identities carry no exp.
356
366
  if (this.isExpired(att.identity)) return this.rejectExpired(ws, msg.id);
357
367
 
368
+ // A woken socket whose subscriptions the map lost cannot be repaired one frame at a
369
+ // time: every id an `unsubscribe` or a re-`subscribe` names refers to a subscription
370
+ // this instance has never seen, and letting one through would repopulate the map —
371
+ // making the socket look healthy while the rest of its subscriptions stay zombies.
372
+ // Close it instead and let the client replay the whole set. A one-shot `call`
373
+ // depends on none of that, so it is answered normally.
374
+ if (msg.type !== "call" && att.subscribed && !this.subsBySocket.has(ws)) {
375
+ try {
376
+ ws.close(WS_CLOSE_RESUBSCRIBE, "resubscribe");
377
+ } catch {
378
+ /* already closing */
379
+ }
380
+ return;
381
+ }
382
+
358
383
  await this.ensureMigrated();
359
384
 
360
385
  switch (msg.type) {
@@ -445,7 +470,19 @@ export class PramenDOBase extends DurableObject<DoEnv> {
445
470
  this.subsBySocket.delete(ws);
446
471
  continue;
447
472
  }
448
- const subs = this.getSubs(ws);
473
+ const subs = this.subsFor(ws, att);
474
+ // Lost to hibernation. Closing is the fix, not a fallback: the client replays
475
+ // every subscription on `open`, and re-running them there returns the state this
476
+ // broadcast was carrying anyway. Pushing on would push to nobody.
477
+ if (subs === null) {
478
+ try {
479
+ ws.close(WS_CLOSE_RESUBSCRIBE, "resubscribe");
480
+ } catch {
481
+ /* already closing */
482
+ }
483
+ this.subsBySocket.delete(ws);
484
+ continue;
485
+ }
449
486
  let dirty = false;
450
487
  for (const sub of subs) {
451
488
  if (!sub.tables.some((t) => written.has(t))) continue;
@@ -680,6 +717,26 @@ export class PramenDOBase extends DurableObject<DoEnv> {
680
717
 
681
718
  private setSubs(ws: WebSocket, subs: Subscription[]): void {
682
719
  this.subsBySocket.set(ws, subs);
720
+ // Keep the durable marker in step, and only when it actually flips — an attachment
721
+ // write per subscription update would be churn for nothing.
722
+ const att = this.getAttachment(ws);
723
+ const subscribed = subs.length > 0;
724
+ if ((att.subscribed ?? false) !== subscribed) this.setAttachment(ws, { ...att, subscribed });
725
+ }
726
+
727
+ /** This socket's subscriptions, or `null` if they were lost to hibernation.
728
+ *
729
+ * The two states the in-memory map cannot tell apart: a socket that has subscribed to
730
+ * nothing, and a socket whose subscriptions the map lost. Both read as an empty list;
731
+ * only the first is harmless. The attachment's `subscribed` bit is what survives
732
+ * hibernation, so it is what separates them. */
733
+ private subsFor(ws: WebSocket, att: SocketAttachment): Subscription[] | null {
734
+ const subs = this.subsBySocket.get(ws);
735
+ // An ABSENT entry is the signal, not an empty one: the upgrade seeds every socket
736
+ // with `[]`, so "no entry" can only mean this instance never saw this socket — it
737
+ // was accepted by an instance that has since been evicted.
738
+ if (subs) return subs;
739
+ return att.subscribed ? null : [];
683
740
  }
684
741
 
685
742
  private send(ws: WebSocket, msg: ServerMsg): void {
@@ -36,7 +36,10 @@ export type ServerMsg =
36
36
  | { type: "result"; id: string; result: unknown }
37
37
  | { type: "error"; id: string; error: string };
38
38
 
39
- /** A live subscription, persisted on the socket so it survives DO hibernation. */
39
+ /** A live subscription. Held in the DO's memory, NOT on the socket the attachment is
40
+ * capped at ~2 KB and a full set of these blows past it. Only a one-bit `subscribed`
41
+ * marker rides the attachment, which is enough for the DO to notice the loss and close
42
+ * the socket so the client replays. */
40
43
  export interface Subscription {
41
44
  id: string;
42
45
  name: string;