@rebasepro/server-postgres 0.10.0 → 0.10.1-canary.14e53ae
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/dist/PostgresBootstrapper.d.ts +7 -3
- package/dist/auth/services.d.ts +43 -4
- package/dist/backup/backup-logic.d.ts +23 -0
- package/dist/backup/backup-service.d.ts +44 -2
- package/dist/backup/pg-tools.d.ts +41 -1
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/cli-helpers.d.ts +33 -1
- package/dist/ensure-collection-tables-CNlIONzj.js +304 -0
- package/dist/ensure-collection-tables-CNlIONzj.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +1472 -4640
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-schema.d.ts +170 -0
- package/dist/schema/destructive-sql.d.ts +49 -0
- package/dist/schema/ensure-collection-tables.d.ts +79 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
- package/dist/services/cdc/CdcListener.d.ts +7 -14
- package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
- package/dist/services/channel-bus/index.d.ts +55 -0
- package/dist/services/channel-history.d.ts +11 -0
- package/dist/services/channel-presence.d.ts +66 -0
- package/dist/services/pg-notify-listener.d.ts +47 -0
- package/dist/services/realtimeService.d.ts +114 -6
- package/dist/src-B0v4IKaI.js +329 -0
- package/dist/src-B0v4IKaI.js.map +1 -0
- package/dist/src-DmsRg8MR.js +4056 -0
- package/dist/src-DmsRg8MR.js.map +1 -0
- package/package.json +6 -6
- package/src/PostgresBootstrapper.ts +72 -3
- package/src/auth/ensure-tables.ts +91 -3
- package/src/auth/services.ts +186 -48
- package/src/backup/backup-cli.ts +60 -1
- package/src/backup/backup-cron.ts +24 -1
- package/src/backup/backup-logic.ts +62 -0
- package/src/backup/backup-service.ts +132 -13
- package/src/backup/pg-tools.ts +70 -2
- package/src/cli-helpers.ts +82 -27
- package/src/cli.ts +152 -6
- package/src/index.ts +4 -0
- package/src/schema/auth-schema.ts +41 -3
- package/src/schema/destructive-sql.ts +94 -0
- package/src/schema/ensure-collection-tables.test.ts +156 -0
- package/src/schema/ensure-collection-tables.ts +297 -0
- package/src/schema/generate-postgres-ddl-logic.ts +3 -3
- package/src/services/cdc/CdcListener.ts +27 -91
- package/src/services/channel-bus/ChannelBus.ts +44 -0
- package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
- package/src/services/channel-bus/index.ts +123 -0
- package/src/services/channel-history.ts +35 -0
- package/src/services/channel-presence.ts +148 -0
- package/src/services/pg-notify-listener.ts +137 -0
- package/src/services/realtimeService.ts +383 -14
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A dedicated, self-healing Postgres `LISTEN` connection.
|
|
3
|
+
*
|
|
4
|
+
* Every cross-instance feature in the backend needs the same thing: one
|
|
5
|
+
* connection *outside* the Drizzle pool that stays open, holds a `LISTEN`, and
|
|
6
|
+
* comes back on its own after the database or the network drops it. CDC needed
|
|
7
|
+
* it first; the channel bus needs it too. This is that connection, with the one
|
|
8
|
+
* behaviour that matters to callers preserved: the **first** connect is
|
|
9
|
+
* validated and rethrown, so a caller can fall back to a different strategy,
|
|
10
|
+
* while every later drop is repaired quietly in the background.
|
|
11
|
+
*
|
|
12
|
+
* `LISTEN` is session state, so this connection must not go through a
|
|
13
|
+
* transaction-mode pooler (PgBouncer): give it the direct database URL.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { Client as PgClient } from "pg";
|
|
17
|
+
import { logger } from "@rebasepro/server";
|
|
18
|
+
|
|
19
|
+
export interface PgNotifyListenerOptions {
|
|
20
|
+
/** Direct Postgres connection string (must bypass a transaction-mode pooler). */
|
|
21
|
+
connectionString: string;
|
|
22
|
+
/** NOTIFY channel to LISTEN on. Must be a plain identifier — it is interpolated. */
|
|
23
|
+
channel: string;
|
|
24
|
+
/** Called for every notification payload received. */
|
|
25
|
+
onPayload: (payload: string) => void | Promise<void>;
|
|
26
|
+
/** Prefix for log lines, e.g. `"[CDC]"`. */
|
|
27
|
+
logLabel: string;
|
|
28
|
+
/** Delay before a reconnect attempt. */
|
|
29
|
+
reconnectDelayMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const DEFAULT_RECONNECT_DELAY_MS = 3000;
|
|
33
|
+
/** Guards the identifier interpolated into `LISTEN`. */
|
|
34
|
+
const SAFE_CHANNEL = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
35
|
+
|
|
36
|
+
export class PgNotifyListener {
|
|
37
|
+
private client?: PgClient;
|
|
38
|
+
private running = false;
|
|
39
|
+
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
40
|
+
|
|
41
|
+
constructor(private readonly options: PgNotifyListenerOptions) {
|
|
42
|
+
if (!SAFE_CHANNEL.test(options.channel)) {
|
|
43
|
+
throw new Error(`Unsafe NOTIFY channel name "${options.channel}" — expected a plain SQL identifier.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Whether the listener is meant to be connected right now. */
|
|
48
|
+
get active(): boolean {
|
|
49
|
+
return this.running;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Connect and begin listening. Idempotent.
|
|
54
|
+
*
|
|
55
|
+
* Rejects if the *initial* connection or `LISTEN` fails, leaving the
|
|
56
|
+
* listener stopped — callers use that to degrade deliberately instead of
|
|
57
|
+
* running blind against a channel nothing is delivering.
|
|
58
|
+
*/
|
|
59
|
+
async start(): Promise<void> {
|
|
60
|
+
if (this.running) return;
|
|
61
|
+
this.running = true;
|
|
62
|
+
try {
|
|
63
|
+
await this.connect({ initial: true });
|
|
64
|
+
} catch (err) {
|
|
65
|
+
this.running = false;
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Stop listening and release the connection. Idempotent. */
|
|
71
|
+
async stop(): Promise<void> {
|
|
72
|
+
this.running = false;
|
|
73
|
+
if (this.reconnectTimer) {
|
|
74
|
+
clearTimeout(this.reconnectTimer);
|
|
75
|
+
this.reconnectTimer = undefined;
|
|
76
|
+
}
|
|
77
|
+
if (this.client) {
|
|
78
|
+
try {
|
|
79
|
+
await this.client.end();
|
|
80
|
+
} catch { /* ignore close errors */ }
|
|
81
|
+
this.client = undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async connect({ initial = false }: { initial?: boolean } = {}): Promise<void> {
|
|
86
|
+
const { connectionString, channel, onPayload, logLabel } = this.options;
|
|
87
|
+
try {
|
|
88
|
+
const client = new PgClient({ connectionString });
|
|
89
|
+
|
|
90
|
+
client.on("error", (err) => {
|
|
91
|
+
logger.error(`❌ ${logLabel} LISTEN client error`, { detail: err.message });
|
|
92
|
+
this.scheduleReconnect();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
client.on("end", () => {
|
|
96
|
+
if (this.running) {
|
|
97
|
+
logger.warn(`⚠️ ${logLabel} LISTEN client disconnected unexpectedly.`);
|
|
98
|
+
this.scheduleReconnect();
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
client.on("notification", (msg) => {
|
|
103
|
+
if (!msg.payload) return;
|
|
104
|
+
// A handler rejection must never surface as an unhandled
|
|
105
|
+
// rejection inside the pg client's event emitter.
|
|
106
|
+
Promise.resolve(onPayload(msg.payload)).catch((err) =>
|
|
107
|
+
logger.error(`❌ ${logLabel} Error handling notification`, { error: err })
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await client.connect();
|
|
112
|
+
await client.query(`LISTEN ${channel}`);
|
|
113
|
+
this.client = client;
|
|
114
|
+
logger.info(`📡 ${logLabel} Listening on channel "${channel}".`);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
// Surface the initial failure so callers can choose to fall back;
|
|
117
|
+
// for reconnects, keep retrying quietly in the background.
|
|
118
|
+
if (initial) throw err;
|
|
119
|
+
logger.error(`❌ ${logLabel} Failed to connect LISTEN client`, { error: err });
|
|
120
|
+
this.scheduleReconnect();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private scheduleReconnect(): void {
|
|
125
|
+
if (!this.running || this.reconnectTimer) return;
|
|
126
|
+
|
|
127
|
+
this.reconnectTimer = setTimeout(async () => {
|
|
128
|
+
this.reconnectTimer = undefined;
|
|
129
|
+
if (!this.running) return;
|
|
130
|
+
if (this.client) {
|
|
131
|
+
try { await this.client.end(); } catch { /* ignore */ }
|
|
132
|
+
this.client = undefined;
|
|
133
|
+
}
|
|
134
|
+
await this.connect();
|
|
135
|
+
}, this.options.reconnectDelayMs ?? DEFAULT_RECONNECT_DELAY_MS);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -4,7 +4,7 @@ import { Client as PgClient } from "pg";
|
|
|
4
4
|
import { randomUUID } from "crypto";
|
|
5
5
|
import { DataService } from "./dataService";
|
|
6
6
|
|
|
7
|
-
import { FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext } from "@rebasepro/types";
|
|
7
|
+
import { FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
|
|
8
8
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
9
|
import { sql as drizzleSql } from "drizzle-orm";
|
|
10
10
|
import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
|
|
@@ -16,6 +16,8 @@ import { sanitizeErrorForClient } from "../utils/pg-error-utils";
|
|
|
16
16
|
import { CdcListener, type CdcChangeEvent } from "./cdc/CdcListener";
|
|
17
17
|
import { deriveRowAddress, getPrimaryKeys, type PrimaryKeyInfo } from "./collection-helpers";
|
|
18
18
|
import { ChannelHistoryStore, type ResolvedRetention } from "./channel-history";
|
|
19
|
+
import { ChannelPresenceStore } from "./channel-presence";
|
|
20
|
+
import { ChannelBus, ChannelBusFrame, MemoryChannelBus, frameByteLength } from "./channel-bus";
|
|
19
21
|
import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
|
|
20
22
|
|
|
21
23
|
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
@@ -76,8 +78,38 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
76
78
|
* wait on each other.
|
|
77
79
|
*/
|
|
78
80
|
private channelSendQueues = new Map<string, Promise<void>>();
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Cross-instance transport for channel frames and presence.
|
|
84
|
+
*
|
|
85
|
+
* Defaults to the memory bus, which publishes nowhere — so a single-instance
|
|
86
|
+
* deployment runs the same fan-out it always did, with one resolved promise
|
|
87
|
+
* per broadcast for company. See `channel-bus/ChannelBus.ts`.
|
|
88
|
+
*/
|
|
89
|
+
private bus: ChannelBus = new MemoryChannelBus();
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The shared presence roster, present only when a real bus is active.
|
|
93
|
+
*
|
|
94
|
+
* Fan-out alone is not enough for presence: `presence_state` has to answer
|
|
95
|
+
* with everyone in the channel, and per-process maps can only answer for
|
|
96
|
+
* this replica's clients. See `channel-presence.ts`.
|
|
97
|
+
*/
|
|
98
|
+
private presenceStore?: ChannelPresenceStore;
|
|
99
|
+
|
|
100
|
+
/** Sweeps roster rows left behind by instances that stopped heartbeating. */
|
|
101
|
+
private presenceSweepInterval?: ReturnType<typeof setInterval>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Channels whose oversized ephemeral broadcasts have already been reported,
|
|
105
|
+
* so a hot channel logs the problem once rather than once per message.
|
|
106
|
+
*/
|
|
107
|
+
private oversizedBroadcastWarned = new Set<string>();
|
|
108
|
+
|
|
79
109
|
private presenceInterval?: ReturnType<typeof setInterval>;
|
|
80
110
|
private static readonly PRESENCE_TIMEOUT_MS = 30000; // 30s
|
|
111
|
+
/** How often stale roster rows from other instances are reaped. */
|
|
112
|
+
private static readonly PRESENCE_SWEEP_INTERVAL_MS = 10000; // 10s
|
|
81
113
|
private dataService: DataService;
|
|
82
114
|
// Enhanced subscriptions storage with full request parameters
|
|
83
115
|
private _subscriptions = new Map<string, {
|
|
@@ -307,15 +339,19 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
307
339
|
for (const [channel, members] of this.channels.entries()) {
|
|
308
340
|
if (members.has(clientId)) {
|
|
309
341
|
members.delete(clientId);
|
|
310
|
-
this.removePresence(clientId, channel);
|
|
342
|
+
this.removePresence(clientId, channel, { skipStore: true });
|
|
311
343
|
if (members.size === 0) this.channels.delete(channel);
|
|
312
344
|
}
|
|
313
345
|
}
|
|
314
346
|
|
|
315
347
|
// Remove from all presence channels
|
|
316
348
|
for (const [channel] of this.presence) {
|
|
317
|
-
this.removePresence(clientId, channel);
|
|
349
|
+
this.removePresence(clientId, channel, { skipStore: true });
|
|
318
350
|
}
|
|
351
|
+
|
|
352
|
+
// One statement for every channel the client was in, rather than one
|
|
353
|
+
// per channel above — a disconnect is the common case, not a rare one.
|
|
354
|
+
void this.presenceStoreOp(() => this.presenceStore!.removeClient(clientId), "client removal");
|
|
319
355
|
}
|
|
320
356
|
|
|
321
357
|
private async handleMessage(clientId: string, message: WebSocketMessage, authContext?: SubscriptionAuthContext) {
|
|
@@ -391,6 +427,16 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
391
427
|
return;
|
|
392
428
|
}
|
|
393
429
|
|
|
430
|
+
// Bound the client-supplied limit with the SAME guarantee the REST
|
|
431
|
+
// ingress applies (`resolveClientListLimit`): clamp to the hard max
|
|
432
|
+
// and default an absent limit by mode. A subscription is re-fetched
|
|
433
|
+
// on every matching write, so an unbounded one is a DoS amplified
|
|
434
|
+
// per write — resolve it once and reuse for the stored request and
|
|
435
|
+
// the initial fetch.
|
|
436
|
+
const boundedLimit = resolveClientListLimit(request.limit, {
|
|
437
|
+
vectorSearch: !!request.vectorSearch
|
|
438
|
+
});
|
|
439
|
+
|
|
394
440
|
// Store subscription with full request parameters and auth context for RLS
|
|
395
441
|
this._subscriptions.set(subscriptionId, {
|
|
396
442
|
clientId,
|
|
@@ -400,7 +446,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
400
446
|
filter: request.filter,
|
|
401
447
|
orderBy: request.orderBy,
|
|
402
448
|
order: request.order,
|
|
403
|
-
limit:
|
|
449
|
+
limit: boundedLimit,
|
|
404
450
|
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
405
451
|
databaseId: request.collection?.databaseId,
|
|
406
452
|
searchString: request.searchString
|
|
@@ -415,7 +461,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
415
461
|
filter: request.filter,
|
|
416
462
|
orderBy: request.orderBy,
|
|
417
463
|
order: request.order,
|
|
418
|
-
limit:
|
|
464
|
+
limit: boundedLimit,
|
|
419
465
|
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
420
466
|
searchString: request.searchString
|
|
421
467
|
},
|
|
@@ -1088,6 +1134,11 @@ roles: activeAuth.roles },
|
|
|
1088
1134
|
const retention = this.channelHistory?.retentionFor(channel);
|
|
1089
1135
|
if (!retention) {
|
|
1090
1136
|
this.fanOutBroadcast(clientId, channel, event, payload);
|
|
1137
|
+
// Other instances get the same frame, but never before the clients
|
|
1138
|
+
// on this one: the local fan-out above is synchronous and the
|
|
1139
|
+
// publish is not, which is also what keeps the ephemeral path free
|
|
1140
|
+
// of any await for a single-instance deployment.
|
|
1141
|
+
this.publishBroadcast(clientId, channel, event, payload);
|
|
1091
1142
|
return;
|
|
1092
1143
|
}
|
|
1093
1144
|
|
|
@@ -1137,6 +1188,7 @@ roles: activeAuth.roles },
|
|
|
1137
1188
|
}
|
|
1138
1189
|
|
|
1139
1190
|
this.fanOutBroadcast(clientId, channel, event, payload, seq);
|
|
1191
|
+
this.publishBroadcast(clientId, channel, event, payload, seq);
|
|
1140
1192
|
|
|
1141
1193
|
try {
|
|
1142
1194
|
await this.channelHistory!.prune(channel, retention);
|
|
@@ -1170,6 +1222,186 @@ roles: activeAuth.roles },
|
|
|
1170
1222
|
}
|
|
1171
1223
|
}
|
|
1172
1224
|
|
|
1225
|
+
// =============================================================================
|
|
1226
|
+
// Cross-Instance Channel Bus
|
|
1227
|
+
// =============================================================================
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Install the transport that carries channel frames between instances.
|
|
1231
|
+
*
|
|
1232
|
+
* Called once at boot. A bus that cannot start is reported and replaced with
|
|
1233
|
+
* the memory bus: losing cross-instance fan-out degrades collaboration to
|
|
1234
|
+
* what it was before this existed, whereas refusing to boot takes the whole
|
|
1235
|
+
* backend down for it.
|
|
1236
|
+
*/
|
|
1237
|
+
async configureChannelBus(bus: ChannelBus): Promise<void> {
|
|
1238
|
+
if (bus.kind === "memory") {
|
|
1239
|
+
this.bus = bus;
|
|
1240
|
+
return;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
try {
|
|
1244
|
+
await bus.start((frame) => this.handleBusFrame(frame));
|
|
1245
|
+
} catch (error) {
|
|
1246
|
+
logger.warn(
|
|
1247
|
+
`⚠️ [ChannelBus] Could not start the "${bus.kind}" channel bus — channel broadcast and presence ` +
|
|
1248
|
+
"stay per-instance. Clients served by different replicas will not see each other.",
|
|
1249
|
+
{ error }
|
|
1250
|
+
);
|
|
1251
|
+
await bus.stop().catch(() => { /* best effort */ });
|
|
1252
|
+
this.bus = new MemoryChannelBus();
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
this.bus = bus;
|
|
1257
|
+
|
|
1258
|
+
// Presence needs shared *state*, not just shared fan-out — see
|
|
1259
|
+
// `channel-presence.ts`. It comes up with the bus and only with it.
|
|
1260
|
+
try {
|
|
1261
|
+
const store = new ChannelPresenceStore(this.db, this.instanceId);
|
|
1262
|
+
await store.ensureTables();
|
|
1263
|
+
this.presenceStore = store;
|
|
1264
|
+
this.ensurePresenceSweep();
|
|
1265
|
+
} catch (error) {
|
|
1266
|
+
logger.warn(
|
|
1267
|
+
"⚠️ [ChannelBus] Could not create the shared presence table — presence rosters will only list " +
|
|
1268
|
+
"clients connected to this instance (broadcast is unaffected).",
|
|
1269
|
+
{ error }
|
|
1270
|
+
);
|
|
1271
|
+
this.presenceStore = undefined;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
logger.info(
|
|
1275
|
+
`📡 [ChannelBus] Cross-instance channels active via ${bus.kind} (instanceId: ${this.instanceId}).`
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** Which transport is in use — `"memory"` means per-instance only. */
|
|
1280
|
+
public getChannelBusKind(): ChannelBus["kind"] {
|
|
1281
|
+
return this.bus.kind;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
/**
|
|
1285
|
+
* Send a broadcast to the other instances.
|
|
1286
|
+
*
|
|
1287
|
+
* Fire-and-forget by design: the clients on this instance have already been
|
|
1288
|
+
* served, and a bus that is briefly unreachable must not turn a broadcast
|
|
1289
|
+
* into an error for the sender.
|
|
1290
|
+
*/
|
|
1291
|
+
private publishBroadcast(clientId: string, channel: string, event: string, payload: unknown, seq?: number): void {
|
|
1292
|
+
if (this.bus.kind === "memory") return;
|
|
1293
|
+
|
|
1294
|
+
const frame: ChannelBusFrame = {
|
|
1295
|
+
kind: "broadcast",
|
|
1296
|
+
sid: this.instanceId,
|
|
1297
|
+
channel,
|
|
1298
|
+
event,
|
|
1299
|
+
from: clientId,
|
|
1300
|
+
...(seq !== undefined ? { seq } : {}),
|
|
1301
|
+
payload
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
// Postgres caps a NOTIFY payload at 8 KB. A retained message is already
|
|
1305
|
+
// durable and addressable, so it travels as a pointer and each receiver
|
|
1306
|
+
// reads the body back — the same shape as the entity path, which
|
|
1307
|
+
// notifies an address and refetches the row.
|
|
1308
|
+
if (frameByteLength(frame) > this.bus.maxFrameBytes) {
|
|
1309
|
+
if (seq === undefined) {
|
|
1310
|
+
this.reportOversizedBroadcast(clientId, channel);
|
|
1311
|
+
return;
|
|
1312
|
+
}
|
|
1313
|
+
void this.publishFrame({
|
|
1314
|
+
kind: "broadcast_ref",
|
|
1315
|
+
sid: this.instanceId,
|
|
1316
|
+
channel,
|
|
1317
|
+
from: clientId,
|
|
1318
|
+
seq
|
|
1319
|
+
});
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
void this.publishFrame(frame);
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
private async publishFrame(frame: ChannelBusFrame): Promise<void> {
|
|
1327
|
+
try {
|
|
1328
|
+
await this.bus.publish(frame);
|
|
1329
|
+
} catch (error) {
|
|
1330
|
+
logger.error("❌ [ChannelBus] Failed to publish frame — other instances did not receive it", {
|
|
1331
|
+
detail: `${frame.kind} on "${frame.channel}"`,
|
|
1332
|
+
error
|
|
1333
|
+
});
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
/**
|
|
1338
|
+
* Tell the sender that a message was delivered locally but nowhere else.
|
|
1339
|
+
*
|
|
1340
|
+
* Staying quiet here would be the worst option available: on one instance
|
|
1341
|
+
* the app works, on two it works for half the users, and nothing in the
|
|
1342
|
+
* logs connects the two. The fix is a one-liner in config — give the
|
|
1343
|
+
* channel a retention rule and the message travels as a pointer instead —
|
|
1344
|
+
* so the message says exactly that.
|
|
1345
|
+
*/
|
|
1346
|
+
private reportOversizedBroadcast(clientId: string, channel: string): void {
|
|
1347
|
+
const remedy =
|
|
1348
|
+
`Add a retention rule for "${channel}" (realtime.channels) — retained messages travel by reference ` +
|
|
1349
|
+
"and have no size limit.";
|
|
1350
|
+
|
|
1351
|
+
if (!this.oversizedBroadcastWarned.has(channel)) {
|
|
1352
|
+
this.oversizedBroadcastWarned.add(channel);
|
|
1353
|
+
logger.warn(
|
|
1354
|
+
`⚠️ [ChannelBus] A broadcast on ephemeral channel "${channel}" exceeds the ` +
|
|
1355
|
+
`${this.bus.maxFrameBytes}-byte limit of the ${this.bus.kind} bus and reached only this instance. ` +
|
|
1356
|
+
remedy
|
|
1357
|
+
);
|
|
1358
|
+
}
|
|
1359
|
+
this.sendError(
|
|
1360
|
+
clientId,
|
|
1361
|
+
`Broadcast on "${channel}" was too large to reach other instances. ${remedy}`,
|
|
1362
|
+
undefined,
|
|
1363
|
+
"CHANNEL_BUS_PAYLOAD_TOO_LARGE"
|
|
1364
|
+
);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/**
|
|
1368
|
+
* Deliver a frame published by another instance to this one's clients.
|
|
1369
|
+
*
|
|
1370
|
+
* Frames we published ourselves are dropped on arrival — the local fan-out
|
|
1371
|
+
* happened before the publish — exactly as the entity-change handler skips
|
|
1372
|
+
* its own `sid`.
|
|
1373
|
+
*/
|
|
1374
|
+
private async handleBusFrame(frame: ChannelBusFrame): Promise<void> {
|
|
1375
|
+
if (frame.sid === this.instanceId) return;
|
|
1376
|
+
|
|
1377
|
+
switch (frame.kind) {
|
|
1378
|
+
case "broadcast":
|
|
1379
|
+
this.fanOutBroadcast(frame.from ?? "", frame.channel, frame.event, frame.payload, frame.seq);
|
|
1380
|
+
return;
|
|
1381
|
+
|
|
1382
|
+
case "broadcast_ref": {
|
|
1383
|
+
// Nothing to read back for: skip the query rather than pay for
|
|
1384
|
+
// a message no client here is waiting for.
|
|
1385
|
+
if (!this.channels.get(frame.channel)?.size) return;
|
|
1386
|
+
|
|
1387
|
+
const entry = await this.channelHistory?.getBySeq(frame.channel, frame.seq);
|
|
1388
|
+
if (!entry) {
|
|
1389
|
+
logger.warn(
|
|
1390
|
+
`⚠️ [ChannelBus] Message ${frame.seq} on "${frame.channel}" is no longer retained — ` +
|
|
1391
|
+
"clients on this instance will need to replay (channel_history) to catch up."
|
|
1392
|
+
);
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
this.fanOutBroadcast(frame.from ?? "", frame.channel, entry.event, entry.payload, entry.seq);
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
case "presence_diff":
|
|
1400
|
+
this.deliverPresenceDiff(frame.channel, frame.joins, frame.leaves);
|
|
1401
|
+
return;
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1173
1405
|
// =============================================================================
|
|
1174
1406
|
// Channel History
|
|
1175
1407
|
// =============================================================================
|
|
@@ -1247,32 +1479,59 @@ roles: activeAuth.roles },
|
|
|
1247
1479
|
// Presence
|
|
1248
1480
|
// =============================================================================
|
|
1249
1481
|
|
|
1250
|
-
/**
|
|
1482
|
+
/**
|
|
1483
|
+
* Track presence in a channel.
|
|
1484
|
+
*
|
|
1485
|
+
* The client re-sends this every ~20s as a heartbeat against the 30s
|
|
1486
|
+
* timeout, so most calls carry the state that is already recorded. Those
|
|
1487
|
+
* refresh `last_seen` and stop there: re-announcing an unchanged state to
|
|
1488
|
+
* every instance would put a bus message per client per heartbeat on the
|
|
1489
|
+
* wire to tell everyone nothing happened.
|
|
1490
|
+
*/
|
|
1251
1491
|
trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void {
|
|
1252
1492
|
if (!this.presence.has(channel)) {
|
|
1253
1493
|
this.presence.set(channel, new Map());
|
|
1254
1494
|
}
|
|
1255
1495
|
|
|
1256
1496
|
const channelPresence = this.presence.get(channel)!;
|
|
1497
|
+
const previous = channelPresence.get(clientId);
|
|
1498
|
+
const changed = !previous || JSON.stringify(previous.state) !== JSON.stringify(state);
|
|
1257
1499
|
channelPresence.set(clientId, { state,
|
|
1258
1500
|
lastSeen: Date.now() });
|
|
1259
1501
|
|
|
1502
|
+
// Refresh the shared roster on every heartbeat — that timestamp is what
|
|
1503
|
+
// tells other instances this client is still here.
|
|
1504
|
+
void this.presenceStoreOp(() => this.presenceStore!.track(channel, clientId, state), "track");
|
|
1505
|
+
|
|
1260
1506
|
// Broadcast join / state update to channel
|
|
1261
|
-
this.
|
|
1507
|
+
this.deliverPresenceDiff(channel, { [clientId]: state }, {});
|
|
1508
|
+
if (changed) {
|
|
1509
|
+
this.publishPresenceDiff(channel, { [clientId]: state }, {});
|
|
1510
|
+
}
|
|
1262
1511
|
|
|
1263
1512
|
// Start cleanup interval if not running
|
|
1264
1513
|
this.ensurePresenceCleanup();
|
|
1265
1514
|
}
|
|
1266
1515
|
|
|
1267
|
-
/**
|
|
1268
|
-
|
|
1516
|
+
/**
|
|
1517
|
+
* Remove presence from a channel.
|
|
1518
|
+
*
|
|
1519
|
+
* `skipStore` is for the socket-close path, which clears every channel at
|
|
1520
|
+
* once and then deletes the client's rows in a single statement instead of
|
|
1521
|
+
* one per channel.
|
|
1522
|
+
*/
|
|
1523
|
+
removePresence(clientId: string, channel: string, options?: { skipStore?: boolean }): void {
|
|
1269
1524
|
const channelPresence = this.presence.get(channel);
|
|
1270
1525
|
if (!channelPresence) return;
|
|
1271
1526
|
|
|
1272
1527
|
const entry = channelPresence.get(clientId);
|
|
1273
1528
|
if (entry) {
|
|
1274
1529
|
channelPresence.delete(clientId);
|
|
1275
|
-
this.
|
|
1530
|
+
this.deliverPresenceDiff(channel, {}, { [clientId]: entry.state });
|
|
1531
|
+
this.publishPresenceDiff(channel, {}, { [clientId]: entry.state });
|
|
1532
|
+
if (!options?.skipStore) {
|
|
1533
|
+
void this.presenceStoreOp(() => this.presenceStore!.remove(channel, clientId), "remove");
|
|
1534
|
+
}
|
|
1276
1535
|
}
|
|
1277
1536
|
|
|
1278
1537
|
if (channelPresence.size === 0) {
|
|
@@ -1280,17 +1539,50 @@ lastSeen: Date.now() });
|
|
|
1280
1539
|
}
|
|
1281
1540
|
}
|
|
1282
1541
|
|
|
1283
|
-
/**
|
|
1542
|
+
/**
|
|
1543
|
+
* Send the full roster for a channel to one client.
|
|
1544
|
+
*
|
|
1545
|
+
* Answered from the shared table when there is one, because "who is in this
|
|
1546
|
+
* document?" has a single answer that must not depend on which replica the
|
|
1547
|
+
* asker happens to be connected to. Without a bus there is nothing to share
|
|
1548
|
+
* and the local map *is* the roster — that path stays synchronous, which is
|
|
1549
|
+
* what it always was.
|
|
1550
|
+
*/
|
|
1284
1551
|
sendPresenceState(clientId: string, channel: string): void {
|
|
1552
|
+
if (!this.presenceStore) {
|
|
1553
|
+
this.sendPresenceStateMessage(clientId, channel, this.localPresences(channel));
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
void this.presenceStore.roster(channel)
|
|
1558
|
+
.then((presences) => {
|
|
1559
|
+
this.sendPresenceStateMessage(clientId, channel, presences);
|
|
1560
|
+
})
|
|
1561
|
+
.catch((error) => {
|
|
1562
|
+
// A roster the asker can act on beats none: fall back to the
|
|
1563
|
+
// clients we can see rather than leaving the request unanswered.
|
|
1564
|
+
logger.warn(`⚠️ [Presence] Could not read the shared roster for "${channel}" — answering with this instance's clients only.`, { error });
|
|
1565
|
+
this.sendPresenceStateMessage(clientId, channel, this.localPresences(channel));
|
|
1566
|
+
});
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
/** Presence of the clients connected to this instance. */
|
|
1570
|
+
private localPresences(channel: string): Record<string, Record<string, unknown>> {
|
|
1285
1571
|
const channelPresence = this.presence.get(channel);
|
|
1286
1572
|
const presences: Record<string, Record<string, unknown>> = {};
|
|
1287
|
-
|
|
1288
1573
|
if (channelPresence) {
|
|
1289
1574
|
for (const [id, { state }] of channelPresence) {
|
|
1290
1575
|
presences[id] = state;
|
|
1291
1576
|
}
|
|
1292
1577
|
}
|
|
1578
|
+
return presences;
|
|
1579
|
+
}
|
|
1293
1580
|
|
|
1581
|
+
private sendPresenceStateMessage(
|
|
1582
|
+
clientId: string,
|
|
1583
|
+
channel: string,
|
|
1584
|
+
presences: Record<string, Record<string, unknown>>
|
|
1585
|
+
): void {
|
|
1294
1586
|
const ws = this.clients.get(clientId);
|
|
1295
1587
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1296
1588
|
ws.send(JSON.stringify({
|
|
@@ -1301,8 +1593,8 @@ lastSeen: Date.now() });
|
|
|
1301
1593
|
}
|
|
1302
1594
|
}
|
|
1303
1595
|
|
|
1304
|
-
/**
|
|
1305
|
-
private
|
|
1596
|
+
/** Deliver a presence diff to this instance's members of the channel. */
|
|
1597
|
+
private deliverPresenceDiff(
|
|
1306
1598
|
channel: string,
|
|
1307
1599
|
joins: Record<string, Record<string, unknown>>,
|
|
1308
1600
|
leaves: Record<string, Record<string, unknown>>
|
|
@@ -1325,6 +1617,26 @@ lastSeen: Date.now() });
|
|
|
1325
1617
|
}
|
|
1326
1618
|
}
|
|
1327
1619
|
|
|
1620
|
+
/** Tell the other instances about a presence change. */
|
|
1621
|
+
private publishPresenceDiff(
|
|
1622
|
+
channel: string,
|
|
1623
|
+
joins: Record<string, Record<string, unknown>>,
|
|
1624
|
+
leaves: Record<string, Record<string, unknown>>
|
|
1625
|
+
): void {
|
|
1626
|
+
if (this.bus.kind === "memory") return;
|
|
1627
|
+
void this.publishFrame({ kind: "presence_diff", sid: this.instanceId, channel, joins, leaves });
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
/** Run a roster write when there is a roster, and never let it throw. */
|
|
1631
|
+
private async presenceStoreOp(op: () => Promise<void>, label: string): Promise<void> {
|
|
1632
|
+
if (!this.presenceStore) return;
|
|
1633
|
+
try {
|
|
1634
|
+
await op();
|
|
1635
|
+
} catch (error) {
|
|
1636
|
+
logger.warn(`⚠️ [Presence] Shared roster ${label} failed`, { error });
|
|
1637
|
+
}
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1328
1640
|
/** Periodic cleanup for stale presences */
|
|
1329
1641
|
private ensurePresenceCleanup(): void {
|
|
1330
1642
|
if (this.presenceInterval) return;
|
|
@@ -1345,6 +1657,43 @@ lastSeen: Date.now() });
|
|
|
1345
1657
|
}, 10000); // Check every 10s
|
|
1346
1658
|
}
|
|
1347
1659
|
|
|
1660
|
+
/**
|
|
1661
|
+
* Reap roster rows whose owning instance stopped heartbeating.
|
|
1662
|
+
*
|
|
1663
|
+
* This is the cross-instance half of the sweep above, and it doubles as
|
|
1664
|
+
* crash recovery: a pod that dies takes its clients with it but leaves
|
|
1665
|
+
* their rows behind, and after one TTL window they look exactly like any
|
|
1666
|
+
* other client that went quiet. The delete returns what it removed, so
|
|
1667
|
+
* whichever instance wins the race is the one that announces the
|
|
1668
|
+
* departures — once for the cluster, not once per replica.
|
|
1669
|
+
*/
|
|
1670
|
+
private ensurePresenceSweep(): void {
|
|
1671
|
+
if (this.presenceSweepInterval || !this.presenceStore) return;
|
|
1672
|
+
|
|
1673
|
+
this.presenceSweepInterval = setInterval(
|
|
1674
|
+
() => void this.sweepStalePresence(),
|
|
1675
|
+
RealtimeService.PRESENCE_SWEEP_INTERVAL_MS
|
|
1676
|
+
);
|
|
1677
|
+
|
|
1678
|
+
// Never hold the process open for housekeeping.
|
|
1679
|
+
(this.presenceSweepInterval as unknown as { unref?: () => void }).unref?.();
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/** One pass of the stale-roster sweep. See {@link ensurePresenceSweep}. */
|
|
1683
|
+
private async sweepStalePresence(): Promise<void> {
|
|
1684
|
+
if (!this.presenceStore) return;
|
|
1685
|
+
try {
|
|
1686
|
+
const removed = await this.presenceStore.sweepStale(RealtimeService.PRESENCE_TIMEOUT_MS);
|
|
1687
|
+
for (const row of removed) {
|
|
1688
|
+
this.debugLog(`👻 [Presence] Reaped stale presence ${row.clientId} on "${row.channel}"`);
|
|
1689
|
+
this.deliverPresenceDiff(row.channel, {}, { [row.clientId]: row.state });
|
|
1690
|
+
this.publishPresenceDiff(row.channel, {}, { [row.clientId]: row.state });
|
|
1691
|
+
}
|
|
1692
|
+
} catch (error) {
|
|
1693
|
+
logger.warn("⚠️ [Presence] Stale-roster sweep failed", { error });
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1348
1697
|
// =============================================================================
|
|
1349
1698
|
// Lifecycle / Cleanup
|
|
1350
1699
|
// =============================================================================
|
|
@@ -1383,10 +1732,30 @@ lastSeen: Date.now() });
|
|
|
1383
1732
|
clearInterval(this.presenceInterval);
|
|
1384
1733
|
this.presenceInterval = undefined;
|
|
1385
1734
|
}
|
|
1735
|
+
if (this.presenceSweepInterval) {
|
|
1736
|
+
clearInterval(this.presenceSweepInterval);
|
|
1737
|
+
this.presenceSweepInterval = undefined;
|
|
1738
|
+
}
|
|
1739
|
+
this.oversizedBroadcastWarned.clear();
|
|
1740
|
+
|
|
1741
|
+
// Drop this instance's roster rows now rather than leaving every other
|
|
1742
|
+
// replica to wait out a TTL window on ghosts — a rolling deploy would
|
|
1743
|
+
// otherwise show 30s of departed users on every restart.
|
|
1744
|
+
if (this.presenceStore) {
|
|
1745
|
+
try {
|
|
1746
|
+
await this.presenceStore.removeInstance();
|
|
1747
|
+
} catch (error) {
|
|
1748
|
+
logger.warn("⚠️ [Presence] Could not clear this instance's roster rows on shutdown", { error });
|
|
1749
|
+
}
|
|
1750
|
+
this.presenceStore = undefined;
|
|
1751
|
+
}
|
|
1386
1752
|
|
|
1387
1753
|
// 4. Disconnect the dedicated LISTEN client(s)
|
|
1388
1754
|
await this.stopListening();
|
|
1389
1755
|
await this.stopCdc();
|
|
1756
|
+
await this.bus.stop().catch((error) =>
|
|
1757
|
+
logger.warn("⚠️ [ChannelBus] Error while stopping the channel bus", { error }));
|
|
1758
|
+
this.bus = new MemoryChannelBus();
|
|
1390
1759
|
|
|
1391
1760
|
// 5. Drop client references (don't close — server.close drains them)
|
|
1392
1761
|
this.clients.clear();
|