@rebasepro/server-postgres 0.9.1-canary.ff338b5 → 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/README.md +21 -0
- package/dist/PostgresBackendDriver.d.ts +18 -0
- package/dist/PostgresBootstrapper.d.ts +11 -1
- package/dist/auth/services.d.ts +93 -54
- 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 +2090 -4741
- package/dist/index.es.js.map +1 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +1 -1
- package/dist/schema/auth-schema.d.ts +194 -24
- 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/schema/introspect-db-logic.d.ts +0 -5
- package/dist/schema/introspect-db-naming.d.ts +10 -0
- package/dist/security/policy-drift.d.ts +24 -0
- package/dist/security/rls-enforcement.d.ts +2 -2
- 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 +129 -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 +183 -8
- 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 +7 -31
- package/src/PostgresBackendDriver.ts +56 -5
- package/src/PostgresBootstrapper.ts +87 -1
- package/src/auth/ensure-tables.ts +187 -19
- package/src/auth/services.ts +309 -170
- 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-bootstrap-sql.ts +7 -1
- package/src/schema/auth-schema.ts +53 -15
- 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/schema/introspect-db-inference.ts +1 -1
- package/src/schema/introspect-db-logic.ts +1 -10
- package/src/schema/introspect-db-naming.ts +15 -0
- package/src/schema/introspect-runtime.ts +1 -1
- package/src/security/policy-drift.test.ts +46 -0
- package/src/security/policy-drift.ts +70 -4
- package/src/security/rls-enforcement.ts +11 -5
- 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 +378 -0
- package/src/services/channel-presence.ts +148 -0
- package/src/services/pg-notify-listener.ts +137 -0
- package/src/services/realtimeService.ts +581 -24
- package/src/websocket.ts +30 -11
|
@@ -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";
|
|
@@ -15,6 +15,10 @@ import { logger } from "@rebasepro/server";
|
|
|
15
15
|
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
|
+
import { ChannelHistoryStore, type ResolvedRetention } from "./channel-history";
|
|
19
|
+
import { ChannelPresenceStore } from "./channel-presence";
|
|
20
|
+
import { ChannelBus, ChannelBusFrame, MemoryChannelBus, frameByteLength } from "./channel-bus";
|
|
21
|
+
import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
|
|
18
22
|
|
|
19
23
|
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
20
24
|
const PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
@@ -24,7 +28,7 @@ const PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
|
24
28
|
* Mirrors the session variables set by PostgresBackendDriver.withAuth().
|
|
25
29
|
*/
|
|
26
30
|
export interface SubscriptionAuthContext {
|
|
27
|
-
|
|
31
|
+
uid: string;
|
|
28
32
|
roles: string[];
|
|
29
33
|
}
|
|
30
34
|
|
|
@@ -52,8 +56,60 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
52
56
|
|
|
53
57
|
// Presence: channel → Map<clientId, { state, lastSeen }>
|
|
54
58
|
private presence = new Map<string, Map<string, { state: Record<string, unknown>; lastSeen: number }>>();
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Ordered, replayable history for channels that opt into it.
|
|
62
|
+
*
|
|
63
|
+
* Undefined until {@link configureChannelHistory} is called, and inert even
|
|
64
|
+
* then unless retention rules were supplied — so presence and ephemeral
|
|
65
|
+
* notification channels never touch it. See `channel-history.ts`.
|
|
66
|
+
*/
|
|
67
|
+
private channelHistory?: ChannelHistoryStore;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* One promise chain per retained channel, so that assigning a sequence
|
|
71
|
+
* number and fanning the message out happen in the same order for every
|
|
72
|
+
* message on that channel.
|
|
73
|
+
*
|
|
74
|
+
* Without it, two concurrent broadcasts can be numbered 4 and 5 by the
|
|
75
|
+
* database and still reach subscribers as 5 then 4 — live order and replay
|
|
76
|
+
* order would disagree, which is exactly the divergence sequence numbers
|
|
77
|
+
* are supposed to rule out. Keyed by channel, so unrelated channels never
|
|
78
|
+
* wait on each other.
|
|
79
|
+
*/
|
|
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
|
+
|
|
55
109
|
private presenceInterval?: ReturnType<typeof setInterval>;
|
|
56
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
|
|
57
113
|
private dataService: DataService;
|
|
58
114
|
// Enhanced subscriptions storage with full request parameters
|
|
59
115
|
private _subscriptions = new Map<string, {
|
|
@@ -73,7 +129,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
73
129
|
searchString?: string;
|
|
74
130
|
};
|
|
75
131
|
// Auth context for RLS — when set, refetches run in a transaction
|
|
76
|
-
// with set_config('app.
|
|
132
|
+
// with set_config('app.uid', ...) / set_config('app.user_roles', ...)
|
|
77
133
|
authContext?: SubscriptionAuthContext;
|
|
78
134
|
}>();
|
|
79
135
|
|
|
@@ -283,15 +339,19 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
283
339
|
for (const [channel, members] of this.channels.entries()) {
|
|
284
340
|
if (members.has(clientId)) {
|
|
285
341
|
members.delete(clientId);
|
|
286
|
-
this.removePresence(clientId, channel);
|
|
342
|
+
this.removePresence(clientId, channel, { skipStore: true });
|
|
287
343
|
if (members.size === 0) this.channels.delete(channel);
|
|
288
344
|
}
|
|
289
345
|
}
|
|
290
346
|
|
|
291
347
|
// Remove from all presence channels
|
|
292
348
|
for (const [channel] of this.presence) {
|
|
293
|
-
this.removePresence(clientId, channel);
|
|
349
|
+
this.removePresence(clientId, channel, { skipStore: true });
|
|
294
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");
|
|
295
355
|
}
|
|
296
356
|
|
|
297
357
|
private async handleMessage(clientId: string, message: WebSocketMessage, authContext?: SubscriptionAuthContext) {
|
|
@@ -322,6 +382,14 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
322
382
|
payload?.payload
|
|
323
383
|
);
|
|
324
384
|
break;
|
|
385
|
+
case "channel_history":
|
|
386
|
+
await this.handleChannelHistoryRequest(
|
|
387
|
+
clientId,
|
|
388
|
+
payload?.channel as string,
|
|
389
|
+
payload?.sinceSeq as number | undefined,
|
|
390
|
+
payload?.limit as number | undefined
|
|
391
|
+
);
|
|
392
|
+
break;
|
|
325
393
|
|
|
326
394
|
// ── Presence ──
|
|
327
395
|
case "presence_track":
|
|
@@ -359,6 +427,16 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
359
427
|
return;
|
|
360
428
|
}
|
|
361
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
|
+
|
|
362
440
|
// Store subscription with full request parameters and auth context for RLS
|
|
363
441
|
this._subscriptions.set(subscriptionId, {
|
|
364
442
|
clientId,
|
|
@@ -368,7 +446,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
368
446
|
filter: request.filter,
|
|
369
447
|
orderBy: request.orderBy,
|
|
370
448
|
order: request.order,
|
|
371
|
-
limit:
|
|
449
|
+
limit: boundedLimit,
|
|
372
450
|
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
373
451
|
databaseId: request.collection?.databaseId,
|
|
374
452
|
searchString: request.searchString
|
|
@@ -383,7 +461,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
383
461
|
filter: request.filter,
|
|
384
462
|
orderBy: request.orderBy,
|
|
385
463
|
order: request.order,
|
|
386
|
-
limit:
|
|
464
|
+
limit: boundedLimit,
|
|
387
465
|
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
388
466
|
searchString: request.searchString
|
|
389
467
|
},
|
|
@@ -669,10 +747,10 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
669
747
|
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
|
|
670
748
|
// Refetches are reads: apply the same GUCs + reader-role downgrade as the
|
|
671
749
|
// driver's read path, so realtime cannot leak rows the initial fetch hid.
|
|
672
|
-
const activeAuth = authContext || {
|
|
750
|
+
const activeAuth = authContext || { uid: "anon",
|
|
673
751
|
roles: ["anon"] };
|
|
674
752
|
return await this.db.transaction(async (tx) => {
|
|
675
|
-
await applyAuthContext(tx, {
|
|
753
|
+
await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
|
|
676
754
|
const txEntityService = new DataService(tx, this.registry);
|
|
677
755
|
let fetchedEntities;
|
|
678
756
|
if (collectionRequest.searchString) {
|
|
@@ -711,7 +789,7 @@ roles: ["anon"] };
|
|
|
711
789
|
|
|
712
790
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
713
791
|
const contextForCallback = {
|
|
714
|
-
user: { uid: activeAuth.
|
|
792
|
+
user: { uid: activeAuth.uid,
|
|
715
793
|
roles: activeAuth.roles },
|
|
716
794
|
driver: this.driver,
|
|
717
795
|
data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
|
|
@@ -849,10 +927,10 @@ roles: activeAuth.roles },
|
|
|
849
927
|
|
|
850
928
|
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
|
|
851
929
|
// Same read isolation as collection refetches: GUCs + reader-role downgrade.
|
|
852
|
-
const activeAuth = authContext || {
|
|
930
|
+
const activeAuth = authContext || { uid: "anon",
|
|
853
931
|
roles: ["anon"] };
|
|
854
932
|
return await this.db.transaction(async (tx) => {
|
|
855
|
-
await applyAuthContext(tx, {
|
|
933
|
+
await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
|
|
856
934
|
const txEntityService = new DataService(tx, this.registry);
|
|
857
935
|
let processedEntity = await txEntityService.fetchOne(notifyPath, id, collection?.databaseId);
|
|
858
936
|
|
|
@@ -867,7 +945,7 @@ roles: ["anon"] };
|
|
|
867
945
|
|
|
868
946
|
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
869
947
|
const contextForCallback = {
|
|
870
|
-
user: { uid: activeAuth.
|
|
948
|
+
user: { uid: activeAuth.uid,
|
|
871
949
|
roles: activeAuth.roles },
|
|
872
950
|
driver: this.driver,
|
|
873
951
|
data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
|
|
@@ -1039,8 +1117,91 @@ roles: activeAuth.roles },
|
|
|
1039
1117
|
this.removePresence(clientId, channel);
|
|
1040
1118
|
}
|
|
1041
1119
|
|
|
1042
|
-
/**
|
|
1120
|
+
/**
|
|
1121
|
+
* Broadcast a message to all clients in a channel except the sender.
|
|
1122
|
+
*
|
|
1123
|
+
* On a channel with no retention rule this is what it always was: a
|
|
1124
|
+
* synchronous fan-out to whoever is connected, with no sequence number, no
|
|
1125
|
+
* SQL and no await — the body below runs to completion before returning.
|
|
1126
|
+
*
|
|
1127
|
+
* On a retained channel the message is durably numbered first and only then
|
|
1128
|
+
* delivered, through a per-channel queue so that delivery order matches
|
|
1129
|
+
* sequence order. That ordering is the whole point: a client that catches up
|
|
1130
|
+
* with `sinceSeq` has to arrive at the same state as one that never
|
|
1131
|
+
* disconnected.
|
|
1132
|
+
*/
|
|
1043
1133
|
broadcastToChannel(clientId: string, channel: string, event: string, payload: unknown): void {
|
|
1134
|
+
const retention = this.channelHistory?.retentionFor(channel);
|
|
1135
|
+
if (!retention) {
|
|
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);
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
const previous = this.channelSendQueues.get(channel) ?? Promise.resolve();
|
|
1146
|
+
const next = previous
|
|
1147
|
+
// A failed predecessor must not poison the chain — the next message
|
|
1148
|
+
// on this channel is independent and still deserves to be sent.
|
|
1149
|
+
.catch(() => { /* already reported below */ })
|
|
1150
|
+
.then(() => this.persistAndFanOut(clientId, channel, event, payload, retention));
|
|
1151
|
+
|
|
1152
|
+
this.channelSendQueues.set(channel, next);
|
|
1153
|
+
void next.finally(() => {
|
|
1154
|
+
// Only clear if nothing has queued behind us in the meantime.
|
|
1155
|
+
if (this.channelSendQueues.get(channel) === next) this.channelSendQueues.delete(channel);
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/**
|
|
1160
|
+
* Number a broadcast, store it, then deliver it.
|
|
1161
|
+
*
|
|
1162
|
+
* A message that cannot be stored is **not** delivered. Delivering it would
|
|
1163
|
+
* put it in front of live subscribers while leaving it absent from every
|
|
1164
|
+
* future replay — the two views of the channel would disagree permanently,
|
|
1165
|
+
* and no later message could repair the gap. Failing loudly to the sender
|
|
1166
|
+
* instead lets it retry, which for an operation stream is the only outcome
|
|
1167
|
+
* that keeps clients convergent.
|
|
1168
|
+
*/
|
|
1169
|
+
private async persistAndFanOut(
|
|
1170
|
+
clientId: string,
|
|
1171
|
+
channel: string,
|
|
1172
|
+
event: string,
|
|
1173
|
+
payload: unknown,
|
|
1174
|
+
retention: ResolvedRetention
|
|
1175
|
+
): Promise<void> {
|
|
1176
|
+
let seq: number;
|
|
1177
|
+
try {
|
|
1178
|
+
({ seq } = await this.channelHistory!.append(channel, event, payload, clientId));
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
logger.error(`❌ [ChannelHistory] Could not persist broadcast on "${channel}" — message dropped`, { error });
|
|
1181
|
+
this.sendError(
|
|
1182
|
+
clientId,
|
|
1183
|
+
`Could not persist broadcast on retained channel "${channel}"`,
|
|
1184
|
+
undefined,
|
|
1185
|
+
"CHANNEL_HISTORY_WRITE_FAILED"
|
|
1186
|
+
);
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
this.fanOutBroadcast(clientId, channel, event, payload, seq);
|
|
1191
|
+
this.publishBroadcast(clientId, channel, event, payload, seq);
|
|
1192
|
+
|
|
1193
|
+
try {
|
|
1194
|
+
await this.channelHistory!.prune(channel, retention);
|
|
1195
|
+
} catch (error) {
|
|
1196
|
+
// Retention is a housekeeping concern; the message is already
|
|
1197
|
+
// delivered and durable, so a failed prune must not surface as a
|
|
1198
|
+
// broadcast failure. It will be retried on the next message.
|
|
1199
|
+
logger.warn(`⚠️ [ChannelHistory] Prune failed for "${channel}"`, { error });
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/** Deliver a broadcast frame to every member of a channel but the sender. */
|
|
1204
|
+
private fanOutBroadcast(clientId: string, channel: string, event: string, payload: unknown, seq?: number): void {
|
|
1044
1205
|
const members = this.channels.get(channel);
|
|
1045
1206
|
if (!members) return;
|
|
1046
1207
|
|
|
@@ -1048,7 +1209,8 @@ roles: activeAuth.roles },
|
|
|
1048
1209
|
type: "broadcast",
|
|
1049
1210
|
channel,
|
|
1050
1211
|
event,
|
|
1051
|
-
payload
|
|
1212
|
+
payload,
|
|
1213
|
+
...(seq !== undefined ? { seq } : {})
|
|
1052
1214
|
});
|
|
1053
1215
|
|
|
1054
1216
|
for (const memberId of members) {
|
|
@@ -1060,36 +1222,316 @@ roles: activeAuth.roles },
|
|
|
1060
1222
|
}
|
|
1061
1223
|
}
|
|
1062
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
|
+
|
|
1405
|
+
// =============================================================================
|
|
1406
|
+
// Channel History
|
|
1407
|
+
// =============================================================================
|
|
1408
|
+
|
|
1409
|
+
/**
|
|
1410
|
+
* Install retention rules and create the tables they need.
|
|
1411
|
+
*
|
|
1412
|
+
* Safe to call with no rules (and safe not to call at all): the store stays
|
|
1413
|
+
* inert, no schema is created, and broadcast keeps its original
|
|
1414
|
+
* fire-and-forget path.
|
|
1415
|
+
*/
|
|
1416
|
+
async configureChannelHistory(rules: ChannelRetentionRule[] | undefined): Promise<void> {
|
|
1417
|
+
this.channelHistory = new ChannelHistoryStore(this.db, rules ?? []);
|
|
1418
|
+
if (!this.channelHistory.enabled) return;
|
|
1419
|
+
await this.channelHistory.ensureTables();
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/** Whether any channel is configured to retain messages. */
|
|
1423
|
+
public isChannelHistoryEnabled(): boolean {
|
|
1424
|
+
return this.channelHistory?.enabled ?? false;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Answer a client's catch-up request.
|
|
1429
|
+
*
|
|
1430
|
+
* A channel with no retention rule is answered with `retained: false`
|
|
1431
|
+
* rather than an empty list, so the client can tell "you missed nothing"
|
|
1432
|
+
* apart from "this channel never keeps anything" — the second means its
|
|
1433
|
+
* reconnect strategy has to be a full resync, and silence would leave it
|
|
1434
|
+
* guessing.
|
|
1435
|
+
*/
|
|
1436
|
+
private async handleChannelHistoryRequest(
|
|
1437
|
+
clientId: string,
|
|
1438
|
+
channel: string,
|
|
1439
|
+
sinceSeq?: number,
|
|
1440
|
+
limit?: number
|
|
1441
|
+
): Promise<void> {
|
|
1442
|
+
if (!channel) return;
|
|
1443
|
+
|
|
1444
|
+
const retention = this.channelHistory?.retentionFor(channel);
|
|
1445
|
+
if (!retention) {
|
|
1446
|
+
this.sendChannelHistory(clientId, channel, [], false);
|
|
1447
|
+
return;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
try {
|
|
1451
|
+
const { messages, latestSeq } = await this.channelHistory!.replay(channel, sinceSeq, limit);
|
|
1452
|
+
this.sendChannelHistory(clientId, channel, messages, true, latestSeq);
|
|
1453
|
+
} catch (error) {
|
|
1454
|
+
logger.error(`❌ [ChannelHistory] Replay failed for "${channel}"`, { error });
|
|
1455
|
+
this.sendError(clientId, `Could not replay history for channel "${channel}"`, undefined, "CHANNEL_HISTORY_READ_FAILED");
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
private sendChannelHistory(
|
|
1460
|
+
clientId: string,
|
|
1461
|
+
channel: string,
|
|
1462
|
+
messages: ChannelHistoryEntry[],
|
|
1463
|
+
retained: boolean,
|
|
1464
|
+
latestSeq?: number
|
|
1465
|
+
): void {
|
|
1466
|
+
const ws = this.clients.get(clientId);
|
|
1467
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1468
|
+
ws.send(JSON.stringify({
|
|
1469
|
+
type: "channel_history",
|
|
1470
|
+
channel,
|
|
1471
|
+
messages,
|
|
1472
|
+
retained,
|
|
1473
|
+
...(latestSeq !== undefined ? { latestSeq } : {})
|
|
1474
|
+
}));
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1063
1478
|
// =============================================================================
|
|
1064
1479
|
// Presence
|
|
1065
1480
|
// =============================================================================
|
|
1066
1481
|
|
|
1067
|
-
/**
|
|
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
|
+
*/
|
|
1068
1491
|
trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void {
|
|
1069
1492
|
if (!this.presence.has(channel)) {
|
|
1070
1493
|
this.presence.set(channel, new Map());
|
|
1071
1494
|
}
|
|
1072
1495
|
|
|
1073
1496
|
const channelPresence = this.presence.get(channel)!;
|
|
1497
|
+
const previous = channelPresence.get(clientId);
|
|
1498
|
+
const changed = !previous || JSON.stringify(previous.state) !== JSON.stringify(state);
|
|
1074
1499
|
channelPresence.set(clientId, { state,
|
|
1075
1500
|
lastSeen: Date.now() });
|
|
1076
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
|
+
|
|
1077
1506
|
// Broadcast join / state update to channel
|
|
1078
|
-
this.
|
|
1507
|
+
this.deliverPresenceDiff(channel, { [clientId]: state }, {});
|
|
1508
|
+
if (changed) {
|
|
1509
|
+
this.publishPresenceDiff(channel, { [clientId]: state }, {});
|
|
1510
|
+
}
|
|
1079
1511
|
|
|
1080
1512
|
// Start cleanup interval if not running
|
|
1081
1513
|
this.ensurePresenceCleanup();
|
|
1082
1514
|
}
|
|
1083
1515
|
|
|
1084
|
-
/**
|
|
1085
|
-
|
|
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 {
|
|
1086
1524
|
const channelPresence = this.presence.get(channel);
|
|
1087
1525
|
if (!channelPresence) return;
|
|
1088
1526
|
|
|
1089
1527
|
const entry = channelPresence.get(clientId);
|
|
1090
1528
|
if (entry) {
|
|
1091
1529
|
channelPresence.delete(clientId);
|
|
1092
|
-
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
|
+
}
|
|
1093
1535
|
}
|
|
1094
1536
|
|
|
1095
1537
|
if (channelPresence.size === 0) {
|
|
@@ -1097,17 +1539,50 @@ lastSeen: Date.now() });
|
|
|
1097
1539
|
}
|
|
1098
1540
|
}
|
|
1099
1541
|
|
|
1100
|
-
/**
|
|
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
|
+
*/
|
|
1101
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>> {
|
|
1102
1571
|
const channelPresence = this.presence.get(channel);
|
|
1103
1572
|
const presences: Record<string, Record<string, unknown>> = {};
|
|
1104
|
-
|
|
1105
1573
|
if (channelPresence) {
|
|
1106
1574
|
for (const [id, { state }] of channelPresence) {
|
|
1107
1575
|
presences[id] = state;
|
|
1108
1576
|
}
|
|
1109
1577
|
}
|
|
1578
|
+
return presences;
|
|
1579
|
+
}
|
|
1110
1580
|
|
|
1581
|
+
private sendPresenceStateMessage(
|
|
1582
|
+
clientId: string,
|
|
1583
|
+
channel: string,
|
|
1584
|
+
presences: Record<string, Record<string, unknown>>
|
|
1585
|
+
): void {
|
|
1111
1586
|
const ws = this.clients.get(clientId);
|
|
1112
1587
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1113
1588
|
ws.send(JSON.stringify({
|
|
@@ -1118,8 +1593,8 @@ lastSeen: Date.now() });
|
|
|
1118
1593
|
}
|
|
1119
1594
|
}
|
|
1120
1595
|
|
|
1121
|
-
/**
|
|
1122
|
-
private
|
|
1596
|
+
/** Deliver a presence diff to this instance's members of the channel. */
|
|
1597
|
+
private deliverPresenceDiff(
|
|
1123
1598
|
channel: string,
|
|
1124
1599
|
joins: Record<string, Record<string, unknown>>,
|
|
1125
1600
|
leaves: Record<string, Record<string, unknown>>
|
|
@@ -1142,6 +1617,26 @@ lastSeen: Date.now() });
|
|
|
1142
1617
|
}
|
|
1143
1618
|
}
|
|
1144
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
|
+
|
|
1145
1640
|
/** Periodic cleanup for stale presences */
|
|
1146
1641
|
private ensurePresenceCleanup(): void {
|
|
1147
1642
|
if (this.presenceInterval) return;
|
|
@@ -1162,6 +1657,43 @@ lastSeen: Date.now() });
|
|
|
1162
1657
|
}, 10000); // Check every 10s
|
|
1163
1658
|
}
|
|
1164
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
|
+
|
|
1165
1697
|
// =============================================================================
|
|
1166
1698
|
// Lifecycle / Cleanup
|
|
1167
1699
|
// =============================================================================
|
|
@@ -1191,14 +1723,39 @@ lastSeen: Date.now() });
|
|
|
1191
1723
|
// 3. Clear broadcast channels and presence
|
|
1192
1724
|
this.channels.clear();
|
|
1193
1725
|
this.presence.clear();
|
|
1726
|
+
// Pending history writes hold the pool open; let them settle before the
|
|
1727
|
+
// caller closes it, but never let a rejected one break shutdown.
|
|
1728
|
+
await Promise.allSettled([...this.channelSendQueues.values()]);
|
|
1729
|
+
this.channelSendQueues.clear();
|
|
1730
|
+
this.channelHistory?.clear();
|
|
1194
1731
|
if (this.presenceInterval) {
|
|
1195
1732
|
clearInterval(this.presenceInterval);
|
|
1196
1733
|
this.presenceInterval = undefined;
|
|
1197
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
|
+
}
|
|
1198
1752
|
|
|
1199
1753
|
// 4. Disconnect the dedicated LISTEN client(s)
|
|
1200
1754
|
await this.stopListening();
|
|
1201
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();
|
|
1202
1759
|
|
|
1203
1760
|
// 5. Drop client references (don't close — server.close drains them)
|
|
1204
1761
|
this.clients.clear();
|