import { WebSocket } from "ws"; import { EventEmitter } from "events"; import { DataDriver, WebSocketMessage, LogicalCondition, OrderByTuple } from "@rebasepro/types"; import { NodePgDatabase } from "drizzle-orm/node-postgres"; import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces"; import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry"; import { ChannelBus } from "./channel-bus"; import type { ChannelRetentionRule } from "@rebasepro/types"; /** * Auth context stored per-subscription so real-time refetches respect RLS. * Mirrors the session variables set by PostgresBackendDriver.withAuth(). */ export interface SubscriptionAuthContext { uid: string; roles: string[]; } /** What a channel frame is asking to do. */ export type ChannelAction = "join" | "broadcast" | "presence" | "history"; /** Everything an authorizer is told about the frame it is asked to allow. */ export interface ChannelAuthorizationRequest { /** The channel the frame names, exactly as the client wrote it. */ channel: string; action: ChannelAction; /** The socket, not the principal — one user may hold several. */ clientId: string; /** The socket's authenticated principal, or the anonymous one. */ user?: SubscriptionAuthContext; } /** * The extension point for channel access rules. * * **This is deliberately not a product API yet.** The rule *language* — a * config key, a per-pattern DSL, how it composes with `securityRules` — is an * open design question (see `docs/channel-authorization.md`), and * inventing one here would be inventing the answer. What exists is the single * place every channel frame passes through, so that whatever shape the rules * eventually take has exactly one seam to plug into and no arm of the switch * can be forgotten. * * Returning `false` — or throwing — refuses the frame. It is consulted *after* * the membership floor below, so an authorizer can only ever narrow access, * never widen it. */ export type ChannelAuthorizer = (request: ChannelAuthorizationRequest) => boolean | Promise; /** * The narrowing a collection subscription was created with, kept so that every * refetch answers the same query the initial fetch did. * * Named once because it used to be written out inline in five places, and a * field missing from one of them is accepted over the wire and then silently * ignored: `offset` was declared on the incoming props and never stored, so a * live list on page three served page one, and `logical` was never stored * either, so an `or(...)` subscription was pushed every row in the table. */ type StoredCollectionRequest = { filter?: Record; logical?: LogicalCondition; orderBy?: string | OrderByTuple[]; order?: "desc" | "asc"; limit?: number; offset?: number; startAfter?: Record; databaseId?: string; searchString?: string; /** Ask each row which declared search field matched — populates `_matches`. */ searchExplain?: boolean; }; /** * A registered subscription, plus the two counters that order its deliveries. * * Every update a subscription delivers is a full re-fetch, and more than one * thing starts one for the same subscription without coordinating: the initial * fetch at subscribe time, and a debounced refetch per notification (app * mutation, cross-instance NOTIFY, or CDC). A fetch that started earlier can * finish later, and the delivery replaces everything the subscriber has — so * the subscriber goes back to the state before the change and stays there, * silently, until the next write to that collection. * * The debounce is not a fix for this. It collapses a burst into one refetch and * does nothing about two refetches that overlap: notification A fires its timer * and starts fetch A, notification B arrives while A is still in flight, and B's * timer fires and starts fetch B regardless. See class 44 in * `docs/bug-classes.md`. * * `started` is taken before the work, `delivered` after it — which makes the * last delivery *started* the last one *delivered*. */ type Subscription = { clientId: string; type: "collection" | "single"; path: string; id?: string | number; collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext; /** How many deliveries have been started for this subscription. */ started: number; /** The highest started-sequence that has already reached the subscriber. */ delivered: number; }; /** * PostgreSQL-specific realtime service. * Handles WebSocket connections and subscriptions for real-time row updates. * * Implements the RealtimeProvider interface for database abstraction. */ export declare class RealtimeService extends EventEmitter implements RealtimeProvider { private db; private registry; /** * Declares to the multi-engine router that channel frames can be handled * here. Read by `createRoutedRealtimeService`, which otherwise would have to * guess — and guessed "the default provider", whichever engine that is. */ readonly supportsChannels = true; private clients; private channels; private presence; /** * Ordered, replayable history for channels that opt into it. * * Undefined until {@link configureChannelHistory} is called, and inert even * then unless retention rules were supplied — so presence and ephemeral * notification channels never touch it. See `channel-history.ts`. */ private channelHistory?; /** * One promise chain per retained channel, so that assigning a sequence * number and fanning the message out happen in the same order for every * message on that channel. * * Without it, two concurrent broadcasts can be numbered 4 and 5 by the * database and still reach subscribers as 5 then 4 — live order and replay * order would disagree, which is exactly the divergence sequence numbers * are supposed to rule out. Keyed by channel, so unrelated channels never * wait on each other. */ private channelSendQueues; /** * Cross-instance transport for channel frames and presence. * * Defaults to the memory bus, which publishes nowhere — so a single-instance * deployment runs the same fan-out it always did, with one resolved promise * per broadcast for company. See `channel-bus/ChannelBus.ts`. */ private bus; /** * The shared presence roster, present only when a real bus is active. * * Fan-out alone is not enough for presence: `presence_state` has to answer * with everyone in the channel, and per-process maps can only answer for * this replica's clients. See `channel-presence.ts`. */ private presenceStore?; /** Sweeps roster rows left behind by instances that stopped heartbeating. */ private presenceSweepInterval?; /** * Channels whose oversized ephemeral broadcasts have already been reported, * so a hot channel logs the problem once rather than once per message. */ private oversizedBroadcastWarned; /** * Optional narrowing on top of the membership floor — see * {@link ChannelAuthorizer}. Unset by default, which leaves membership as * the whole of the rule. */ private channelAuthorizer?; /** * Whether a notification from another instance has ever arrived. * * The entity LISTEN handler sees a foreign `sid` on every cross-instance * change, which is proof that this deployment runs more than one pod — the * one fact needed to tell "the memory bus is fine here" from "broadcast and * presence silently reach a fraction of your users". */ private foreignInstanceSeen; /** So the multi-pod memory-bus warning is emitted once, not once per join. */ private memoryBusWarned; private presenceInterval?; private static readonly PRESENCE_TIMEOUT_MS; /** How often stale roster rows from other instances are reaped. */ private static readonly PRESENCE_SWEEP_INTERVAL_MS; private dataService; private _subscriptions; private subscriptionCallbacks; private driver?; /** Unique identifier for this process instance, used to skip own notifications. */ private readonly instanceId; /** Dedicated pg.Client for LISTEN (outside the Drizzle pool). */ private listenClient?; /** Connection string used for reconnecting the LISTEN client. */ private listenConnectionString?; /** Whether cross-instance broadcasting is active. */ private broadcasting; /** Reconnection timer handle. */ private reconnectTimer?; /** Debounce timers for collection refetches to prevent refetch storms. */ private refetchTimers; /** Debounce window (ms) for coalescing rapid row updates into a single correctness refetch. */ private static readonly REFETCH_DEBOUNCE_MS; /** Dedicated LISTEN client for DB-level change events (undefined unless CDC is enabled). */ private cdcListener?; /** Whether database-level CDC is the active cross-instance change source. */ private cdcActive; /** Junction table → the child lists its rows belong to, built when CDC starts. */ private junctionLinkMap?; /** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */ private cdcTableMap?; /** * Short-lived record of `path/id` keys this instance just fanned out via the * app path (a Rebase-API mutation). When CDC echoes the same committed change * back to *this* instance, we suppress the duplicate — the change was already * delivered locally. Other instances have no such record, so they still * deliver the CDC event. External writes (psql, cron, SQL editor) never match * and always flow through. Keyed → expiry timestamp (ms). */ private recentAppEmits; /** How long an app-emit key suppresses its own CDC echo. Covers NOTIFY round-trip latency. */ private static readonly CDC_DEDUP_WINDOW_MS; constructor(db: NodePgDatabase, registry: PostgresCollectionRegistry); /** * Restricted role that auth-scoped refetches run as (via `SET LOCAL ROLE`) * so RLS `select` policies bind. Set by the bootstrapper alongside * `PostgresBackendDriver.rlsUserRole`; undefined when the connection * is already subject to RLS natively. Without this, realtime refetches * would leak rows the initial (isolated) fetch correctly hid. */ rlsUserRole?: string; /** Whether to emit verbose debug logs (disabled in production). */ private static readonly DEBUG; private debugLog; setDataDriver(driver: DataDriver): void; get subscriptions(): Map; /** * Claim a delivery slot for a subscription, before doing the work. * * Returns the check to run immediately before delivering. It refuses in * three cases, all of which used to deliver: * * - **Out of order.** A newer refetch has already delivered, so this one is * stale — the subscriber would go back to the state before the change. * - **Unsubscribed.** The subscription was cancelled while the fetch was in * flight. The `has(subscriptionId)` check the debounced refetches ran * *before* the await cannot answer this; only a check after it can. * - **Replaced.** The same id can name a *different* subscription by the * time a fetch lands — a re-subscribe overwrites the map entry, and the * old filter's rows would be delivered to the new subscriber. * * The last two are identity, not presence: the map has to still hold *this * exact object*, not merely something under this id. */ private beginDelivery; registerDataDriverSubscription(subscriptionId: string, subscription: { clientId: string; type: "collection" | "single"; path: string; id?: string | number; collectionRequest?: StoredCollectionRequest; authContext?: SubscriptionAuthContext; }): void; addSubscriptionCallback(subscriptionId: string, callback: (data: Record[] | Record | null) => void): void; removeSubscriptionCallback(subscriptionId: string): void; /** * Subscribe to collection changes (RealtimeProvider interface) */ subscribeToCollection(subscriptionId: string, config: CollectionSubscriptionConfig, callback?: (rows: Record[]) => void): void; /** * Subscribe to single row changes (RealtimeProvider interface) */ subscribeToOne(subscriptionId: string, config: SingleSubscriptionConfig, callback?: (row: Record | null) => void): void; /** * Unsubscribe from a subscription (RealtimeProvider interface) */ unsubscribe(subscriptionId: string): void; addClient(clientId: string, ws: WebSocket): void; handleClientMessage(clientId: string, message: WebSocketMessage, authContext?: SubscriptionAuthContext): Promise; removeClient(clientId: string): Promise; private handleMessage; private handleCollectionSubscription; private handleEntitySubscription; private handleUnsubscribe; /** * Enhanced notification method that handles nested relation updates. * @param broadcast When true (default), also sends a pg_notify so other instances * pick up the change. Set to false when handling an incoming * cross-instance notification to avoid infinite loops. * @param origin `"app"` (default) — a Rebase-API mutation on this instance; * `"cdc"` — a database-level change observed via CDC (any writer, * any instance). The origin drives de-duplication: an app emit * records the change so this instance can suppress the matching * CDC echo, while an unmatched CDC event is delivered normally. */ notifyUpdate(path: string, id: string, row: Record | null, databaseId?: string, broadcast?: boolean, origin?: "app" | "cdc"): Promise; /** * Notify subscriptions for a specific path. * * **A subscriber only ever receives rows re-read under its own scope.** * `row` is used to decide *that* something changed, never to say *what* — * every delivery below goes through a refetch that binds the subscription's * own auth context. * * It used to be conditional. The CDC path already did the right thing: it * discards the captured tuple and emits `{_rebase_invalidated: true}`, and * that marker selected the refetch branch. But the marker is produced in * exactly two places, and the *other* side of each branch here shipped the * row it was handed straight to the socket. Two of the three entry paths * took that side — every API mutation (`PostgresBackendDriver.save` passes * the row it just wrote, read under the **writer's** scope) and the legacy * cross-instance LISTEN handler (which re-reads on the owner connection, * bypassing RLS altogether). Path matching was the only filter applied: the * subscription's own `filter`/`logical` was never evaluated, and any * `afterRead` redaction was the writer's rather than the reader's. * * A single-row subscription was the sharpest case. `subscribe_one` on a row * RLS denies is accepted and answered `null`; the next update then pushed * the full row with no later correction. The collection variant was merely * papered over ~300 ms later by the debounced refetch — after the bytes had * already reached the browser. * * The same defect was found and fixed on the Mongo driver in `065e2b615` * (see `packages/server-mongo/test/realtime-authorization.test.ts`); this is * the Postgres half, stated as one rule rather than three patched branches. * * The cost is the instant row-level patch that used to precede the refetch: * cross-tab feedback now waits for the debounce. That is the price of not * being able to know, without asking the database as this subscriber, * whether this subscriber may see the row at all. */ private notifyPathUpdate; /** * Debounce a collection refetch for a WebSocket subscription. * Coalesces rapid row mutations into a single database query. */ private debouncedCollectionRefetch; /** * Debounce a collection refetch for a DataDriver callback subscription. */ private debouncedDriverRefetch; /** * Fetch a collection with optional RLS auth context. * When authContext is provided, the fetch runs inside a transaction * with set_config calls so PostgreSQL RLS policies are enforced. */ private fetchCollectionWithAuth; /** * Debounce an row refetch for a WebSocket subscription. */ private debouncedSingleRefetch; /** * Debounce an row refetch for a Driver callback subscription. */ private debouncedSingleDriverRefetch; /** * Fetch a single row with optional RLS auth context. */ private fetchEntityWithAuth; private sendCollectionUpdate; private sendSingleUpdate; /** * Send a lightweight row-level patch to a collection subscriber. * The client can merge this into its cached data for instant feedback. * * The key columns ride along: the patch names a row by address, and the * client has to find that row among the ones it cached — which carry * columns and no address. The SDK holds no collection config to derive one * from, so this is the only place the mapping can come from. */ /** The key columns of the collection at `path`, if they can be resolved. */ private primaryKeysForPath; private sendError; private sendMessage; /** * Extract parent paths from a nested path like "posts/70/tags" * Returns ["posts", "posts/70"] for the example above */ private getParentPaths; /** * Install a channel authorizer — see {@link ChannelAuthorizer}. * * Nothing in the framework calls this yet: it is the seam a rules API will * be built on, kept deliberately separate from the membership floor so the * floor holds whether or not anyone uses it. */ setChannelAuthorizer(authorizer: ChannelAuthorizer | undefined): void; /** Which action each channel frame is asking to perform. */ private static readonly CHANNEL_ACTIONS; /** * The one door every channel frame comes through. * * Returns synchronously — and so dispatches synchronously — unless an * authorizer is installed. That matters: a client sends `join_channel`, * `presence_state` and `channel_history` back to back on connect, and the * socket's message handler processes each frame up to its first `await`, * so a gate that always yielded would let the reads overtake the join that * is about to authorize them. */ private handleChannelMessage; /** Perform an already-authorized channel frame. */ private dispatchChannelMessage; /** * Decide whether a client may perform an action on a channel. * * **Membership is the floor.** Reading a channel's presence roster, replaying * its retained history and broadcasting into it all require that this client * has joined it. That is a low bar — joining is open to anyone who can name * the channel — but it is not the bar that was there before, which was none * at all: `channel_history` and `presence_state` answered any socket about * any channel, and a broadcast fanned out to members the sender had never * joined. Two internal tables (`rebase.channel_presence`, * `rebase.channel_messages`) are held outside RLS on the strength of this * check, so it fails closed: an authorizer that throws refuses the frame. * * Anything richer than membership belongs in a {@link ChannelAuthorizer}; * this method is where it is consulted, and the only place. */ private authorizeChannelAction; /** Tell the client why its channel frame went nowhere, and say so in the log. */ private denyChannelAction; /** Join a broadcast channel */ joinChannel(clientId: string, channel: string): void; /** * Say something the first time channels are used on a deployment that is * demonstrably multi-pod while the bus is still the in-memory default. * * Every other warning in this subsystem covers a *configured* bus failing — * the case where the operator already knew a bus mattered. The common * misconfiguration is the opposite one: scaled to two replicas, never * touched `realtime.bus`, and broadcast and presence quietly serve a * fraction of the room. The evidence is already in the process, so use it. */ private warnIfMemoryBusOnMultiplePods; /** Leave a broadcast channel */ leaveChannel(clientId: string, channel: string): void; /** * Broadcast a message to all clients in a channel except the sender. * * On a channel with no retention rule this is what it always was: a * synchronous fan-out to whoever is connected, with no sequence number, no * SQL and no await — the body below runs to completion before returning. * * On a retained channel the message is durably numbered first and only then * delivered, through a per-channel queue so that delivery order matches * sequence order. That ordering is the whole point: a client that catches up * with `sinceSeq` has to arrive at the same state as one that never * disconnected. */ broadcastToChannel(clientId: string, channel: string, event: string, payload: unknown): void; /** * Number a broadcast, store it, then deliver it. * * A message that cannot be stored is **not** delivered. Delivering it would * put it in front of live subscribers while leaving it absent from every * future replay — the two views of the channel would disagree permanently, * and no later message could repair the gap. Failing loudly to the sender * instead lets it retry, which for an operation stream is the only outcome * that keeps clients convergent. */ private persistAndFanOut; /** Deliver a broadcast frame to every member of a channel but the sender. */ private fanOutBroadcast; /** * Install the transport that carries channel frames between instances. * * Called once at boot. A bus that cannot start is reported and replaced with * the memory bus: losing cross-instance fan-out degrades collaboration to * what it was before this existed, whereas refusing to boot takes the whole * backend down for it. */ configureChannelBus(bus: ChannelBus): Promise; /** Which transport is in use — `"memory"` means per-instance only. */ getChannelBusKind(): ChannelBus["kind"]; /** * Send a broadcast to the other instances. * * Fire-and-forget by design: the clients on this instance have already been * served, and a bus that is briefly unreachable must not turn a broadcast * into an error for the sender. */ private publishBroadcast; private publishFrame; /** * Tell the sender that a message was delivered locally but nowhere else. * * Staying quiet here would be the worst option available: on one instance * the app works, on two it works for half the users, and nothing in the * logs connects the two. The fix is a one-liner in config — give the * channel a retention rule and the message travels as a pointer instead — * so the message says exactly that. */ private reportOversizedBroadcast; /** * Deliver a frame published by another instance to this one's clients. * * Frames we published ourselves are dropped on arrival — the local fan-out * happened before the publish — exactly as the entity-change handler skips * its own `sid`. */ private handleBusFrame; /** * Install retention rules and create the tables they need. * * Safe to call with no rules (and safe not to call at all): the store stays * inert, no schema is created, and broadcast keeps its original * fire-and-forget path. */ configureChannelHistory(rules: ChannelRetentionRule[] | undefined, options?: { provision?: boolean; }): Promise; /** Whether any channel is configured to retain messages. */ isChannelHistoryEnabled(): boolean; /** * Answer a client's catch-up request. * * A channel with no retention rule is answered with `retained: false` * rather than an empty list, so the client can tell "you missed nothing" * apart from "this channel never keeps anything" — the second means its * reconnect strategy has to be a full resync, and silence would leave it * guessing. */ private handleChannelHistoryRequest; private sendChannelHistory; /** * Track presence in a channel. * * The client re-sends this every ~20s as a heartbeat against the 30s * timeout, so most calls carry the state that is already recorded. Those * refresh `last_seen` and stop there: re-announcing an unchanged state to * every instance would put a bus message per client per heartbeat on the * wire to tell everyone nothing happened. */ trackPresence(clientId: string, channel: string, state: Record): void; /** * Remove presence from a channel. * * `skipStore` is for the socket-close path, which clears every channel at * once and then deletes the client's rows in a single statement instead of * one per channel. */ removePresence(clientId: string, channel: string, options?: { skipStore?: boolean; }): void; /** * Send the full roster for a channel to one client. * * Answered from the shared table when there is one, because "who is in this * document?" has a single answer that must not depend on which replica the * asker happens to be connected to. Without a bus there is nothing to share * and the local map *is* the roster — that path stays synchronous, which is * what it always was. */ sendPresenceState(clientId: string, channel: string): void; /** Presence of the clients connected to this instance. */ private localPresences; private sendPresenceStateMessage; /** Deliver a presence diff to this instance's members of the channel. */ private deliverPresenceDiff; /** Tell the other instances about a presence change. */ private publishPresenceDiff; /** Run a roster write when there is a roster, and never let it throw. */ private presenceStoreOp; /** Periodic cleanup for stale presences */ private ensurePresenceCleanup; /** * Reap roster rows whose owning instance stopped heartbeating. * * This is the cross-instance half of the sweep above, and it doubles as * crash recovery: a pod that dies takes its clients with it but leaves * their rows behind, and after one TTL window they look exactly like any * other client that went quiet. The delete returns what it removed, so * whichever instance wins the race is the one that announces the * departures — once for the cluster, not once per replica. */ private ensurePresenceSweep; /** One pass of the stale-roster sweep. See {@link ensurePresenceSweep}. */ private sweepStalePresence; /** * Gracefully tear down all realtime resources. * * This MUST be called during process shutdown, **before** `pool.end()`. * It ensures: * 1. All debounced refetch timers are cancelled (prevents queries after pool closes). * 2. All subscription state and callbacks are cleared. * 3. The dedicated LISTEN client (outside the pool) is disconnected. * 4. All WebSocket clients are removed (but not forcefully closed — the * HTTP server close will handle that). */ destroy(): Promise; /** Whether database-level change capture is currently the active source. */ isCdcActive(): boolean; /** * Enable database-level change capture as the realtime source. * * A dedicated LISTEN client consumes committed changes from the `rebase_cdc` * channel (fed by CDC triggers — see {@link provisionTriggerCdc}) and routes * them into the same {@link notifyUpdate} pipeline used by API mutations. The * effect: subscribers see a change no matter how it was written — psql, a * cron in another service, raw SQL, or the Studio SQL editor — exactly like * Supabase Realtime tailing the WAL. * * Because CDC observes every commit on every instance, it also *replaces* the * legacy per-mutation cross-instance broadcast (see the guard in * {@link notifyUpdate}); callers should not also call {@link startListening}. * * @param connectionString Direct Postgres connection for the LISTEN client * (bypass PgBouncer — LISTEN needs a session connection). */ enableCdc(connectionString: string): Promise; /** Stop the CDC listener and clear its state. */ stopCdc(): Promise; /** * Build the reverse map from database table → collection. A change event * carries `schema` + `table`; realtime subscriptions are keyed by collection * path (slug). We index by both `schema.table` and bare `table` so the lookup * works whether or not the collection declares an explicit schema. */ private buildCdcTableMap; private resolveCollectionForTable; /** * Route a captured database change into the realtime pipeline. * * Delivery is RLS-safe by construction: the raw tuple from the WAL/trigger is * NOT forwarded to subscribers. Instead the change is marked invalidated, so * every matching subscription re-reads the row under its own auth context via * {@link fetchCollectionWithAuth} / {@link fetchEntityWithAuth}. A subscriber * therefore only ever receives rows its RLS policies permit — filtering is per * subscriber, never per publisher. */ private handleCdcEvent; /** * Deliver a change on a many-to-many junction table as a change to the child * lists it belongs to. * * Linking a tag to a post writes only `posts_tags`. That table backs no * collection, so change capture dropped the event as unmapped and the * subscribers of `posts/1/tags` never heard about it — every other write in * the system was realtime, and this one silently was not. The junction row * carries both ids, so it names its own paths exactly. * * Notifies the nested path rather than either endpoint collection, because * invalidation walks *parent* paths and never child ones: telling `tags` it * changed would not reach a subscription on `posts/1/tags`. * * Returns whether the table was recognised as a junction. */ private handleJunctionCdcEvent; /** Compute the canonical (possibly composite) id string from a captured row. */ private extractIdFromCdcRow; private dedupKey; /** Record that this instance just delivered `key` via the app path. */ private markAppEmit; /** Consume a matching app-emit record if present and unexpired; true ⇒ suppress the CDC echo. */ private consumeAppEmit; /** * Enable cross-instance realtime broadcasting via Postgres LISTEN/NOTIFY. * Creates a dedicated pg.Client (outside the Drizzle pool) that stays * connected and listens for change notifications from other instances. * * This is an **optional** feature — if never called, the backend operates * in single-instance mode (the default, perfectly fine for most setups). * * @param connectionString Raw Postgres connection string for the LISTEN client. */ startListening(connectionString: string): Promise; /** * Stop listening and clean up the dedicated LISTEN connection. */ stopListening(): Promise; /** * Broadcast a change notification to other instances via pg_notify. * Uses the main Drizzle connection (pooled) — NOT the LISTEN client. */ private broadcastChange; /** * Create and connect the dedicated LISTEN client with auto-reconnect. */ private connectListenClient; /** * Schedule a reconnection attempt with a fixed 3s delay. */ private scheduleReconnect; } /** * Alias for RealtimeService for consistent naming with other database implementations. * This allows code to use PostgresRealtimeProvider alongside future MongoRealtimeProvider, etc. */ export declare const PostgresRealtimeProvider: typeof RealtimeService; export {};