@rebasepro/server-postgres 0.17.3 → 0.18.1
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/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/BranchService-ByLfM_Tt.js +360 -0
- package/dist/BranchService-ByLfM_Tt.js.map +1 -0
- package/dist/__vite-browser-external-BnuHet1e.js +46 -0
- package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
- package/dist/arg-Dni7MzLB.js +87 -0
- package/dist/arg-Dni7MzLB.js.map +1 -0
- package/dist/auth-users-columns-D2LBFrMH.js +116 -0
- package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/backup-cli-oM9WQVlV.js +408 -0
- package/dist/backup-cli-oM9WQVlV.js.map +1 -0
- package/dist/backup-service-Br3tqwsy.js +715 -0
- package/dist/backup-service-Br3tqwsy.js.map +1 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors-DxN4yzdH.js +657 -0
- package/dist/cli-errors-DxN4yzdH.js.map +1 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/cli-output-CNdMql-L.js +47 -0
- package/dist/cli-output-CNdMql-L.js.map +1 -0
- package/dist/cli.js +2020 -0
- package/dist/cli.js.map +1 -0
- package/dist/collection-index-BHgT_TKd.js +276 -0
- package/dist/collection-index-BHgT_TKd.js.map +1 -0
- package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
- package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
- package/dist/{connection-GOKU3Hu5.js → databasePoolManager-Bj5FbeAs.js} +112 -40
- package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/doctor-D7HWmGzs.js +974 -0
- package/dist/doctor-D7HWmGzs.js.map +1 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-2c2NV8zX.js} +7 -27
- package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
- package/dist/ensure-tables-CIojK-Rx.js +650 -0
- package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
- package/dist/{ensure-collection-tables-DgVixhX3.js → generate-postgres-ddl-logic-CtnpIBVv.js} +126 -984
- package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +7423 -1133
- package/dist/index.es.js.map +1 -1
- package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
- package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
- package/dist/policy-drift-kNJ6X2Uj.js +313 -0
- package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
- package/dist/{backup-service-DCk7KhhL.js → rls-bootstrap-sql-DLDnUjtx.js} +737 -2689
- package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-DeYEjYLt.js} +65 -190
- package/dist/rls-enforcement-DeYEjYLt.js.map +1 -0
- package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor-cli.js +129 -0
- package/dist/schema/doctor-cli.js.map +1 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -1
- package/dist/schema/generate-drizzle-schema.js +24 -0
- package/dist/schema/generate-drizzle-schema.js.map +1 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl.js +138 -0
- package/dist/schema/generate-postgres-ddl.js.map +1 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/schema/introspect-db.js +866 -0
- package/dist/schema/introspect-db.js.map +1 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/source-Br7L7GOI.js +410 -0
- package/dist/source-Br7L7GOI.js.map +1 -0
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/dist/vector-index-DkWxm2de.js +309 -0
- package/dist/vector-index-DkWxm2de.js.map +1 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-policies-UCqgv_8c.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- package/src/websocket.ts +0 -855
|
@@ -1,2545 +0,0 @@
|
|
|
1
|
-
import { WebSocket } from "ws";
|
|
2
|
-
import { EventEmitter } from "events";
|
|
3
|
-
import { Client as PgClient } from "pg";
|
|
4
|
-
import { randomUUID } from "crypto";
|
|
5
|
-
import { DataService } from "./dataService";
|
|
6
|
-
|
|
7
|
-
import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, LogicalCondition, OrderByTuple, CollectionConfig, RebaseCallContext, resolveClientListLimit, ListLimitError } from "@rebasepro/types";
|
|
8
|
-
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
|
-
import { sql as drizzleSql } from "drizzle-orm";
|
|
10
|
-
import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
|
|
11
|
-
import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
|
|
12
|
-
import { buildPropertyCallbacks, getTableName, OrderBySpecError, parseOrderBySpecStrict } from "@rebasepro/common";
|
|
13
|
-
import { applyAuthContext } from "../security/rls-enforcement";
|
|
14
|
-
import { buildJunctionLinkMap, type JunctionLink } from "./cdc/junction-tables";
|
|
15
|
-
import { logger } from "@rebasepro/server";
|
|
16
|
-
import { sanitizeErrorForClient } from "../utils/pg-error-utils";
|
|
17
|
-
import { CdcListener, type CdcChangeEvent } from "./cdc/CdcListener";
|
|
18
|
-
import { deriveRowAddress, getPrimaryKeys, type PrimaryKeyInfo } from "./collection-helpers";
|
|
19
|
-
import { ChannelHistoryStore, type ResolvedRetention } from "./channel-history";
|
|
20
|
-
import { ChannelPresenceStore } from "./channel-presence";
|
|
21
|
-
import { ChannelBus, ChannelBusFrame, MemoryChannelBus, frameByteLength } from "./channel-bus";
|
|
22
|
-
import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
|
|
23
|
-
|
|
24
|
-
/** Channel name used for Postgres LISTEN/NOTIFY cross-instance realtime. */
|
|
25
|
-
const PG_NOTIFY_CHANNEL = "rebase_entity_changes";
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Auth context stored per-subscription so real-time refetches respect RLS.
|
|
29
|
-
* Mirrors the session variables set by PostgresBackendDriver.withAuth().
|
|
30
|
-
*/
|
|
31
|
-
export interface SubscriptionAuthContext {
|
|
32
|
-
uid: string;
|
|
33
|
-
roles: string[];
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** What a channel frame is asking to do. */
|
|
37
|
-
export type ChannelAction = "join" | "broadcast" | "presence" | "history";
|
|
38
|
-
|
|
39
|
-
/** Everything an authorizer is told about the frame it is asked to allow. */
|
|
40
|
-
export interface ChannelAuthorizationRequest {
|
|
41
|
-
/** The channel the frame names, exactly as the client wrote it. */
|
|
42
|
-
channel: string;
|
|
43
|
-
action: ChannelAction;
|
|
44
|
-
/** The socket, not the principal — one user may hold several. */
|
|
45
|
-
clientId: string;
|
|
46
|
-
/** The socket's authenticated principal, or the anonymous one. */
|
|
47
|
-
user?: SubscriptionAuthContext;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The extension point for channel access rules.
|
|
52
|
-
*
|
|
53
|
-
* **This is deliberately not a product API yet.** The rule *language* — a
|
|
54
|
-
* config key, a per-pattern DSL, how it composes with `securityRules` — is an
|
|
55
|
-
* open design question (see `docs/channel-authorization.md`), and
|
|
56
|
-
* inventing one here would be inventing the answer. What exists is the single
|
|
57
|
-
* place every channel frame passes through, so that whatever shape the rules
|
|
58
|
-
* eventually take has exactly one seam to plug into and no arm of the switch
|
|
59
|
-
* can be forgotten.
|
|
60
|
-
*
|
|
61
|
-
* Returning `false` — or throwing — refuses the frame. It is consulted *after*
|
|
62
|
-
* the membership floor below, so an authorizer can only ever narrow access,
|
|
63
|
-
* never widen it.
|
|
64
|
-
*/
|
|
65
|
-
export type ChannelAuthorizer = (request: ChannelAuthorizationRequest) => boolean | Promise<boolean>;
|
|
66
|
-
|
|
67
|
-
interface DataDriverWithData extends DataDriver {
|
|
68
|
-
data: unknown;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
type RealTimeListenCollectionProps = ListenCollectionProps & {
|
|
72
|
-
subscriptionId: string
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* The narrowing a collection subscription was created with, kept so that every
|
|
77
|
-
* refetch answers the same query the initial fetch did.
|
|
78
|
-
*
|
|
79
|
-
* Named once because it used to be written out inline in five places, and a
|
|
80
|
-
* field missing from one of them is accepted over the wire and then silently
|
|
81
|
-
* ignored: `offset` was declared on the incoming props and never stored, so a
|
|
82
|
-
* live list on page three served page one, and `logical` was never stored
|
|
83
|
-
* either, so an `or(...)` subscription was pushed every row in the table.
|
|
84
|
-
*/
|
|
85
|
-
type StoredCollectionRequest = {
|
|
86
|
-
filter?: Record<string, unknown>;
|
|
87
|
-
logical?: LogicalCondition;
|
|
88
|
-
orderBy?: string | OrderByTuple[];
|
|
89
|
-
order?: "desc" | "asc";
|
|
90
|
-
limit?: number;
|
|
91
|
-
offset?: number;
|
|
92
|
-
startAfter?: Record<string, unknown>;
|
|
93
|
-
databaseId?: string;
|
|
94
|
-
searchString?: string;
|
|
95
|
-
/** Ask each row which declared search field matched — populates `_matches`. */
|
|
96
|
-
searchExplain?: boolean;
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
type RealTimeListenEntityProps = ListenOneProps & { subscriptionId: string };
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* A registered subscription, plus the two counters that order its deliveries.
|
|
103
|
-
*
|
|
104
|
-
* Every update a subscription delivers is a full re-fetch, and more than one
|
|
105
|
-
* thing starts one for the same subscription without coordinating: the initial
|
|
106
|
-
* fetch at subscribe time, and a debounced refetch per notification (app
|
|
107
|
-
* mutation, cross-instance NOTIFY, or CDC). A fetch that started earlier can
|
|
108
|
-
* finish later, and the delivery replaces everything the subscriber has — so
|
|
109
|
-
* the subscriber goes back to the state before the change and stays there,
|
|
110
|
-
* silently, until the next write to that collection.
|
|
111
|
-
*
|
|
112
|
-
* The debounce is not a fix for this. It collapses a burst into one refetch and
|
|
113
|
-
* does nothing about two refetches that overlap: notification A fires its timer
|
|
114
|
-
* and starts fetch A, notification B arrives while A is still in flight, and B's
|
|
115
|
-
* timer fires and starts fetch B regardless. See class 44 in
|
|
116
|
-
* `docs/bug-classes.md`.
|
|
117
|
-
*
|
|
118
|
-
* `started` is taken before the work, `delivered` after it — which makes the
|
|
119
|
-
* last delivery *started* the last one *delivered*.
|
|
120
|
-
*/
|
|
121
|
-
type Subscription = {
|
|
122
|
-
clientId: string;
|
|
123
|
-
type: "collection" | "single";
|
|
124
|
-
path: string;
|
|
125
|
-
id?: string | number;
|
|
126
|
-
// Store full collection request parameters for proper refetching
|
|
127
|
-
collectionRequest?: StoredCollectionRequest;
|
|
128
|
-
// Auth context for RLS — when set, refetches run in a transaction
|
|
129
|
-
// with set_config('app.uid', ...) / set_config('app.user_roles', ...)
|
|
130
|
-
authContext?: SubscriptionAuthContext;
|
|
131
|
-
/** How many deliveries have been started for this subscription. */
|
|
132
|
-
started: number;
|
|
133
|
-
/** The highest started-sequence that has already reached the subscriber. */
|
|
134
|
-
delivered: number;
|
|
135
|
-
};
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* PostgreSQL-specific realtime service.
|
|
139
|
-
* Handles WebSocket connections and subscriptions for real-time row updates.
|
|
140
|
-
*
|
|
141
|
-
* Implements the RealtimeProvider interface for database abstraction.
|
|
142
|
-
*/
|
|
143
|
-
export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
144
|
-
/**
|
|
145
|
-
* Declares to the multi-engine router that channel frames can be handled
|
|
146
|
-
* here. Read by `createRoutedRealtimeService`, which otherwise would have to
|
|
147
|
-
* guess — and guessed "the default provider", whichever engine that is.
|
|
148
|
-
*/
|
|
149
|
-
public readonly supportsChannels = true;
|
|
150
|
-
|
|
151
|
-
private clients = new Map<string, WebSocket>();
|
|
152
|
-
|
|
153
|
-
// Broadcast channels: channel name → set of client IDs
|
|
154
|
-
private channels = new Map<string, Set<string>>();
|
|
155
|
-
|
|
156
|
-
// Presence: channel → Map<clientId, { state, lastSeen }>
|
|
157
|
-
private presence = new Map<string, Map<string, { state: Record<string, unknown>; lastSeen: number }>>();
|
|
158
|
-
|
|
159
|
-
/**
|
|
160
|
-
* Ordered, replayable history for channels that opt into it.
|
|
161
|
-
*
|
|
162
|
-
* Undefined until {@link configureChannelHistory} is called, and inert even
|
|
163
|
-
* then unless retention rules were supplied — so presence and ephemeral
|
|
164
|
-
* notification channels never touch it. See `channel-history.ts`.
|
|
165
|
-
*/
|
|
166
|
-
private channelHistory?: ChannelHistoryStore;
|
|
167
|
-
|
|
168
|
-
/**
|
|
169
|
-
* One promise chain per retained channel, so that assigning a sequence
|
|
170
|
-
* number and fanning the message out happen in the same order for every
|
|
171
|
-
* message on that channel.
|
|
172
|
-
*
|
|
173
|
-
* Without it, two concurrent broadcasts can be numbered 4 and 5 by the
|
|
174
|
-
* database and still reach subscribers as 5 then 4 — live order and replay
|
|
175
|
-
* order would disagree, which is exactly the divergence sequence numbers
|
|
176
|
-
* are supposed to rule out. Keyed by channel, so unrelated channels never
|
|
177
|
-
* wait on each other.
|
|
178
|
-
*/
|
|
179
|
-
private channelSendQueues = new Map<string, Promise<void>>();
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Cross-instance transport for channel frames and presence.
|
|
183
|
-
*
|
|
184
|
-
* Defaults to the memory bus, which publishes nowhere — so a single-instance
|
|
185
|
-
* deployment runs the same fan-out it always did, with one resolved promise
|
|
186
|
-
* per broadcast for company. See `channel-bus/ChannelBus.ts`.
|
|
187
|
-
*/
|
|
188
|
-
private bus: ChannelBus = new MemoryChannelBus();
|
|
189
|
-
|
|
190
|
-
/**
|
|
191
|
-
* The shared presence roster, present only when a real bus is active.
|
|
192
|
-
*
|
|
193
|
-
* Fan-out alone is not enough for presence: `presence_state` has to answer
|
|
194
|
-
* with everyone in the channel, and per-process maps can only answer for
|
|
195
|
-
* this replica's clients. See `channel-presence.ts`.
|
|
196
|
-
*/
|
|
197
|
-
private presenceStore?: ChannelPresenceStore;
|
|
198
|
-
|
|
199
|
-
/** Sweeps roster rows left behind by instances that stopped heartbeating. */
|
|
200
|
-
private presenceSweepInterval?: ReturnType<typeof setInterval>;
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Channels whose oversized ephemeral broadcasts have already been reported,
|
|
204
|
-
* so a hot channel logs the problem once rather than once per message.
|
|
205
|
-
*/
|
|
206
|
-
private oversizedBroadcastWarned = new Set<string>();
|
|
207
|
-
|
|
208
|
-
/**
|
|
209
|
-
* Optional narrowing on top of the membership floor — see
|
|
210
|
-
* {@link ChannelAuthorizer}. Unset by default, which leaves membership as
|
|
211
|
-
* the whole of the rule.
|
|
212
|
-
*/
|
|
213
|
-
private channelAuthorizer?: ChannelAuthorizer;
|
|
214
|
-
|
|
215
|
-
/**
|
|
216
|
-
* Whether a notification from another instance has ever arrived.
|
|
217
|
-
*
|
|
218
|
-
* The entity LISTEN handler sees a foreign `sid` on every cross-instance
|
|
219
|
-
* change, which is proof that this deployment runs more than one pod — the
|
|
220
|
-
* one fact needed to tell "the memory bus is fine here" from "broadcast and
|
|
221
|
-
* presence silently reach a fraction of your users".
|
|
222
|
-
*/
|
|
223
|
-
private foreignInstanceSeen = false;
|
|
224
|
-
|
|
225
|
-
/** So the multi-pod memory-bus warning is emitted once, not once per join. */
|
|
226
|
-
private memoryBusWarned = false;
|
|
227
|
-
|
|
228
|
-
private presenceInterval?: ReturnType<typeof setInterval>;
|
|
229
|
-
private static readonly PRESENCE_TIMEOUT_MS = 30000; // 30s
|
|
230
|
-
/** How often stale roster rows from other instances are reaped. */
|
|
231
|
-
private static readonly PRESENCE_SWEEP_INTERVAL_MS = 10000; // 10s
|
|
232
|
-
private dataService: DataService;
|
|
233
|
-
// Enhanced subscriptions storage with full request parameters
|
|
234
|
-
private _subscriptions = new Map<string, Subscription>();
|
|
235
|
-
|
|
236
|
-
// Add callback storage for DataDriver subscriptions
|
|
237
|
-
private subscriptionCallbacks = new Map<string, (data: Record<string, unknown>[] | Record<string, unknown> | null) => void>();
|
|
238
|
-
|
|
239
|
-
private driver?: DataDriver;
|
|
240
|
-
|
|
241
|
-
// ── Cross-instance LISTEN/NOTIFY ──
|
|
242
|
-
/** Unique identifier for this process instance, used to skip own notifications. */
|
|
243
|
-
private readonly instanceId = `inst_${randomUUID().slice(0, 8)}`;
|
|
244
|
-
/** Dedicated pg.Client for LISTEN (outside the Drizzle pool). */
|
|
245
|
-
private listenClient?: PgClient;
|
|
246
|
-
/** Connection string used for reconnecting the LISTEN client. */
|
|
247
|
-
private listenConnectionString?: string;
|
|
248
|
-
/** Whether cross-instance broadcasting is active. */
|
|
249
|
-
private broadcasting = false;
|
|
250
|
-
/** Reconnection timer handle. */
|
|
251
|
-
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
252
|
-
/** Debounce timers for collection refetches to prevent refetch storms. */
|
|
253
|
-
private refetchTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
254
|
-
/** Debounce window (ms) for coalescing rapid row updates into a single correctness refetch. */
|
|
255
|
-
private static readonly REFETCH_DEBOUNCE_MS = 300;
|
|
256
|
-
|
|
257
|
-
// ── Database-level Change Data Capture (CDC) ──
|
|
258
|
-
/** Dedicated LISTEN client for DB-level change events (undefined unless CDC is enabled). */
|
|
259
|
-
private cdcListener?: CdcListener;
|
|
260
|
-
/** Whether database-level CDC is the active cross-instance change source. */
|
|
261
|
-
private cdcActive = false;
|
|
262
|
-
/** Junction table → the child lists its rows belong to, built when CDC starts. */
|
|
263
|
-
private junctionLinkMap?: Map<string, JunctionLink[]>;
|
|
264
|
-
|
|
265
|
-
/** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */
|
|
266
|
-
private cdcTableMap?: Map<string, CollectionConfig>;
|
|
267
|
-
/**
|
|
268
|
-
* Short-lived record of `path/id` keys this instance just fanned out via the
|
|
269
|
-
* app path (a Rebase-API mutation). When CDC echoes the same committed change
|
|
270
|
-
* back to *this* instance, we suppress the duplicate — the change was already
|
|
271
|
-
* delivered locally. Other instances have no such record, so they still
|
|
272
|
-
* deliver the CDC event. External writes (psql, cron, SQL editor) never match
|
|
273
|
-
* and always flow through. Keyed → expiry timestamp (ms).
|
|
274
|
-
*/
|
|
275
|
-
private recentAppEmits = new Map<string, number>();
|
|
276
|
-
/** How long an app-emit key suppresses its own CDC echo. Covers NOTIFY round-trip latency. */
|
|
277
|
-
private static readonly CDC_DEDUP_WINDOW_MS = 5000;
|
|
278
|
-
|
|
279
|
-
constructor(private db: NodePgDatabase<any>, private registry: PostgresCollectionRegistry) {
|
|
280
|
-
super();
|
|
281
|
-
this.dataService = new DataService(db, registry);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Restricted role that auth-scoped refetches run as (via `SET LOCAL ROLE`)
|
|
286
|
-
* so RLS `select` policies bind. Set by the bootstrapper alongside
|
|
287
|
-
* `PostgresBackendDriver.rlsUserRole`; undefined when the connection
|
|
288
|
-
* is already subject to RLS natively. Without this, realtime refetches
|
|
289
|
-
* would leak rows the initial (isolated) fetch correctly hid.
|
|
290
|
-
*/
|
|
291
|
-
public rlsUserRole?: string;
|
|
292
|
-
|
|
293
|
-
/** Whether to emit verbose debug logs (disabled in production). */
|
|
294
|
-
private static readonly DEBUG = process.env.NODE_ENV !== "production";
|
|
295
|
-
private debugLog(...args: unknown[]) {
|
|
296
|
-
if (RealtimeService.DEBUG) console.debug(...args);
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
setDataDriver(driver: DataDriver) {
|
|
300
|
-
this.driver = driver;
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// Make subscriptions accessible for DataDriver
|
|
304
|
-
get subscriptions() {
|
|
305
|
-
return this._subscriptions;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
/**
|
|
309
|
-
* Claim a delivery slot for a subscription, before doing the work.
|
|
310
|
-
*
|
|
311
|
-
* Returns the check to run immediately before delivering. It refuses in
|
|
312
|
-
* three cases, all of which used to deliver:
|
|
313
|
-
*
|
|
314
|
-
* - **Out of order.** A newer refetch has already delivered, so this one is
|
|
315
|
-
* stale — the subscriber would go back to the state before the change.
|
|
316
|
-
* - **Unsubscribed.** The subscription was cancelled while the fetch was in
|
|
317
|
-
* flight. The `has(subscriptionId)` check the debounced refetches ran
|
|
318
|
-
* *before* the await cannot answer this; only a check after it can.
|
|
319
|
-
* - **Replaced.** The same id can name a *different* subscription by the
|
|
320
|
-
* time a fetch lands — a re-subscribe overwrites the map entry, and the
|
|
321
|
-
* old filter's rows would be delivered to the new subscriber.
|
|
322
|
-
*
|
|
323
|
-
* The last two are identity, not presence: the map has to still hold *this
|
|
324
|
-
* exact object*, not merely something under this id.
|
|
325
|
-
*/
|
|
326
|
-
private beginDelivery(subscriptionId: string, subscription: Subscription): () => boolean {
|
|
327
|
-
const seq = ++subscription.started;
|
|
328
|
-
return () => {
|
|
329
|
-
if (this._subscriptions.get(subscriptionId) !== subscription) return false;
|
|
330
|
-
if (seq <= subscription.delivered) return false;
|
|
331
|
-
subscription.delivered = seq;
|
|
332
|
-
return true;
|
|
333
|
-
};
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
// Add public method to register DataDriver subscriptions
|
|
337
|
-
registerDataDriverSubscription(subscriptionId: string, subscription: {
|
|
338
|
-
clientId: string;
|
|
339
|
-
type: "collection" | "single";
|
|
340
|
-
path: string;
|
|
341
|
-
id?: string | number;
|
|
342
|
-
collectionRequest?: StoredCollectionRequest;
|
|
343
|
-
authContext?: SubscriptionAuthContext;
|
|
344
|
-
}) {
|
|
345
|
-
this.debugLog("📋 [RealtimeService] Registering DataDriver subscription:", subscriptionId, subscription.authContext ? "(with auth)" : "(no auth)");
|
|
346
|
-
this._subscriptions.set(subscriptionId, { ...subscription, started: 0, delivered: 0 });
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
// Add callback management methods
|
|
350
|
-
addSubscriptionCallback(subscriptionId: string, callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void) {
|
|
351
|
-
this.debugLog("📋 [RealtimeService] Adding callback for subscription:", subscriptionId);
|
|
352
|
-
this.subscriptionCallbacks.set(subscriptionId, callback);
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
removeSubscriptionCallback(subscriptionId: string) {
|
|
356
|
-
this.debugLog("📋 [RealtimeService] Removing callback for subscription:", subscriptionId);
|
|
357
|
-
this.subscriptionCallbacks.delete(subscriptionId);
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
// =============================================================================
|
|
361
|
-
// RealtimeProvider Interface Methods
|
|
362
|
-
// =============================================================================
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Subscribe to collection changes (RealtimeProvider interface)
|
|
366
|
-
*/
|
|
367
|
-
subscribeToCollection(
|
|
368
|
-
subscriptionId: string,
|
|
369
|
-
config: CollectionSubscriptionConfig,
|
|
370
|
-
callback?: (rows: Record<string, unknown>[]) => void
|
|
371
|
-
): void {
|
|
372
|
-
this._subscriptions.set(subscriptionId, {
|
|
373
|
-
clientId: config.clientId,
|
|
374
|
-
type: "collection",
|
|
375
|
-
path: config.path,
|
|
376
|
-
collectionRequest: {
|
|
377
|
-
filter: config.filter as Record<string, unknown> | undefined,
|
|
378
|
-
orderBy: config.orderBy,
|
|
379
|
-
order: config.order,
|
|
380
|
-
limit: config.limit,
|
|
381
|
-
startAfter: config.startAfter as Record<string, unknown> | undefined,
|
|
382
|
-
databaseId: config.databaseId,
|
|
383
|
-
searchString: config.searchString,
|
|
384
|
-
searchExplain: config.searchExplain
|
|
385
|
-
},
|
|
386
|
-
started: 0,
|
|
387
|
-
delivered: 0
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
if (callback) {
|
|
391
|
-
this.subscriptionCallbacks.set(subscriptionId, callback as (data: Record<string, unknown>[] | Record<string, unknown> | null) => void);
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Subscribe to single row changes (RealtimeProvider interface)
|
|
397
|
-
*/
|
|
398
|
-
subscribeToOne(
|
|
399
|
-
subscriptionId: string,
|
|
400
|
-
config: SingleSubscriptionConfig,
|
|
401
|
-
callback?: (row: Record<string, unknown> | null) => void
|
|
402
|
-
): void {
|
|
403
|
-
this._subscriptions.set(subscriptionId, {
|
|
404
|
-
clientId: config.clientId,
|
|
405
|
-
type: "single",
|
|
406
|
-
path: config.path,
|
|
407
|
-
id: config.id,
|
|
408
|
-
started: 0,
|
|
409
|
-
delivered: 0
|
|
410
|
-
});
|
|
411
|
-
|
|
412
|
-
if (callback) {
|
|
413
|
-
this.subscriptionCallbacks.set(subscriptionId, callback as (data: Record<string, unknown>[] | Record<string, unknown> | null) => void);
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Unsubscribe from a subscription (RealtimeProvider interface)
|
|
419
|
-
*/
|
|
420
|
-
unsubscribe(subscriptionId: string): void {
|
|
421
|
-
this._subscriptions.delete(subscriptionId);
|
|
422
|
-
this.subscriptionCallbacks.delete(subscriptionId);
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// =============================================================================
|
|
426
|
-
// WebSocket Client Management
|
|
427
|
-
// =============================================================================
|
|
428
|
-
|
|
429
|
-
addClient(clientId: string, ws: WebSocket) {
|
|
430
|
-
this.clients.set(clientId, ws);
|
|
431
|
-
|
|
432
|
-
ws.on("close", () => {
|
|
433
|
-
this.removeClient(clientId);
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
ws.on("error", (error) => {
|
|
437
|
-
logger.error("WebSocket error for client", { detail: clientId, error });
|
|
438
|
-
this.removeClient(clientId);
|
|
439
|
-
});
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Public method to handle messages from external sources (like main WebSocket handler)
|
|
443
|
-
async handleClientMessage(clientId: string, message: WebSocketMessage, authContext?: SubscriptionAuthContext) {
|
|
444
|
-
await this.handleMessage(clientId, message, authContext);
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
async removeClient(clientId: string) {
|
|
448
|
-
this.clients.delete(clientId);
|
|
449
|
-
|
|
450
|
-
// Remove all subscriptions, callbacks, and pending refetch timers for this client
|
|
451
|
-
for (const [subscriptionId, subscription] of this._subscriptions.entries()) {
|
|
452
|
-
if (subscription.clientId === clientId) {
|
|
453
|
-
this._subscriptions.delete(subscriptionId);
|
|
454
|
-
this.subscriptionCallbacks.delete(subscriptionId);
|
|
455
|
-
|
|
456
|
-
// Cancel any pending debounced refetch timers
|
|
457
|
-
for (const prefix of ["ws_", "drv_", "wse_", "drve_"]) {
|
|
458
|
-
const key = `${prefix}${subscriptionId}`;
|
|
459
|
-
const timer = this.refetchTimers.get(key);
|
|
460
|
-
if (timer) { clearTimeout(timer); this.refetchTimers.delete(key); }
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
// The shared rows go before the announcement, not after it. Every
|
|
466
|
-
// `removePresence` below publishes a departure — to local members and
|
|
467
|
-
// over the bus — while `sendPresenceState` answers a *newly arriving*
|
|
468
|
-
// subscriber from this table. Announcing first leaves a window where the
|
|
469
|
-
// table still lists someone who has left: a client hydrating inside it is
|
|
470
|
-
// handed the ghost, and the diff that would have corrected it was
|
|
471
|
-
// broadcast before that client existed, so it holds the ghost until the
|
|
472
|
-
// TTL sweep rather than for the length of one statement.
|
|
473
|
-
//
|
|
474
|
-
// One statement for every channel the client was in, rather than one per
|
|
475
|
-
// channel below — a disconnect is the common case, not a rare one. The
|
|
476
|
-
// subscription and timer cleanup above stays synchronous on purpose: it
|
|
477
|
-
// is what leaks if the database is slow, and it owes nothing to the
|
|
478
|
-
// shared table. With no bus configured this is a no-op that never awaits
|
|
479
|
-
// a query.
|
|
480
|
-
await this.presenceStoreOp(() => this.presenceStore!.removeClient(clientId), "client removal");
|
|
481
|
-
|
|
482
|
-
// Remove from all broadcast channels
|
|
483
|
-
for (const [channel, members] of this.channels.entries()) {
|
|
484
|
-
if (members.has(clientId)) {
|
|
485
|
-
members.delete(clientId);
|
|
486
|
-
this.removePresence(clientId, channel, { skipStore: true });
|
|
487
|
-
if (members.size === 0) this.channels.delete(channel);
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
// Remove from all presence channels
|
|
492
|
-
for (const [channel] of this.presence) {
|
|
493
|
-
this.removePresence(clientId, channel, { skipStore: true });
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
private async handleMessage(clientId: string, message: WebSocketMessage, authContext?: SubscriptionAuthContext) {
|
|
498
|
-
const payload = message.payload as Record<string, unknown> | undefined;
|
|
499
|
-
switch (message.type) {
|
|
500
|
-
case "subscribe_collection":
|
|
501
|
-
await this.handleCollectionSubscription(clientId, message.payload as RealTimeListenCollectionProps, authContext);
|
|
502
|
-
break;
|
|
503
|
-
case "subscribe_one":
|
|
504
|
-
await this.handleEntitySubscription(clientId, message.payload as RealTimeListenEntityProps, authContext);
|
|
505
|
-
break;
|
|
506
|
-
case "unsubscribe":
|
|
507
|
-
await this.handleUnsubscribe(clientId, message.subscriptionId!);
|
|
508
|
-
break;
|
|
509
|
-
|
|
510
|
-
// ── Broadcast Channels & Presence ──
|
|
511
|
-
//
|
|
512
|
-
// One arm for all of them, because every one has to pass the same
|
|
513
|
-
// gate and a switch with seven arms is a place to forget it once.
|
|
514
|
-
// See `handleChannelMessage`.
|
|
515
|
-
case "join_channel":
|
|
516
|
-
case "leave_channel":
|
|
517
|
-
case "broadcast":
|
|
518
|
-
case "channel_history":
|
|
519
|
-
case "presence_track":
|
|
520
|
-
case "presence_untrack":
|
|
521
|
-
case "presence_state":
|
|
522
|
-
await this.handleChannelMessage(clientId, message.type, payload, authContext);
|
|
523
|
-
break;
|
|
524
|
-
|
|
525
|
-
default:
|
|
526
|
-
this.sendError(clientId, "Unknown message type " + message.type, message.subscriptionId);
|
|
527
|
-
}
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
private async handleCollectionSubscription(clientId: string, request: RealTimeListenCollectionProps, authContext?: SubscriptionAuthContext) {
|
|
531
|
-
const subscriptionId = request.subscriptionId;
|
|
532
|
-
|
|
533
|
-
try {
|
|
534
|
-
// Early validation: ensure the requested collection exists in the registry
|
|
535
|
-
const collection = this.registry.getCollectionByPath(request.path);
|
|
536
|
-
if (!collection) {
|
|
537
|
-
const registered = this.registry.getCollections().map(c => c.slug).join(", ");
|
|
538
|
-
const msg = `Collection not found: '${request.path}'. Registered: [${registered}]`;
|
|
539
|
-
logger.error(`[RealtimeService] ${msg}`);
|
|
540
|
-
this.sendError(clientId, msg, subscriptionId);
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// A vector search cannot be served here, and the parameter used to
|
|
545
|
-
// be read for one thing only — the limit default below — and then
|
|
546
|
-
// dropped: the stored request carries no `vectorSearch` and the
|
|
547
|
-
// refetch has no branch for one. So `.vectorSearch(…).listen()`
|
|
548
|
-
// delivered an ordinary `id DESC` listing, with no `_distance` and
|
|
549
|
-
// no error, forever. Refusing says what the silence did not.
|
|
550
|
-
if (request.vectorSearch) {
|
|
551
|
-
const msg =
|
|
552
|
-
"Realtime subscriptions do not support vector search: a subscription is re-run on every " +
|
|
553
|
-
"matching write, and nothing here computes distances. Use `.vectorSearch(...).find()` for " +
|
|
554
|
-
"the query, and subscribe without it if you need live updates.";
|
|
555
|
-
logger.warn(`[RealtimeService] ${msg}`);
|
|
556
|
-
this.sendError(clientId, msg, subscriptionId, "VECTOR_SEARCH_NOT_LIVE");
|
|
557
|
-
return;
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// Bound the client-supplied limit with the SAME guarantee the REST
|
|
561
|
-
// ingress applies (`resolveClientListLimit`): default an absent
|
|
562
|
-
// limit by mode, refuse one above the ceiling. A subscription is
|
|
563
|
-
// re-fetched on every matching write, so an unbounded one is a DoS
|
|
564
|
-
// amplified per write — resolve it once and reuse for the stored
|
|
565
|
-
// request and the initial fetch.
|
|
566
|
-
//
|
|
567
|
-
// Refusing matters more here than on the REST route: a
|
|
568
|
-
// `collection_update` frame carries rows and nothing else — no
|
|
569
|
-
// `total`, no `hasMore` — so a subscriber handed a quietly smaller
|
|
570
|
-
// page has no way at all to learn it is not seeing the collection.
|
|
571
|
-
let boundedLimit: number;
|
|
572
|
-
try {
|
|
573
|
-
boundedLimit = resolveClientListLimit(request.limit);
|
|
574
|
-
} catch (e) {
|
|
575
|
-
if (!(e instanceof ListLimitError)) throw e;
|
|
576
|
-
logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
|
|
577
|
-
this.sendError(clientId, e.message, subscriptionId, "INVALID_LIMIT");
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
// The sort arrives as whatever JSON the client put in the frame, so
|
|
582
|
-
// its *shape* is checked here the way the REST ingress checks the
|
|
583
|
-
// query parameter. Unchecked, a malformed entry reads as a field
|
|
584
|
-
// name that resolves to no column, and under lenient unknown-field
|
|
585
|
-
// handling the subscription then streams rows in no order at all
|
|
586
|
-
// while reporting nothing wrong.
|
|
587
|
-
let orderBy: OrderByTuple[] | undefined;
|
|
588
|
-
try {
|
|
589
|
-
orderBy = parseOrderBySpecStrict(request.orderBy, request.order);
|
|
590
|
-
} catch (e) {
|
|
591
|
-
if (!(e instanceof OrderBySpecError)) throw e;
|
|
592
|
-
logger.warn(`[RealtimeService] Refused subscription to '${request.path}': ${e.message}`);
|
|
593
|
-
this.sendError(clientId, e.message, subscriptionId, e.code);
|
|
594
|
-
return;
|
|
595
|
-
}
|
|
596
|
-
|
|
597
|
-
// Store subscription with full request parameters and auth context for RLS
|
|
598
|
-
const subscription: Subscription = {
|
|
599
|
-
clientId,
|
|
600
|
-
type: "collection",
|
|
601
|
-
path: request.path,
|
|
602
|
-
collectionRequest: {
|
|
603
|
-
filter: request.filter,
|
|
604
|
-
logical: request.logical,
|
|
605
|
-
orderBy,
|
|
606
|
-
order: request.order,
|
|
607
|
-
limit: boundedLimit,
|
|
608
|
-
offset: request.offset,
|
|
609
|
-
startAfter: request.startAfter as Record<string, unknown> | undefined,
|
|
610
|
-
databaseId: request.collection?.databaseId,
|
|
611
|
-
searchString: request.searchString,
|
|
612
|
-
searchExplain: request.searchExplain
|
|
613
|
-
},
|
|
614
|
-
authContext,
|
|
615
|
-
started: 0,
|
|
616
|
-
delivered: 0
|
|
617
|
-
};
|
|
618
|
-
this._subscriptions.set(subscriptionId, subscription);
|
|
619
|
-
|
|
620
|
-
// The subscription is registered before this fetch runs, so a write
|
|
621
|
-
// arriving in that window starts a refetch of its own — with nothing
|
|
622
|
-
// ordering the two. Claim a slot first: this fetch is the oldest, so
|
|
623
|
-
// if the refetch answers first, this one no longer delivers.
|
|
624
|
-
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
625
|
-
|
|
626
|
-
// Send initial data. Built from the request the subscription just
|
|
627
|
-
// stored, so the first answer and every refetch after it cannot
|
|
628
|
-
// describe different queries.
|
|
629
|
-
const rows = await this.fetchCollectionWithAuth(
|
|
630
|
-
request.path,
|
|
631
|
-
subscription.collectionRequest!,
|
|
632
|
-
authContext
|
|
633
|
-
);
|
|
634
|
-
|
|
635
|
-
if (canDeliver()) {
|
|
636
|
-
this.sendCollectionUpdate(clientId, subscriptionId, rows, request.path);
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
} catch (error) {
|
|
640
|
-
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
641
|
-
this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
642
|
-
}
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
private async handleEntitySubscription(clientId: string, request: RealTimeListenEntityProps, authContext?: SubscriptionAuthContext) {
|
|
646
|
-
const subscriptionId = request.subscriptionId;
|
|
647
|
-
|
|
648
|
-
try {
|
|
649
|
-
// Early validation: ensure the requested collection exists in the registry
|
|
650
|
-
const collection = this.registry.getCollectionByPath(request.path);
|
|
651
|
-
if (!collection) {
|
|
652
|
-
const registered = this.registry.getCollections().map(c => c.slug).join(", ");
|
|
653
|
-
const msg = `Collection not found: '${request.path}'. Registered: [${registered}]`;
|
|
654
|
-
logger.error(`[RealtimeService] ${msg}`);
|
|
655
|
-
this.sendError(clientId, msg, subscriptionId);
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
// Store subscription in memory with auth context for RLS
|
|
660
|
-
const subscription: Subscription = {
|
|
661
|
-
clientId,
|
|
662
|
-
type: "single",
|
|
663
|
-
path: request.path,
|
|
664
|
-
id: request.id,
|
|
665
|
-
authContext,
|
|
666
|
-
started: 0,
|
|
667
|
-
delivered: 0
|
|
668
|
-
};
|
|
669
|
-
this._subscriptions.set(subscriptionId, subscription);
|
|
670
|
-
|
|
671
|
-
// Same race as the collection case: a write landing between the
|
|
672
|
-
// registration above and this fetch starts a refetch that can answer
|
|
673
|
-
// first, and this one must not overwrite it afterwards.
|
|
674
|
-
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
675
|
-
|
|
676
|
-
// Send initial data
|
|
677
|
-
const row = await this.fetchEntityWithAuth(
|
|
678
|
-
request.path,
|
|
679
|
-
String(request.id),
|
|
680
|
-
authContext
|
|
681
|
-
);
|
|
682
|
-
|
|
683
|
-
if (canDeliver()) {
|
|
684
|
-
this.sendSingleUpdate(clientId, subscriptionId, row || null);
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
} catch (error) {
|
|
688
|
-
const sanitized = sanitizeErrorForClient(error, request.path);
|
|
689
|
-
this.sendError(clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
private async handleUnsubscribe(_clientId: string, subscriptionId: string) {
|
|
694
|
-
this._subscriptions.delete(subscriptionId);
|
|
695
|
-
this.subscriptionCallbacks.delete(subscriptionId);
|
|
696
|
-
// Cancel any pending debounced refetch
|
|
697
|
-
for (const prefix of ["ws_", "drv_", "wse_", "drve_"]) {
|
|
698
|
-
const key = `${prefix}${subscriptionId}`;
|
|
699
|
-
const timer = this.refetchTimers.get(key);
|
|
700
|
-
if (timer) { clearTimeout(timer); this.refetchTimers.delete(key); }
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
/**
|
|
705
|
-
* Enhanced notification method that handles nested relation updates.
|
|
706
|
-
* @param broadcast When true (default), also sends a pg_notify so other instances
|
|
707
|
-
* pick up the change. Set to false when handling an incoming
|
|
708
|
-
* cross-instance notification to avoid infinite loops.
|
|
709
|
-
* @param origin `"app"` (default) — a Rebase-API mutation on this instance;
|
|
710
|
-
* `"cdc"` — a database-level change observed via CDC (any writer,
|
|
711
|
-
* any instance). The origin drives de-duplication: an app emit
|
|
712
|
-
* records the change so this instance can suppress the matching
|
|
713
|
-
* CDC echo, while an unmatched CDC event is delivered normally.
|
|
714
|
-
*/
|
|
715
|
-
async notifyUpdate(path: string, id: string, row: Record<string, unknown> | null, databaseId?: string, broadcast = true, origin: "app" | "cdc" = "app") {
|
|
716
|
-
this.debugLog("🔔 [RealtimeService] notifyUpdate called for path:", path, "id:", id, "isDelete:", row === null, "origin:", origin);
|
|
717
|
-
|
|
718
|
-
// De-duplicate against database-level CDC. The app path (a mutation made
|
|
719
|
-
// through the Rebase API) fans out locally AND, once CDC is active, the
|
|
720
|
-
// same committed change is echoed back to this instance via the WAL /
|
|
721
|
-
// trigger stream. Record app emits so we can drop that echo here; deliver
|
|
722
|
-
// any CDC event we did not originate (external writes, other instances).
|
|
723
|
-
if (this.cdcActive) {
|
|
724
|
-
const key = this.dedupKey(path, id, databaseId);
|
|
725
|
-
if (origin === "cdc") {
|
|
726
|
-
if (this.consumeAppEmit(key)) {
|
|
727
|
-
this.debugLog("🔁 [RealtimeService] Suppressing CDC echo of local app mutation:", key);
|
|
728
|
-
return;
|
|
729
|
-
}
|
|
730
|
-
} else {
|
|
731
|
-
this.markAppEmit(key);
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
// Get all paths that need to be notified - the direct path plus any parent paths
|
|
736
|
-
const pathsToNotify = [path];
|
|
737
|
-
|
|
738
|
-
// If this is a nested relation path (like "posts/70/tags"), also notify parent paths
|
|
739
|
-
if (path.includes("/") && path.split("/").length > 1) {
|
|
740
|
-
const parentPaths = this.getParentPaths(path);
|
|
741
|
-
pathsToNotify.push(...parentPaths);
|
|
742
|
-
this.debugLog(`🔗 [RealtimeService] Nested path detected. Will notify paths: ${pathsToNotify.join(", ")}`);
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
// Process each path that needs notification
|
|
746
|
-
for (const notifyPath of pathsToNotify) {
|
|
747
|
-
await this.notifyPathUpdate(notifyPath, path, id, row, databaseId);
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
// Broadcast to other instances via pg_notify (only for local mutations).
|
|
751
|
-
// When CDC is active it IS the cross-instance channel — every instance
|
|
752
|
-
// observes every commit through the change stream — so the legacy
|
|
753
|
-
// per-mutation broadcast is redundant (and would double-deliver). Skip it.
|
|
754
|
-
if (broadcast && this.broadcasting && !this.cdcActive) {
|
|
755
|
-
try {
|
|
756
|
-
await this.broadcastChange(path, id, databaseId);
|
|
757
|
-
} catch (err) {
|
|
758
|
-
logger.error("❌ [RealtimeService] Failed to broadcast change via pg_notify", { error: err });
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
this.debugLog("🔔 [RealtimeService] notifyUpdate completed for path:", path);
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
/**
|
|
766
|
-
* Notify subscriptions for a specific path.
|
|
767
|
-
*
|
|
768
|
-
* **A subscriber only ever receives rows re-read under its own scope.**
|
|
769
|
-
* `row` is used to decide *that* something changed, never to say *what* —
|
|
770
|
-
* every delivery below goes through a refetch that binds the subscription's
|
|
771
|
-
* own auth context.
|
|
772
|
-
*
|
|
773
|
-
* It used to be conditional. The CDC path already did the right thing: it
|
|
774
|
-
* discards the captured tuple and emits `{_rebase_invalidated: true}`, and
|
|
775
|
-
* that marker selected the refetch branch. But the marker is produced in
|
|
776
|
-
* exactly two places, and the *other* side of each branch here shipped the
|
|
777
|
-
* row it was handed straight to the socket. Two of the three entry paths
|
|
778
|
-
* took that side — every API mutation (`PostgresBackendDriver.save` passes
|
|
779
|
-
* the row it just wrote, read under the **writer's** scope) and the legacy
|
|
780
|
-
* cross-instance LISTEN handler (which re-reads on the owner connection,
|
|
781
|
-
* bypassing RLS altogether). Path matching was the only filter applied: the
|
|
782
|
-
* subscription's own `filter`/`logical` was never evaluated, and any
|
|
783
|
-
* `afterRead` redaction was the writer's rather than the reader's.
|
|
784
|
-
*
|
|
785
|
-
* A single-row subscription was the sharpest case. `subscribe_one` on a row
|
|
786
|
-
* RLS denies is accepted and answered `null`; the next update then pushed
|
|
787
|
-
* the full row with no later correction. The collection variant was merely
|
|
788
|
-
* papered over ~300 ms later by the debounced refetch — after the bytes had
|
|
789
|
-
* already reached the browser.
|
|
790
|
-
*
|
|
791
|
-
* The same defect was found and fixed on the Mongo driver in `065e2b615`
|
|
792
|
-
* (see `packages/server-mongo/test/realtime-authorization.test.ts`); this is
|
|
793
|
-
* the Postgres half, stated as one rule rather than three patched branches.
|
|
794
|
-
*
|
|
795
|
-
* The cost is the instant row-level patch that used to precede the refetch:
|
|
796
|
-
* cross-tab feedback now waits for the debounce. That is the price of not
|
|
797
|
-
* being able to know, without asking the database as this subscriber,
|
|
798
|
-
* whether this subscriber may see the row at all.
|
|
799
|
-
*/
|
|
800
|
-
private async notifyPathUpdate(notifyPath: string, originalPath: string, id: string, row: Record<string, unknown> | null, _databaseId?: string) {
|
|
801
|
-
this.debugLog(`📡 [RealtimeService] Notifying path: ${notifyPath} (original: ${originalPath})`);
|
|
802
|
-
|
|
803
|
-
// Find all relevant subscriptions for this specific path
|
|
804
|
-
const allSubscriptions = Array.from(this._subscriptions.entries()).filter(([, sub]) => {
|
|
805
|
-
const isPathMatch = sub.path === notifyPath;
|
|
806
|
-
|
|
807
|
-
// For row subscriptions, check if the id matches (only for exact path matches)
|
|
808
|
-
if (sub.type === "single") {
|
|
809
|
-
return isPathMatch && (notifyPath === originalPath ? sub.id === id : true);
|
|
810
|
-
}
|
|
811
|
-
// For collection subscriptions, it's always relevant if the path matches
|
|
812
|
-
if (sub.type === "collection") {
|
|
813
|
-
return isPathMatch;
|
|
814
|
-
}
|
|
815
|
-
return false;
|
|
816
|
-
});
|
|
817
|
-
|
|
818
|
-
this.debugLog(`📡 [RealtimeService] Found ${allSubscriptions.length} subscriptions for path: ${notifyPath}`);
|
|
819
|
-
|
|
820
|
-
// Separate WebSocket subscriptions from DataDriver callback subscriptions
|
|
821
|
-
const webSocketSubscriptions = allSubscriptions.filter(([, sub]) =>
|
|
822
|
-
sub.clientId !== "driver" && this.clients.has(sub.clientId)
|
|
823
|
-
);
|
|
824
|
-
|
|
825
|
-
const driverSubscriptions = allSubscriptions.filter(([subscriptionId, sub]) =>
|
|
826
|
-
sub.clientId === "driver" && this.subscriptionCallbacks.has(subscriptionId)
|
|
827
|
-
);
|
|
828
|
-
|
|
829
|
-
// Handle WebSocket subscriptions
|
|
830
|
-
for (const [subscriptionId, subscription] of webSocketSubscriptions) {
|
|
831
|
-
try {
|
|
832
|
-
if (subscription.type === "single" && notifyPath === originalPath) {
|
|
833
|
-
this.debouncedSingleRefetch(subscriptionId, notifyPath, id, subscription);
|
|
834
|
-
} else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
835
|
-
this.debouncedCollectionRefetch(subscriptionId, notifyPath, subscription);
|
|
836
|
-
}
|
|
837
|
-
} catch (error) {
|
|
838
|
-
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
839
|
-
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
|
|
843
|
-
// Handle DataDriver callback subscriptions
|
|
844
|
-
for (const [subscriptionId, subscription] of driverSubscriptions) {
|
|
845
|
-
try {
|
|
846
|
-
const callback = this.subscriptionCallbacks.get(subscriptionId);
|
|
847
|
-
if (!callback) continue;
|
|
848
|
-
|
|
849
|
-
if (subscription.type === "single" && notifyPath === originalPath) {
|
|
850
|
-
this.debouncedSingleDriverRefetch(subscriptionId, notifyPath, id, subscription, callback);
|
|
851
|
-
} else if (subscription.type === "collection" && subscription.collectionRequest) {
|
|
852
|
-
// Debounce collection refetches for DataDriver subscriptions too
|
|
853
|
-
this.debouncedDriverRefetch(subscriptionId, notifyPath, subscription, callback);
|
|
854
|
-
}
|
|
855
|
-
} catch (error) {
|
|
856
|
-
logger.error(`❌ [RealtimeService] Error processing DataDriver subscription ${subscriptionId}`, { error: error });
|
|
857
|
-
}
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
|
|
861
|
-
/**
|
|
862
|
-
* Debounce a collection refetch for a WebSocket subscription.
|
|
863
|
-
* Coalesces rapid row mutations into a single database query.
|
|
864
|
-
*/
|
|
865
|
-
private debouncedCollectionRefetch(
|
|
866
|
-
subscriptionId: string,
|
|
867
|
-
notifyPath: string,
|
|
868
|
-
subscription: Subscription
|
|
869
|
-
) {
|
|
870
|
-
const timerKey = `ws_${subscriptionId}`;
|
|
871
|
-
const existing = this.refetchTimers.get(timerKey);
|
|
872
|
-
if (existing) clearTimeout(existing);
|
|
873
|
-
|
|
874
|
-
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
875
|
-
this.refetchTimers.delete(timerKey);
|
|
876
|
-
// Cheap bail before spending a query: the client may have
|
|
877
|
-
// disconnected, or re-subscribed under the same id. It is only an
|
|
878
|
-
// optimisation — `canDeliver()` after the await is what makes the
|
|
879
|
-
// delivery safe, because the same things can happen *during* it.
|
|
880
|
-
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
881
|
-
// Claimed here rather than when the timer was scheduled: the
|
|
882
|
-
// debounce coalesces, and no work exists to order until it fires.
|
|
883
|
-
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
884
|
-
try {
|
|
885
|
-
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
|
|
886
|
-
if (canDeliver()) {
|
|
887
|
-
this.sendCollectionUpdate(subscription.clientId, subscriptionId, rows, notifyPath);
|
|
888
|
-
}
|
|
889
|
-
} catch (error) {
|
|
890
|
-
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
891
|
-
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
892
|
-
}
|
|
893
|
-
}, RealtimeService.REFETCH_DEBOUNCE_MS));
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
/**
|
|
897
|
-
* Debounce a collection refetch for a DataDriver callback subscription.
|
|
898
|
-
*/
|
|
899
|
-
private debouncedDriverRefetch(
|
|
900
|
-
subscriptionId: string,
|
|
901
|
-
notifyPath: string,
|
|
902
|
-
subscription: Subscription,
|
|
903
|
-
callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
|
|
904
|
-
) {
|
|
905
|
-
const timerKey = `drv_${subscriptionId}`;
|
|
906
|
-
const existing = this.refetchTimers.get(timerKey);
|
|
907
|
-
if (existing) clearTimeout(existing);
|
|
908
|
-
|
|
909
|
-
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
910
|
-
this.refetchTimers.delete(timerKey);
|
|
911
|
-
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
912
|
-
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
913
|
-
try {
|
|
914
|
-
const rows = await this.fetchCollectionWithAuth(notifyPath, subscription.collectionRequest!, subscription.authContext);
|
|
915
|
-
if (canDeliver()) callback(rows);
|
|
916
|
-
} catch (error) {
|
|
917
|
-
logger.error(`❌ [RealtimeService] Error in debounced driver refetch for ${subscriptionId}`, { error: error });
|
|
918
|
-
}
|
|
919
|
-
}, RealtimeService.REFETCH_DEBOUNCE_MS));
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
/**
|
|
923
|
-
* Fetch a collection with optional RLS auth context.
|
|
924
|
-
* When authContext is provided, the fetch runs inside a transaction
|
|
925
|
-
* with set_config calls so PostgreSQL RLS policies are enforced.
|
|
926
|
-
*/
|
|
927
|
-
private async fetchCollectionWithAuth(
|
|
928
|
-
notifyPath: string,
|
|
929
|
-
collectionRequest: StoredCollectionRequest,
|
|
930
|
-
authContext?: SubscriptionAuthContext
|
|
931
|
-
): Promise<Record<string, unknown>[]> {
|
|
932
|
-
if (this.driver) {
|
|
933
|
-
const collection = this.registry.getCollectionByPath(notifyPath);
|
|
934
|
-
const fetchFn = async () => this.driver!.fetchCollection({
|
|
935
|
-
path: notifyPath,
|
|
936
|
-
collection: collection,
|
|
937
|
-
filter: collectionRequest.filter as FetchCollectionProps["filter"],
|
|
938
|
-
logical: collectionRequest.logical,
|
|
939
|
-
orderBy: collectionRequest.orderBy,
|
|
940
|
-
order: collectionRequest.order,
|
|
941
|
-
limit: collectionRequest.limit,
|
|
942
|
-
offset: collectionRequest.offset,
|
|
943
|
-
startAfter: collectionRequest.startAfter,
|
|
944
|
-
searchString: collectionRequest.searchString,
|
|
945
|
-
searchExplain: collectionRequest.searchExplain
|
|
946
|
-
});
|
|
947
|
-
|
|
948
|
-
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
|
|
949
|
-
// Refetches are reads: apply the same GUCs + reader-role downgrade as the
|
|
950
|
-
// driver's read path, so realtime cannot leak rows the initial fetch hid.
|
|
951
|
-
const activeAuth = authContext || { uid: ANONYMOUS_USER_ID,
|
|
952
|
-
roles: ["anon"] };
|
|
953
|
-
return await this.db.transaction(async (tx) => {
|
|
954
|
-
await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
|
|
955
|
-
const txEntityService = new DataService(tx, this.registry);
|
|
956
|
-
let fetchedEntities;
|
|
957
|
-
if (collectionRequest.searchString) {
|
|
958
|
-
fetchedEntities = await txEntityService.searchRows(
|
|
959
|
-
notifyPath,
|
|
960
|
-
collectionRequest.searchString,
|
|
961
|
-
{
|
|
962
|
-
filter: collectionRequest.filter as FilterValues<string>,
|
|
963
|
-
// The subscription stored a group; the search branch
|
|
964
|
-
// did not pass it on, so a filtered live search
|
|
965
|
-
// widened to every row matching the text.
|
|
966
|
-
logical: collectionRequest.logical,
|
|
967
|
-
orderBy: collectionRequest.orderBy,
|
|
968
|
-
order: collectionRequest.order,
|
|
969
|
-
limit: collectionRequest.limit,
|
|
970
|
-
databaseId: collectionRequest.databaseId,
|
|
971
|
-
searchExplain: collectionRequest.searchExplain
|
|
972
|
-
}
|
|
973
|
-
);
|
|
974
|
-
} else {
|
|
975
|
-
fetchedEntities = await txEntityService.fetchCollection(notifyPath, {
|
|
976
|
-
filter: collectionRequest.filter as FilterValues<string>,
|
|
977
|
-
logical: collectionRequest.logical,
|
|
978
|
-
orderBy: collectionRequest.orderBy,
|
|
979
|
-
order: collectionRequest.order,
|
|
980
|
-
limit: collectionRequest.limit,
|
|
981
|
-
offset: collectionRequest.offset,
|
|
982
|
-
startAfter: collectionRequest.startAfter,
|
|
983
|
-
databaseId: collectionRequest.databaseId
|
|
984
|
-
});
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
// Re-apply `afterRead` lifecycle hooks to ensure consistent data structures
|
|
988
|
-
// between the initial driver fetch and this RLS-bound refetch.
|
|
989
|
-
const registryCollection = this.registry.getCollectionByPath(notifyPath);
|
|
990
|
-
const resolvedCollection = collection ? { ...collection,
|
|
991
|
-
...registryCollection } as CollectionConfig : registryCollection as CollectionConfig;
|
|
992
|
-
|
|
993
|
-
const callbacks = resolvedCollection?.callbacks;
|
|
994
|
-
const globalCallbacks = this.registry?.getGlobalCallbacks();
|
|
995
|
-
const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
|
|
996
|
-
|
|
997
|
-
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
998
|
-
const contextForCallback = {
|
|
999
|
-
user: { uid: activeAuth.uid,
|
|
1000
|
-
roles: activeAuth.roles },
|
|
1001
|
-
driver: this.driver,
|
|
1002
|
-
data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
|
|
1003
|
-
} as unknown as RebaseCallContext;
|
|
1004
|
-
|
|
1005
|
-
return await Promise.all(fetchedEntities.map(async (fetchedRow) => {
|
|
1006
|
-
let processedEntity = fetchedRow;
|
|
1007
|
-
// 1. Global callbacks first
|
|
1008
|
-
if (globalCallbacks?.afterRead) {
|
|
1009
|
-
processedEntity = await globalCallbacks.afterRead({
|
|
1010
|
-
collection: resolvedCollection,
|
|
1011
|
-
path: notifyPath,
|
|
1012
|
-
row: processedEntity,
|
|
1013
|
-
context: contextForCallback
|
|
1014
|
-
}) ?? processedEntity;
|
|
1015
|
-
}
|
|
1016
|
-
// 2. Collection callbacks second
|
|
1017
|
-
if (callbacks?.afterRead) {
|
|
1018
|
-
processedEntity = await callbacks.afterRead({
|
|
1019
|
-
collection: resolvedCollection,
|
|
1020
|
-
path: notifyPath,
|
|
1021
|
-
row: processedEntity,
|
|
1022
|
-
context: contextForCallback
|
|
1023
|
-
}) ?? processedEntity;
|
|
1024
|
-
}
|
|
1025
|
-
// 3. Property callbacks third
|
|
1026
|
-
if (propertyCallbacks?.afterRead) {
|
|
1027
|
-
processedEntity = await propertyCallbacks.afterRead({
|
|
1028
|
-
collection: resolvedCollection,
|
|
1029
|
-
path: notifyPath,
|
|
1030
|
-
row: processedEntity,
|
|
1031
|
-
context: contextForCallback
|
|
1032
|
-
}) ?? processedEntity;
|
|
1033
|
-
}
|
|
1034
|
-
return processedEntity;
|
|
1035
|
-
}));
|
|
1036
|
-
}
|
|
1037
|
-
|
|
1038
|
-
return fetchedEntities;
|
|
1039
|
-
});
|
|
1040
|
-
}
|
|
1041
|
-
|
|
1042
|
-
// No driver — use dataService directly (no auth wrapping possible).
|
|
1043
|
-
// The `logical` group is carried here as well: this branch answers the
|
|
1044
|
-
// same subscription as the one above, and a fallback that drops a
|
|
1045
|
-
// condition returns *more* rows than the path it stands in for.
|
|
1046
|
-
if (collectionRequest.searchString) {
|
|
1047
|
-
return await this.dataService.searchRows(
|
|
1048
|
-
notifyPath,
|
|
1049
|
-
collectionRequest.searchString,
|
|
1050
|
-
{
|
|
1051
|
-
filter: collectionRequest.filter as FilterValues<string>,
|
|
1052
|
-
logical: collectionRequest.logical,
|
|
1053
|
-
orderBy: collectionRequest.orderBy,
|
|
1054
|
-
order: collectionRequest.order,
|
|
1055
|
-
limit: collectionRequest.limit,
|
|
1056
|
-
databaseId: collectionRequest.databaseId,
|
|
1057
|
-
searchExplain: collectionRequest.searchExplain
|
|
1058
|
-
}
|
|
1059
|
-
);
|
|
1060
|
-
}
|
|
1061
|
-
return await this.dataService.fetchCollection(notifyPath, {
|
|
1062
|
-
filter: collectionRequest.filter as FilterValues<string>,
|
|
1063
|
-
logical: collectionRequest.logical,
|
|
1064
|
-
orderBy: collectionRequest.orderBy,
|
|
1065
|
-
order: collectionRequest.order,
|
|
1066
|
-
limit: collectionRequest.limit,
|
|
1067
|
-
offset: collectionRequest.offset,
|
|
1068
|
-
startAfter: collectionRequest.startAfter,
|
|
1069
|
-
databaseId: collectionRequest.databaseId
|
|
1070
|
-
});
|
|
1071
|
-
}
|
|
1072
|
-
|
|
1073
|
-
/**
|
|
1074
|
-
* Debounce an row refetch for a WebSocket subscription.
|
|
1075
|
-
*/
|
|
1076
|
-
private debouncedSingleRefetch(
|
|
1077
|
-
subscriptionId: string,
|
|
1078
|
-
notifyPath: string,
|
|
1079
|
-
id: string,
|
|
1080
|
-
subscription: Subscription
|
|
1081
|
-
) {
|
|
1082
|
-
const timerKey = `wse_${subscriptionId}`;
|
|
1083
|
-
const existing = this.refetchTimers.get(timerKey);
|
|
1084
|
-
if (existing) clearTimeout(existing);
|
|
1085
|
-
|
|
1086
|
-
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
1087
|
-
this.refetchTimers.delete(timerKey);
|
|
1088
|
-
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
1089
|
-
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
1090
|
-
try {
|
|
1091
|
-
const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
|
|
1092
|
-
if (canDeliver()) {
|
|
1093
|
-
this.sendSingleUpdate(subscription.clientId, subscriptionId, row || null);
|
|
1094
|
-
}
|
|
1095
|
-
} catch (error) {
|
|
1096
|
-
const sanitized = sanitizeErrorForClient(error, notifyPath);
|
|
1097
|
-
this.sendError(subscription.clientId, sanitized.message, subscriptionId, sanitized.code);
|
|
1098
|
-
}
|
|
1099
|
-
}, RealtimeService.REFETCH_DEBOUNCE_MS));
|
|
1100
|
-
}
|
|
1101
|
-
|
|
1102
|
-
/**
|
|
1103
|
-
* Debounce an row refetch for a Driver callback subscription.
|
|
1104
|
-
*/
|
|
1105
|
-
private debouncedSingleDriverRefetch(
|
|
1106
|
-
subscriptionId: string,
|
|
1107
|
-
notifyPath: string,
|
|
1108
|
-
id: string,
|
|
1109
|
-
subscription: Subscription,
|
|
1110
|
-
callback: (data: Record<string, unknown>[] | Record<string, unknown> | null) => void
|
|
1111
|
-
) {
|
|
1112
|
-
const timerKey = `drve_${subscriptionId}`;
|
|
1113
|
-
const existing = this.refetchTimers.get(timerKey);
|
|
1114
|
-
if (existing) clearTimeout(existing);
|
|
1115
|
-
|
|
1116
|
-
this.refetchTimers.set(timerKey, setTimeout(async () => {
|
|
1117
|
-
this.refetchTimers.delete(timerKey);
|
|
1118
|
-
if (this._subscriptions.get(subscriptionId) !== subscription) return;
|
|
1119
|
-
const canDeliver = this.beginDelivery(subscriptionId, subscription);
|
|
1120
|
-
try {
|
|
1121
|
-
const row = await this.fetchEntityWithAuth(notifyPath, id, subscription.authContext);
|
|
1122
|
-
if (canDeliver()) callback(row || null);
|
|
1123
|
-
} catch (error) {
|
|
1124
|
-
logger.error(`❌ [RealtimeService] Error in debounced row driver refetch for ${subscriptionId}`, { error: error });
|
|
1125
|
-
}
|
|
1126
|
-
}, RealtimeService.REFETCH_DEBOUNCE_MS));
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
|
-
/**
|
|
1130
|
-
* Fetch a single row with optional RLS auth context.
|
|
1131
|
-
*/
|
|
1132
|
-
private async fetchEntityWithAuth(
|
|
1133
|
-
notifyPath: string,
|
|
1134
|
-
id: string | number,
|
|
1135
|
-
authContext?: SubscriptionAuthContext
|
|
1136
|
-
): Promise<Record<string, unknown> | undefined> {
|
|
1137
|
-
if (this.driver) {
|
|
1138
|
-
const collection = this.registry.getCollectionByPath(notifyPath);
|
|
1139
|
-
const fetchFn = async () => this.driver!.fetchOne({
|
|
1140
|
-
path: notifyPath,
|
|
1141
|
-
id,
|
|
1142
|
-
collection
|
|
1143
|
-
});
|
|
1144
|
-
|
|
1145
|
-
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
|
|
1146
|
-
// Same read isolation as collection refetches: GUCs + reader-role downgrade.
|
|
1147
|
-
const activeAuth = authContext || { uid: ANONYMOUS_USER_ID,
|
|
1148
|
-
roles: ["anon"] };
|
|
1149
|
-
return await this.db.transaction(async (tx) => {
|
|
1150
|
-
await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
|
|
1151
|
-
const txEntityService = new DataService(tx, this.registry);
|
|
1152
|
-
let processedEntity = await txEntityService.fetchOne(notifyPath, id, collection?.databaseId);
|
|
1153
|
-
|
|
1154
|
-
if (processedEntity) {
|
|
1155
|
-
const registryCollection = this.registry.getCollectionByPath(notifyPath);
|
|
1156
|
-
const resolvedCollection = collection ? { ...collection,
|
|
1157
|
-
...registryCollection } as CollectionConfig : registryCollection as CollectionConfig;
|
|
1158
|
-
|
|
1159
|
-
const callbacks = resolvedCollection?.callbacks;
|
|
1160
|
-
const globalCallbacks = this.registry?.getGlobalCallbacks();
|
|
1161
|
-
const propertyCallbacks = resolvedCollection?.properties ? buildPropertyCallbacks(resolvedCollection.properties) : undefined;
|
|
1162
|
-
|
|
1163
|
-
if (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead) {
|
|
1164
|
-
const contextForCallback = {
|
|
1165
|
-
user: { uid: activeAuth.uid,
|
|
1166
|
-
roles: activeAuth.roles },
|
|
1167
|
-
driver: this.driver,
|
|
1168
|
-
data: (this.driver && "data" in this.driver) ? (this.driver as DataDriverWithData).data : undefined
|
|
1169
|
-
} as unknown as RebaseCallContext;
|
|
1170
|
-
|
|
1171
|
-
// 1. Global callbacks first
|
|
1172
|
-
if (globalCallbacks?.afterRead) {
|
|
1173
|
-
processedEntity = await globalCallbacks.afterRead({
|
|
1174
|
-
collection: resolvedCollection,
|
|
1175
|
-
path: notifyPath,
|
|
1176
|
-
row: processedEntity,
|
|
1177
|
-
context: contextForCallback
|
|
1178
|
-
}) ?? processedEntity;
|
|
1179
|
-
}
|
|
1180
|
-
// 2. Collection callbacks second
|
|
1181
|
-
if (callbacks?.afterRead) {
|
|
1182
|
-
processedEntity = await callbacks.afterRead({
|
|
1183
|
-
collection: resolvedCollection,
|
|
1184
|
-
path: notifyPath,
|
|
1185
|
-
row: processedEntity,
|
|
1186
|
-
context: contextForCallback
|
|
1187
|
-
}) ?? processedEntity;
|
|
1188
|
-
}
|
|
1189
|
-
// 3. Property callbacks third
|
|
1190
|
-
if (propertyCallbacks?.afterRead) {
|
|
1191
|
-
processedEntity = await propertyCallbacks.afterRead({
|
|
1192
|
-
collection: resolvedCollection,
|
|
1193
|
-
path: notifyPath,
|
|
1194
|
-
row: processedEntity,
|
|
1195
|
-
context: contextForCallback
|
|
1196
|
-
}) ?? processedEntity;
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1200
|
-
|
|
1201
|
-
return processedEntity;
|
|
1202
|
-
});
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
return await this.dataService.fetchOne(notifyPath, id);
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
private sendCollectionUpdate(clientId: string, subscriptionId: string, rows: Record<string, unknown>[], path: string) {
|
|
1209
|
-
const message: CollectionUpdateMessage = {
|
|
1210
|
-
type: "collection_update",
|
|
1211
|
-
subscriptionId,
|
|
1212
|
-
rows: rows,
|
|
1213
|
-
pks: this.primaryKeysForPath(path)
|
|
1214
|
-
};
|
|
1215
|
-
this.sendMessage(clientId, message);
|
|
1216
|
-
}
|
|
1217
|
-
|
|
1218
|
-
private sendSingleUpdate(clientId: string, subscriptionId: string, row: Record<string, unknown> | null) {
|
|
1219
|
-
const message: SingleUpdateMessage = {
|
|
1220
|
-
type: "single_update",
|
|
1221
|
-
subscriptionId,
|
|
1222
|
-
row: row
|
|
1223
|
-
};
|
|
1224
|
-
this.sendMessage(clientId, message);
|
|
1225
|
-
}
|
|
1226
|
-
|
|
1227
|
-
/**
|
|
1228
|
-
* Send a lightweight row-level patch to a collection subscriber.
|
|
1229
|
-
* The client can merge this into its cached data for instant feedback.
|
|
1230
|
-
*
|
|
1231
|
-
* The key columns ride along: the patch names a row by address, and the
|
|
1232
|
-
* client has to find that row among the ones it cached — which carry
|
|
1233
|
-
* columns and no address. The SDK holds no collection config to derive one
|
|
1234
|
-
* from, so this is the only place the mapping can come from.
|
|
1235
|
-
*/
|
|
1236
|
-
/** The key columns of the collection at `path`, if they can be resolved. */
|
|
1237
|
-
private primaryKeysForPath(path: string): PrimaryKeyInfo[] | undefined {
|
|
1238
|
-
try {
|
|
1239
|
-
const collection = this.registry.getCollectionByPath(path);
|
|
1240
|
-
if (!collection) return undefined;
|
|
1241
|
-
const keys = getPrimaryKeys(collection, this.registry);
|
|
1242
|
-
return keys.length > 0 ? keys : undefined;
|
|
1243
|
-
} catch {
|
|
1244
|
-
// `getCollectionByPath` throws on a path it cannot walk — and this
|
|
1245
|
-
// is called for parent paths too, which include entity paths like
|
|
1246
|
-
// `posts/1` that name no collection. Telling the subscriber nothing
|
|
1247
|
-
// is right here; letting it throw would drop the notification.
|
|
1248
|
-
return undefined;
|
|
1249
|
-
}
|
|
1250
|
-
}
|
|
1251
|
-
|
|
1252
|
-
private sendError(clientId: string, error: string, subscriptionId?: string, code?: string) {
|
|
1253
|
-
const message = {
|
|
1254
|
-
type: "error" as const,
|
|
1255
|
-
subscriptionId,
|
|
1256
|
-
payload: {
|
|
1257
|
-
error: code ? { message: error, code } : error
|
|
1258
|
-
},
|
|
1259
|
-
error
|
|
1260
|
-
};
|
|
1261
|
-
this.sendMessage(clientId, message);
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
private sendMessage(clientId: string, message: CollectionUpdateMessage | SingleUpdateMessage | CollectionPatchMessage | { type: string; subscriptionId?: string; error?: string; payload?: unknown }) {
|
|
1265
|
-
const client = this.clients.get(clientId);
|
|
1266
|
-
if (client && client.readyState === WebSocket.OPEN) {
|
|
1267
|
-
client.send(JSON.stringify(message));
|
|
1268
|
-
}
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
/**
|
|
1272
|
-
* Extract parent paths from a nested path like "posts/70/tags"
|
|
1273
|
-
* Returns ["posts", "posts/70"] for the example above
|
|
1274
|
-
*/
|
|
1275
|
-
private getParentPaths(path: string): string[] {
|
|
1276
|
-
const segments = path.split("/").filter(s => s.length > 0);
|
|
1277
|
-
const parentPaths: string[] = [];
|
|
1278
|
-
|
|
1279
|
-
// Build parent paths progressively
|
|
1280
|
-
for (let i = 1; i < segments.length; i += 2) {
|
|
1281
|
-
const parentPath = segments.slice(0, i).join("/");
|
|
1282
|
-
if (parentPath) {
|
|
1283
|
-
parentPaths.push(parentPath);
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
// If there's an row ID, add the path including the row
|
|
1287
|
-
if (i + 1 < segments.length) {
|
|
1288
|
-
const pathWithEntity = segments.slice(0, i + 1).join("/");
|
|
1289
|
-
parentPaths.push(pathWithEntity);
|
|
1290
|
-
}
|
|
1291
|
-
}
|
|
1292
|
-
|
|
1293
|
-
return parentPaths;
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
// =============================================================================
|
|
1297
|
-
// Broadcast Channels
|
|
1298
|
-
// =============================================================================
|
|
1299
|
-
|
|
1300
|
-
/**
|
|
1301
|
-
* Install a channel authorizer — see {@link ChannelAuthorizer}.
|
|
1302
|
-
*
|
|
1303
|
-
* Nothing in the framework calls this yet: it is the seam a rules API will
|
|
1304
|
-
* be built on, kept deliberately separate from the membership floor so the
|
|
1305
|
-
* floor holds whether or not anyone uses it.
|
|
1306
|
-
*/
|
|
1307
|
-
setChannelAuthorizer(authorizer: ChannelAuthorizer | undefined): void {
|
|
1308
|
-
this.channelAuthorizer = authorizer;
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
|
-
/** Which action each channel frame is asking to perform. */
|
|
1312
|
-
private static readonly CHANNEL_ACTIONS: Record<string, ChannelAction> = {
|
|
1313
|
-
join_channel: "join",
|
|
1314
|
-
broadcast: "broadcast",
|
|
1315
|
-
channel_history: "history",
|
|
1316
|
-
presence_track: "join",
|
|
1317
|
-
presence_state: "presence"
|
|
1318
|
-
};
|
|
1319
|
-
|
|
1320
|
-
/**
|
|
1321
|
-
* The one door every channel frame comes through.
|
|
1322
|
-
*
|
|
1323
|
-
* Returns synchronously — and so dispatches synchronously — unless an
|
|
1324
|
-
* authorizer is installed. That matters: a client sends `join_channel`,
|
|
1325
|
-
* `presence_state` and `channel_history` back to back on connect, and the
|
|
1326
|
-
* socket's message handler processes each frame up to its first `await`,
|
|
1327
|
-
* so a gate that always yielded would let the reads overtake the join that
|
|
1328
|
-
* is about to authorize them.
|
|
1329
|
-
*/
|
|
1330
|
-
private handleChannelMessage(
|
|
1331
|
-
clientId: string,
|
|
1332
|
-
type: string,
|
|
1333
|
-
payload: Record<string, unknown> | undefined,
|
|
1334
|
-
authContext?: SubscriptionAuthContext
|
|
1335
|
-
): void | Promise<void> {
|
|
1336
|
-
const channel = payload?.channel as string;
|
|
1337
|
-
|
|
1338
|
-
// Leaving and untracking only ever remove the caller's own state, so
|
|
1339
|
-
// they need no permission — refusing them could only strand a client.
|
|
1340
|
-
if (type === "leave_channel") {
|
|
1341
|
-
this.leaveChannel(clientId, channel);
|
|
1342
|
-
return;
|
|
1343
|
-
}
|
|
1344
|
-
if (type === "presence_untrack") {
|
|
1345
|
-
this.removePresence(clientId, channel);
|
|
1346
|
-
return;
|
|
1347
|
-
}
|
|
1348
|
-
|
|
1349
|
-
const action = RealtimeService.CHANNEL_ACTIONS[type];
|
|
1350
|
-
const allowed = this.authorizeChannelAction(clientId, channel, action, authContext);
|
|
1351
|
-
if (allowed === false) return;
|
|
1352
|
-
if (allowed === true) return this.dispatchChannelMessage(clientId, type, channel, payload);
|
|
1353
|
-
return allowed.then((ok) => {
|
|
1354
|
-
if (ok) return this.dispatchChannelMessage(clientId, type, channel, payload);
|
|
1355
|
-
});
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
/** Perform an already-authorized channel frame. */
|
|
1359
|
-
private dispatchChannelMessage(
|
|
1360
|
-
clientId: string,
|
|
1361
|
-
type: string,
|
|
1362
|
-
channel: string,
|
|
1363
|
-
payload: Record<string, unknown> | undefined
|
|
1364
|
-
): void | Promise<void> {
|
|
1365
|
-
switch (type) {
|
|
1366
|
-
case "join_channel":
|
|
1367
|
-
this.joinChannel(clientId, channel);
|
|
1368
|
-
return;
|
|
1369
|
-
case "broadcast":
|
|
1370
|
-
this.broadcastToChannel(clientId, channel, payload?.event as string, payload?.payload);
|
|
1371
|
-
return;
|
|
1372
|
-
case "channel_history":
|
|
1373
|
-
return this.handleChannelHistoryRequest(
|
|
1374
|
-
clientId,
|
|
1375
|
-
channel,
|
|
1376
|
-
payload?.sinceSeq as number | undefined,
|
|
1377
|
-
payload?.limit as number | undefined
|
|
1378
|
-
);
|
|
1379
|
-
case "presence_track":
|
|
1380
|
-
// Auto-join the channel so presence works without a separate join
|
|
1381
|
-
this.joinChannel(clientId, channel);
|
|
1382
|
-
this.trackPresence(clientId, channel, payload?.state as Record<string, unknown> ?? {});
|
|
1383
|
-
return;
|
|
1384
|
-
case "presence_state":
|
|
1385
|
-
this.sendPresenceState(clientId, channel);
|
|
1386
|
-
return;
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
/**
|
|
1391
|
-
* Decide whether a client may perform an action on a channel.
|
|
1392
|
-
*
|
|
1393
|
-
* **Membership is the floor.** Reading a channel's presence roster, replaying
|
|
1394
|
-
* its retained history and broadcasting into it all require that this client
|
|
1395
|
-
* has joined it. That is a low bar — joining is open to anyone who can name
|
|
1396
|
-
* the channel — but it is not the bar that was there before, which was none
|
|
1397
|
-
* at all: `channel_history` and `presence_state` answered any socket about
|
|
1398
|
-
* any channel, and a broadcast fanned out to members the sender had never
|
|
1399
|
-
* joined. Two internal tables (`rebase.channel_presence`,
|
|
1400
|
-
* `rebase.channel_messages`) are held outside RLS on the strength of this
|
|
1401
|
-
* check, so it fails closed: an authorizer that throws refuses the frame.
|
|
1402
|
-
*
|
|
1403
|
-
* Anything richer than membership belongs in a {@link ChannelAuthorizer};
|
|
1404
|
-
* this method is where it is consulted, and the only place.
|
|
1405
|
-
*/
|
|
1406
|
-
private authorizeChannelAction(
|
|
1407
|
-
clientId: string,
|
|
1408
|
-
channel: string,
|
|
1409
|
-
action: ChannelAction,
|
|
1410
|
-
authContext?: SubscriptionAuthContext
|
|
1411
|
-
): boolean | Promise<boolean> {
|
|
1412
|
-
// Joining is what establishes membership, so it cannot require it.
|
|
1413
|
-
if (action !== "join" && !this.channels.get(channel)?.has(clientId)) {
|
|
1414
|
-
this.denyChannelAction(clientId, channel, action, "not a member of the channel");
|
|
1415
|
-
return false;
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
const authorizer = this.channelAuthorizer;
|
|
1419
|
-
if (!authorizer) return true;
|
|
1420
|
-
|
|
1421
|
-
let verdict: boolean | Promise<boolean>;
|
|
1422
|
-
try {
|
|
1423
|
-
verdict = authorizer({ channel, action, clientId, user: authContext });
|
|
1424
|
-
} catch (error) {
|
|
1425
|
-
logger.error(`❌ [Channels] Authorizer threw for ${action} on "${channel}" — refusing`, { error });
|
|
1426
|
-
this.denyChannelAction(clientId, channel, action, "channel authorization failed");
|
|
1427
|
-
return false;
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
if (typeof verdict === "boolean") {
|
|
1431
|
-
if (!verdict) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
|
|
1432
|
-
return verdict;
|
|
1433
|
-
}
|
|
1434
|
-
|
|
1435
|
-
return verdict.then(
|
|
1436
|
-
(ok) => {
|
|
1437
|
-
if (!ok) this.denyChannelAction(clientId, channel, action, "refused by the channel authorizer");
|
|
1438
|
-
return ok;
|
|
1439
|
-
},
|
|
1440
|
-
(error) => {
|
|
1441
|
-
logger.error(`❌ [Channels] Authorizer rejected for ${action} on "${channel}" — refusing`, { error });
|
|
1442
|
-
this.denyChannelAction(clientId, channel, action, "channel authorization failed");
|
|
1443
|
-
return false;
|
|
1444
|
-
}
|
|
1445
|
-
);
|
|
1446
|
-
}
|
|
1447
|
-
|
|
1448
|
-
/** Tell the client why its channel frame went nowhere, and say so in the log. */
|
|
1449
|
-
private denyChannelAction(clientId: string, channel: string, action: ChannelAction, reason: string): void {
|
|
1450
|
-
this.debugLog(`🚫 [Channels] Refused ${action} on "${channel}" for ${clientId}: ${reason}`);
|
|
1451
|
-
this.sendError(
|
|
1452
|
-
clientId,
|
|
1453
|
-
`Refused ${action} on channel "${channel}": ${reason}`,
|
|
1454
|
-
undefined,
|
|
1455
|
-
"CHANNEL_FORBIDDEN"
|
|
1456
|
-
);
|
|
1457
|
-
}
|
|
1458
|
-
|
|
1459
|
-
/** Join a broadcast channel */
|
|
1460
|
-
joinChannel(clientId: string, channel: string): void {
|
|
1461
|
-
if (!this.channels.has(channel)) {
|
|
1462
|
-
this.channels.set(channel, new Set());
|
|
1463
|
-
}
|
|
1464
|
-
this.channels.get(channel)!.add(clientId);
|
|
1465
|
-
this.warnIfMemoryBusOnMultiplePods();
|
|
1466
|
-
this.debugLog(`📡 [Broadcast] Client ${clientId} joined channel: ${channel}`);
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
/**
|
|
1470
|
-
* Say something the first time channels are used on a deployment that is
|
|
1471
|
-
* demonstrably multi-pod while the bus is still the in-memory default.
|
|
1472
|
-
*
|
|
1473
|
-
* Every other warning in this subsystem covers a *configured* bus failing —
|
|
1474
|
-
* the case where the operator already knew a bus mattered. The common
|
|
1475
|
-
* misconfiguration is the opposite one: scaled to two replicas, never
|
|
1476
|
-
* touched `realtime.bus`, and broadcast and presence quietly serve a
|
|
1477
|
-
* fraction of the room. The evidence is already in the process, so use it.
|
|
1478
|
-
*/
|
|
1479
|
-
private warnIfMemoryBusOnMultiplePods(): void {
|
|
1480
|
-
if (this.memoryBusWarned) return;
|
|
1481
|
-
if (this.bus.kind !== "memory" || !this.foreignInstanceSeen) return;
|
|
1482
|
-
this.memoryBusWarned = true;
|
|
1483
|
-
logger.warn(
|
|
1484
|
-
"⚠️ [ChannelBus] Channels are in use with the in-memory bus, but notifications from another " +
|
|
1485
|
-
"instance have been seen — this deployment runs more than one process. Broadcast and presence " +
|
|
1486
|
-
"reach only the clients connected to this one. Set `realtime.bus` (or REALTIME_CHANNEL_BUS=postgres) " +
|
|
1487
|
-
"to make channels cross-instance."
|
|
1488
|
-
);
|
|
1489
|
-
}
|
|
1490
|
-
|
|
1491
|
-
/** Leave a broadcast channel */
|
|
1492
|
-
leaveChannel(clientId: string, channel: string): void {
|
|
1493
|
-
const members = this.channels.get(channel);
|
|
1494
|
-
if (members) {
|
|
1495
|
-
members.delete(clientId);
|
|
1496
|
-
if (members.size === 0) this.channels.delete(channel);
|
|
1497
|
-
}
|
|
1498
|
-
// Also remove presence
|
|
1499
|
-
this.removePresence(clientId, channel);
|
|
1500
|
-
}
|
|
1501
|
-
|
|
1502
|
-
/**
|
|
1503
|
-
* Broadcast a message to all clients in a channel except the sender.
|
|
1504
|
-
*
|
|
1505
|
-
* On a channel with no retention rule this is what it always was: a
|
|
1506
|
-
* synchronous fan-out to whoever is connected, with no sequence number, no
|
|
1507
|
-
* SQL and no await — the body below runs to completion before returning.
|
|
1508
|
-
*
|
|
1509
|
-
* On a retained channel the message is durably numbered first and only then
|
|
1510
|
-
* delivered, through a per-channel queue so that delivery order matches
|
|
1511
|
-
* sequence order. That ordering is the whole point: a client that catches up
|
|
1512
|
-
* with `sinceSeq` has to arrive at the same state as one that never
|
|
1513
|
-
* disconnected.
|
|
1514
|
-
*/
|
|
1515
|
-
broadcastToChannel(clientId: string, channel: string, event: string, payload: unknown): void {
|
|
1516
|
-
const retention = this.channelHistory?.retentionFor(channel);
|
|
1517
|
-
if (!retention) {
|
|
1518
|
-
this.fanOutBroadcast(clientId, channel, event, payload);
|
|
1519
|
-
// Other instances get the same frame, but never before the clients
|
|
1520
|
-
// on this one: the local fan-out above is synchronous and the
|
|
1521
|
-
// publish is not, which is also what keeps the ephemeral path free
|
|
1522
|
-
// of any await for a single-instance deployment.
|
|
1523
|
-
this.publishBroadcast(clientId, channel, event, payload);
|
|
1524
|
-
return;
|
|
1525
|
-
}
|
|
1526
|
-
|
|
1527
|
-
const previous = this.channelSendQueues.get(channel) ?? Promise.resolve();
|
|
1528
|
-
const next = previous
|
|
1529
|
-
// A failed predecessor must not poison the chain — the next message
|
|
1530
|
-
// on this channel is independent and still deserves to be sent.
|
|
1531
|
-
.catch(() => { /* already reported below */ })
|
|
1532
|
-
.then(() => this.persistAndFanOut(clientId, channel, event, payload, retention));
|
|
1533
|
-
|
|
1534
|
-
this.channelSendQueues.set(channel, next);
|
|
1535
|
-
void next.finally(() => {
|
|
1536
|
-
// Only clear if nothing has queued behind us in the meantime.
|
|
1537
|
-
if (this.channelSendQueues.get(channel) === next) this.channelSendQueues.delete(channel);
|
|
1538
|
-
});
|
|
1539
|
-
}
|
|
1540
|
-
|
|
1541
|
-
/**
|
|
1542
|
-
* Number a broadcast, store it, then deliver it.
|
|
1543
|
-
*
|
|
1544
|
-
* A message that cannot be stored is **not** delivered. Delivering it would
|
|
1545
|
-
* put it in front of live subscribers while leaving it absent from every
|
|
1546
|
-
* future replay — the two views of the channel would disagree permanently,
|
|
1547
|
-
* and no later message could repair the gap. Failing loudly to the sender
|
|
1548
|
-
* instead lets it retry, which for an operation stream is the only outcome
|
|
1549
|
-
* that keeps clients convergent.
|
|
1550
|
-
*/
|
|
1551
|
-
private async persistAndFanOut(
|
|
1552
|
-
clientId: string,
|
|
1553
|
-
channel: string,
|
|
1554
|
-
event: string,
|
|
1555
|
-
payload: unknown,
|
|
1556
|
-
retention: ResolvedRetention
|
|
1557
|
-
): Promise<void> {
|
|
1558
|
-
let seq: number;
|
|
1559
|
-
try {
|
|
1560
|
-
({ seq } = await this.channelHistory!.append(channel, event, payload, clientId));
|
|
1561
|
-
} catch (error) {
|
|
1562
|
-
logger.error(`❌ [ChannelHistory] Could not persist broadcast on "${channel}" — message dropped`, { error });
|
|
1563
|
-
this.sendError(
|
|
1564
|
-
clientId,
|
|
1565
|
-
`Could not persist broadcast on retained channel "${channel}"`,
|
|
1566
|
-
undefined,
|
|
1567
|
-
"CHANNEL_HISTORY_WRITE_FAILED"
|
|
1568
|
-
);
|
|
1569
|
-
return;
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
this.fanOutBroadcast(clientId, channel, event, payload, seq);
|
|
1573
|
-
this.publishBroadcast(clientId, channel, event, payload, seq);
|
|
1574
|
-
|
|
1575
|
-
try {
|
|
1576
|
-
await this.channelHistory!.prune(channel, retention);
|
|
1577
|
-
} catch (error) {
|
|
1578
|
-
// Retention is a housekeeping concern; the message is already
|
|
1579
|
-
// delivered and durable, so a failed prune must not surface as a
|
|
1580
|
-
// broadcast failure. It will be retried on the next message.
|
|
1581
|
-
logger.warn(`⚠️ [ChannelHistory] Prune failed for "${channel}"`, { error });
|
|
1582
|
-
}
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
/** Deliver a broadcast frame to every member of a channel but the sender. */
|
|
1586
|
-
private fanOutBroadcast(clientId: string, channel: string, event: string, payload: unknown, seq?: number): void {
|
|
1587
|
-
const members = this.channels.get(channel);
|
|
1588
|
-
if (!members) return;
|
|
1589
|
-
|
|
1590
|
-
const message = JSON.stringify({
|
|
1591
|
-
type: "broadcast",
|
|
1592
|
-
channel,
|
|
1593
|
-
event,
|
|
1594
|
-
payload,
|
|
1595
|
-
...(seq !== undefined ? { seq } : {})
|
|
1596
|
-
});
|
|
1597
|
-
|
|
1598
|
-
for (const memberId of members) {
|
|
1599
|
-
if (memberId === clientId) continue; // Don't echo back to sender
|
|
1600
|
-
const ws = this.clients.get(memberId);
|
|
1601
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1602
|
-
ws.send(message);
|
|
1603
|
-
}
|
|
1604
|
-
}
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
// =============================================================================
|
|
1608
|
-
// Cross-Instance Channel Bus
|
|
1609
|
-
// =============================================================================
|
|
1610
|
-
|
|
1611
|
-
/**
|
|
1612
|
-
* Install the transport that carries channel frames between instances.
|
|
1613
|
-
*
|
|
1614
|
-
* Called once at boot. A bus that cannot start is reported and replaced with
|
|
1615
|
-
* the memory bus: losing cross-instance fan-out degrades collaboration to
|
|
1616
|
-
* what it was before this existed, whereas refusing to boot takes the whole
|
|
1617
|
-
* backend down for it.
|
|
1618
|
-
*/
|
|
1619
|
-
async configureChannelBus(bus: ChannelBus): Promise<void> {
|
|
1620
|
-
if (bus.kind === "memory") {
|
|
1621
|
-
this.bus = bus;
|
|
1622
|
-
return;
|
|
1623
|
-
}
|
|
1624
|
-
|
|
1625
|
-
try {
|
|
1626
|
-
await bus.start((frame) => this.handleBusFrame(frame));
|
|
1627
|
-
} catch (error) {
|
|
1628
|
-
logger.warn(
|
|
1629
|
-
`⚠️ [ChannelBus] Could not start the "${bus.kind}" channel bus — channel broadcast and presence ` +
|
|
1630
|
-
"stay per-instance. Clients served by different replicas will not see each other.",
|
|
1631
|
-
{ error }
|
|
1632
|
-
);
|
|
1633
|
-
await bus.stop().catch(() => { /* best effort */ });
|
|
1634
|
-
this.bus = new MemoryChannelBus();
|
|
1635
|
-
return;
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
this.bus = bus;
|
|
1639
|
-
|
|
1640
|
-
// Presence needs shared *state*, not just shared fan-out — see
|
|
1641
|
-
// `channel-presence.ts`. It comes up with the bus and only with it.
|
|
1642
|
-
try {
|
|
1643
|
-
const store = new ChannelPresenceStore(this.db, this.instanceId);
|
|
1644
|
-
await store.ensureTables();
|
|
1645
|
-
this.presenceStore = store;
|
|
1646
|
-
this.ensurePresenceSweep();
|
|
1647
|
-
} catch (error) {
|
|
1648
|
-
logger.warn(
|
|
1649
|
-
"⚠️ [ChannelBus] Could not create the shared presence table — presence rosters will only list " +
|
|
1650
|
-
"clients connected to this instance (broadcast is unaffected).",
|
|
1651
|
-
{ error }
|
|
1652
|
-
);
|
|
1653
|
-
this.presenceStore = undefined;
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
logger.info(
|
|
1657
|
-
`📡 [ChannelBus] Cross-instance channels active via ${bus.kind} (instanceId: ${this.instanceId}).`
|
|
1658
|
-
);
|
|
1659
|
-
}
|
|
1660
|
-
|
|
1661
|
-
/** Which transport is in use — `"memory"` means per-instance only. */
|
|
1662
|
-
public getChannelBusKind(): ChannelBus["kind"] {
|
|
1663
|
-
return this.bus.kind;
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
/**
|
|
1667
|
-
* Send a broadcast to the other instances.
|
|
1668
|
-
*
|
|
1669
|
-
* Fire-and-forget by design: the clients on this instance have already been
|
|
1670
|
-
* served, and a bus that is briefly unreachable must not turn a broadcast
|
|
1671
|
-
* into an error for the sender.
|
|
1672
|
-
*/
|
|
1673
|
-
private publishBroadcast(clientId: string, channel: string, event: string, payload: unknown, seq?: number): void {
|
|
1674
|
-
if (this.bus.kind === "memory") return;
|
|
1675
|
-
|
|
1676
|
-
const frame: ChannelBusFrame = {
|
|
1677
|
-
kind: "broadcast",
|
|
1678
|
-
sid: this.instanceId,
|
|
1679
|
-
channel,
|
|
1680
|
-
event,
|
|
1681
|
-
from: clientId,
|
|
1682
|
-
...(seq !== undefined ? { seq } : {}),
|
|
1683
|
-
payload
|
|
1684
|
-
};
|
|
1685
|
-
|
|
1686
|
-
// Postgres caps a NOTIFY payload at 8 KB. A retained message is already
|
|
1687
|
-
// durable and addressable, so it travels as a pointer and each receiver
|
|
1688
|
-
// reads the body back — the same shape as the entity path, which
|
|
1689
|
-
// notifies an address and refetches the row.
|
|
1690
|
-
if (frameByteLength(frame) > this.bus.maxFrameBytes) {
|
|
1691
|
-
if (seq === undefined) {
|
|
1692
|
-
this.reportOversizedBroadcast(clientId, channel);
|
|
1693
|
-
return;
|
|
1694
|
-
}
|
|
1695
|
-
void this.publishFrame({
|
|
1696
|
-
kind: "broadcast_ref",
|
|
1697
|
-
sid: this.instanceId,
|
|
1698
|
-
channel,
|
|
1699
|
-
from: clientId,
|
|
1700
|
-
seq
|
|
1701
|
-
});
|
|
1702
|
-
return;
|
|
1703
|
-
}
|
|
1704
|
-
|
|
1705
|
-
void this.publishFrame(frame);
|
|
1706
|
-
}
|
|
1707
|
-
|
|
1708
|
-
private async publishFrame(frame: ChannelBusFrame): Promise<void> {
|
|
1709
|
-
try {
|
|
1710
|
-
await this.bus.publish(frame);
|
|
1711
|
-
} catch (error) {
|
|
1712
|
-
logger.error("❌ [ChannelBus] Failed to publish frame — other instances did not receive it", {
|
|
1713
|
-
detail: `${frame.kind} on "${frame.channel}"`,
|
|
1714
|
-
error
|
|
1715
|
-
});
|
|
1716
|
-
}
|
|
1717
|
-
}
|
|
1718
|
-
|
|
1719
|
-
/**
|
|
1720
|
-
* Tell the sender that a message was delivered locally but nowhere else.
|
|
1721
|
-
*
|
|
1722
|
-
* Staying quiet here would be the worst option available: on one instance
|
|
1723
|
-
* the app works, on two it works for half the users, and nothing in the
|
|
1724
|
-
* logs connects the two. The fix is a one-liner in config — give the
|
|
1725
|
-
* channel a retention rule and the message travels as a pointer instead —
|
|
1726
|
-
* so the message says exactly that.
|
|
1727
|
-
*/
|
|
1728
|
-
private reportOversizedBroadcast(clientId: string, channel: string): void {
|
|
1729
|
-
const remedy =
|
|
1730
|
-
`Add a retention rule for "${channel}" (realtime.channels) — retained messages travel by reference ` +
|
|
1731
|
-
"and have no size limit.";
|
|
1732
|
-
|
|
1733
|
-
if (!this.oversizedBroadcastWarned.has(channel)) {
|
|
1734
|
-
this.oversizedBroadcastWarned.add(channel);
|
|
1735
|
-
logger.warn(
|
|
1736
|
-
`⚠️ [ChannelBus] A broadcast on ephemeral channel "${channel}" exceeds the ` +
|
|
1737
|
-
`${this.bus.maxFrameBytes}-byte limit of the ${this.bus.kind} bus and reached only this instance. ` +
|
|
1738
|
-
remedy
|
|
1739
|
-
);
|
|
1740
|
-
}
|
|
1741
|
-
this.sendError(
|
|
1742
|
-
clientId,
|
|
1743
|
-
`Broadcast on "${channel}" was too large to reach other instances. ${remedy}`,
|
|
1744
|
-
undefined,
|
|
1745
|
-
"CHANNEL_BUS_PAYLOAD_TOO_LARGE"
|
|
1746
|
-
);
|
|
1747
|
-
}
|
|
1748
|
-
|
|
1749
|
-
/**
|
|
1750
|
-
* Deliver a frame published by another instance to this one's clients.
|
|
1751
|
-
*
|
|
1752
|
-
* Frames we published ourselves are dropped on arrival — the local fan-out
|
|
1753
|
-
* happened before the publish — exactly as the entity-change handler skips
|
|
1754
|
-
* its own `sid`.
|
|
1755
|
-
*/
|
|
1756
|
-
private async handleBusFrame(frame: ChannelBusFrame): Promise<void> {
|
|
1757
|
-
if (frame.sid === this.instanceId) return;
|
|
1758
|
-
|
|
1759
|
-
switch (frame.kind) {
|
|
1760
|
-
case "broadcast":
|
|
1761
|
-
this.fanOutBroadcast(frame.from ?? "", frame.channel, frame.event, frame.payload, frame.seq);
|
|
1762
|
-
return;
|
|
1763
|
-
|
|
1764
|
-
case "broadcast_ref": {
|
|
1765
|
-
// Nothing to read back for: skip the query rather than pay for
|
|
1766
|
-
// a message no client here is waiting for.
|
|
1767
|
-
if (!this.channels.get(frame.channel)?.size) return;
|
|
1768
|
-
|
|
1769
|
-
const entry = await this.channelHistory?.getBySeq(frame.channel, frame.seq);
|
|
1770
|
-
if (!entry) {
|
|
1771
|
-
logger.warn(
|
|
1772
|
-
`⚠️ [ChannelBus] Message ${frame.seq} on "${frame.channel}" is no longer retained — ` +
|
|
1773
|
-
"clients on this instance will need to replay (channel_history) to catch up."
|
|
1774
|
-
);
|
|
1775
|
-
return;
|
|
1776
|
-
}
|
|
1777
|
-
this.fanOutBroadcast(frame.from ?? "", frame.channel, entry.event, entry.payload, entry.seq);
|
|
1778
|
-
return;
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
case "presence_diff":
|
|
1782
|
-
this.deliverPresenceDiff(frame.channel, frame.joins, frame.leaves);
|
|
1783
|
-
return;
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
|
|
1787
|
-
// =============================================================================
|
|
1788
|
-
// Channel History
|
|
1789
|
-
// =============================================================================
|
|
1790
|
-
|
|
1791
|
-
/**
|
|
1792
|
-
* Install retention rules and create the tables they need.
|
|
1793
|
-
*
|
|
1794
|
-
* Safe to call with no rules (and safe not to call at all): the store stays
|
|
1795
|
-
* inert, no schema is created, and broadcast keeps its original
|
|
1796
|
-
* fire-and-forget path.
|
|
1797
|
-
*/
|
|
1798
|
-
async configureChannelHistory(
|
|
1799
|
-
rules: ChannelRetentionRule[] | undefined,
|
|
1800
|
-
options?: { provision?: boolean }
|
|
1801
|
-
): Promise<void> {
|
|
1802
|
-
// The store is built in every process, whether or not this one creates
|
|
1803
|
-
// the tables: retaining a message is what a process does when it
|
|
1804
|
-
// *publishes* to a retained channel, and a function handler publishes as
|
|
1805
|
-
// readily as a websocket client does. Only the DDL is owned.
|
|
1806
|
-
this.channelHistory = new ChannelHistoryStore(this.db, rules ?? []);
|
|
1807
|
-
if (!this.channelHistory.enabled) return;
|
|
1808
|
-
if (options?.provision === false) return;
|
|
1809
|
-
await this.channelHistory.ensureTables();
|
|
1810
|
-
}
|
|
1811
|
-
|
|
1812
|
-
/** Whether any channel is configured to retain messages. */
|
|
1813
|
-
public isChannelHistoryEnabled(): boolean {
|
|
1814
|
-
return this.channelHistory?.enabled ?? false;
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
/**
|
|
1818
|
-
* Answer a client's catch-up request.
|
|
1819
|
-
*
|
|
1820
|
-
* A channel with no retention rule is answered with `retained: false`
|
|
1821
|
-
* rather than an empty list, so the client can tell "you missed nothing"
|
|
1822
|
-
* apart from "this channel never keeps anything" — the second means its
|
|
1823
|
-
* reconnect strategy has to be a full resync, and silence would leave it
|
|
1824
|
-
* guessing.
|
|
1825
|
-
*/
|
|
1826
|
-
private async handleChannelHistoryRequest(
|
|
1827
|
-
clientId: string,
|
|
1828
|
-
channel: string,
|
|
1829
|
-
sinceSeq?: number,
|
|
1830
|
-
limit?: number
|
|
1831
|
-
): Promise<void> {
|
|
1832
|
-
if (!channel) return;
|
|
1833
|
-
|
|
1834
|
-
const retention = this.channelHistory?.retentionFor(channel);
|
|
1835
|
-
if (!retention) {
|
|
1836
|
-
this.sendChannelHistory(clientId, channel, [], false);
|
|
1837
|
-
return;
|
|
1838
|
-
}
|
|
1839
|
-
|
|
1840
|
-
try {
|
|
1841
|
-
const { messages, latestSeq } = await this.channelHistory!.replay(channel, sinceSeq, limit);
|
|
1842
|
-
this.sendChannelHistory(clientId, channel, messages, true, latestSeq);
|
|
1843
|
-
} catch (error) {
|
|
1844
|
-
logger.error(`❌ [ChannelHistory] Replay failed for "${channel}"`, { error });
|
|
1845
|
-
this.sendError(clientId, `Could not replay history for channel "${channel}"`, undefined, "CHANNEL_HISTORY_READ_FAILED");
|
|
1846
|
-
}
|
|
1847
|
-
}
|
|
1848
|
-
|
|
1849
|
-
private sendChannelHistory(
|
|
1850
|
-
clientId: string,
|
|
1851
|
-
channel: string,
|
|
1852
|
-
messages: ChannelHistoryEntry[],
|
|
1853
|
-
retained: boolean,
|
|
1854
|
-
latestSeq?: number
|
|
1855
|
-
): void {
|
|
1856
|
-
const ws = this.clients.get(clientId);
|
|
1857
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1858
|
-
ws.send(JSON.stringify({
|
|
1859
|
-
type: "channel_history",
|
|
1860
|
-
channel,
|
|
1861
|
-
messages,
|
|
1862
|
-
retained,
|
|
1863
|
-
...(latestSeq !== undefined ? { latestSeq } : {})
|
|
1864
|
-
}));
|
|
1865
|
-
}
|
|
1866
|
-
}
|
|
1867
|
-
|
|
1868
|
-
// =============================================================================
|
|
1869
|
-
// Presence
|
|
1870
|
-
// =============================================================================
|
|
1871
|
-
|
|
1872
|
-
/**
|
|
1873
|
-
* Track presence in a channel.
|
|
1874
|
-
*
|
|
1875
|
-
* The client re-sends this every ~20s as a heartbeat against the 30s
|
|
1876
|
-
* timeout, so most calls carry the state that is already recorded. Those
|
|
1877
|
-
* refresh `last_seen` and stop there: re-announcing an unchanged state to
|
|
1878
|
-
* every instance would put a bus message per client per heartbeat on the
|
|
1879
|
-
* wire to tell everyone nothing happened.
|
|
1880
|
-
*/
|
|
1881
|
-
trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void {
|
|
1882
|
-
if (!this.presence.has(channel)) {
|
|
1883
|
-
this.presence.set(channel, new Map());
|
|
1884
|
-
}
|
|
1885
|
-
|
|
1886
|
-
const channelPresence = this.presence.get(channel)!;
|
|
1887
|
-
const previous = channelPresence.get(clientId);
|
|
1888
|
-
const changed = !previous || JSON.stringify(previous.state) !== JSON.stringify(state);
|
|
1889
|
-
channelPresence.set(clientId, { state,
|
|
1890
|
-
lastSeen: Date.now() });
|
|
1891
|
-
|
|
1892
|
-
// Refresh the shared roster on every heartbeat — that timestamp is what
|
|
1893
|
-
// tells other instances this client is still here.
|
|
1894
|
-
void this.presenceStoreOp(() => this.presenceStore!.track(channel, clientId, state), "track");
|
|
1895
|
-
|
|
1896
|
-
// Broadcast join / state update to channel
|
|
1897
|
-
this.deliverPresenceDiff(channel, { [clientId]: state }, {});
|
|
1898
|
-
if (changed) {
|
|
1899
|
-
this.publishPresenceDiff(channel, { [clientId]: state }, {});
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
// Start cleanup interval if not running
|
|
1903
|
-
this.ensurePresenceCleanup();
|
|
1904
|
-
}
|
|
1905
|
-
|
|
1906
|
-
/**
|
|
1907
|
-
* Remove presence from a channel.
|
|
1908
|
-
*
|
|
1909
|
-
* `skipStore` is for the socket-close path, which clears every channel at
|
|
1910
|
-
* once and then deletes the client's rows in a single statement instead of
|
|
1911
|
-
* one per channel.
|
|
1912
|
-
*/
|
|
1913
|
-
removePresence(clientId: string, channel: string, options?: { skipStore?: boolean }): void {
|
|
1914
|
-
const channelPresence = this.presence.get(channel);
|
|
1915
|
-
if (!channelPresence) return;
|
|
1916
|
-
|
|
1917
|
-
const entry = channelPresence.get(clientId);
|
|
1918
|
-
if (entry) {
|
|
1919
|
-
channelPresence.delete(clientId);
|
|
1920
|
-
this.deliverPresenceDiff(channel, {}, { [clientId]: entry.state });
|
|
1921
|
-
this.publishPresenceDiff(channel, {}, { [clientId]: entry.state });
|
|
1922
|
-
if (!options?.skipStore) {
|
|
1923
|
-
void this.presenceStoreOp(() => this.presenceStore!.remove(channel, clientId), "remove");
|
|
1924
|
-
}
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
if (channelPresence.size === 0) {
|
|
1928
|
-
this.presence.delete(channel);
|
|
1929
|
-
}
|
|
1930
|
-
}
|
|
1931
|
-
|
|
1932
|
-
/**
|
|
1933
|
-
* Send the full roster for a channel to one client.
|
|
1934
|
-
*
|
|
1935
|
-
* Answered from the shared table when there is one, because "who is in this
|
|
1936
|
-
* document?" has a single answer that must not depend on which replica the
|
|
1937
|
-
* asker happens to be connected to. Without a bus there is nothing to share
|
|
1938
|
-
* and the local map *is* the roster — that path stays synchronous, which is
|
|
1939
|
-
* what it always was.
|
|
1940
|
-
*/
|
|
1941
|
-
sendPresenceState(clientId: string, channel: string): void {
|
|
1942
|
-
if (!this.presenceStore) {
|
|
1943
|
-
this.sendPresenceStateMessage(clientId, channel, this.localPresences(channel));
|
|
1944
|
-
return;
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
void this.presenceStore.roster(channel)
|
|
1948
|
-
.then((presences) => {
|
|
1949
|
-
this.sendPresenceStateMessage(clientId, channel, presences);
|
|
1950
|
-
})
|
|
1951
|
-
.catch((error) => {
|
|
1952
|
-
// A roster the asker can act on beats none: fall back to the
|
|
1953
|
-
// clients we can see rather than leaving the request unanswered.
|
|
1954
|
-
logger.warn(`⚠️ [Presence] Could not read the shared roster for "${channel}" — answering with this instance's clients only.`, { error });
|
|
1955
|
-
this.sendPresenceStateMessage(clientId, channel, this.localPresences(channel));
|
|
1956
|
-
});
|
|
1957
|
-
}
|
|
1958
|
-
|
|
1959
|
-
/** Presence of the clients connected to this instance. */
|
|
1960
|
-
private localPresences(channel: string): Record<string, Record<string, unknown>> {
|
|
1961
|
-
const channelPresence = this.presence.get(channel);
|
|
1962
|
-
const presences: Record<string, Record<string, unknown>> = {};
|
|
1963
|
-
if (channelPresence) {
|
|
1964
|
-
for (const [id, { state }] of channelPresence) {
|
|
1965
|
-
presences[id] = state;
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
return presences;
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
private sendPresenceStateMessage(
|
|
1972
|
-
clientId: string,
|
|
1973
|
-
channel: string,
|
|
1974
|
-
presences: Record<string, Record<string, unknown>>
|
|
1975
|
-
): void {
|
|
1976
|
-
const ws = this.clients.get(clientId);
|
|
1977
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
1978
|
-
ws.send(JSON.stringify({
|
|
1979
|
-
type: "presence_state",
|
|
1980
|
-
channel,
|
|
1981
|
-
presences
|
|
1982
|
-
}));
|
|
1983
|
-
}
|
|
1984
|
-
}
|
|
1985
|
-
|
|
1986
|
-
/** Deliver a presence diff to this instance's members of the channel. */
|
|
1987
|
-
private deliverPresenceDiff(
|
|
1988
|
-
channel: string,
|
|
1989
|
-
joins: Record<string, Record<string, unknown>>,
|
|
1990
|
-
leaves: Record<string, Record<string, unknown>>
|
|
1991
|
-
): void {
|
|
1992
|
-
const members = this.channels.get(channel);
|
|
1993
|
-
if (!members) return;
|
|
1994
|
-
|
|
1995
|
-
const message = JSON.stringify({
|
|
1996
|
-
type: "presence_diff",
|
|
1997
|
-
channel,
|
|
1998
|
-
joins,
|
|
1999
|
-
leaves
|
|
2000
|
-
});
|
|
2001
|
-
|
|
2002
|
-
for (const memberId of members) {
|
|
2003
|
-
const ws = this.clients.get(memberId);
|
|
2004
|
-
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
2005
|
-
ws.send(message);
|
|
2006
|
-
}
|
|
2007
|
-
}
|
|
2008
|
-
}
|
|
2009
|
-
|
|
2010
|
-
/** Tell the other instances about a presence change. */
|
|
2011
|
-
private publishPresenceDiff(
|
|
2012
|
-
channel: string,
|
|
2013
|
-
joins: Record<string, Record<string, unknown>>,
|
|
2014
|
-
leaves: Record<string, Record<string, unknown>>
|
|
2015
|
-
): void {
|
|
2016
|
-
if (this.bus.kind === "memory") return;
|
|
2017
|
-
void this.publishFrame({ kind: "presence_diff", sid: this.instanceId, channel, joins, leaves });
|
|
2018
|
-
}
|
|
2019
|
-
|
|
2020
|
-
/** Run a roster write when there is a roster, and never let it throw. */
|
|
2021
|
-
private async presenceStoreOp(op: () => Promise<void>, label: string): Promise<void> {
|
|
2022
|
-
if (!this.presenceStore) return;
|
|
2023
|
-
try {
|
|
2024
|
-
await op();
|
|
2025
|
-
} catch (error) {
|
|
2026
|
-
logger.warn(`⚠️ [Presence] Shared roster ${label} failed`, { error });
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
|
|
2030
|
-
/** Periodic cleanup for stale presences */
|
|
2031
|
-
private ensurePresenceCleanup(): void {
|
|
2032
|
-
if (this.presenceInterval) return;
|
|
2033
|
-
this.presenceInterval = setInterval(() => {
|
|
2034
|
-
const now = Date.now();
|
|
2035
|
-
for (const [channel, channelPresence] of this.presence) {
|
|
2036
|
-
for (const [clientId, entry] of channelPresence) {
|
|
2037
|
-
if (now - entry.lastSeen > RealtimeService.PRESENCE_TIMEOUT_MS) {
|
|
2038
|
-
this.removePresence(clientId, channel);
|
|
2039
|
-
}
|
|
2040
|
-
}
|
|
2041
|
-
}
|
|
2042
|
-
// Stop interval if no presences tracked
|
|
2043
|
-
if (this.presence.size === 0 && this.presenceInterval) {
|
|
2044
|
-
clearInterval(this.presenceInterval);
|
|
2045
|
-
this.presenceInterval = undefined;
|
|
2046
|
-
}
|
|
2047
|
-
}, 10000); // Check every 10s
|
|
2048
|
-
}
|
|
2049
|
-
|
|
2050
|
-
/**
|
|
2051
|
-
* Reap roster rows whose owning instance stopped heartbeating.
|
|
2052
|
-
*
|
|
2053
|
-
* This is the cross-instance half of the sweep above, and it doubles as
|
|
2054
|
-
* crash recovery: a pod that dies takes its clients with it but leaves
|
|
2055
|
-
* their rows behind, and after one TTL window they look exactly like any
|
|
2056
|
-
* other client that went quiet. The delete returns what it removed, so
|
|
2057
|
-
* whichever instance wins the race is the one that announces the
|
|
2058
|
-
* departures — once for the cluster, not once per replica.
|
|
2059
|
-
*/
|
|
2060
|
-
private ensurePresenceSweep(): void {
|
|
2061
|
-
if (this.presenceSweepInterval || !this.presenceStore) return;
|
|
2062
|
-
|
|
2063
|
-
this.presenceSweepInterval = setInterval(
|
|
2064
|
-
() => void this.sweepStalePresence(),
|
|
2065
|
-
RealtimeService.PRESENCE_SWEEP_INTERVAL_MS
|
|
2066
|
-
);
|
|
2067
|
-
|
|
2068
|
-
// Never hold the process open for housekeeping.
|
|
2069
|
-
(this.presenceSweepInterval as unknown as { unref?: () => void }).unref?.();
|
|
2070
|
-
}
|
|
2071
|
-
|
|
2072
|
-
/** One pass of the stale-roster sweep. See {@link ensurePresenceSweep}. */
|
|
2073
|
-
private async sweepStalePresence(): Promise<void> {
|
|
2074
|
-
if (!this.presenceStore) return;
|
|
2075
|
-
try {
|
|
2076
|
-
const removed = await this.presenceStore.sweepStale(RealtimeService.PRESENCE_TIMEOUT_MS);
|
|
2077
|
-
for (const row of removed) {
|
|
2078
|
-
this.debugLog(`👻 [Presence] Reaped stale presence ${row.clientId} on "${row.channel}"`);
|
|
2079
|
-
this.deliverPresenceDiff(row.channel, {}, { [row.clientId]: row.state });
|
|
2080
|
-
this.publishPresenceDiff(row.channel, {}, { [row.clientId]: row.state });
|
|
2081
|
-
}
|
|
2082
|
-
} catch (error) {
|
|
2083
|
-
logger.warn("⚠️ [Presence] Stale-roster sweep failed", { error });
|
|
2084
|
-
}
|
|
2085
|
-
}
|
|
2086
|
-
|
|
2087
|
-
// =============================================================================
|
|
2088
|
-
// Lifecycle / Cleanup
|
|
2089
|
-
// =============================================================================
|
|
2090
|
-
|
|
2091
|
-
/**
|
|
2092
|
-
* Gracefully tear down all realtime resources.
|
|
2093
|
-
*
|
|
2094
|
-
* This MUST be called during process shutdown, **before** `pool.end()`.
|
|
2095
|
-
* It ensures:
|
|
2096
|
-
* 1. All debounced refetch timers are cancelled (prevents queries after pool closes).
|
|
2097
|
-
* 2. All subscription state and callbacks are cleared.
|
|
2098
|
-
* 3. The dedicated LISTEN client (outside the pool) is disconnected.
|
|
2099
|
-
* 4. All WebSocket clients are removed (but not forcefully closed — the
|
|
2100
|
-
* HTTP server close will handle that).
|
|
2101
|
-
*/
|
|
2102
|
-
async destroy(): Promise<void> {
|
|
2103
|
-
// 1. Cancel every pending debounced refetch timer
|
|
2104
|
-
for (const [key, timer] of this.refetchTimers) {
|
|
2105
|
-
clearTimeout(timer);
|
|
2106
|
-
this.refetchTimers.delete(key);
|
|
2107
|
-
}
|
|
2108
|
-
|
|
2109
|
-
// 2. Clear subscriptions and callbacks
|
|
2110
|
-
this._subscriptions.clear();
|
|
2111
|
-
this.subscriptionCallbacks.clear();
|
|
2112
|
-
|
|
2113
|
-
// 3. Clear broadcast channels and presence
|
|
2114
|
-
this.channels.clear();
|
|
2115
|
-
this.presence.clear();
|
|
2116
|
-
// Pending history writes hold the pool open; let them settle before the
|
|
2117
|
-
// caller closes it, but never let a rejected one break shutdown.
|
|
2118
|
-
await Promise.allSettled([...this.channelSendQueues.values()]);
|
|
2119
|
-
this.channelSendQueues.clear();
|
|
2120
|
-
this.channelHistory?.clear();
|
|
2121
|
-
if (this.presenceInterval) {
|
|
2122
|
-
clearInterval(this.presenceInterval);
|
|
2123
|
-
this.presenceInterval = undefined;
|
|
2124
|
-
}
|
|
2125
|
-
if (this.presenceSweepInterval) {
|
|
2126
|
-
clearInterval(this.presenceSweepInterval);
|
|
2127
|
-
this.presenceSweepInterval = undefined;
|
|
2128
|
-
}
|
|
2129
|
-
this.oversizedBroadcastWarned.clear();
|
|
2130
|
-
|
|
2131
|
-
// Drop this instance's roster rows now rather than leaving every other
|
|
2132
|
-
// replica to wait out a TTL window on ghosts — a rolling deploy would
|
|
2133
|
-
// otherwise show 30s of departed users on every restart.
|
|
2134
|
-
if (this.presenceStore) {
|
|
2135
|
-
try {
|
|
2136
|
-
await this.presenceStore.removeInstance();
|
|
2137
|
-
} catch (error) {
|
|
2138
|
-
logger.warn("⚠️ [Presence] Could not clear this instance's roster rows on shutdown", { error });
|
|
2139
|
-
}
|
|
2140
|
-
this.presenceStore = undefined;
|
|
2141
|
-
}
|
|
2142
|
-
|
|
2143
|
-
// 4. Disconnect the dedicated LISTEN client(s)
|
|
2144
|
-
await this.stopListening();
|
|
2145
|
-
await this.stopCdc();
|
|
2146
|
-
await this.bus.stop().catch((error) =>
|
|
2147
|
-
logger.warn("⚠️ [ChannelBus] Error while stopping the channel bus", { error }));
|
|
2148
|
-
this.bus = new MemoryChannelBus();
|
|
2149
|
-
|
|
2150
|
-
// 5. Drop client references (don't close — server.close drains them)
|
|
2151
|
-
this.clients.clear();
|
|
2152
|
-
|
|
2153
|
-
this.debugLog("🧹 [RealtimeService] destroy() complete — all resources released.");
|
|
2154
|
-
}
|
|
2155
|
-
|
|
2156
|
-
// =============================================================================
|
|
2157
|
-
// Database-level Change Data Capture (CDC)
|
|
2158
|
-
// =============================================================================
|
|
2159
|
-
|
|
2160
|
-
/** Whether database-level change capture is currently the active source. */
|
|
2161
|
-
public isCdcActive(): boolean {
|
|
2162
|
-
return this.cdcActive;
|
|
2163
|
-
}
|
|
2164
|
-
|
|
2165
|
-
/**
|
|
2166
|
-
* Enable database-level change capture as the realtime source.
|
|
2167
|
-
*
|
|
2168
|
-
* A dedicated LISTEN client consumes committed changes from the `rebase_cdc`
|
|
2169
|
-
* channel (fed by CDC triggers — see {@link provisionTriggerCdc}) and routes
|
|
2170
|
-
* them into the same {@link notifyUpdate} pipeline used by API mutations. The
|
|
2171
|
-
* effect: subscribers see a change no matter how it was written — psql, a
|
|
2172
|
-
* cron in another service, raw SQL, or the Studio SQL editor — exactly like
|
|
2173
|
-
* Supabase Realtime tailing the WAL.
|
|
2174
|
-
*
|
|
2175
|
-
* Because CDC observes every commit on every instance, it also *replaces* the
|
|
2176
|
-
* legacy per-mutation cross-instance broadcast (see the guard in
|
|
2177
|
-
* {@link notifyUpdate}); callers should not also call {@link startListening}.
|
|
2178
|
-
*
|
|
2179
|
-
* @param connectionString Direct Postgres connection for the LISTEN client
|
|
2180
|
-
* (bypass PgBouncer — LISTEN needs a session connection).
|
|
2181
|
-
*/
|
|
2182
|
-
async enableCdc(connectionString: string): Promise<void> {
|
|
2183
|
-
if (this.cdcActive) {
|
|
2184
|
-
logger.warn("⚠️ [CDC] enableCdc called but CDC is already active. Ignoring.");
|
|
2185
|
-
return;
|
|
2186
|
-
}
|
|
2187
|
-
this.cdcTableMap = this.buildCdcTableMap();
|
|
2188
|
-
this.junctionLinkMap = buildJunctionLinkMap(this.registry);
|
|
2189
|
-
this.cdcListener = new CdcListener(connectionString, (event) => this.handleCdcEvent(event));
|
|
2190
|
-
try {
|
|
2191
|
-
// start() validates the initial connection; if it can't be established
|
|
2192
|
-
// it rejects here, and we leave CDC inactive so the caller can fall
|
|
2193
|
-
// back to app-level realtime rather than silently dropping events.
|
|
2194
|
-
await this.cdcListener.start();
|
|
2195
|
-
} catch (err) {
|
|
2196
|
-
await this.cdcListener.stop().catch(() => { /* best effort */ });
|
|
2197
|
-
this.cdcListener = undefined;
|
|
2198
|
-
this.cdcTableMap = undefined;
|
|
2199
|
-
this.junctionLinkMap = undefined;
|
|
2200
|
-
throw err;
|
|
2201
|
-
}
|
|
2202
|
-
this.cdcActive = true;
|
|
2203
|
-
// The bootstrapper says the same thing one line later, in the
|
|
2204
|
-
// vocabulary of the setting that produced it (REALTIME_CDC).
|
|
2205
|
-
logger.debug(
|
|
2206
|
-
`📡 [RealtimeService] Database-level change capture ACTIVE — writes from ANY source now emit realtime events ` +
|
|
2207
|
-
`(${this.cdcTableMap.size} mapped table key(s)).`
|
|
2208
|
-
);
|
|
2209
|
-
}
|
|
2210
|
-
|
|
2211
|
-
/** Stop the CDC listener and clear its state. */
|
|
2212
|
-
async stopCdc(): Promise<void> {
|
|
2213
|
-
this.cdcActive = false;
|
|
2214
|
-
if (this.cdcListener) {
|
|
2215
|
-
await this.cdcListener.stop();
|
|
2216
|
-
this.cdcListener = undefined;
|
|
2217
|
-
}
|
|
2218
|
-
this.cdcTableMap = undefined;
|
|
2219
|
-
this.junctionLinkMap = undefined;
|
|
2220
|
-
this.recentAppEmits.clear();
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
/**
|
|
2224
|
-
* Build the reverse map from database table → collection. A change event
|
|
2225
|
-
* carries `schema` + `table`; realtime subscriptions are keyed by collection
|
|
2226
|
-
* path (slug). We index by both `schema.table` and bare `table` so the lookup
|
|
2227
|
-
* works whether or not the collection declares an explicit schema.
|
|
2228
|
-
*/
|
|
2229
|
-
private buildCdcTableMap(): Map<string, CollectionConfig> {
|
|
2230
|
-
const map = new Map<string, CollectionConfig>();
|
|
2231
|
-
for (const collection of this.registry.getCollections()) {
|
|
2232
|
-
const table = getTableName(collection);
|
|
2233
|
-
if (!table) continue;
|
|
2234
|
-
const schema = (collection as { schema?: string }).schema ?? "public";
|
|
2235
|
-
map.set(`${schema}.${table}`, collection);
|
|
2236
|
-
// Bare-table fallback; first registration wins to keep it deterministic.
|
|
2237
|
-
if (!map.has(table)) map.set(table, collection);
|
|
2238
|
-
}
|
|
2239
|
-
return map;
|
|
2240
|
-
}
|
|
2241
|
-
|
|
2242
|
-
private resolveCollectionForTable(schema: string, table: string): CollectionConfig | undefined {
|
|
2243
|
-
if (!this.cdcTableMap) return undefined;
|
|
2244
|
-
return this.cdcTableMap.get(`${schema}.${table}`) ?? this.cdcTableMap.get(table);
|
|
2245
|
-
}
|
|
2246
|
-
|
|
2247
|
-
/**
|
|
2248
|
-
* Route a captured database change into the realtime pipeline.
|
|
2249
|
-
*
|
|
2250
|
-
* Delivery is RLS-safe by construction: the raw tuple from the WAL/trigger is
|
|
2251
|
-
* NOT forwarded to subscribers. Instead the change is marked invalidated, so
|
|
2252
|
-
* every matching subscription re-reads the row under its own auth context via
|
|
2253
|
-
* {@link fetchCollectionWithAuth} / {@link fetchEntityWithAuth}. A subscriber
|
|
2254
|
-
* therefore only ever receives rows its RLS policies permit — filtering is per
|
|
2255
|
-
* subscriber, never per publisher.
|
|
2256
|
-
*/
|
|
2257
|
-
private async handleCdcEvent(event: CdcChangeEvent): Promise<void> {
|
|
2258
|
-
const collection = this.resolveCollectionForTable(event.schema, event.table);
|
|
2259
|
-
if (!collection) {
|
|
2260
|
-
// A junction table backs no collection, but its rows *are* a child
|
|
2261
|
-
// list. Route the change to the lists it changes before giving up.
|
|
2262
|
-
if (await this.handleJunctionCdcEvent(event)) return;
|
|
2263
|
-
|
|
2264
|
-
// Unmapped table (not backed by a collection) — nothing to deliver.
|
|
2265
|
-
this.debugLog(`📡 [CDC] Ignoring change on unmapped table ${event.schema}.${event.table}`);
|
|
2266
|
-
return;
|
|
2267
|
-
}
|
|
2268
|
-
|
|
2269
|
-
const path = collection.slug;
|
|
2270
|
-
const databaseId = (collection as { databaseId?: string }).databaseId;
|
|
2271
|
-
const id = this.extractIdFromCdcRow(collection, event.row);
|
|
2272
|
-
|
|
2273
|
-
// Deletes carry a null row (subscribers drop the id); inserts/updates carry
|
|
2274
|
-
// an invalidation marker that forces a per-subscriber RLS-bound refetch.
|
|
2275
|
-
const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
|
|
2276
|
-
|
|
2277
|
-
await this.notifyUpdate(path, id, row, databaseId, /* broadcast */ false, /* origin */ "cdc");
|
|
2278
|
-
}
|
|
2279
|
-
|
|
2280
|
-
/**
|
|
2281
|
-
* Deliver a change on a many-to-many junction table as a change to the child
|
|
2282
|
-
* lists it belongs to.
|
|
2283
|
-
*
|
|
2284
|
-
* Linking a tag to a post writes only `posts_tags`. That table backs no
|
|
2285
|
-
* collection, so change capture dropped the event as unmapped and the
|
|
2286
|
-
* subscribers of `posts/1/tags` never heard about it — every other write in
|
|
2287
|
-
* the system was realtime, and this one silently was not. The junction row
|
|
2288
|
-
* carries both ids, so it names its own paths exactly.
|
|
2289
|
-
*
|
|
2290
|
-
* Notifies the nested path rather than either endpoint collection, because
|
|
2291
|
-
* invalidation walks *parent* paths and never child ones: telling `tags` it
|
|
2292
|
-
* changed would not reach a subscription on `posts/1/tags`.
|
|
2293
|
-
*
|
|
2294
|
-
* Returns whether the table was recognised as a junction.
|
|
2295
|
-
*/
|
|
2296
|
-
private async handleJunctionCdcEvent(event: CdcChangeEvent): Promise<boolean> {
|
|
2297
|
-
const links = this.junctionLinkMap?.get(`${event.schema}.${event.table}`)
|
|
2298
|
-
?? this.junctionLinkMap?.get(event.table);
|
|
2299
|
-
if (!links?.length) return false;
|
|
2300
|
-
|
|
2301
|
-
for (const link of links) {
|
|
2302
|
-
const sourceId = event.row?.[link.sourceColumn];
|
|
2303
|
-
const targetId = event.row?.[link.targetColumn];
|
|
2304
|
-
if (sourceId === undefined || sourceId === null || targetId === undefined || targetId === null) {
|
|
2305
|
-
this.debugLog(
|
|
2306
|
-
`📡 [CDC] Junction row on ${event.table} is missing '${link.sourceColumn}'/'${link.targetColumn}' — skipping.`
|
|
2307
|
-
);
|
|
2308
|
-
continue;
|
|
2309
|
-
}
|
|
2310
|
-
|
|
2311
|
-
const path = `${link.parentCollection.slug}/${String(sourceId)}/${link.relationKey}`;
|
|
2312
|
-
// An unlink removes the target from this list; a link invalidates it
|
|
2313
|
-
// so each subscriber refetches under its own RLS context.
|
|
2314
|
-
const row = event.op === "DELETE" ? null : { _rebase_invalidated: true };
|
|
2315
|
-
|
|
2316
|
-
await this.notifyUpdate(
|
|
2317
|
-
path,
|
|
2318
|
-
String(targetId),
|
|
2319
|
-
row,
|
|
2320
|
-
(link.parentCollection as { databaseId?: string }).databaseId,
|
|
2321
|
-
/* broadcast */ false,
|
|
2322
|
-
/* origin */ "cdc"
|
|
2323
|
-
);
|
|
2324
|
-
}
|
|
2325
|
-
|
|
2326
|
-
return true;
|
|
2327
|
-
}
|
|
2328
|
-
|
|
2329
|
-
/** Compute the canonical (possibly composite) id string from a captured row. */
|
|
2330
|
-
private extractIdFromCdcRow(collection: CollectionConfig, row: Record<string, unknown>): string {
|
|
2331
|
-
// Unaddressable falls back to a collection-level invalidation: single-row
|
|
2332
|
-
// subs won't match, but collection subs still refetch.
|
|
2333
|
-
return deriveRowAddress(row, collection, this.registry) || "*";
|
|
2334
|
-
}
|
|
2335
|
-
|
|
2336
|
-
// ── App/CDC de-duplication ──
|
|
2337
|
-
|
|
2338
|
-
private dedupKey(path: string, id: string, databaseId?: string): string {
|
|
2339
|
-
return `${databaseId ?? ""}::${path}::${id}`;
|
|
2340
|
-
}
|
|
2341
|
-
|
|
2342
|
-
/** Record that this instance just delivered `key` via the app path. */
|
|
2343
|
-
private markAppEmit(key: string): void {
|
|
2344
|
-
const now = Date.now();
|
|
2345
|
-
this.recentAppEmits.set(key, now + RealtimeService.CDC_DEDUP_WINDOW_MS);
|
|
2346
|
-
// Opportunistic purge so the map cannot grow unbounded under write load.
|
|
2347
|
-
if (this.recentAppEmits.size > 1000) {
|
|
2348
|
-
for (const [k, expiry] of this.recentAppEmits) {
|
|
2349
|
-
if (expiry <= now) this.recentAppEmits.delete(k);
|
|
2350
|
-
}
|
|
2351
|
-
}
|
|
2352
|
-
}
|
|
2353
|
-
|
|
2354
|
-
/** Consume a matching app-emit record if present and unexpired; true ⇒ suppress the CDC echo. */
|
|
2355
|
-
private consumeAppEmit(key: string): boolean {
|
|
2356
|
-
const expiry = this.recentAppEmits.get(key);
|
|
2357
|
-
if (expiry === undefined) return false;
|
|
2358
|
-
this.recentAppEmits.delete(key);
|
|
2359
|
-
return expiry > Date.now();
|
|
2360
|
-
}
|
|
2361
|
-
|
|
2362
|
-
// =============================================================================
|
|
2363
|
-
// Cross-Instance LISTEN/NOTIFY
|
|
2364
|
-
// =============================================================================
|
|
2365
|
-
|
|
2366
|
-
/**
|
|
2367
|
-
* Enable cross-instance realtime broadcasting via Postgres LISTEN/NOTIFY.
|
|
2368
|
-
* Creates a dedicated pg.Client (outside the Drizzle pool) that stays
|
|
2369
|
-
* connected and listens for change notifications from other instances.
|
|
2370
|
-
*
|
|
2371
|
-
* This is an **optional** feature — if never called, the backend operates
|
|
2372
|
-
* in single-instance mode (the default, perfectly fine for most setups).
|
|
2373
|
-
*
|
|
2374
|
-
* @param connectionString Raw Postgres connection string for the LISTEN client.
|
|
2375
|
-
*/
|
|
2376
|
-
async startListening(connectionString: string): Promise<void> {
|
|
2377
|
-
if (this.broadcasting) {
|
|
2378
|
-
logger.warn("⚠️ [RealtimeService] startListening called but already listening. Ignoring.");
|
|
2379
|
-
return;
|
|
2380
|
-
}
|
|
2381
|
-
|
|
2382
|
-
this.listenConnectionString = connectionString;
|
|
2383
|
-
// Set broadcasting BEFORE connecting so that scheduleReconnect()
|
|
2384
|
-
// works correctly if the initial connection attempt fails.
|
|
2385
|
-
this.broadcasting = true;
|
|
2386
|
-
await this.connectListenClient();
|
|
2387
|
-
logger.info(`📡 [RealtimeService] Cross-instance realtime enabled (instanceId: ${this.instanceId})`);
|
|
2388
|
-
}
|
|
2389
|
-
|
|
2390
|
-
/**
|
|
2391
|
-
* Stop listening and clean up the dedicated LISTEN connection.
|
|
2392
|
-
*/
|
|
2393
|
-
async stopListening(): Promise<void> {
|
|
2394
|
-
this.broadcasting = false;
|
|
2395
|
-
if (this.reconnectTimer) {
|
|
2396
|
-
clearTimeout(this.reconnectTimer);
|
|
2397
|
-
this.reconnectTimer = undefined;
|
|
2398
|
-
}
|
|
2399
|
-
if (this.listenClient) {
|
|
2400
|
-
try {
|
|
2401
|
-
await this.listenClient.end();
|
|
2402
|
-
} catch { /* ignore close errors */ }
|
|
2403
|
-
this.listenClient = undefined;
|
|
2404
|
-
}
|
|
2405
|
-
logger.info("📡 [RealtimeService] Cross-instance realtime disabled.");
|
|
2406
|
-
}
|
|
2407
|
-
|
|
2408
|
-
/**
|
|
2409
|
-
* Broadcast a change notification to other instances via pg_notify.
|
|
2410
|
-
* Uses the main Drizzle connection (pooled) — NOT the LISTEN client.
|
|
2411
|
-
*/
|
|
2412
|
-
private async broadcastChange(path: string, id: string, databaseId?: string): Promise<void> {
|
|
2413
|
-
const payload = JSON.stringify({
|
|
2414
|
-
sid: this.instanceId,
|
|
2415
|
-
p: path,
|
|
2416
|
-
eid: id,
|
|
2417
|
-
db: databaseId ?? null
|
|
2418
|
-
});
|
|
2419
|
-
await this.db.execute(drizzleSql`SELECT pg_notify(${PG_NOTIFY_CHANNEL}, ${payload})`);
|
|
2420
|
-
}
|
|
2421
|
-
|
|
2422
|
-
/**
|
|
2423
|
-
* Create and connect the dedicated LISTEN client with auto-reconnect.
|
|
2424
|
-
*/
|
|
2425
|
-
private async connectListenClient(): Promise<void> {
|
|
2426
|
-
if (!this.listenConnectionString) return;
|
|
2427
|
-
|
|
2428
|
-
let pending: PgClient | undefined;
|
|
2429
|
-
try {
|
|
2430
|
-
// See `PgNotifyListener.connect` — same shape, same reason. Until
|
|
2431
|
-
// `this.listenClient` is assigned, nothing else in this class knows
|
|
2432
|
-
// the connection exists, so a throw between `connect()` and that
|
|
2433
|
-
// assignment leaks a live backend and `scheduleReconnect` opens
|
|
2434
|
-
// another one three seconds later.
|
|
2435
|
-
const client = new PgClient({ connectionString: this.listenConnectionString });
|
|
2436
|
-
pending = client;
|
|
2437
|
-
|
|
2438
|
-
client.on("error", (err) => {
|
|
2439
|
-
logger.error("❌ [RealtimeService] LISTEN client error", { detail: err.message });
|
|
2440
|
-
this.scheduleReconnect();
|
|
2441
|
-
});
|
|
2442
|
-
|
|
2443
|
-
client.on("end", () => {
|
|
2444
|
-
if (this.broadcasting) {
|
|
2445
|
-
logger.warn("⚠️ [RealtimeService] LISTEN client disconnected unexpectedly.");
|
|
2446
|
-
this.scheduleReconnect();
|
|
2447
|
-
}
|
|
2448
|
-
});
|
|
2449
|
-
|
|
2450
|
-
client.on("notification", async (msg) => {
|
|
2451
|
-
if (!msg.payload) return;
|
|
2452
|
-
try {
|
|
2453
|
-
const { sid, p, eid, db } = JSON.parse(msg.payload) as {
|
|
2454
|
-
sid: string;
|
|
2455
|
-
p: string;
|
|
2456
|
-
eid: string;
|
|
2457
|
-
db: string | null;
|
|
2458
|
-
};
|
|
2459
|
-
|
|
2460
|
-
// Skip our own notifications — already processed locally
|
|
2461
|
-
if (sid === this.instanceId) return;
|
|
2462
|
-
|
|
2463
|
-
// A foreign sid is proof of a second process. Nothing here
|
|
2464
|
-
// needs that fact, but the channel path does — see
|
|
2465
|
-
// `warnIfMemoryBusOnMultiplePods`.
|
|
2466
|
-
this.foreignInstanceSeen = true;
|
|
2467
|
-
|
|
2468
|
-
this.debugLog(`📡 [RealtimeService] Received cross-instance notification: path=${p}, id=${eid}, from=${sid}`);
|
|
2469
|
-
|
|
2470
|
-
// Refetch the row from the DB so row subscriptions
|
|
2471
|
-
// receive the actual data instead of null (which the client
|
|
2472
|
-
// would interpret as "deleted").
|
|
2473
|
-
let refetchedRow: Record<string, unknown> | null = null;
|
|
2474
|
-
try {
|
|
2475
|
-
if (this.driver) {
|
|
2476
|
-
const collection = this.registry.getCollectionByPath(p);
|
|
2477
|
-
const fetched = await this.driver.fetchOne({
|
|
2478
|
-
path: p,
|
|
2479
|
-
id: eid,
|
|
2480
|
-
collection: collection
|
|
2481
|
-
});
|
|
2482
|
-
refetchedRow = fetched ?? null;
|
|
2483
|
-
} else {
|
|
2484
|
-
const fetched = await this.dataService.fetchOne(
|
|
2485
|
-
p, eid, db ?? undefined
|
|
2486
|
-
);
|
|
2487
|
-
refetchedRow = fetched ?? null;
|
|
2488
|
-
}
|
|
2489
|
-
} catch (fetchErr) {
|
|
2490
|
-
// If the fetch fails (e.g. row was deleted), refetchedRow stays null
|
|
2491
|
-
this.debugLog(`📡 [RealtimeService] Could not refetch row ${eid} from ${p} — treating as deleted`, fetchErr);
|
|
2492
|
-
}
|
|
2493
|
-
|
|
2494
|
-
// Trigger local fan-out with broadcast=false to avoid re-broadcasting
|
|
2495
|
-
await this.notifyUpdate(p, eid, refetchedRow, db ?? undefined, false);
|
|
2496
|
-
} catch (err) {
|
|
2497
|
-
logger.error("❌ [RealtimeService] Error processing cross-instance notification", { error: err });
|
|
2498
|
-
}
|
|
2499
|
-
});
|
|
2500
|
-
|
|
2501
|
-
await client.connect();
|
|
2502
|
-
await client.query(`LISTEN ${PG_NOTIFY_CHANNEL}`);
|
|
2503
|
-
this.listenClient = client;
|
|
2504
|
-
// Adopted: `destroy()` and `scheduleReconnect` close it now.
|
|
2505
|
-
pending = undefined;
|
|
2506
|
-
|
|
2507
|
-
this.debugLog(`📡 [RealtimeService] LISTEN client connected on channel "${PG_NOTIFY_CHANNEL}"`);
|
|
2508
|
-
} catch (err) {
|
|
2509
|
-
if (pending) {
|
|
2510
|
-
try { await pending.end(); } catch { /* already dead */ }
|
|
2511
|
-
}
|
|
2512
|
-
logger.error("❌ [RealtimeService] Failed to connect LISTEN client", { error: err });
|
|
2513
|
-
this.scheduleReconnect();
|
|
2514
|
-
}
|
|
2515
|
-
}
|
|
2516
|
-
|
|
2517
|
-
/**
|
|
2518
|
-
* Schedule a reconnection attempt with a fixed 3s delay.
|
|
2519
|
-
*/
|
|
2520
|
-
private scheduleReconnect(): void {
|
|
2521
|
-
if (!this.broadcasting || this.reconnectTimer) return;
|
|
2522
|
-
|
|
2523
|
-
const delay = 3000; // Fixed 3s delay; simple and predictable
|
|
2524
|
-
this.debugLog(`📡 [RealtimeService] Scheduling LISTEN reconnect in ${delay}ms...`);
|
|
2525
|
-
|
|
2526
|
-
this.reconnectTimer = setTimeout(async () => {
|
|
2527
|
-
this.reconnectTimer = undefined;
|
|
2528
|
-
if (!this.broadcasting) return;
|
|
2529
|
-
|
|
2530
|
-
// Clean up old client
|
|
2531
|
-
if (this.listenClient) {
|
|
2532
|
-
try { await this.listenClient.end(); } catch { /* ignore */ }
|
|
2533
|
-
this.listenClient = undefined;
|
|
2534
|
-
}
|
|
2535
|
-
|
|
2536
|
-
await this.connectListenClient();
|
|
2537
|
-
}, delay);
|
|
2538
|
-
}
|
|
2539
|
-
}
|
|
2540
|
-
|
|
2541
|
-
/**
|
|
2542
|
-
* Alias for RealtimeService for consistent naming with other database implementations.
|
|
2543
|
-
* This allows code to use PostgresRealtimeProvider alongside future MongoRealtimeProvider, etc.
|
|
2544
|
-
*/
|
|
2545
|
-
export const PostgresRealtimeProvider = RealtimeService;
|