@rebasepro/server-postgres 0.10.1-canary.a54c057 → 0.10.1-canary.af2af91

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.
Files changed (69) hide show
  1. package/dist/PostgresBootstrapper.d.ts +7 -3
  2. package/dist/auth/services.d.ts +43 -4
  3. package/dist/backup/backup-logic.d.ts +23 -0
  4. package/dist/backup/backup-service.d.ts +44 -2
  5. package/dist/backup/pg-tools.d.ts +41 -1
  6. package/dist/chunk-DSJWtz9O.js +40 -0
  7. package/dist/cli-helpers.d.ts +33 -1
  8. package/dist/ensure-collection-tables-BVvtkkRm.js +304 -0
  9. package/dist/ensure-collection-tables-BVvtkkRm.js.map +1 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.es.js +1854 -4745
  12. package/dist/index.es.js.map +1 -1
  13. package/dist/schema/auth-schema.d.ts +170 -0
  14. package/dist/schema/destructive-sql.d.ts +49 -0
  15. package/dist/schema/ensure-collection-tables.d.ts +79 -0
  16. package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
  17. package/dist/services/FetchService.d.ts +21 -8
  18. package/dist/services/PersistService.d.ts +12 -0
  19. package/dist/services/RelationService.d.ts +30 -0
  20. package/dist/services/cdc/CdcListener.d.ts +7 -14
  21. package/dist/services/cdc/junction-tables.d.ts +38 -0
  22. package/dist/services/channel-bus/ChannelBus.d.ts +29 -0
  23. package/dist/services/channel-bus/PostgresChannelBus.d.ts +111 -0
  24. package/dist/services/channel-bus/index.d.ts +55 -0
  25. package/dist/services/channel-history.d.ts +11 -0
  26. package/dist/services/channel-presence.d.ts +66 -0
  27. package/dist/services/nested-path.d.ts +59 -0
  28. package/dist/services/pg-notify-listener.d.ts +47 -0
  29. package/dist/services/realtimeService.d.ts +133 -6
  30. package/dist/src-CBgtrPhJ.js +336 -0
  31. package/dist/src-CBgtrPhJ.js.map +1 -0
  32. package/dist/src-lcfUP4xg.js +4106 -0
  33. package/dist/src-lcfUP4xg.js.map +1 -0
  34. package/dist/utils/drizzle-conditions.d.ts +41 -0
  35. package/package.json +8 -9
  36. package/src/PostgresBootstrapper.ts +82 -4
  37. package/src/auth/ensure-tables.ts +70 -56
  38. package/src/auth/services.ts +186 -48
  39. package/src/backup/backup-cli.ts +60 -1
  40. package/src/backup/backup-cron.ts +24 -1
  41. package/src/backup/backup-logic.ts +62 -0
  42. package/src/backup/backup-service.ts +132 -13
  43. package/src/backup/pg-tools.ts +70 -2
  44. package/src/cli-helpers.ts +82 -27
  45. package/src/cli.ts +152 -6
  46. package/src/index.ts +4 -0
  47. package/src/schema/auth-schema.ts +41 -3
  48. package/src/schema/destructive-sql.ts +94 -0
  49. package/src/schema/doctor.ts +6 -6
  50. package/src/schema/ensure-collection-tables.test.ts +156 -0
  51. package/src/schema/ensure-collection-tables.ts +297 -0
  52. package/src/schema/generate-drizzle-schema-logic.ts +13 -9
  53. package/src/schema/generate-postgres-ddl-logic.ts +22 -15
  54. package/src/schema/introspect-db-inference.ts +13 -13
  55. package/src/schema/introspect-db-logic.ts +6 -6
  56. package/src/services/FetchService.ts +104 -114
  57. package/src/services/PersistService.ts +127 -85
  58. package/src/services/RelationService.ts +95 -6
  59. package/src/services/cdc/CdcListener.ts +27 -91
  60. package/src/services/cdc/junction-tables.ts +91 -0
  61. package/src/services/channel-bus/ChannelBus.ts +44 -0
  62. package/src/services/channel-bus/PostgresChannelBus.ts +299 -0
  63. package/src/services/channel-bus/index.ts +123 -0
  64. package/src/services/channel-history.ts +35 -0
  65. package/src/services/channel-presence.ts +148 -0
  66. package/src/services/nested-path.ts +130 -0
  67. package/src/services/pg-notify-listener.ts +137 -0
  68. package/src/services/realtimeService.ts +443 -14
  69. package/src/utils/drizzle-conditions.ts +154 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Channel bus over Postgres LISTEN/NOTIFY.
