@ultimat3/realtime 9.0.0 → 11.0.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/src/server.ts CHANGED
@@ -121,6 +121,9 @@ export {
121
121
  type ReplicationStreamStats,
122
122
  } from './pg-replication';
123
123
  export { bunPgStream, type PgTarget, parsePgUrl, type SslMode } from './pg-socket';
124
+ // The value domain a WAL tuple lands in. Public because it is `PgOutputMessage`'s and
125
+ // `entityRow`'s: a caller naming either type has to be able to name what is inside one.
126
+ export { decodeValue, type PhysicalRow, type PhysicalValue } from './pg-values';
124
127
  export type { PgStream } from './pg-wire';
125
128
  export {
126
129
  type PgColumn,
package/src/socket.ts CHANGED
@@ -185,7 +185,21 @@ export class SyncSocket {
185
185
  }
186
186
  return false;
187
187
  }
188
- this.#ws.send(encode(frame));
188
+ // `WsLike.send` is declared `: number` for this line and no other: Bun answers `0` for a
189
+ // message it DROPPED — the socket closed between the buffered-amount check above and this
190
+ // write — and `-1` under backpressure. Discarded, a dropped frame read as delivered, so
191
+ // `live-fanout` advanced the subscriber's cursor past a patch that never left and
192
+ // `sync-frames`' desync mark was never taken: permanently stale on a healthy socket, which is
193
+ // the exact outcome every other `socket.send` on this node reads its answer to prevent.
194
+ if (this.#ws.send(encode(frame)) <= 0) {
195
+ this.droppedFrames += 1;
196
+ // The same ceiling backpressure takes: a socket the runtime keeps refusing is one to close,
197
+ // and the two are one failure — the write went nowhere either way.
198
+ if (this.droppedFrames > this.#maxDroppedFrames) {
199
+ this.close(CLOSE.overloaded, 'backpressure');
200
+ }
201
+ return false;
202
+ }
189
203
  this.sentFrames += 1;
190
204
  return true;
191
205
  }
package/src/sync-node.ts CHANGED
@@ -6,6 +6,8 @@
6
6
 
7
7
  import { type Clock, logger, markReady, reportError, systemClock, uuid } from '@ultimat3/core';
8
8
  import type { ChannelHub, Topic } from './channel';
9
+ import { detach } from './detach';
10
+ import { evictInChunks } from './drain-evictions';
9
11
  import { isClientFault } from './errors';
10
12
  import type { Transport, TransportSubscription } from './fanout';
11
13
  import type { LiveQueryRegistry } from './live-query';
@@ -162,25 +164,6 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
162
164
  let reauthing: ReturnType<typeof setInterval> | null = null;
163
165
  let idling: ReturnType<typeof setInterval> | null = null;
164
166
 
165
- /**
166
- * Work nobody is waiting on — a presence leave from a synchronous close, a sweep on a timer, a
167
- * fanout off the change bus. It reaches the bus or a policy, so it can fail; failing must not take
168
- * a socket or the process with it, and must not be silent either, or "the room still shows someone
169
- * who left" and "that change reached nobody" have nothing to read. `operation` stays low
170
- * cardinality so the monitor can group on it; the topic or entity goes in `at`.
171
- */
172
- const detach = (work: Promise<unknown>, operation: string, at?: string): void => {
173
- void work.catch((error: unknown) => {
174
- logger.error(`${operation} failed`, {
175
- ...(at === undefined ? {} : { at }),
176
- error: error instanceof Error ? error.message : String(error),
177
- });
178
- // Nobody is awaiting this, so the log is the only trace it leaves — and a log is not a
179
- // signal anyone is paged on. The bus is this node's dependency, never the client's.
180
- reportError(error, { source: 'realtime', scope: { operation } });
181
- });
182
- };
183
-
184
167
  /**
185
168
  * Everything `start()` acquired that is not a socket: the change subscription and the presence
186
169
  * sweep. Both `drain()` and `stop()` run it, because a `drain()` is terminal on its own — it
@@ -205,8 +188,12 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
205
188
  * Everything one socket held, released once. Bun's `close` callback runs it, and so does a
206
189
  * revoked grant — a socket this node closes itself gets no callback in a unit test, and in
207
190
  * production the second run is the no-op every step here already is.
191
+ *
192
+ * Returns the presence leaves it started, which is the only step here that is not over when this
193
+ * function returns: `close` is a SYNCHRONOUS Bun callback and cannot await one, so the promise is
194
+ * both detached (that path has nobody to wait for it) and handed back (the drain does).
208
195
  */
