@rebasepro/server-postgres 0.17.3 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/BranchService-ByLfM_Tt.js +360 -0
  4. package/dist/BranchService-ByLfM_Tt.js.map +1 -0
  5. package/dist/__vite-browser-external-BnuHet1e.js +46 -0
  6. package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
  7. package/dist/arg-Dni7MzLB.js +87 -0
  8. package/dist/arg-Dni7MzLB.js.map +1 -0
  9. package/dist/auth-users-columns-D2LBFrMH.js +116 -0
  10. package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
  11. package/dist/backup-argv.d.ts +19 -0
  12. package/dist/backup-cli-oM9WQVlV.js +408 -0
  13. package/dist/backup-cli-oM9WQVlV.js.map +1 -0
  14. package/dist/backup-service-Br3tqwsy.js +715 -0
  15. package/dist/backup-service-Br3tqwsy.js.map +1 -0
  16. package/dist/branch-argv.d.ts +28 -0
  17. package/dist/branch-prune.d.ts +77 -0
  18. package/dist/cli-collections-path.d.ts +31 -0
  19. package/dist/cli-errors-DxN4yzdH.js +657 -0
  20. package/dist/cli-errors-DxN4yzdH.js.map +1 -0
  21. package/dist/cli-errors.d.ts +123 -0
  22. package/dist/cli-flags.d.ts +101 -0
  23. package/dist/cli-helpers.d.ts +94 -10
  24. package/dist/cli-output-CNdMql-L.js +47 -0
  25. package/dist/cli-output-CNdMql-L.js.map +1 -0
  26. package/dist/cli.js +2020 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/collection-index-BHgT_TKd.js +276 -0
  29. package/dist/collection-index-BHgT_TKd.js.map +1 -0
  30. package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
  31. package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
  32. package/dist/{connection-GOKU3Hu5.js → databasePoolManager-Bj5FbeAs.js} +112 -40
  33. package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
  34. package/dist/databasePoolManager.d.ts +27 -0
  35. package/dist/doctor-D7HWmGzs.js +974 -0
  36. package/dist/doctor-D7HWmGzs.js.map +1 -0
  37. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-2c2NV8zX.js} +7 -27
  38. package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
  39. package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
  40. package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
  41. package/dist/ensure-tables-CIojK-Rx.js +650 -0
  42. package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
  43. package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
  44. package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
  45. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
  46. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
  47. package/dist/{ensure-collection-tables-DgVixhX3.js → generate-postgres-ddl-logic-CtnpIBVv.js} +126 -984
  48. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
  49. package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
  50. package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
  51. package/dist/index.d.ts +12 -0
  52. package/dist/index.es.js +7423 -1133
  53. package/dist/index.es.js.map +1 -1
  54. package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
  55. package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
  56. package/dist/policy-drift-kNJ6X2Uj.js +313 -0
  57. package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
  58. package/dist/{backup-service-DCk7KhhL.js → rls-bootstrap-sql-DLDnUjtx.js} +737 -2689
  59. package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
  60. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-DeYEjYLt.js} +65 -190
  61. package/dist/rls-enforcement-DeYEjYLt.js.map +1 -0
  62. package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
  63. package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
  64. package/dist/schema/column-type-drift.d.ts +79 -0
  65. package/dist/schema/doctor-cli.js +129 -0
  66. package/dist/schema/doctor-cli.js.map +1 -0
  67. package/dist/schema/doctor.d.ts +42 -0
  68. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  69. package/dist/schema/generate-drizzle-schema.d.ts +1 -1
  70. package/dist/schema/generate-drizzle-schema.js +24 -0
  71. package/dist/schema/generate-drizzle-schema.js.map +1 -0
  72. package/dist/schema/generate-next-step.d.ts +30 -0
  73. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  74. package/dist/schema/generate-postgres-ddl.d.ts +1 -1
  75. package/dist/schema/generate-postgres-ddl.js +138 -0
  76. package/dist/schema/generate-postgres-ddl.js.map +1 -0
  77. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  78. package/dist/schema/introspect-db-queries.d.ts +34 -0
  79. package/dist/schema/introspect-db.js +866 -0
  80. package/dist/schema/introspect-db.js.map +1 -0
  81. package/dist/security/rls-enforcement.d.ts +63 -2
  82. package/dist/services/BranchService.d.ts +86 -1
  83. package/dist/services/collection-helpers.d.ts +1 -1
  84. package/dist/services/realtimeService.d.ts +30 -2
  85. package/dist/services/row-pipeline.d.ts +12 -1
  86. package/dist/source-Br7L7GOI.js +410 -0
  87. package/dist/source-Br7L7GOI.js.map +1 -0
  88. package/dist/utils/pg-error-utils.d.ts +3 -1
  89. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  90. package/dist/vector-index-DkWxm2de.js +309 -0
  91. package/dist/vector-index-DkWxm2de.js.map +1 -0
  92. package/package.json +32 -27
  93. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  94. package/dist/collection-index-BRUg10H5.js +0 -5690
  95. package/dist/collection-index-BRUg10H5.js.map +0 -1
  96. package/dist/connection-GOKU3Hu5.js.map +0 -1
  97. package/dist/ensure-collection-policies-UCqgv_8c.js.map +0 -1
  98. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  99. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  100. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  101. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  102. package/dist/src-DiB5RP2Z.js +0 -541
  103. package/dist/src-DiB5RP2Z.js.map +0 -1
  104. package/dist/websocket-BZ4H5wUz.js +0 -8198
  105. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  106. package/src/PostgresAdapter.ts +0 -91
  107. package/src/PostgresBackendDriver.ts +0 -1857
  108. package/src/PostgresBootstrapper.ts +0 -1157
  109. package/src/auth/ensure-tables.ts +0 -896
  110. package/src/auth/schema-version.ts +0 -260
  111. package/src/auth/services.ts +0 -1610
  112. package/src/backup/backup-cli.ts +0 -520
  113. package/src/backup/backup-cron.ts +0 -212
  114. package/src/backup/backup-logic.ts +0 -93
  115. package/src/backup/backup-service.ts +0 -485
  116. package/src/backup/index.ts +0 -12
  117. package/src/backup/pg-tools.ts +0 -403
  118. package/src/backup/retention.ts +0 -86
  119. package/src/cli-errors.ts +0 -265
  120. package/src/cli-helpers.ts +0 -569
  121. package/src/cli-output.ts +0 -43
  122. package/src/cli.ts +0 -1339
  123. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  124. package/src/collections/buildRegistry.ts +0 -68
  125. package/src/collections/validate-relations.ts +0 -402
  126. package/src/connection.ts +0 -333
  127. package/src/data-transformer.ts +0 -845
  128. package/src/databasePoolManager.ts +0 -95
  129. package/src/history/HistoryService.ts +0 -254
  130. package/src/history/ensure-history-table.ts +0 -53
  131. package/src/index.ts +0 -19
  132. package/src/interfaces.ts +0 -60
  133. package/src/module-dir.ts +0 -7
  134. package/src/schema/atlas-argv.ts +0 -94
  135. package/src/schema/auth-schema.ts +0 -298
  136. package/src/schema/auth-users-columns.ts +0 -131
  137. package/src/schema/carved-out-migration.ts +0 -404
  138. package/src/schema/classify-change.ts +0 -436
  139. package/src/schema/collection-index.ts +0 -427
  140. package/src/schema/collections-schema-version.ts +0 -103
  141. package/src/schema/destructive-sql.ts +0 -94
  142. package/src/schema/doctor-cli.ts +0 -66
  143. package/src/schema/doctor-policy-checks.ts +0 -105
  144. package/src/schema/doctor.ts +0 -837
  145. package/src/schema/drizzle-ddl.ts +0 -33
  146. package/src/schema/dynamic-tables.test.ts +0 -302
  147. package/src/schema/dynamic-tables.ts +0 -293
  148. package/src/schema/ensure-collection-policies.ts +0 -198
  149. package/src/schema/ensure-collection-tables.test.ts +0 -518
  150. package/src/schema/ensure-collection-tables.ts +0 -1267
  151. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  152. package/src/schema/generate-drizzle-schema.ts +0 -143
  153. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  154. package/src/schema/generate-postgres-ddl.ts +0 -180
  155. package/src/schema/generate-schema-commit.ts +0 -267
  156. package/src/schema/generated-schema-staleness.ts +0 -284
  157. package/src/schema/introspect-db-constraints.ts +0 -385
  158. package/src/schema/introspect-db-inference.ts +0 -255
  159. package/src/schema/introspect-db-logic.ts +0 -1387
  160. package/src/schema/introspect-db-naming.ts +0 -15
  161. package/src/schema/introspect-db-project.ts +0 -78
  162. package/src/schema/introspect-db-queries.ts +0 -326
  163. package/src/schema/introspect-db-structure.ts +0 -670
  164. package/src/schema/introspect-db-types.ts +0 -56
  165. package/src/schema/introspect-db.ts +0 -257
  166. package/src/schema/introspect-runtime.test.ts +0 -260
  167. package/src/schema/introspect-runtime.ts +0 -327
  168. package/src/schema/non-sql-collections.test.ts +0 -131
  169. package/src/schema/rls-bootstrap-sql.ts +0 -288
  170. package/src/schema/search-column.ts +0 -643
  171. package/src/schema/test-schema.ts +0 -11
  172. package/src/schema/vector-index.ts +0 -491
  173. package/src/security/anonymous-grants.test.ts +0 -73
  174. package/src/security/policy-drift.test.ts +0 -435
  175. package/src/security/policy-drift.ts +0 -473
  176. package/src/security/rls-enforcement.ts +0 -505
  177. package/src/services/BranchService.ts +0 -326
  178. package/src/services/FetchService.ts +0 -2115
  179. package/src/services/PersistService.ts +0 -557
  180. package/src/services/RelationService.ts +0 -978
  181. package/src/services/RelationWriteService.ts +0 -653
  182. package/src/services/cdc/CdcListener.ts +0 -103
  183. package/src/services/cdc/junction-tables.ts +0 -91
  184. package/src/services/cdc/trigger-cdc.ts +0 -173
  185. package/src/services/channel-bus/ChannelBus.ts +0 -44
  186. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  187. package/src/services/channel-bus/index.ts +0 -114
  188. package/src/services/channel-history.ts +0 -424
  189. package/src/services/channel-presence.ts +0 -185
  190. package/src/services/collection-helpers.ts +0 -420
  191. package/src/services/dataService.ts +0 -256
  192. package/src/services/index.ts +0 -14
  193. package/src/services/junction-writes.ts +0 -295
  194. package/src/services/nested-path.ts +0 -145
  195. package/src/services/pg-notify-listener.ts +0 -151
  196. package/src/services/realtimeService.ts +0 -2545
  197. package/src/services/row-pipeline.ts +0 -240
  198. package/src/services/write-denial.ts +0 -55
  199. package/src/types.ts +0 -4
  200. package/src/utils/connection-string.ts +0 -58
  201. package/src/utils/drizzle-conditions.ts +0 -2743
  202. package/src/utils/pg-array-null-patch.ts +0 -42
  203. package/src/utils/pg-error-utils.ts +0 -391
  204. 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
- }