@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/CLAUDE.md +96 -7
- package/README.md +33 -9
- package/package.json +3 -3
- package/src/change-buffer.ts +40 -4
- package/src/client-contract.ts +23 -0
- package/src/client.ts +1 -0
- package/src/cursor.ts +13 -7
- package/src/detach.ts +27 -0
- package/src/drain-evictions.ts +31 -0
- package/src/errors.ts +39 -92
- package/src/hooks.ts +38 -5
- package/src/index.ts +4 -1
- package/src/live-query.ts +11 -2
- package/src/local-store.ts +12 -2
- package/src/nats-lib-client.ts +8 -2
- package/src/nats-transport.ts +3 -1
- package/src/pg-array.ts +141 -0
- package/src/pg-connection.ts +15 -1
- package/src/pg-entity-row.ts +7 -7
- package/src/pg-replication.ts +76 -12
- package/src/pg-values.ts +166 -0
- package/src/pgoutput.ts +15 -72
- package/src/realtime-error.ts +28 -0
- package/src/replication-errors.ts +87 -0
- package/src/server-render-client.ts +96 -0
- package/src/server.ts +3 -0
- package/src/socket.ts +15 -1
- package/src/sync-node.ts +24 -26
- package/src/sync-upgrade.ts +15 -6
- package/src/type-pins.ts +12 -1
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
|
-
|
|
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):
|
|
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)
|
|
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):
|
|
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
|
|
482
|
-
//
|
|
483
|
-
|
|
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.
|
package/src/sync-upgrade.ts
CHANGED
|
@@ -101,12 +101,21 @@ export async function handleUpgrade(
|
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
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
|
+
>;
|