209
- const teardown = (socket: SyncSocket): void => {
196
+ const teardown = (socket: SyncSocket): readonly Promise<unknown>[] => {
210
197
  options.registry.unsubscribeSocket(socket.id);
211
198
  const topics = [...socket.topics] as Topic[];
212
199
  for (const name of topics) options.hub.unsubscribe(socket, name);
@@ -215,9 +202,17 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
215
202
  // A closed socket is a leave, said now rather than left to TTL: everyone else would otherwise
216
203
  // keep rendering a member who is provably gone for the rest of its window. The write is on the
217
204
  // bus and the close callback is synchronous, so it cannot be awaited here.
205
+ const leaves: Promise<unknown>[] = [];
218
206
  if (presence) {
219
- for (const name of topics) detach(presence.leave(name, socket.id), 'presence.leave', name);
207
+ for (const name of topics) {
208
+ const leave = presence.leave(name, socket.id);
209
+ // Detached as well as returned: `detach` attaches the reporting catch, so a caller that
210
+ // awaits this later is awaiting a promise whose rejection is already handled.
211
+ detach(leave, 'presence.leave', name);
212
+ leaves.push(leave);
213
+ }
220
214
  }
215
+ return leaves;
221
216
  };
222
217
 
223
218
  /**
@@ -226,9 +221,9 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
226
221
  * because dropping the socket from the table is three of `teardown`'s five steps and the two it
227
222
  * misses are the ones another node can see.
228
223
  */
229
- const evict = (socket: SyncSocket, code: number, reason: string): void => {
224
+ const evict = (socket: SyncSocket, code: number, reason: string): readonly Promise<unknown>[] => {
230
225
  socket.close(code, reason);
231
- teardown(socket);
226
+ return teardown(socket);
232
227
  };
233
228
 
234
229
  /**
@@ -478,9 +473,12 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
478
473
  // five steps, and the two they skip are the ones the rest of the fleet can see. A drained
479
474
  // socket that never left its presence set is a member every other node renders for a full
480
475
  // TTL — during a rolling restart, beside the same client's reconnection under a new id —
481
- // and its live subscriptions stay in the registry, so `entry.subscribers` never empties and
482
- // the matcher, the shared window and the retained ring are pinned for the process's life.
483
- for (const socket of [...sockets.all()]) evict(socket, CLOSE.goingAway, 'drain');
476
+ // and its live subscriptions stay in the registry, so `entry.subscribers` never empties.
477
+ //
478
+ // AWAITED, in chunks: a leave is a write to the shared set, so a drain that merely started
479
+ // them released, closed the hub and let the process exit with N·M writes still on the wire —
480
+ // which is that same full-TTL double vision, reached the long way round.
481
+ await evictInChunks([...sockets.all()], (socket) => evict(socket, CLOSE.goingAway, 'drain'));
484
482
  // Released once the sockets are gone rather than at the top: a client is entitled to its
485
483
  // patches for the whole grace window, and it is entitled to them *before* the hub the
486
484
  // fanout writes through is closed.
@@ -101,12 +101,21 @@ export async function handleUpgrade(
101
101
  );
102
102
  }
103
103
  }
104
- // Asked again, because `authenticate` is app code and awaiting it is awaiting a token service: a
105
- // request that passed the check above can be parked there when SIGTERM lands, and the `accept`
106
- // phase is over by the time it gets here. Upgrading then is the one socket that phase exists to
107
- // refuse the load balancer has already been told this node is out, so nothing takes it over. No
108
- // second `tryAccept()`: that budget was spent above.
109
- if (!deps.ready()) return shed(deps);
104
+ // BOTH facts asked again, because `authenticate` is app code and awaiting it is awaiting a token
105
+ // service: everything this request read above is history by the time it gets here.
106
+ //
107
+ // `ready`, because SIGTERM can land while the request is parked and the `accept` phase is over
108
+ // by now — upgrading then is the one socket that phase exists to refuse, on a node the load
109
+ // balancer has already been told is out.
110
+ //
111
+ // The socket COUNT, for the same reason and it was the half that was missing: a restart storm
112
+ // dials every client of a dead node at this one at once, each parked in the token service having
113
+ // passed the cap while the node still held nothing — so a node capped at 2 accepted as many
114
+ // sockets as there were parked requests, and `maxConnections` bounded nothing that a herd could
115
+ // reach. Sound because there is no await between this line and `server.upgrade`, and the count
116
+ // moves INSIDE it: Bun runs `websocket.open` synchronously there, which is where `sockets.add`
117
+ // runs. No second `tryAccept()`: that budget was spent above.
118
+ if (!deps.ready() || deps.socketCount() >= deps.maxConnections) return shed(deps);
110
119
  const data: WsData = {
111
120
  socketId: deps.newSocketId(),
112
121
  clientBuildId: url.searchParams.get('build') ?? deps.buildId,
package/src/type-pins.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  // stops catching the typo that makes a subscription match nothing.
8
8
 
9
9
  import type { Query } from '@ultimat3/query';
10
- import type { LiveHandle, Unsubscribe } from './client';
10
+ import type { LiveClient, LiveClientLike, LiveHandle, Unsubscribe } from './client';
11
11
  import type { LiveRows } from './hooks';
12
12
  import type { LiveQueryHook, LiveQuerySource } from './query-hook';
13
13
 
@@ -70,3 +70,14 @@ export type _LiveRowsIsDisposable = Assert<[LiveRows] extends [Disposable] ? tru
70
70
 
71
71
  /** `channel.subscribe()`'s return must stay both callable and `Disposable`. */
72
72
  export type _UnsubscribeIsDisposable = Assert<[Unsubscribe] extends [Disposable] ? true : false>;
73
+
74
+ /**
75
+ * The hook seam takes `LiveClientLike`, not `LiveClient` — a structural shape, so the server
76
+ * render's client can satisfy it without dragging the connection lifecycle into every island that
77
+ * calls `useLive` (measured: 8,368 B → 26,571 B). This is what keeps the two in step: a member
78
+ * `hooks.ts` needs and `LiveClient` stops providing fails HERE, at the build, rather than at the
79
+ * one app that registered a real client.
80
+ */
81
+ export type _LiveClientSatisfiesTheHookSeam = Assert<
82
+ [LiveClient] extends [LiveClientLike] ? true : false
83
+ >;