@rebasepro/server-postgres 0.17.3-canary.gdd23447 → 0.18.0

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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,424 +0,0 @@
1
- /**
2
- * Ordered, replayable per-channel message history.
3
- *
4
- * Broadcast on its own is fire-and-forget to whoever is connected at the
5
- * instant it is sent: fine for presence and for "someone saved" notifications,
6
- * not enough for op-based collaborative editing, where a client that blinks
7
- * out for two seconds has to resync a whole document rather than catch up on
8
- * the four operations it missed. This adds the missing half — every retained
9
- * broadcast gets a per-channel sequence number, and a client can ask for
10
- * everything after the last one it saw.
11
- *
12
- * Three decisions worth stating, because each rules out a simpler-looking one:
13
- *
14
- * - **Retention is server-side and opt-in.** A channel is created by whoever
15
- * names it, so a client-supplied history depth would let any visitor commit
16
- * the backend to unbounded storage. And presence channels — the common case
17
- * — must not pay for this: with no rules configured nothing is written, no
18
- * table is created, and `broadcast` runs exactly the code it ran before.
19
- *
20
- * - **Sequence numbers come from the database, not from a counter in this
21
- * process.** They have to survive a restart and be shared across instances;
22
- * an in-memory counter would restart at 1 after a deploy and hand a
23
- * reconnecting client a replay from the wrong era, silently.
24
- *
25
- * - **The cursor row outlives the messages it numbered.** Pruning is what
26
- * makes retention affordable, but pruning the cursor along with the messages
27
- * would restart the sequence and make `sinceSeq` mean something different
28
- * before and after — the worst kind of bug, because replay would still
29
- * return rows and they would look plausible. Cursors are tiny and are kept
30
- * forever; see {@link prune}, which touches only `channel_messages`.
31
- */
32
-
33
- import { sql } from "drizzle-orm";
34
- import { NodePgDatabase } from "drizzle-orm/node-postgres";
35
- import type { ChannelHistoryEntry, ChannelRetentionRule } from "@rebasepro/types";
36
- import { logger } from "@rebasepro/server";
37
- import { revokeInternalTableSql } from "@rebasepro/common";
38
- import { drizzleDdlBootstrapper } from "../schema/drizzle-ddl";
39
-
40
- /** How many messages a replay returns when the caller does not say. */
41
- const DEFAULT_REPLAY_LIMIT = 200;
42
-
43
- /**
44
- * Hard ceiling on one replay, whatever the caller asks for.
45
- *
46
- * A reconnecting client names its own `limit`, so this is the only thing
47
- * standing between a stale `sinceSeq` and a single frame carrying a channel's
48
- * entire retained history. A client that is further behind than this is told so
49
- * via `latestSeq` and can decide to resync wholesale instead of paging.
50
- */
51
- const MAX_REPLAY_LIMIT = 1000;
52
-
53
- /** Minimum gap between two prunes of the same channel. */
54
- const PRUNE_THROTTLE_MS = 30_000;
55
-
56
- /**
57
- * Parse a retention TTL into milliseconds.
58
- *
59
- * Accepts a raw millisecond count or a short duration string (`"30s"`, `"15m"`,
60
- * `"24h"`, `"7d"`). Returns undefined for anything unparseable, which the
61
- * caller treats as "no TTL" — a misspelt duration must not silently become an
62
- * aggressive one.
63
- */
64
- export function parseTtlMs(ttl: number | string | undefined): number | undefined {
65
- if (ttl === undefined || ttl === null) return undefined;
66
- if (typeof ttl === "number") return Number.isFinite(ttl) && ttl > 0 ? ttl : undefined;
67
-
68
- const match = /^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)\s*$/i.exec(ttl);
69
- if (!match) {
70
- logger.warn(`⚠️ [ChannelHistory] Ignoring unparseable retention ttl "${ttl}" — expected e.g. "30s", "15m", "24h", "7d".`);
71
- return undefined;
72
- }
73
- const value = parseFloat(match[1]);
74
- const unit = match[2].toLowerCase();
75
- const multiplier = unit === "ms" ? 1
76
- : unit === "s" ? 1_000
77
- : unit === "m" ? 60_000
78
- : unit === "h" ? 3_600_000
79
- : 86_400_000;
80
- const ms = value * multiplier;
81
- return ms > 0 ? ms : undefined;
82
- }
83
-
84
- /**
85
- * Whether `channel` is covered by `rule`.
86
- *
87
- * Exact match, or a trailing `*` acting as a prefix. Not a general glob: this
88
- * decides what reaches disk, and a pattern language whose reach is not obvious
89
- * at a glance is the wrong tool for that job.
90
- */
91
- export function channelMatchesRule(channel: string, rule: ChannelRetentionRule): boolean {
92
- const pattern = rule.match;
93
- if (pattern === "*") return true;
94
- if (pattern.endsWith("*")) return channel.startsWith(pattern.slice(0, -1));
95
- return channel === pattern;
96
- }
97
-
98
- /** A rule with its TTL already resolved to milliseconds. */
99
- export interface ResolvedRetention {
100
- limit?: number;
101
- ttlMs?: number;
102
- }
103
-
104
- /**
105
- * Persistence and replay for retained channels.
106
- *
107
- * Inert unless constructed with at least one rule: {@link enabled} is false,
108
- * {@link ensureTables} does nothing, and {@link retentionFor} answers undefined
109
- * for every channel, so the realtime service never reaches the SQL below.
110
- */
111
- export class ChannelHistoryStore {
112
- private rules: ChannelRetentionRule[];
113
- /** Resolved rule per channel name, so the match runs once per channel. */
114
- private resolved = new Map<string, ResolvedRetention | null>();
115
- /** Channel → timestamp of its last prune, for {@link PRUNE_THROTTLE_MS}. */
116
- private lastPruned = new Map<string, number>();
117
- private tablesReady = false;
118
-
119
- constructor(private db: NodePgDatabase<Record<string, unknown>>, rules: ChannelRetentionRule[] = []) {
120
- this.rules = rules.filter(rule => {
121
- if (!rule?.match) {
122
- logger.warn("⚠️ [ChannelHistory] Ignoring a retention rule with no `match`.");
123
- return false;
124
- }
125
- const hasBound = rule.limit !== undefined || rule.ttl !== undefined;
126
- if (!hasBound) {
127
- // Unbounded retention is almost never intended and cannot be
128
- // walked back once the table has grown, so it is refused rather
129
- // than honoured.
130
- logger.warn(`⚠️ [ChannelHistory] Retention rule "${rule.match}" sets neither \`limit\` nor \`ttl\` — ignoring it, as it would retain forever.`);
131
- return false;
132
- }
133
- return true;
134
- });
135
- }
136
-
137
- /** Whether any channel retains anything at all. */
138
- get enabled(): boolean {
139
- return this.rules.length > 0;
140
- }
141
-
142
- /**
143
- * The retention that applies to `channel`, or undefined when none does.
144
- *
145
- * First matching rule wins, so callers order them most-specific first.
146
- */
147
- retentionFor(channel: string): ResolvedRetention | undefined {
148
- if (!this.enabled || !channel) return undefined;
149
-
150
- const cached = this.resolved.get(channel);
151
- if (cached !== undefined) return cached ?? undefined;
152
-
153
- const rule = this.rules.find(r => channelMatchesRule(channel, r));
154
- const resolved: ResolvedRetention | null = rule
155
- ? { limit: rule.limit, ttlMs: parseTtlMs(rule.ttl) }
156
- : null;
157
-
158
- // Bounded by the number of distinct channel names seen, which is the
159
- // same thing the in-memory channel and presence maps are bounded by.
160
- this.resolved.set(channel, resolved);
161
- return resolved ?? undefined;
162
- }
163
-
164
- /**
165
- * Create the history tables. Idempotent, and a no-op when no rule is set —
166
- * a deployment that never retains anything gets no schema for it.
167
- */
168
- async ensureTables(): Promise<void> {
169
- if (!this.enabled || this.tablesReady) return;
170
-
171
- // Contained, retrying steps rather than one straight sequence — see the
172
- // note on `ChannelPresenceStore.ensureTables`. The failure mode here is
173
- // the same and the stakes are the same: the two `REVOKE`s at the end are
174
- // what keep retained broadcasts off the end-user role, and a lost create
175
- // race used to skip them.
176
- const ddl = drizzleDdlBootstrapper(this.db, "channel-history");
177
-
178
- await ddl.ensureObject("rebase schema", "CREATE SCHEMA IF NOT EXISTS rebase");
179
-
180
- // The primary key is exactly the replay query's access path
181
- // (`channel = $1 AND seq > $2 ORDER BY seq`), so it needs no further
182
- // index of its own.
183
- await ddl.ensureObject("channel_messages table", `
184
- CREATE TABLE IF NOT EXISTS rebase.channel_messages (
185
- channel TEXT NOT NULL,
186
- seq BIGINT NOT NULL,
187
- event TEXT NOT NULL,
188
- payload JSONB,
189
- sender_id TEXT,
190
- created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
191
- PRIMARY KEY (channel, seq)
192
- )
193
- `);
194
-
195
- // Only for the TTL arm of pruning; the limit arm rides the primary key.
196
- await ddl.ensureObject("channel_messages created_at index", `
197
- CREATE INDEX IF NOT EXISTS idx_channel_messages_created
198
- ON rebase.channel_messages (created_at)
199
- `);
200
-
201
- // Never pruned — see the note at the top of this file. One row per
202
- // channel that has ever retained a message.
203
- await ddl.ensureObject("channel_cursors table", `
204
- CREATE TABLE IF NOT EXISTS rebase.channel_cursors (
205
- channel TEXT PRIMARY KEY,
206
- last_seq BIGINT NOT NULL
207
- )
208
- `);
209
-
210
- // Retained broadcasts for every channel in one table, with no RLS: who
211
- // may replay a channel is decided before the read, by the channel gate
212
- // in `realtimeService.authorizeChannelAction` — a replay is answered
213
- // only for a client that has joined the channel, plus whatever an
214
- // installed `ChannelAuthorizer` adds. That gate is the entire reason
215
- // this table can sit outside the RLS model, so it fails closed; the
216
- // rule language it does not yet have is written up in
217
- // `docs/channel-authorization.md`. The driver's schema-wide
218
- // grant reaches these (created here, after it ran), so take the
219
- // privilege back.
220
- //
221
- // Driven off a probe of what exists rather than off who won each create.
222
- const [messagesReady, cursorsReady] = await Promise.all([
223
- ddl.isReadable("rebase.channel_messages"),
224
- ddl.isReadable("rebase.channel_cursors")
225
- ]);
226
- if (messagesReady) {
227
- await ddl.step("channel_messages revoke", () =>
228
- this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_messages")))
229
- );
230
- }
231
- if (cursorsReady) {
232
- await ddl.step("channel_cursors revoke", () =>
233
- this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_cursors")))
234
- );
235
- }
236
-
237
- if (!messagesReady || !cursorsReady) {
238
- // Left un-ready on purpose so the next call retries. Announcing
239
- // "ready" here is what would turn a half-created schema into replays
240
- // that answer empty forever.
241
- logger.warn(
242
- "[ChannelHistory] Retained-channel tables are not both present; history is not ready yet."
243
- );
244
- return;
245
- }
246
-
247
- this.tablesReady = true;
248
- logger.info(`✅ [ChannelHistory] Retained channels ready (${this.rules.length} rule(s)).`);
249
- }
250
-
251
- /**
252
- * Append a broadcast and return the sequence number it was given.
253
- *
254
- * The sequence is allocated by the same statement that stores the message,
255
- * so a crash between the two is not a possibility. `ON CONFLICT DO UPDATE`
256
- * takes a row lock on the channel's cursor, which is what makes concurrent
257
- * broadcasts to one channel line up in a single order — and what keeps
258
- * different channels from contending with each other at all.
259
- */
260
- async append(
261
- channel: string,
262
- event: string,
263
- payload: unknown,
264
- senderId?: string
265
- ): Promise<{ seq: number; at: string }> {
266
- const result = await this.db.execute(sql`
267
- WITH next AS (
268
- INSERT INTO rebase.channel_cursors (channel, last_seq)
269
- VALUES (${channel}, 1)
270
- ON CONFLICT (channel)
271
- DO UPDATE SET last_seq = rebase.channel_cursors.last_seq + 1
272
- RETURNING last_seq
273
- )
274
- INSERT INTO rebase.channel_messages (channel, seq, event, payload, sender_id)
275
- SELECT ${channel}, next.last_seq, ${event}, ${JSON.stringify(payload ?? null)}::jsonb, ${senderId ?? null}
276
- FROM next
277
- RETURNING seq, created_at
278
- `);
279
-
280
- const row = result.rows[0] as { seq: string | number; created_at: Date | string } | undefined;
281
- if (!row) throw new Error(`Failed to append to channel history for "${channel}"`);
282
-
283
- return {
284
- // BIGINT comes back as a string from node-postgres; the wire type is
285
- // a number, and a channel would need 2^53 messages to notice.
286
- seq: Number(row.seq),
287
- at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
288
- };
289
- }
290
-
291
- /**
292
- * Everything retained for `channel` after `sinceSeq`, oldest first.
293
- *
294
- * `latestSeq` is reported whether or not the messages were capped, so a
295
- * client that is further behind than one page can tell.
296
- */
297
- async replay(
298
- channel: string,
299
- sinceSeq = 0,
300
- limit = DEFAULT_REPLAY_LIMIT
301
- ): Promise<{ messages: ChannelHistoryEntry[]; latestSeq: number }> {
302
- const capped = Math.max(1, Math.min(Math.floor(limit) || DEFAULT_REPLAY_LIMIT, MAX_REPLAY_LIMIT));
303
- const after = Number.isFinite(sinceSeq) && sinceSeq > 0 ? Math.floor(sinceSeq) : 0;
304
-
305
- const result = await this.db.execute(sql`
306
- SELECT seq, event, payload, sender_id, created_at
307
- FROM rebase.channel_messages
308
- WHERE channel = ${channel} AND seq > ${after}
309
- ORDER BY seq ASC
310
- LIMIT ${capped}
311
- `);
312
-
313
- const messages = (result.rows as Array<{
314
- seq: string | number;
315
- event: string;
316
- payload: unknown;
317
- sender_id: string | null;
318
- created_at: Date | string;
319
- }>).map(row => ({
320
- seq: Number(row.seq),
321
- event: row.event,
322
- payload: row.payload,
323
- senderId: row.sender_id ?? undefined,
324
- at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
325
- }));
326
-
327
- // Read from the cursor rather than from the messages: the cursor is the
328
- // authority on how far the channel has got, and still says so after
329
- // pruning has removed the messages it counted.
330
- const cursor = await this.db.execute(sql`
331
- SELECT last_seq FROM rebase.channel_cursors WHERE channel = ${channel}
332
- `);
333
- const cursorRow = cursor.rows[0] as { last_seq: string | number } | undefined;
334
- const latestSeq = cursorRow ? Number(cursorRow.last_seq) : 0;
335
-
336
- return { messages, latestSeq };
337
- }
338
-
339
- /**
340
- * One retained message by its address.
341
- *
342
- * This is what makes the cross-instance pointer path work: a broadcast too
343
- * large to travel inside a `pg_notify` payload is already stored here, so
344
- * the notification carries `(channel, seq)` and each receiving instance
345
- * reads the body back. Returns null when the message has since been pruned
346
- * — a receiver that is that far behind has nothing useful to deliver, and
347
- * the client's own `channel_history` replay is the repair path.
348
- */
349
- async getBySeq(channel: string, seq: number): Promise<ChannelHistoryEntry | null> {
350
- const result = await this.db.execute(sql`
351
- SELECT seq, event, payload, sender_id, created_at
352
- FROM rebase.channel_messages
353
- WHERE channel = ${channel} AND seq = ${seq}
354
- `);
355
-
356
- const row = result.rows[0] as {
357
- seq: string | number;
358
- event: string;
359
- payload: unknown;
360
- sender_id: string | null;
361
- created_at: Date | string;
362
- } | undefined;
363
- if (!row) return null;
364
-
365
- return {
366
- seq: Number(row.seq),
367
- event: row.event,
368
- payload: row.payload,
369
- senderId: row.sender_id ?? undefined,
370
- at: row.created_at instanceof Date ? row.created_at.toISOString() : String(row.created_at)
371
- };
372
- }
373
-
374
- /**
375
- * Enforce a channel's retention bounds.
376
- *
377
- * Throttled per channel, so a burst of operations prunes once rather than
378
- * once per message — the cost then tracks elapsed time instead of write
379
- * volume, which is what makes retention affordable on a hot channel.
380
- */
381
- async prune(channel: string, retention: ResolvedRetention): Promise<number> {
382
- const now = Date.now();
383
- const last = this.lastPruned.get(channel) ?? 0;
384
- if (now - last < PRUNE_THROTTLE_MS) return 0;
385
- this.lastPruned.set(channel, now);
386
-
387
- let deleted = 0;
388
-
389
- if (retention.ttlMs !== undefined) {
390
- const result = await this.db.execute(sql`
391
- DELETE FROM rebase.channel_messages
392
- WHERE channel = ${channel}
393
- AND created_at < NOW() - MAKE_INTERVAL(secs => ${retention.ttlMs / 1000})
394
- `);
395
- deleted += result.rowCount ?? 0;
396
- }
397
-
398
- if (retention.limit !== undefined && retention.limit > 0) {
399
- // OFFSET past the newest `limit` rows to find the highest seq that
400
- // is no longer wanted, then delete everything at or below it. Fewer
401
- // rows than the limit leaves the subquery empty, and the comparison
402
- // with NULL deletes nothing.
403
- const result = await this.db.execute(sql`
404
- DELETE FROM rebase.channel_messages
405
- WHERE channel = ${channel}
406
- AND seq <= (
407
- SELECT seq FROM rebase.channel_messages
408
- WHERE channel = ${channel}
409
- ORDER BY seq DESC
410
- OFFSET ${Math.floor(retention.limit)} LIMIT 1
411
- )
412
- `);
413
- deleted += result.rowCount ?? 0;
414
- }
415
-
416
- return deleted;
417
- }
418
-
419
- /** Forget throttle and match caches. Called on shutdown. */
420
- clear(): void {
421
- this.resolved.clear();
422
- this.lastPruned.clear();
423
- }
424
- }
@@ -1,185 +0,0 @@
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
-
29
- import { sql } from "drizzle-orm";
30
- import { NodePgDatabase } from "drizzle-orm/node-postgres";
31
- import { revokeInternalTableSql } from "@rebasepro/common";
32
- import { drizzleDdlBootstrapper } from "../schema/drizzle-ddl";
33
-
34
- /** A tracked client, as any instance sees it. */
35
- export interface PresenceRow {
36
- channel: string;
37
- clientId: string;
38
- state: Record<string, unknown>;
39
- }
40
-
41
- export class ChannelPresenceStore {
42
- private tablesReady = false;
43
-
44
- constructor(
45
- private readonly db: NodePgDatabase<Record<string, unknown>>,
46
- private readonly instanceId: string
47
- ) {}
48
-
49
- /**
50
- * Create the roster table. Idempotent, and safe to run on every instance at
51
- * once.
52
- *
53
- * Written as separate contained steps rather than one straight sequence for
54
- * a reason that only bites with more than one replica, which is exactly the
55
- * deployment shape this table exists to serve: `CREATE … IF NOT EXISTS`
56
- * reads the catalog and then writes to it non-atomically, so peers booting
57
- * together collide, and the loser used to abandon everything after it —
58
- * including the trailing `REVOKE`. That revoke is the only thing keeping the
59
- * roster off the end-user role, so losing a boot race silently left the
60
- * whole channel roster readable by every signed-in user.
61
- *
62
- * `tablesReady` is now set from a probe of what exists, not from having been
63
- * the instance that created it.
64
- */
65
- async ensureTables(): Promise<void> {
66
- if (this.tablesReady) return;
67
-
68
- const ddl = drizzleDdlBootstrapper(this.db, "channel-presence");
69
-
70
- await ddl.ensureObject("rebase schema", "CREATE SCHEMA IF NOT EXISTS rebase");
71
-
72
- // Keyed by (channel, client_id): a client id is globally unique, so the
73
- // instance is a column rather than part of the identity — a client that
74
- // reconnects onto another replica replaces its own row instead of
75
- // appearing twice in the roster.
76
- await ddl.ensureObject("channel_presence table", `
77
- CREATE TABLE IF NOT EXISTS rebase.channel_presence (
78
- channel TEXT NOT NULL,
79
- client_id TEXT NOT NULL,
80
- instance_id TEXT NOT NULL,
81
- state JSONB NOT NULL DEFAULT '{}'::jsonb,
82
- last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
83
- PRIMARY KEY (channel, client_id)
84
- )
85
- `);
86
-
87
- // The sweep's access path; the roster read rides the primary key.
88
- await ddl.ensureObject("channel_presence last_seen index", `
89
- CREATE INDEX IF NOT EXISTS idx_channel_presence_last_seen
90
- ON rebase.channel_presence (last_seen)
91
- `);
92
-
93
- // The roster of every client on every channel, with no RLS — a row
94
- // policy has nothing to match on here, since a channel name is a string
95
- // a client invents rather than a row anyone owns. What guards it is the
96
- // channel gate in `realtimeService.authorizeChannelAction`: presence is
97
- // readable only to a client that has joined the channel, plus whatever
98
- // an installed `ChannelAuthorizer` adds. That gate is the entire reason
99
- // this table can sit outside the RLS model, so it fails closed —
100
- // see `docs/channel-authorization.md` for what it does *not*
101
- // yet decide. Revoke the schema-wide grant the driver handed out
102
- // before this table existed.
103
- //
104
- // Driven off the probe, not off who won the create: the privilege has to
105
- // come off whether this instance created the table or found it.
106
- if (await ddl.isReadable("rebase.channel_presence")) {
107
- await ddl.step("channel_presence revoke", () =>
108
- this.db.execute(sql.raw(revokeInternalTableSql("rebase", "channel_presence")))
109
- );
110
- this.tablesReady = true;
111
- }
112
- }
113
-
114
- /** Record (or refresh) a client's presence. */
115
- async track(channel: string, clientId: string, state: Record<string, unknown>): Promise<void> {
116
- await this.db.execute(sql`
117
- INSERT INTO rebase.channel_presence (channel, client_id, instance_id, state, last_seen)
118
- VALUES (${channel}, ${clientId}, ${this.instanceId}, ${JSON.stringify(state ?? {})}::jsonb, NOW())
119
- ON CONFLICT (channel, client_id) DO UPDATE
120
- SET state = EXCLUDED.state,
121
- instance_id = EXCLUDED.instance_id,
122
- last_seen = NOW()
123
- `);
124
- }
125
-
126
- /** Drop one client's presence in one channel. */
127
- async remove(channel: string, clientId: string): Promise<void> {
128
- await this.db.execute(sql`
129
- DELETE FROM rebase.channel_presence
130
- WHERE channel = ${channel} AND client_id = ${clientId}
131
- `);
132
- }
133
-
134
- /** Drop a client from every channel — used when its socket closes. */
135
- async removeClient(clientId: string): Promise<void> {
136
- await this.db.execute(sql`
137
- DELETE FROM rebase.channel_presence WHERE client_id = ${clientId}
138
- `);
139
- }
140
-
141
- /** The global roster for a channel. */
142
- async roster(channel: string): Promise<Record<string, Record<string, unknown>>> {
143
- const result = await this.db.execute(sql`
144
- SELECT client_id, state FROM rebase.channel_presence WHERE channel = ${channel}
145
- `);
146
-
147
- const presences: Record<string, Record<string, unknown>> = {};
148
- for (const row of result.rows as Array<{ client_id: string; state: Record<string, unknown> | null }>) {
149
- presences[row.client_id] = row.state ?? {};
150
- }
151
- return presences;
152
- }
153
-
154
- /**
155
- * Reap rows this instance is not responsible for and that have gone quiet.
156
- *
157
- * Own rows are excluded because the in-process sweep already handles them —
158
- * and handles them better, since it can tell "the socket is gone" from "the
159
- * heartbeat is late". What is left is precisely the interesting case: rows
160
- * written by an instance that is no longer writing.
161
- *
162
- * Returns what was removed, so the caller can announce it.
163
- */
164
- async sweepStale(ttlMs: number): Promise<PresenceRow[]> {
165
- const result = await this.db.execute(sql`
166
- DELETE FROM rebase.channel_presence
167
- WHERE instance_id <> ${this.instanceId}
168
- AND last_seen < NOW() - MAKE_INTERVAL(secs => ${ttlMs / 1000})
169
- RETURNING channel, client_id, state
170
- `);
171
-
172
- return (result.rows as Array<{ channel: string; client_id: string; state: Record<string, unknown> | null }>)
173
- .map(row => ({ channel: row.channel, clientId: row.client_id, state: row.state ?? {} }));
174
- }
175
-
176
- /**
177
- * Remove every row this instance owns. Called on graceful shutdown so a
178
- * rolling deploy does not leave a TTL window of ghosts in every roster.
179
- */
180
- async removeInstance(): Promise<void> {
181
- await this.db.execute(sql`
182
- DELETE FROM rebase.channel_presence WHERE instance_id = ${this.instanceId}
183
- `);
184
- }
185
- }