3
+ *
4
+ * Chosen because it needs nothing that a Rebase deployment does not already
5
+ * have — the same database, the same direct URL the CDC listener uses. Three
6
+ * properties of `NOTIFY` shape everything below:
7
+ *
8
+ * - **8000 bytes per payload.** Presence and cursors fit with room to spare; a
9
+ * scene snapshot does not. Rather than truncate or drop, an oversized frame
10
+ * on a *retained* channel is published as a pointer — the body is already in
11
+ * `rebase.channel_messages` with a sequence number, so the receiver reads it
12
+ * back. That is the same trick the entity path uses (notify an address,
13
+ * refetch the row), applied to a different table. On an ephemeral channel
14
+ * there is nothing to point at, so the publish is refused loudly instead of
15
+ * reaching some instances and not others.
16
+ *
17
+ * - **A notify is a query on the primary database.** Not a slow one, but it
18
+ * competes with the application's real queries, and that — not throughput —
19
+ * is what actually limits this transport. Measured, it carried ~10k
20
+ * cross-instance messages/second and stayed flat out to eight instances; what
21
+ * it should not do is spend 10k queries/second of the database's budget on
22
+ * cursor movement. Hence the batching below.
23
+ *
24
+ * - **Delivery is best-effort.** Retained channels repair themselves through
25
+ * the client's history replay, so a lost frame costs a live update rather
26
+ * than correctness. That is what makes coalescing safe.
27
+ */
28
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
29
+ import { ChannelBus, ChannelBusFrame, ChannelBusHandler } from "./ChannelBus";
30
+ /** NOTIFY channel carrying channel-bus frames. */
31
+ export declare const CHANNEL_BUS_NOTIFY_CHANNEL = "rebase_channel_bus";
32
+ /**
33
+ * Postgres refuses a NOTIFY payload of 8000 bytes or more. The margin below it
34
+ * is for nothing in particular — it is there so that a payload which passes this
35
+ * check cannot fail at the server for being a few bytes over.
36
+ */
37
+ export declare const PG_NOTIFY_MAX_PAYLOAD_BYTES = 7500;
38
+ /**
39
+ * How long a batching window stays open.
40
+ *
41
+ * Ten milliseconds is below the threshold where a human notices a cursor lag,
42
+ * and it is the difference between one query per message and one query per
43
+ * window under load. Set to 0 to disable coalescing entirely.
44
+ */
45
+ export declare const DEFAULT_BATCH_WINDOW_MS = 10;
46
+ export declare class PostgresChannelBus implements ChannelBus {
47
+ private readonly db;
48
+ private readonly connectionString;
49
+ readonly kind: "postgres";
50
+ readonly maxFrameBytes = 7500;
51
+ private listener?;
52
+ private readonly batchWindowMs;
53
+ /**
54
+ * Frames waiting for the current window to close.
55
+ *
56
+ * The window is opened by a publish that found none open, and that publish
57
+ * is sent *immediately* rather than joining a batch — see {@link publish}.
58
+ */
59
+ private pending;
60
+ private pendingBytes;
61
+ private windowTimer?;
62
+ private stopped;
63
+ constructor(db: NodePgDatabase<Record<string, unknown>>, connectionString: string, options?: {
64
+ batchWindowMs?: number;
65
+ });
66
+ start(handler: ChannelBusHandler): Promise<void>;
67
+ /**
68
+ * Publish, coalescing under load.
69
+ *
70
+ * The window is *leading edge*: a publish arriving when no window is open is
71
+ * sent straight away and opens one, so an idle channel pays no added latency
72
+ * at all. Frames arriving while it is open are collected and leave together
73
+ * when it closes. The effect is that cost tracks elapsed time rather than
74
+ * message count — one query per window instead of one per message — which is
75
+ * the same shape as the retention pruning throttle, for the same reason.
76
+ *
77
+ * The returned promise settles when the frame has actually left, not when it
78
+ * was queued, so the contract ("reaches the other instances, or rejects")
79
+ * still holds.
80
+ */
81
+ publish(frame: ChannelBusFrame): Promise<void>;
82
+ stop(): Promise<void>;
83
+ private openWindow;
84
+ /** Send everything queued and settle the promises waiting on it. */
85
+ private flush;
86
+ /**
87
+ * One NOTIFY.
88
+ *
89
+ * A single frame goes out in the plain, unwrapped shape. That is not just
90
+ * economy: during a rolling deploy an instance running the previous build
91
+ * understands only that shape, and low-rate traffic — presence, the tail of
92
+ * a session — is exactly what is flowing while pods restart. Batching only
93
+ * appears under load, which shrinks the mixed-version window to almost
94
+ * nothing.
95
+ */
96
+ private send;
97
+ }
98
+ /**
99
+ * Parse a bus payload into the frames it carries.
100
+ *
101
+ * Accepts both wire shapes — a bare frame and a `{ batch: [...] }` envelope —
102
+ * so an instance on the new build understands one on the old. Returns an empty
103
+ * array for anything unrecognisable: a malformed or future-versioned message
104
+ * must never take the listener down.
105
+ */
106
+ export declare function parseChannelBusPayload(payload: string): ChannelBusFrame[];
107
+ /**
108
+ * Parse a single bus frame, returning null for anything that is not a frame we
109
+ * understand.
110
+ */
111
+ export declare function parseChannelBusFrame(payload: string): ChannelBusFrame | null;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Resolution of the channel bus from config, environment, or a supplied instance.
3
+ *
4
+ * Opt-in, like every other cross-cutting realtime switch here: with nothing
5
+ * configured a deployment gets the memory bus and behaves exactly as it did
6
+ * before this existed. Unlike `REALTIME_CDC=auto`, there is no "try it and see"
7
+ * default — a bus changes where messages go, and quietly turning on a Postgres
8
+ * NOTIFY per broadcast because a direct URL happened to be set is not a
9
+ * decision to make on the user's behalf.
10
+ *
11
+ * Two transports ship, and neither adds a service to a deployment. A third is
12
+ * not a code change here: `realtime.bus` also accepts an already-constructed
13
+ * {@link ChannelBus}, so a transport published as its own package plugs in
14
+ * without this file learning about it. See `@rebasepro/types` →
15
+ * `types/channel_bus.ts` for the contract such a package implements.
16
+ */
17
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
18
+ import { type ChannelBus, type ChannelBusConfig, type ChannelBusSetting } from "@rebasepro/types";
19
+ export * from "./ChannelBus";
20
+ export { PostgresChannelBus, CHANNEL_BUS_NOTIFY_CHANNEL, PG_NOTIFY_MAX_PAYLOAD_BYTES, DEFAULT_BATCH_WINDOW_MS, parseChannelBusFrame, parseChannelBusPayload } from "./PostgresChannelBus";
21
+ export interface ChannelBusDeps {
22
+ db: NodePgDatabase<Record<string, unknown>>;
23
+ /**
24
+ * Direct (non-pooled) Postgres URL for the LISTEN client. `LISTEN` is
25
+ * session state, so behind PgBouncer in transaction mode this must be the
26
+ * database itself and not the pooler.
27
+ */
28
+ directUrl?: string;
29
+ }
30
+ /**
31
+ * Merge `REALTIME_CHANNEL_BUS` into the configured bus.
32
+ *
33
+ * The environment wins over a *named* built-in, so the transport can be changed
34
+ * per deployment without a rebuild — the same reason `REALTIME_CDC` is an env
35
+ * var. It does **not** win over a supplied instance: the env var can only name
36
+ * transports this package knows how to construct, so honouring it there would
37
+ * mean silently discarding the object the application handed us.
38
+ */
39
+ export declare function resolveChannelBusSetting(configured?: ChannelBusSetting): ChannelBusSetting;
40
+ /**
41
+ * @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
42
+ * supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
43
+ * config-only callers keep their exact types.
44
+ */
45
+ export declare function resolveChannelBusConfig(configured?: ChannelBusConfig): ChannelBusConfig;
46
+ /**
47
+ * Produce the bus a setting asks for.
48
+ *
49
+ * An instance is handed straight back — constructing it was the application's
50
+ * job, and this function has nothing to add. A named built-in that turns out to
51
+ * be unusable degrades to the memory bus, with the reason logged, rather than
52
+ * throwing: a misconfigured bus should cost a deployment its cross-instance
53
+ * fan-out, not its ability to boot.
54
+ */
55
+ export declare function createChannelBus(setting: ChannelBusSetting, deps: ChannelBusDeps): ChannelBus;
@@ -105,6 +105,17 @@ export declare class ChannelHistoryStore {
105
105
  messages: ChannelHistoryEntry[];
106
106
  latestSeq: number;
107
107
  }>;
108
+ /**
109
+ * One retained message by its address.
110
+ *
111
+ * This is what makes the cross-instance pointer path work: a broadcast too
112
+ * large to travel inside a `pg_notify` payload is already stored here, so
113
+ * the notification carries `(channel, seq)` and each receiving instance
114
+ * reads the body back. Returns null when the message has since been pruned
115
+ * — a receiver that is that far behind has nothing useful to deliver, and
116
+ * the client's own `channel_history` replay is the repair path.
117
+ */
118
+ getBySeq(channel: string, seq: number): Promise<ChannelHistoryEntry | null>;
108
119
  /**
109
120
  * Enforce a channel's retention bounds.
110
121
  *
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The shared presence roster.
3
+ *
4
+ * Broadcast only ever needed *fan-out* to work across instances — a frame goes
5
+ * out, whoever is connected receives it. Presence needs more than that, because
6
+ * `presence_state` is a question ("who is in this document?") and a per-process
7
+ * `Map` can only answer for the clients that happen to share a replica with the
8
+ * asker. Two people editing the same scene through different pods would each
9
+ * see an empty room while broadcasting cursors at each other perfectly.
10
+ *
11
+ * So presence gets one row per tracked client, in Postgres, readable by every
12
+ * instance. Three consequences worth stating:
13
+ *
14
+ * - **The table is the roster; the in-process map is a cache of our own
15
+ * clients.** Reads answer from the table when this store is active, so the
16
+ * answer is the same whichever instance is asked.
17
+ *
18
+ * - **`last_seen` is the liveness signal, and it is already there.** The client
19
+ * heartbeats presence every ~20 s against a 30 s window; the sweep that has
20
+ * always reaped local stale entries now also reaps rows belonging to
21
+ * instances that stopped writing — which is exactly what a crashed pod looks
22
+ * like. Crash recovery is a property of the TTL, not a separate mechanism.
23
+ *
24
+ * - **The sweep deletes with `RETURNING`.** Whichever instance wins the delete
25
+ * is the one that announces the departures, so a stale client produces one
26
+ * `presence_diff` for the cluster rather than one per replica.
27
+ */
28
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
29
+ /** A tracked client, as any instance sees it. */
30
+ export interface PresenceRow {
31
+ channel: string;
32
+ clientId: string;
33
+ state: Record<string, unknown>;
34
+ }
35
+ export declare class ChannelPresenceStore {
36
+ private readonly db;
37
+ private readonly instanceId;
38
+ private tablesReady;
39
+ constructor(db: NodePgDatabase<Record<string, unknown>>, instanceId: string);
40
+ /** Create the roster table. Idempotent. */
41
+ ensureTables(): Promise<void>;
42
+ /** Record (or refresh) a client's presence. */
43
+ track(channel: string, clientId: string, state: Record<string, unknown>): Promise<void>;
44
+ /** Drop one client's presence in one channel. */
45
+ remove(channel: string, clientId: string): Promise<void>;
46
+ /** Drop a client from every channel — used when its socket closes. */
47
+ removeClient(clientId: string): Promise<void>;
48
+ /** The global roster for a channel. */
49
+ roster(channel: string): Promise<Record<string, Record<string, unknown>>>;
50
+ /**
51
+ * Reap rows this instance is not responsible for and that have gone quiet.
52
+ *
53
+ * Own rows are excluded because the in-process sweep already handles them —
54
+ * and handles them better, since it can tell "the socket is gone" from "the
55
+ * heartbeat is late". What is left is precisely the interesting case: rows
56
+ * written by an instance that is no longer writing.
57
+ *
58
+ * Returns what was removed, so the caller can announce it.
59
+ */
60
+ sweepStale(ttlMs: number): Promise<PresenceRow[]>;
61
+ /**
62
+ * Remove every row this instance owns. Called on graceful shutdown so a
63
+ * rolling deploy does not leave a TTL window of ghosts in every roster.
64
+ */
65
+ removeInstance(): Promise<void>;
66
+ }
@@ -0,0 +1,59 @@
1
+ import { CollectionConfig, Relation } from "@rebasepro/types";
2
+ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
3
+ /**
4
+ * The last hop of a nested collection path, e.g. `authors/1/posts`.
5
+ *
6
+ * The walk that produces this was written out four separate times — in
7
+ * `FetchService.fetchCollectionFromPath`, `FetchService.countEntitiesFromPath`,
8
+ * `PersistService.save` and `CollectionRegistry.getCollectionByPath` — and had
9
+ * drifted, so the read path and the write path did not agree on which relation
10
+ * a path named. It lives here once now.
11
+ */
12
+ export interface NestedPathHop {
13
+ /** The collection the final relation is declared on (e.g. `authors`). */
14
+ parentCollection: CollectionConfig;
15
+ /** The parent's id as it appeared in the path, unparsed. */
16
+ parentId: string;
17
+ /** The path segment that named the relation (e.g. `posts`). */
18
+ relationKey: string;
19
+ relation: Relation;
20
+ /** `relation.target()`, resolved once. */
21
+ targetCollection: CollectionConfig;
22
+ }
23
+ /**
24
+ * True when `path` addresses rows through a relation rather than a root
25
+ * collection.
26
+ *
27
+ * Any separator at all counts — a root collection slug never contains one — so
28
+ * a malformed path like `collection/id` is a *broken* nested path and gets
29
+ * reported as one by {@link resolveNestedPath}, rather than being looked up as
30
+ * a root collection whose slug happens to contain a slash.
31
+ */
32
+ export declare function isNestedPath(path: string): boolean;
33
+ export declare function splitPathSegments(path: string): string[];
34
+ /**
35
+ * Walk a nested collection path down to the relation it ends in.
36
+ *
37
+ * Returns `undefined` for a plain root-collection path so callers can keep the
38
+ * root case on its existing code path. Throws when the path is malformed, or
39
+ * when a segment names a relation that does not exist — the same errors the
40
+ * individual walks used to raise, with the available names attached.
41
+ */
42
+ export declare function resolveNestedPath(path: string, registry: PostgresCollectionRegistry): NestedPathHop | undefined;
43
+ /**
44
+ * A relation reached through a junction table — many-to-many, or a multi-hop
45
+ * `joinPath`. The target row is shared with other parents, so writing "through"
46
+ * such a path addresses the *link*, not the row.
47
+ */
48
+ export declare function isJunctionBackedRelation(relation: Relation): boolean;
49
+ /**
50
+ * Reject a nested write whose final segment is a to-one relation.
51
+ *
52
+ * There is no column on the target row that records a to-one parent — the
53
+ * foreign key lives on the *parent* table. The write path used to fall through
54
+ * to `relation.localKey` here and stamp the parent's own FK column onto the
55
+ * target row, which either raised an opaque "column does not exist" or, when a
56
+ * column of that name happened to exist on the target, silently wrote the wrong
57
+ * one.
58
+ */
59
+ export declare function assertWritableThrough(hop: NestedPathHop, path: string): void;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * A dedicated, self-healing Postgres `LISTEN` connection.
3
+ *
4
+ * Every cross-instance feature in the backend needs the same thing: one
5
+ * connection *outside* the Drizzle pool that stays open, holds a `LISTEN`, and
6
+ * comes back on its own after the database or the network drops it. CDC needed
7
+ * it first; the channel bus needs it too. This is that connection, with the one
8
+ * behaviour that matters to callers preserved: the **first** connect is
9
+ * validated and rethrown, so a caller can fall back to a different strategy,
10
+ * while every later drop is repaired quietly in the background.
11
+ *
12
+ * `LISTEN` is session state, so this connection must not go through a
13
+ * transaction-mode pooler (PgBouncer): give it the direct database URL.
14
+ */
15
+ export interface PgNotifyListenerOptions {
16
+ /** Direct Postgres connection string (must bypass a transaction-mode pooler). */
17
+ connectionString: string;
18
+ /** NOTIFY channel to LISTEN on. Must be a plain identifier — it is interpolated. */
19
+ channel: string;
20
+ /** Called for every notification payload received. */
21
+ onPayload: (payload: string) => void | Promise<void>;
22
+ /** Prefix for log lines, e.g. `"[CDC]"`. */
23
+ logLabel: string;
24
+ /** Delay before a reconnect attempt. */
25
+ reconnectDelayMs?: number;
26
+ }
27
+ export declare class PgNotifyListener {
28
+ private readonly options;
29
+ private client?;
30
+ private running;
31
+ private reconnectTimer?;
32
+ constructor(options: PgNotifyListenerOptions);
33
+ /** Whether the listener is meant to be connected right now. */
34
+ get active(): boolean;
35
+ /**
36
+ * Connect and begin listening. Idempotent.
37
+ *
38
+ * Rejects if the *initial* connection or `LISTEN` fails, leaving the
39
+ * listener stopped — callers use that to degrade deliberately instead of
40
+ * running blind against a channel nothing is delivering.
41
+ */
42
+ start(): Promise<void>;
43
+ /** Stop listening and release the connection. Idempotent. */
44
+ stop(): Promise<void>;
45
+ private connect;
46
+ private scheduleReconnect;
47
+ }
@@ -4,6 +4,7 @@ import { DataDriver, WebSocketMessage } from "@rebasepro/types";
4
4
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
5
5
  import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
6
6
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
7
+ import { ChannelBus } from "./channel-bus";
7
8
  import type { ChannelRetentionRule } from "@rebasepro/types";
8
9
  /**
9
10
  * Auth context stored per-subscription so real-time refetches respect RLS.
@@ -45,8 +46,33 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
45
46
  * wait on each other.
46
47
  */
47
48
  private channelSendQueues;
49
+ /**
50
+ * Cross-instance transport for channel frames and presence.
51
+ *
52
+ * Defaults to the memory bus, which publishes nowhere — so a single-instance
53
+ * deployment runs the same fan-out it always did, with one resolved promise
54
+ * per broadcast for company. See `channel-bus/ChannelBus.ts`.
55
+ */
56
+ private bus;
57
+ /**
58
+ * The shared presence roster, present only when a real bus is active.
59
+ *
60
+ * Fan-out alone is not enough for presence: `presence_state` has to answer
61
+ * with everyone in the channel, and per-process maps can only answer for
62
+ * this replica's clients. See `channel-presence.ts`.
63
+ */
64
+ private presenceStore?;
65
+ /** Sweeps roster rows left behind by instances that stopped heartbeating. */
66
+ private presenceSweepInterval?;
67
+ /**
68
+ * Channels whose oversized ephemeral broadcasts have already been reported,
69
+ * so a hot channel logs the problem once rather than once per message.
70
+ */
71
+ private oversizedBroadcastWarned;
48
72
  private presenceInterval?;
49
73
  private static readonly PRESENCE_TIMEOUT_MS;
74
+ /** How often stale roster rows from other instances are reaped. */
75
+ private static readonly PRESENCE_SWEEP_INTERVAL_MS;
50
76
  private dataService;
51
77
  private _subscriptions;
52
78
  private subscriptionCallbacks;
@@ -69,6 +95,8 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
69
95
  private cdcListener?;
70
96
  /** Whether database-level CDC is the active cross-instance change source. */
71
97
  private cdcActive;
98
+ /** Junction table → the child lists its rows belong to, built when CDC starts. */
99
+ private junctionLinkMap?;
72
100
  /** Reverse lookup: `schema.table` (and bare `table`) → collection, built when CDC starts. */
73
101
  private cdcTableMap?;
74
102
  /**
@@ -245,6 +273,44 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
245
273
  private persistAndFanOut;
246
274
  /** Deliver a broadcast frame to every member of a channel but the sender. */
247
275
  private fanOutBroadcast;
276
+ /**
277
+ * Install the transport that carries channel frames between instances.
278
+ *
279
+ * Called once at boot. A bus that cannot start is reported and replaced with
280
+ * the memory bus: losing cross-instance fan-out degrades collaboration to
281
+ * what it was before this existed, whereas refusing to boot takes the whole
282
+ * backend down for it.
283
+ */
284
+ configureChannelBus(bus: ChannelBus): Promise<void>;
285
+ /** Which transport is in use — `"memory"` means per-instance only. */
286
+ getChannelBusKind(): ChannelBus["kind"];
287
+ /**
288
+ * Send a broadcast to the other instances.
289
+ *
290
+ * Fire-and-forget by design: the clients on this instance have already been
291
+ * served, and a bus that is briefly unreachable must not turn a broadcast
292
+ * into an error for the sender.
293
+ */
294
+ private publishBroadcast;
295
+ private publishFrame;
296
+ /**
297
+ * Tell the sender that a message was delivered locally but nowhere else.
298
+ *
299
+ * Staying quiet here would be the worst option available: on one instance
300
+ * the app works, on two it works for half the users, and nothing in the
301
+ * logs connects the two. The fix is a one-liner in config — give the
302
+ * channel a retention rule and the message travels as a pointer instead —
303
+ * so the message says exactly that.
304
+ */
305
+ private reportOversizedBroadcast;
306
+ /**
307
+ * Deliver a frame published by another instance to this one's clients.
308
+ *
309
+ * Frames we published ourselves are dropped on arrival — the local fan-out
310
+ * happened before the publish — exactly as the entity-change handler skips
311
+ * its own `sid`.
312
+ */
313
+ private handleBusFrame;
248
314
  /**
249
315
  * Install retention rules and create the tables they need.
250
316
  *
@@ -266,16 +332,60 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
266
332
  */
267
333
  private handleChannelHistoryRequest;
268
334
  private sendChannelHistory;
269
- /** Track presence in a channel */
335
+ /**
336
+ * Track presence in a channel.
337
+ *
338
+ * The client re-sends this every ~20s as a heartbeat against the 30s
339
+ * timeout, so most calls carry the state that is already recorded. Those
340
+ * refresh `last_seen` and stop there: re-announcing an unchanged state to
341
+ * every instance would put a bus message per client per heartbeat on the
342
+ * wire to tell everyone nothing happened.
343
+ */
270
344
  trackPresence(clientId: string, channel: string, state: Record<string, unknown>): void;
271
- /** Remove presence from a channel */
272
- removePresence(clientId: string, channel: string): void;
273
- /** Send full presence state to a specific client */
345
+ /**
346
+ * Remove presence from a channel.
347
+ *
348
+ * `skipStore` is for the socket-close path, which clears every channel at
349
+ * once and then deletes the client's rows in a single statement instead of
350
+ * one per channel.
351
+ */
352
+ removePresence(clientId: string, channel: string, options?: {
353
+ skipStore?: boolean;
354
+ }): void;
355
+ /**
356
+ * Send the full roster for a channel to one client.
357
+ *
358
+ * Answered from the shared table when there is one, because "who is in this
359
+ * document?" has a single answer that must not depend on which replica the
360
+ * asker happens to be connected to. Without a bus there is nothing to share
361
+ * and the local map *is* the roster — that path stays synchronous, which is
362
+ * what it always was.
363
+ */
274
364
  sendPresenceState(clientId: string, channel: string): void;
275
- /** Broadcast presence diff (joins/leaves) to channel */
276
- private broadcastPresenceDiff;
365
+ /** Presence of the clients connected to this instance. */
366
+ private localPresences;
367
+ private sendPresenceStateMessage;
368
+ /** Deliver a presence diff to this instance's members of the channel. */
369
+ private deliverPresenceDiff;
370
+ /** Tell the other instances about a presence change. */
371
+ private publishPresenceDiff;
372
+ /** Run a roster write when there is a roster, and never let it throw. */
373
+ private presenceStoreOp;
277
374
  /** Periodic cleanup for stale presences */
278
375
  private ensurePresenceCleanup;
376
+ /**
377
+ * Reap roster rows whose owning instance stopped heartbeating.
378
+ *
379
+ * This is the cross-instance half of the sweep above, and it doubles as
380
+ * crash recovery: a pod that dies takes its clients with it but leaves
381
+ * their rows behind, and after one TTL window they look exactly like any
382
+ * other client that went quiet. The delete returns what it removed, so
383
+ * whichever instance wins the race is the one that announces the
384
+ * departures — once for the cluster, not once per replica.
385
+ */
386
+ private ensurePresenceSweep;
387
+ /** One pass of the stale-roster sweep. See {@link ensurePresenceSweep}. */
388
+ private sweepStalePresence;
279
389
  /**
280
390
  * Gracefully tear down all realtime resources.
281
391
  *
@@ -329,6 +439,23 @@ export declare class RealtimeService extends EventEmitter implements RealtimePro
329
439
  * subscriber, never per publisher.
330
440
  */
331
441
  private handleCdcEvent;
442
+ /**
443
+ * Deliver a change on a many-to-many junction table as a change to the child
444
+ * lists it belongs to.
445
+ *
446
+ * Linking a tag to a post writes only `posts_tags`. That table backs no
447
+ * collection, so change capture dropped the event as unmapped and the
448
+ * subscribers of `posts/1/tags` never heard about it — every other write in
449
+ * the system was realtime, and this one silently was not. The junction row
450
+ * carries both ids, so it names its own paths exactly.
451
+ *
452
+ * Notifies the nested path rather than either endpoint collection, because
453
+ * invalidation walks *parent* paths and never child ones: telling `tags` it
454
+ * changed would not reach a subscription on `posts/1/tags`.
455
+ *
456
+ * Returns whether the table was recognised as a junction.
457
+ */
458
+ private handleJunctionCdcEvent;
332
459
  /** Compute the canonical (possibly composite) id string from a captured row. */
333
460
  private extractIdFromCdcRow;
334
461
  private dedupKey;