@rebasepro/server-postgres 0.24.0 → 0.24.1-canary.gcb918fc

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 (172) hide show
  1. package/README.md +16 -0
  2. package/dist/{BranchService-BRt78gfa.js → BranchService-Bsn2wfaj.js} +3 -2
  3. package/dist/{BranchService-BRt78gfa.js.map → BranchService-Bsn2wfaj.js.map} +1 -1
  4. package/dist/PostgresBackendDriver.d.ts +91 -2
  5. package/dist/PostgresBootstrapper.d.ts +101 -2
  6. package/dist/auth/ensure-tables.d.ts +12 -0
  7. package/dist/auth/services.d.ts +6 -0
  8. package/dist/backup/backup-cli.d.ts +14 -0
  9. package/dist/backup/backup-service.d.ts +2 -2
  10. package/dist/backup/pg-binary.d.ts +5 -0
  11. package/dist/{backup-cli-DW5p9_zv.js → backup-cli-DnpJi6I7.js} +37 -29
  12. package/dist/backup-cli-DnpJi6I7.js.map +1 -0
  13. package/dist/{backup-service-EwcDVG-8.js → backup-service-BTBB7RXn.js} +55 -21
  14. package/dist/backup-service-BTBB7RXn.js.map +1 -0
  15. package/dist/boot-ddl-DVpsFN6r.js +105 -0
  16. package/dist/boot-ddl-DVpsFN6r.js.map +1 -0
  17. package/dist/cli-console-Ca6ZlAVU.js +154 -0
  18. package/dist/cli-console-Ca6ZlAVU.js.map +1 -0
  19. package/dist/cli-console.d.ts +39 -0
  20. package/dist/cli-errors-CaI4ntyD.js +1919 -0
  21. package/dist/cli-errors-CaI4ntyD.js.map +1 -0
  22. package/dist/cli-errors.d.ts +51 -5
  23. package/dist/{cli-flags-BglvpjHv.js → cli-flags-JNI-pwSf.js} +6 -3
  24. package/dist/cli-flags-JNI-pwSf.js.map +1 -0
  25. package/dist/cli-helpers.d.ts +40 -0
  26. package/dist/cli-output.d.ts +16 -28
  27. package/dist/cli-push.d.ts +87 -0
  28. package/dist/cli.js +566 -182
  29. package/dist/cli.js.map +1 -1
  30. package/dist/{column-plan-helpers-1-LQD0yI.js → column-plan-helpers-drZIsSNf.js} +10 -4
  31. package/dist/{column-plan-helpers-1-LQD0yI.js.map → column-plan-helpers-drZIsSNf.js.map} +1 -1
  32. package/dist/{databasePoolManager-Bj5FbeAs.js → connection-C9mT8bgf.js} +24 -112
  33. package/dist/connection-C9mT8bgf.js.map +1 -0
  34. package/dist/connection.d.ts +13 -2
  35. package/dist/databasePoolManager-BIuewidb.js +119 -0
  36. package/dist/databasePoolManager-BIuewidb.js.map +1 -0
  37. package/dist/{ensure-collection-tables-kkkHk8oo.js → deferred-index-build-DP1_SNFo.js} +484 -346
  38. package/dist/deferred-index-build-DP1_SNFo.js.map +1 -0
  39. package/dist/{doctor-C-sYWbmt.js → doctor-C4WiftHm.js} +63 -52
  40. package/dist/doctor-C4WiftHm.js.map +1 -0
  41. package/dist/ensure-collection-policies-CXpbeNag.js +541 -0
  42. package/dist/ensure-collection-policies-CXpbeNag.js.map +1 -0
  43. package/dist/ensure-collection-tables-BhsU6qUE.js +532 -0
  44. package/dist/ensure-collection-tables-BhsU6qUE.js.map +1 -0
  45. package/dist/{ensure-tables-BmI_tRxc.js → ensure-tables-CVm_yvzb.js} +173 -77
  46. package/dist/ensure-tables-CVm_yvzb.js.map +1 -0
  47. package/dist/enum-labels-kept-D8fI1MoX.js +56 -0
  48. package/dist/enum-labels-kept-D8fI1MoX.js.map +1 -0
  49. package/dist/generate-drizzle-schema-Bz52jH2h.js +57 -0
  50. package/dist/generate-drizzle-schema-Bz52jH2h.js.map +1 -0
  51. package/dist/{generate-drizzle-schema-logic-sSFDp6LR.js → generate-drizzle-schema-logic-B74WN37S.js} +5 -5
  52. package/dist/generate-drizzle-schema-logic-B74WN37S.js.map +1 -0
  53. package/dist/generate-files-CdEckX6c.js +199 -0
  54. package/dist/generate-files-CdEckX6c.js.map +1 -0
  55. package/dist/{generate-postgres-ddl-logic-BJsLaVNX.js → generate-postgres-ddl-logic-B64O2oZF.js} +3 -3
  56. package/dist/{generate-postgres-ddl-logic-BJsLaVNX.js.map → generate-postgres-ddl-logic-B64O2oZF.js.map} +1 -1
  57. package/dist/generated-sql.d.ts +8 -2
  58. package/dist/history/HistoryService.d.ts +64 -5
  59. package/dist/history/ensure-history-table.d.ts +63 -4
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.es.js +139 -19236
  62. package/dist/index.es.js.map +1 -1
  63. package/dist/{introspect-db-logic-kCETE8TY.js → introspect-db-logic-C-Di8dA9.js} +44 -6
  64. package/dist/introspect-db-logic-C-Di8dA9.js.map +1 -0
  65. package/dist/{introspect-db-queries-C_Q5VgQw.js → introspect-db-queries-D6kAqReI.js} +23 -1
  66. package/dist/introspect-db-queries-D6kAqReI.js.map +1 -0
  67. package/dist/open-rebase-CbOur0TU.js +20858 -0
  68. package/dist/open-rebase-CbOur0TU.js.map +1 -0
  69. package/dist/open-rebase.d.ts +86 -0
  70. package/dist/pg-error-utils-vJezlXdI.js +388 -0
  71. package/dist/pg-error-utils-vJezlXdI.js.map +1 -0
  72. package/dist/{plan-schema-DU9exq6C.js → plan-schema-B1YF_tYq.js} +151 -41
  73. package/dist/plan-schema-B1YF_tYq.js.map +1 -0
  74. package/dist/{policy-drift-B-J2hhm0.js → policy-drift-Dm8i-s1J.js} +23 -6
  75. package/dist/policy-drift-Dm8i-s1J.js.map +1 -0
  76. package/dist/push-index-prebuild-Db4AQF4i.js +45 -0
  77. package/dist/push-index-prebuild-Db4AQF4i.js.map +1 -0
  78. package/dist/push-legacy-renames-BK6ilQq2.js +34 -0
  79. package/dist/push-legacy-renames-BK6ilQq2.js.map +1 -0
  80. package/dist/{render-ddl-Ds2t_d9V.js → render-ddl-CtC3y9Ol.js} +8 -4
  81. package/dist/render-ddl-CtC3y9Ol.js.map +1 -0
  82. package/dist/{rls-bootstrap-sql-_KNnjanK.js → rls-bootstrap-sql-RQUljnJ1.js} +125 -1270
  83. package/dist/rls-bootstrap-sql-RQUljnJ1.js.map +1 -0
  84. package/dist/{rls-enforcement-CfXOJJaW.js → rls-enforcement-DvzqlaL1.js} +129 -17
  85. package/dist/rls-enforcement-DvzqlaL1.js.map +1 -0
  86. package/dist/schema/auth-schema.d.ts +2 -2
  87. package/dist/schema/boot-ddl.d.ts +47 -0
  88. package/dist/schema/collection-index.d.ts +45 -0
  89. package/dist/schema/column-plan-helpers.d.ts +5 -0
  90. package/dist/schema/deferred-index-build.d.ts +131 -0
  91. package/dist/schema/destructive-sql.d.ts +7 -0
  92. package/dist/schema/doctor-cli.js +110 -6
  93. package/dist/schema/doctor-cli.js.map +1 -1
  94. package/dist/schema/doctor-search-scale.d.ts +45 -0
  95. package/dist/schema/doctor.d.ts +20 -1
  96. package/dist/schema/ensure-collection-policies.d.ts +73 -35
  97. package/dist/schema/ensure-collection-tables.d.ts +56 -3
  98. package/dist/schema/enum-labels-kept.d.ts +26 -0
  99. package/dist/schema/generate-drizzle-schema.js +1 -1
  100. package/dist/schema/generate-files.d.ts +44 -0
  101. package/dist/schema/generate-next-step.d.ts +0 -13
  102. package/dist/schema/generate-postgres-ddl.js +10 -81
  103. package/dist/schema/generate-postgres-ddl.js.map +1 -1
  104. package/dist/schema/introspect-db-logic.d.ts +12 -0
  105. package/dist/schema/introspect-db-queries.d.ts +11 -1
  106. package/dist/schema/introspect-db-structure.d.ts +10 -0
  107. package/dist/schema/introspect-db.js +4 -5
  108. package/dist/schema/introspect-db.js.map +1 -1
  109. package/dist/schema/migration-post-process.d.ts +34 -0
  110. package/dist/schema/plan/diff-plan.d.ts +126 -0
  111. package/dist/schema/plan/types.d.ts +8 -0
  112. package/dist/schema/push-index-prebuild.d.ts +25 -0
  113. package/dist/schema/push-legacy-renames.d.ts +20 -0
  114. package/dist/schema/rls-bootstrap-sql.d.ts +15 -0
  115. package/dist/security/rls-enforcement.d.ts +32 -4
  116. package/dist/services/FetchService.d.ts +58 -8
  117. package/dist/services/PersistService.d.ts +15 -0
  118. package/dist/services/RelationService.d.ts +88 -3
  119. package/dist/services/cdc/CdcListener.d.ts +17 -6
  120. package/dist/services/cdc/trigger-cdc.d.ts +151 -27
  121. package/dist/services/channel-bus/index.d.ts +1 -1
  122. package/dist/services/channel-history.d.ts +15 -4
  123. package/dist/services/collection-helpers.d.ts +15 -0
  124. package/dist/services/field-viewer.d.ts +15 -1
  125. package/dist/services/pg-notify-listener.d.ts +7 -0
  126. package/dist/services/realtimeService.d.ts +314 -14
  127. package/dist/services/row-pipeline.d.ts +16 -9
  128. package/dist/services/socket-liveness.d.ts +42 -2
  129. package/dist/services/sql-script.d.ts +207 -3
  130. package/dist/services/vector-iterative-scan.d.ts +40 -0
  131. package/dist/services/write-depth.d.ts +6 -0
  132. package/dist/services/write-transaction-scope.d.ts +9 -0
  133. package/dist/socket-opt-ins.d.ts +64 -0
  134. package/dist/source-DBDbBTL4.js +811 -0
  135. package/dist/source-DBDbBTL4.js.map +1 -0
  136. package/dist/tsx-loader.d.ts +14 -0
  137. package/dist/utils/drizzle-conditions.d.ts +21 -1
  138. package/dist/utils/error-cause.d.ts +7 -0
  139. package/dist/utils/pg-error-utils.d.ts +2 -4
  140. package/dist/utils/result-rows.d.ts +10 -0
  141. package/dist/websocket-BLGc5yhs.js +1604 -0
  142. package/dist/websocket-BLGc5yhs.js.map +1 -0
  143. package/dist/websocket.d.ts +13 -0
  144. package/package.json +7 -7
  145. package/dist/backup-cli-DW5p9_zv.js.map +0 -1
  146. package/dist/backup-service-EwcDVG-8.js.map +0 -1
  147. package/dist/cli-errors-DsA-K9uP.js +0 -893
  148. package/dist/cli-errors-DsA-K9uP.js.map +0 -1
  149. package/dist/cli-flags-BglvpjHv.js.map +0 -1
  150. package/dist/cli-output-CNdMql-L.js +0 -47
  151. package/dist/cli-output-CNdMql-L.js.map +0 -1
  152. package/dist/collection-index-DdnsxiJ_.js +0 -286
  153. package/dist/collection-index-DdnsxiJ_.js.map +0 -1
  154. package/dist/databasePoolManager-Bj5FbeAs.js.map +0 -1
  155. package/dist/doctor-C-sYWbmt.js.map +0 -1
  156. package/dist/ensure-collection-policies-B1ureSIV.js +0 -160
  157. package/dist/ensure-collection-policies-B1ureSIV.js.map +0 -1
  158. package/dist/ensure-collection-tables-kkkHk8oo.js.map +0 -1
  159. package/dist/ensure-tables-BmI_tRxc.js.map +0 -1
  160. package/dist/generate-drizzle-schema-93M0lUxK.js +0 -123
  161. package/dist/generate-drizzle-schema-93M0lUxK.js.map +0 -1
  162. package/dist/generate-drizzle-schema-logic-sSFDp6LR.js.map +0 -1
  163. package/dist/introspect-db-logic-kCETE8TY.js.map +0 -1
  164. package/dist/introspect-db-queries-C_Q5VgQw.js.map +0 -1
  165. package/dist/plan-schema-DU9exq6C.js.map +0 -1
  166. package/dist/policy-drift-B-J2hhm0.js.map +0 -1
  167. package/dist/render-ddl-Ds2t_d9V.js.map +0 -1
  168. package/dist/rls-bootstrap-sql-_KNnjanK.js.map +0 -1
  169. package/dist/rls-enforcement-CfXOJJaW.js.map +0 -1
  170. package/dist/source-Br7L7GOI.js +0 -410
  171. package/dist/source-Br7L7GOI.js.map +0 -1
  172. package/dist/utils/sql-redaction.d.ts +0 -21
@@ -0,0 +1,1604 @@
1
+ import { createRequire as __createRequire } from "module";
2
+ __createRequire(import.meta.url);
3
+ import { r as __exportAll } from "./__vite-browser-external-BnuHet1e.js";
4
+ import { A as budgetSendBuffers, M as terminateIfBacklogged, N as assertReadRequestReadable, ct as SqlResultTooLargeError, it as effectiveSqlRole, j as reapSilentSockets, lt as isNestedPath, ot as sqlRoleRanAs, st as SQL_RESULT_BYTE_BUDGET, ut as resolveNestedPath } from "./open-rebase-CbOur0TU.js";
5
+ import { r as BranchingUnsupportedError } from "./BranchService-Bsn2wfaj.js";
6
+ import { ApiError, RUNTIME_DEFAULT_MAX_BODY_SIZE, assertFieldOpsValid, assertNestedWriteAllowed, assertNoFieldOpsOnCreate, assertNoUserUpsert, assertUserCreationBodyValid, assertWriteRequestValid, createUserThroughAuthCollection, decideImpersonation, declaredErrorAnswer, extractUserFromToken, getAccessModel, logger, resolveConflictTarget, resolveRequireAuth, safeCompare } from "@rebasepro/server";
7
+ import { ANONYMOUS_USER_ID, ListLimitError, hasAdminRole, isSQLAdmin, isSchemaAdmin, resolveClientListLimit, scopeGrants, scopesForRoles } from "@rebasepro/types";
8
+ import { UnknownFilterOperatorError, deserializeFilter, redactSqlLiterals } from "@rebasepro/common";
9
+ import { unref } from "@rebasepro/utils";
10
+ import { WebSocket, WebSocketServer } from "ws";
11
+ import { inspect } from "util";
12
+ //#region src/socket-opt-ins.ts
13
+ /**
14
+ * Behaviours a client opts into when it opens the socket.
15
+ *
16
+ * The client names them on the socket URL, `?rebase_caps=heartbeat,channel_seq,cancel_sql`,
17
+ * and the server answers with a `hello` frame listing the ones it will honour:
18
+ *
19
+ * ```json
20
+ * { "type": "hello", "capabilities": ["heartbeat", "channel_seq", "cancel_sql"], "heartbeatMs": 30000, "clientId": "client_…" }
21
+ * ```
22
+ *
23
+ * Both halves are needed, so both directions of a version mismatch keep
24
+ * working. An older client names none and gets no `hello`, no heartbeat and no
25
+ * acknowledgement frames — nothing it would not recognise. An older server
26
+ * ignores the query parameter and sends no `hello`, and a newer client that
27
+ * hears none behaves as it always did.
28
+ *
29
+ * One list for both kinds: what the server sends (`heartbeat`, `channel_seq`)
30
+ * and the frames it takes beyond the base protocol (`cancel_sql`). Named before
31
+ * sign-in, so a socket that never signs in is served as one that does.
32
+ *
33
+ * - `heartbeat`: a `{ "type": "heartbeat" }` frame every `heartbeatMs`. A
34
+ * browser cannot send a WebSocket ping or see one, so a connection that went
35
+ * half-open — a NAT that forgot it, a laptop that slept — looks connected to
36
+ * it forever. With these, two missed beats tell it to redial.
37
+ * - `channel_seq`: on a retained channel, the sender of a broadcast gets
38
+ * `{ "type": "broadcast_ack", "channel", "seq" }` where every other member
39
+ * gets the message. Its own messages then leave no hole in the sequence it
40
+ * watches, so a hole means a message it really missed — see
41
+ * `RealtimeChannel` in the SDK.
42
+ * - `cancel_sql`: the server takes `CANCEL_SQL { requestId }`, which stops a
43
+ * console run (`EXECUTE_SQL`, `mode: "script"`) of the same socket. A client
44
+ * sends it only to a server whose `hello` listed it.
45
+ *
46
+ * @module
47
+ */
48
+ /** The query parameter a client names its opt-ins in. */
49
+ var SOCKET_OPT_IN_PARAM = "rebase_caps";
50
+ /** Every behaviour this server can be asked for. */
51
+ var SOCKET_OPT_INS = [
52
+ "heartbeat",
53
+ "channel_seq",
54
+ "cancel_sql"
55
+ ];
56
+ function isSocketOptIn(name) {
57
+ return SOCKET_OPT_INS.includes(name);
58
+ }
59
+ /**
60
+ * What the client that opened this connection asked for, from its request
61
+ * URL: `undefined` when it named nothing (a client that predates this, which
62
+ * is sent no `hello`), otherwise the names this server honours — possibly
63
+ * none.
64
+ */
65
+ function requestedOptIns(url) {
66
+ if (!url) return void 0;
67
+ const query = url.indexOf("?");
68
+ if (query === -1) return void 0;
69
+ const raw = new URLSearchParams(url.slice(query + 1)).get(SOCKET_OPT_IN_PARAM);
70
+ if (raw === null) return void 0;
71
+ return new Set(raw.split(",").map((name) => name.trim()).filter(isSocketOptIn));
72
+ }
73
+ /**
74
+ * The `hello` frame for a client that asked for `optIns`. With `channel_seq`
75
+ * it also names the socket's own id, the `senderId` its broadcasts carry in a
76
+ * channel's history, so a client filling a hole from history can tell its own
77
+ * messages (which it is never sent live) from the ones it missed.
78
+ */
79
+ function helloFrame(optIns, heartbeatMs, clientId) {
80
+ return JSON.stringify({
81
+ type: "hello",
82
+ capabilities: [...optIns],
83
+ ...optIns.has("heartbeat") ? { heartbeatMs } : {},
84
+ ...optIns.has("channel_seq") ? { clientId } : {}
85
+ });
86
+ }
87
+ var HEARTBEAT_FRAME = JSON.stringify({ type: "heartbeat" });
88
+ /**
89
+ * Send a heartbeat every `intervalMs` to each socket `sockets()` lists. One
90
+ * timer for the whole server, unref'd so it never holds the process open.
91
+ *
92
+ * @returns Stops it.
93
+ */
94
+ function startHeartbeats(sockets, intervalMs) {
95
+ const timer = setInterval(() => {
96
+ for (const ws of sockets()) {
97
+ if (ws.readyState !== WebSocket.OPEN) continue;
98
+ try {
99
+ ws.send(HEARTBEAT_FRAME);
100
+ } catch {}
101
+ }
102
+ }, intervalMs);
103
+ unref(timer);
104
+ return () => clearInterval(timer);
105
+ }
106
+ //#endregion
107
+ //#region src/websocket.ts
108
+ var websocket_exports = /* @__PURE__ */ __exportAll({
109
+ ADMIN_PLANE_TYPES: () => ADMIN_PLANE_TYPES,
110
+ PUBLIC_TYPES: () => PUBLIC_TYPES,
111
+ SESSION_ENDED_CLOSE_CODE: () => SESSION_ENDED_CLOSE_CODE,
112
+ TOO_MANY_SOCKETS_CLOSE_CODE: () => TOO_MANY_SOCKETS_CLOSE_CODE,
113
+ createPostgresWebSocket: () => createPostgresWebSocket,
114
+ isSocketRealtimeService: () => isSocketRealtimeService,
115
+ socketRealtimeMissing: () => socketRealtimeMissing
116
+ });
117
+ /**
118
+ * Narrow a bootstrapper's realtime provider to what the socket calls.
119
+ *
120
+ * The provider arrives typed as the engine-agnostic `RealtimeProvider`, which
121
+ * declares none of the client-connection methods. Checked rather than cast, so
122
+ * a provider missing one refuses at boot, naming it, instead of on a client's
123
+ * first frame.
124
+ */
125
+ function socketRealtimeMissing(provider) {
126
+ return [
127
+ "addClient",
128
+ "rescopeClient",
129
+ "handleClientMessage"
130
+ ].filter((method) => !(method in provider) || typeof Reflect.get(provider, method) !== "function");
131
+ }
132
+ function isSocketRealtimeService(provider) {
133
+ return socketRealtimeMissing(provider).length === 0;
134
+ }
135
+ /** What a script run says besides its rows, which an `EXECUTE_SQL` answer carries as `result`. */
136
+ function scriptDescription({ rows: _rows, ...description }) {
137
+ return description;
138
+ }
139
+ /**
140
+ * The uid the service key authenticates as, here and through the built-in
141
+ * auth adapter — which is how the socket tells that credential apart from a
142
+ * person's session.
143
+ */
144
+ var SERVICE_KEY_UID = "service";
145
+ /** Maximum messages per client per window */
146
+ var WS_RATE_LIMIT = 2e3;
147
+ /** Rate limit window in milliseconds (60 seconds) */
148
+ var WS_RATE_WINDOW_MS = 6e4;
149
+ /**
150
+ * Channel frames get their own budget, because they are a different workload.
151
+ *
152
+ * 2000/minute is 33/second, which is generous for queries and subscriptions and
153
+ * an order of magnitude below what the documented channel idiom asks for: the
154
+ * capacity note in `docs/backend/realtime.md` uses 60 fps cursor movement as
155
+ * its worked example, and the presence idiom re-`track()`s on every move, so
156
+ * one client sustaining that sends ~120 frames/second — 7200 a minute. Sharing
157
+ * one counter meant the cursor stream ate the query budget and then froze for
158
+ * the rest of the window.
159
+ *
160
+ * The number is sized to that documented workload and nothing more; it is not
161
+ * a considered product limit (see `docs/channel-authorization.md`).
162
+ */
163
+ var WS_CHANNEL_RATE_LIMIT = 7200;
164
+ /**
165
+ * Realtime frames that read the database as they arrive: a subscription's
166
+ * first frame is a fetch and a count, run as the subscriber, and a history
167
+ * request reads the channel's retained messages. Each is a data request by
168
+ * another name, and counts in the caller's data bucket like a
169
+ * `FETCH_COLLECTION` — past the socket's own allowance.
170
+ */
171
+ var FETCHING_REALTIME_TYPES = /* @__PURE__ */ new Set([
172
+ "subscribe_collection",
173
+ "subscribe_one",
174
+ "channel_history"
175
+ ]);
176
+ /**
177
+ * How many fetching realtime frames one socket may send without counting them
178
+ * in its caller's data bucket: at least as many as a socket may hold
179
+ * subscriptions, so a reconnect that re-subscribes everything it held is
180
+ * never refused, and never spends the budget the person's next request needs.
181
+ * The default data bucket is 1,000 requests per 15 minutes, so counting a
182
+ * reconnect against it would have refused most of one, or locked the person
183
+ * out of the data API until the window turned.
184
+ */
185
+ var SOCKET_FETCH_ALLOWANCE = 1e3;
186
+ /**
187
+ * How long a spent allowance takes to come back in full, in ms — the data
188
+ * API's default window. A socket churning subscriptions gets its allowance
189
+ * once, and then this much a quarter-hour; past that, every subscribe is a
190
+ * request in its person's bucket, which their HTTP requests and other sockets
191
+ * share.
192
+ */
193
+ var SOCKET_FETCH_ALLOWANCE_REFILL_MS = 15 * 6e4;
194
+ /**
195
+ * The largest frame a socket that has not authenticated may send, in bytes.
196
+ *
197
+ * All such a socket can usefully say is `AUTHENTICATE` with a token — a few
198
+ * KiB with custom claims — and everything else is answered `UNAUTHORIZED`.
199
+ * Without this, each of those answers came after the whole frame was buffered,
200
+ * stringified and parsed, up to the body limit: a handful of connections that
201
+ * never signed in could hold the process's memory. A larger frame closes the
202
+ * socket with 1009 before it is read.
203
+ */
204
+ var MAX_UNAUTHENTICATED_FRAME_BYTES = 64 * 1024;
205
+ /**
206
+ * How often a socket that only listens has its identity re-read, in ms.
207
+ *
208
+ * A socket that sends frames is re-checked before each one, as an HTTP request
209
+ * is; a socket that only receives pushes sends none, so without this a
210
+ * subscription opened by an identity that has since signed out everywhere, been
211
+ * demoted or deleted went on receiving rows as that identity for as long as the
212
+ * connection lasted.
213
+ */
214
+ var DEFAULT_IDENTITY_RECHECK_MS = 3e4;
215
+ /**
216
+ * How many auth-store lookups the sweep runs at once. It shares the pool with
217
+ * every REST request — 20 connections by default — so it takes a few of them,
218
+ * however many sockets are due: 5,000 sockets at ~0.3 ms a lookup is under
219
+ * half a second of sweep, rather than a burst that held the whole pool.
220
+ */
221
+ var IDENTITY_SWEEP_CONCURRENCY = 4;
222
+ /**
223
+ * Channel frames are re-checked at most this often, in ms. They are the
224
+ * high-rate traffic (see `WS_CHANNEL_RATE_LIMIT`): a database read for every
225
+ * cursor position would make realtime presence the busiest query a backend
226
+ * runs, for a window no wider than this.
227
+ */
228
+ var CHANNEL_RECHECK_MIN_MS = 1e3;
229
+ /**
230
+ * The close code for a socket whose identity ended: signed out, revoked,
231
+ * demoted out of existence, deleted or expired. In the 4000–4999 range the
232
+ * protocol leaves to applications. The SDK reconnects and authenticates with
233
+ * whatever session it holds now — a refreshed token, or none.
234
+ */
235
+ var SESSION_ENDED_CLOSE_CODE = 4001;
236
+ /**
237
+ * The close code for a socket refused because its user already holds
238
+ * `REALTIME_MAX_SOCKETS_PER_USER` sockets on this server. The SDK reconnects
239
+ * with backoff, so a tab refused this way gets in once another one closes.
240
+ */
241
+ var TOO_MANY_SOCKETS_CLOSE_CODE = 4002;
242
+ /**
243
+ * When a JWT expires, in ms, or undefined for a credential that is not one
244
+ * (an API key, the service key, an adapter's opaque token).
245
+ *
246
+ * Read without verifying, and only ever for a token that was verified a moment
247
+ * ago by the same request: the signature is not in question, only the instant.
248
+ */
249
+ function jwtExpiryMs(token) {
250
+ const parts = token.split(".");
251
+ if (parts.length !== 3) return void 0;
252
+ try {
253
+ const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
254
+ if (typeof claims !== "object" || claims === null || !("exp" in claims)) return void 0;
255
+ return typeof claims.exp === "number" ? claims.exp * 1e3 : void 0;
256
+ } catch {
257
+ return;
258
+ }
259
+ }
260
+ /** Everything about an identity that decides what its frames may do. */
261
+ function identityFingerprint(user) {
262
+ return JSON.stringify([
263
+ user.uid,
264
+ [...user.roles].sort(),
265
+ user.isAdmin,
266
+ user.isAnonymous,
267
+ user.scopes ?? null,
268
+ user.claims ?? null,
269
+ user.apiKey ?? null
270
+ ]);
271
+ }
272
+ /** Frames counted against the channel budget rather than the general one. */
273
+ var CHANNEL_MESSAGE_TYPES = /* @__PURE__ */ new Set([
274
+ "join_channel",
275
+ "leave_channel",
276
+ "broadcast",
277
+ "presence_track",
278
+ "presence_untrack",
279
+ "presence_state",
280
+ "channel_history"
281
+ ]);
282
+ /**
283
+ * WebSocket message types on the admin plane, and the scope each needs.
284
+ *
285
+ * Exported so the test can READ it. A privileged verb added without a scope
286
+ * check is what this list exists to make impossible: `PUBLIC_TYPES` below is
287
+ * its counterpart, and a test asserts that every `case` this file handles
288
+ * appears in exactly one of the two — so a verb added to neither fails rather
289
+ * than defaulting to reachable.
290
+ *
291
+ * All of these run on the owner connection, outside RLS. `database:read`
292
+ * covers the catalogue — including `FETCH_APPLICATION_ROLES`, which runs
293
+ * `SELECT DISTINCT unnest(roles)` over the users table — and
294
+ * `database:write` covers running SQL and creating or deleting branches.
295
+ */
296
+ var ADMIN_PLANE_TYPES = /* @__PURE__ */ new Map([
297
+ ["EXECUTE_SQL", "database:write"],
298
+ ["CANCEL_SQL", "database:write"],
299
+ ["CREATE_BRANCH", "database:write"],
300
+ ["DELETE_BRANCH", "database:write"],
301
+ ["FETCH_DATABASES", "database:read"],
302
+ ["FETCH_ROLES", "database:read"],
303
+ ["FETCH_APPLICATION_ROLES", "database:read"],
304
+ ["FETCH_UNMAPPED_TABLES", "database:read"],
305
+ ["FETCH_TABLE_METADATA", "database:read"],
306
+ ["FETCH_CURRENT_DATABASE", "database:read"],
307
+ ["LIST_BRANCHES", "database:read"]
308
+ ]);
309
+ /**
310
+ * The data scope each data verb needs, for a session whose credential narrows
311
+ * it. A person's own session holds the whole data plane; RLS decides its rows.
312
+ */
313
+ var DATA_VERB_SCOPES = /* @__PURE__ */ new Map([
314
+ ["FETCH_COLLECTION", "data:read"],
315
+ ["FETCH_ONE", "data:read"],
316
+ ["COUNT", "data:read"],
317
+ ["CHECK_UNIQUE_FIELD", "data:read"],
318
+ ["subscribe_collection", "data:read"],
319
+ ["subscribe_one", "data:read"],
320
+ ["SAVE", "data:write"],
321
+ ["DELETE", "data:delete"]
322
+ ]);
323
+ /**
324
+ * Message types deliberately reachable by a non-admin session.
325
+ *
326
+ * Data operations, gated per row by RLS and per request by the same write
327
+ * checks the REST layer applies — not by this list. It exists so that "which
328
+ * bucket is this verb in" is a question with an answer for every verb, and
329
+ * adding one without answering it is a test failure.
330
+ */
331
+ var PUBLIC_TYPES = /* @__PURE__ */ new Set([
332
+ "FETCH_COLLECTION",
333
+ "FETCH_ONE",
334
+ "COUNT",
335
+ "SAVE",
336
+ "DELETE",
337
+ "CHECK_UNIQUE_FIELD"
338
+ ]);
339
+ /**
340
+ * Refuse a path whose collection — its first segment, for a nested path — this
341
+ * server does not have, with the answer REST gives (`NOT_FOUND`).
342
+ */
343
+ function assertKnownCollection(registry, path) {
344
+ if (!registry || typeof path !== "string") return;
345
+ const root = path.split("/").find((segment) => segment !== "");
346
+ if (root !== void 0 && !registry.getCollectionByPath(root)) throw ApiError.notFound(`Unknown collection '${root}'.`);
347
+ }
348
+ /**
349
+ * Recursively extract the deepest error message from an error's cause chain (e.g., Drizzle wrapping a PG error).
350
+ */
351
+ function extractErrorMessage(error) {
352
+ if (!error) return "Unknown error";
353
+ if (error instanceof Error) {
354
+ if ("cause" in error && error.cause) return extractErrorMessage(error.cause);
355
+ return error.message;
356
+ }
357
+ if (typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
358
+ return String(error);
359
+ }
360
+ /**
361
+ * Who a socket reads and writes as, for its request frames and its
362
+ * subscriptions alike.
363
+ *
364
+ * One answer because there were two, and they disagreed about guests. The
365
+ * subscriptions carried the session's `isAnonymous`; the request frames wrote
366
+ * `false` for every signed-in session, so a guest read and wrote as an account
367
+ * through `FETCH_*`, `SAVE` and `DELETE` while its subscriptions did not. And a
368
+ * socket with no session was a guest on the request frames (`true`) but not on
369
+ * its subscriptions or over REST: an unauthenticated caller is not a guest
370
+ * session.
371
+ */
372
+ function sessionAuthContext(session) {
373
+ if (!session?.user) return {
374
+ uid: ANONYMOUS_USER_ID,
375
+ roles: ["anon"],
376
+ isAnonymous: false
377
+ };
378
+ return {
379
+ uid: session.user.uid,
380
+ roles: session.user.roles ?? [],
381
+ isAnonymous: session.user.isAnonymous === true,
382
+ ...session.user.claims ? { claims: session.user.claims } : {}
383
+ };
384
+ }
385
+ /**
386
+ * Everything a session may do: its credential's scopes, or what its roles hold
387
+ * in `model` — the backend's access model.
388
+ */
389
+ function sessionScopes(session, model) {
390
+ if (!session?.user) return [];
391
+ return session.user.scopes ?? scopesForRoles(session.user.roles, model);
392
+ }
393
+ /**
394
+ * The collection a data frame addresses, for a narrowed session's scope check.
395
+ * A nested path (`authors/1/posts`) answers undefined: its target is a
396
+ * relation's, not a slug on the wire, so only an unqualified scope covers it.
397
+ */
398
+ function frameCollection(type, payload) {
399
+ if (typeof payload !== "object" || payload === null) return void 0;
400
+ let path = "path" in payload ? payload.path : void 0;
401
+ if (type === "DELETE" && "row" in payload && typeof payload.row === "object" && payload.row !== null && "path" in payload.row) path = payload.row.path;
402
+ if (typeof path !== "string" || path === "" || isNestedPath(path)) return void 0;
403
+ return path;
404
+ }
405
+ /**
406
+ * A URL path with its trailing `/` dropped, so `/app` and `/app/` are one
407
+ * path — and `/` stays `/`.
408
+ */
409
+ function normalizeSocketPath(path) {
410
+ const trimmed = (path.startsWith("/") ? path : `/${path}`).replace(/\/+$/, "");
411
+ return trimmed === "" ? "/" : trimmed;
412
+ }
413
+ /** The path an upgrade request asks for, without its query. */
414
+ function upgradePathOf(request) {
415
+ const url = request.url ?? "/";
416
+ const queryAt = url.indexOf("?");
417
+ return normalizeSocketPath(queryAt === -1 ? url : url.slice(0, queryAt));
418
+ }
419
+ /**
420
+ * Answer an upgrade nobody will take with a plain HTTP 404 and close it, so
421
+ * the client sees a refusal instead of a socket that never opens.
422
+ */
423
+ function refuseUpgrade(socket) {
424
+ if (!socket.writable) {
425
+ socket.destroy();
426
+ return;
427
+ }
428
+ socket.once("finish", () => socket.destroy());
429
+ socket.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
430
+ }
431
+ /** A frame's size in bytes, however `ws` delivered it. */
432
+ function frameBytes(data) {
433
+ if (Array.isArray(data)) return data.reduce((total, chunk) => total + chunk.length, 0);
434
+ return data.byteLength;
435
+ }
436
+ /**
437
+ * Where a connection comes from, as the data API's rate limiter reads an
438
+ * address: the upgrade request's headers (believed only as far as proxies are
439
+ * declared) and the socket's own address.
440
+ */
441
+ function connectionOrigin(request) {
442
+ return {
443
+ header: (name) => {
444
+ const value = request?.headers[name];
445
+ return Array.isArray(value) ? value.join(", ") : value;
446
+ },
447
+ socketAddress: request?.socket?.remoteAddress
448
+ };
449
+ }
450
+ /**
451
+ * A request frame with its `filter` decoded by the codec every other door uses.
452
+ *
453
+ * REST, MCP and the in-process SDK read `where` through `deserializeFilter`,
454
+ * so `{ title: ["foo", "bar"] }` is an implicit `in` and `["!!", "x"]` is
455
+ * refused by name. The socket handed the frame's filter to the driver raw,
456
+ * which read the first element as an operator: one filter, two meanings,
457
+ * depending on the transport. A typed `[op, value]` tuple decodes to itself.
458
+ */
459
+ function withDecodedFilter(request) {
460
+ if (!request?.filter) return request;
461
+ try {
462
+ return {
463
+ ...request,
464
+ filter: deserializeFilter(request.filter)
465
+ };
466
+ } catch (e) {
467
+ if (e instanceof UnknownFilterOperatorError) throw ApiError.badRequest(e.message, e.code, e.details);
468
+ throw e;
469
+ }
470
+ }
471
+ /**
472
+ * `options` carry the data API's limits, for the same rows reached through this door:
473
+ * frames larger than its body limit close the socket with 1009 before they are
474
+ * read, and every data frame counts in the caller's data-API bucket — the one
475
+ * their HTTP requests count in — rather than only in a budget per connection,
476
+ * which a caller multiplied by opening more.
477
+ */
478
+ function createPostgresWebSocket(server, realtimeService, driver, authConfig, authAdapter, options) {
479
+ const clientSessions = /* @__PURE__ */ new Map();
480
+ /** The sockets that opted into heartbeats — see `socket-opt-ins.ts`. */
481
+ const heartbeatSockets = /* @__PURE__ */ new Set();
482
+ const heartbeatIntervalMs = options?.heartbeatIntervalMs ?? 3e4;
483
+ /** How many sockets one identity may hold here; unset is no ceiling. See `REALTIME_MAX_SOCKETS_PER_USER`. */
484
+ const maxSocketsPerUser = options?.maxSocketsPerUser;
485
+ const isProduction = process.env.NODE_ENV === "production";
486
+ /**
487
+ * Debug tracing for this socket.
488
+ *
489
+ * Through `logger.debug`, not `console.debug`. A bare console call sits
490
+ * outside the one level system, so `LOG_LEVEL=warn` — a line the scaffold's
491
+ * own `.env.example` ships — silenced every structured line in the process
492
+ * and left these ones printing; the deletion of the old `console.debug`
493
+ * override is what exposed that. It also skipped the redactor, and the
494
+ * extras these calls carry are rows.
495
+ *
496
+ * The production guard stays on top of the level gate rather than being
497
+ * replaced by it: these lines trace requests and their payloads, and
498
+ * `LOG_LEVEL=debug` on a deployment is not consent to put a user's row on
499
+ * stdout.
500
+ */
501
+ const wsDebug = (message, ...extras) => {
502
+ if (isProduction) return;
503
+ if (extras.length === 0) logger.debug(message);
504
+ else logger.debug(message, { details: extras.length === 1 ? extras[0] : extras });
505
+ };
506
+ const maxPayload = options?.maxPayload ?? RUNTIME_DEFAULT_MAX_BODY_SIZE;
507
+ const wss = new WebSocketServer({
508
+ noServer: true,
509
+ maxPayload: maxPayload > 0 ? maxPayload : 0
510
+ });
511
+ wss.on("error", (err) => {
512
+ logger.error("❌ [WebSocket Server] Error", { error: err });
513
+ });
514
+ const socketPath = normalizeSocketPath(options?.path ?? "/");
515
+ const onUpgrade = (request, socket, head) => {
516
+ if (upgradePathOf(request) !== socketPath) {
517
+ if (server.listenerCount("upgrade") <= 1) refuseUpgrade(socket);
518
+ return;
519
+ }
520
+ wss.handleUpgrade(request, socket, head, (client) => wss.emit("connection", client, request));
521
+ };
522
+ if (typeof server.on === "function") {
523
+ server.on("upgrade", onUpgrade);
524
+ wss.on("close", () => server.off("upgrade", onUpgrade));
525
+ }
526
+ const requireAuth = !!authAdapter || resolveRequireAuth(authConfig);
527
+ const verifySessionToken = options?.verifyAccessToken ?? extractUserFromToken;
528
+ const accessModel = () => options?.accessModel ?? getAccessModel();
529
+ if (requireAuth && !authAdapter && !authConfig?.jwtSecret && !authConfig?.serviceKey) logger.warn("🔐 [WebSocket Server] Authentication is required but no adapter, jwtSecret or serviceKey is configured — no client can complete AUTH, so every realtime message will be refused with UNAUTHORIZED.");
530
+ /**
531
+ * Who a credential is, now: the same verification `AUTHENTICATE` runs, so
532
+ * re-asking it later answers what signing in would answer. A refusal is a
533
+ * value; a store that cannot answer throws.
534
+ */
535
+ async function resolveCaller(token) {
536
+ if (token.startsWith("rk_")) {
537
+ const resolved = options?.resolveApiKey ? await options.resolveApiKey(token) : void 0;
538
+ if (!resolved || !("uid" in resolved)) return {
539
+ refused: resolved?.message ?? "API keys are not enabled on this server",
540
+ apiKey: true
541
+ };
542
+ return {
543
+ credential: "api-key",
544
+ user: {
545
+ uid: resolved.uid,
546
+ roles: resolved.roles,
547
+ isAdmin: hasAdminRole(resolved.roles),
548
+ isAnonymous: false,
549
+ scopes: resolved.scopes,
550
+ ...resolved.apiKey ? { apiKey: {
551
+ id: resolved.apiKey.id,
552
+ rate_limit: resolved.apiKey.rate_limit
553
+ } } : {}
554
+ }
555
+ };
556
+ }
557
+ if (authAdapter) {
558
+ const adapterUser = authAdapter.verifyToken ? await authAdapter.verifyToken(token) : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", { headers: { Authorization: `Bearer ${token}` } }));
559
+ if (!adapterUser) return { refused: "Invalid or expired token" };
560
+ return {
561
+ credential: adapterUser.uid === SERVICE_KEY_UID ? "service-key" : "session",
562
+ user: {
563
+ uid: adapterUser.uid,
564
+ roles: adapterUser.roles,
565
+ isAdmin: adapterUser.isAdmin,
566
+ isAnonymous: adapterUser.isAnonymous === true,
567
+ ...adapterUser.claims ? { claims: adapterUser.claims } : {}
568
+ }
569
+ };
570
+ }
571
+ if (authConfig?.serviceKey && safeCompare(token, authConfig.serviceKey)) return {
572
+ credential: "service-key",
573
+ user: {
574
+ uid: SERVICE_KEY_UID,
575
+ roles: ["admin"],
576
+ isAdmin: true,
577
+ isAnonymous: false
578
+ }
579
+ };
580
+ const jwtPayload = await verifySessionToken(token);
581
+ if (!jwtPayload) return { refused: "Invalid or expired token" };
582
+ return {
583
+ credential: "session",
584
+ user: {
585
+ uid: jwtPayload.uid,
586
+ roles: jwtPayload.roles ?? [],
587
+ isAdmin: hasAdminRole(jwtPayload.roles ?? []),
588
+ isAnonymous: jwtPayload.isAnonymous === true,
589
+ claims: jwtPayload.claims
590
+ }
591
+ };
592
+ }
593
+ /**
594
+ * Who this socket runs as: its caller, or — when it asked to `impersonate`
595
+ * — the user it named, by the same decision the data API takes for
596
+ * `x-rebase-impersonate`. A refusal carries the code to answer with.
597
+ *
598
+ * `audit` is passed by `AUTHENTICATE` only: the re-check before every frame
599
+ * decides again without writing a security event each time.
600
+ */
601
+ async function resolveIdentity(token, impersonate, audit) {
602
+ const caller = await resolveCaller(token);
603
+ if (!("user" in caller) || impersonate === void 0) return caller;
604
+ const decision = await decideImpersonation({
605
+ requestedUid: impersonate,
606
+ credential: caller.credential,
607
+ caller: caller.user,
608
+ resolveUser: authAdapter?.resolveUser ? authAdapter.resolveUser.bind(authAdapter) : void 0,
609
+ audit
610
+ });
611
+ if ("refused" in decision) return {
612
+ refused: decision.refused.message,
613
+ code: decision.refused.code
614
+ };
615
+ const target = decision.granted;
616
+ return { user: {
617
+ uid: target.uid,
618
+ roles: target.roles,
619
+ isAdmin: hasAdminRole(target.roles),
620
+ isAnonymous: target.isAnonymous === true,
621
+ ...target.claims ? { claims: target.claims } : {},
622
+ impersonator: decision.impersonator
623
+ } };
624
+ }
625
+ /** Stop honouring the session at the instant its token expires. */
626
+ function scheduleExpiry(clientId, token) {
627
+ const session = clientSessions.get(clientId);
628
+ if (!session) return;
629
+ if (session.expiryTimer) clearTimeout(session.expiryTimer);
630
+ session.expiryTimer = void 0;
631
+ const expiresAt = jwtExpiryMs(token);
632
+ if (expiresAt === void 0) return;
633
+ const delay = Math.max(0, expiresAt - Date.now());
634
+ if (delay > 2147483647) return;
635
+ session.expiryTimer = setTimeout(() => {
636
+ if (clientSessions.get(clientId)?.credential === token) endSession(clientId, "TOKEN_EXPIRED", "The token this socket authenticated with has expired.");
637
+ }, delay);
638
+ session.expiryTimer.unref?.();
639
+ }
640
+ /**
641
+ * The identity this socket authenticated as is over: say so, forget it,
642
+ * and close. Closing rather than carrying on anonymously, because every
643
+ * subscription the socket holds was opened as that identity — the SDK
644
+ * reconnects, authenticates with whatever session it has now, and
645
+ * re-subscribes as that.
646
+ */
647
+ function endSession(clientId, code, message) {
648
+ const session = clientSessions.get(clientId);
649
+ if (!session) return;
650
+ if (session.expiryTimer) clearTimeout(session.expiryTimer);
651
+ session.expiryTimer = void 0;
652
+ session.user = void 0;
653
+ session.credential = void 0;
654
+ session.authenticated = !requireAuth;
655
+ wsDebug(`[WS] ${clientId} session ended: ${code}`);
656
+ try {
657
+ session.ws.send(JSON.stringify({
658
+ type: "AUTH_ERROR",
659
+ payload: { error: {
660
+ code,
661
+ message
662
+ } }
663
+ }));
664
+ } catch {}
665
+ session.ws.close(SESSION_ENDED_CLOSE_CODE, message.slice(0, 120));
666
+ }
667
+ /**
668
+ * Is the identity this socket authenticated as still the one its
669
+ * credential names?
670
+ *
671
+ * Asked of the database, through the same verification as
672
+ * `AUTHENTICATE`, before each frame and by the sweep — never by an event
673
+ * one instance publishes to the others. A sign-out, a demotion or a
674
+ * deletion is written to the database by whichever instance served it;
675
+ * re-reading there is correct on every instance with nothing in between,
676
+ * where an event would reach only the instances a bus connects (and the
677
+ * bus is opt-in).
678
+ *
679
+ * - `"live"`: still honoured; roles or claims that changed are applied,
680
+ * and the socket's subscriptions are re-scoped to them.
681
+ * - `"ended"`: refused — revoked, signed out, deleted, expired. The
682
+ * socket is closed.
683
+ * - `"unavailable"`: the store could not answer. The frame is refused,
684
+ * the session kept, and the next frame or sweep asks again.
685
+ */
686
+ async function recheckIdentity(clientId, resolve = resolveIdentity) {
687
+ const session = clientSessions.get(clientId);
688
+ const credential = session?.credential;
689
+ if (!session?.user || !credential) return "live";
690
+ let outcome;
691
+ try {
692
+ outcome = await resolve(credential, session.impersonate);
693
+ } catch {
694
+ return "unavailable";
695
+ }
696
+ if (session.credential !== credential || !session.user) return session.user ? "live" : "ended";
697
+ session.checkedAt = Date.now();
698
+ if (!("user" in outcome)) {
699
+ if (outcome.code) endSession(clientId, outcome.code, outcome.refused);
700
+ else endSession(clientId, "SESSION_ENDED", "This session has ended: it was signed out, revoked, or its account changed. Authenticate again.");
701
+ return "ended";
702
+ }
703
+ if (identityFingerprint(outcome.user) !== identityFingerprint(session.user)) {
704
+ session.user = outcome.user;
705
+ await realtimeService.rescopeClient(clientId, sessionAuthContext(session));
706
+ }
707
+ return "live";
708
+ }
709
+ /** The per-frame check in flight on each socket, and what it asks. */
710
+ const frameChecks = /* @__PURE__ */ new Map();
711
+ /**
712
+ * {@link recheckIdentity} for a frame, shared by the frames of one socket
713
+ * that arrive while it is in flight.
714
+ *
715
+ * A client that reconnects after a deploy re-sends all its subscriptions
716
+ * at once, and each frame asked the auth store the same question at the
717
+ * same moment: a thousand clients with ten subscriptions were ten thousand
718
+ * lookups where a thousand answer. A frame that arrives while this
719
+ * socket's check of the same credential and impersonation target is under
720
+ * way waits for that answer instead. The answer is the flight's only:
721
+ * dropped as it settles, so the next frame asks afresh. What this trades
722
+ * is bounded by one lookup: a demotion that lands while the shared lookup
723
+ * runs is seen by the next frame rather than by these.
724
+ */
725
+ function recheckIdentityForFrame(clientId) {
726
+ const session = clientSessions.get(clientId);
727
+ const credential = session?.credential;
728
+ if (!session || !credential) return recheckIdentity(clientId);
729
+ const inFlight = frameChecks.get(clientId);
730
+ if (inFlight && inFlight.credential === credential && inFlight.impersonate === session.impersonate) return inFlight.outcome;
731
+ const outcome = recheckIdentity(clientId).finally(() => {
732
+ if (frameChecks.get(clientId)?.outcome === outcome) frameChecks.delete(clientId);
733
+ });
734
+ frameChecks.set(clientId, {
735
+ credential,
736
+ impersonate: session.impersonate,
737
+ outcome
738
+ });
739
+ return outcome;
740
+ }
741
+ /**
742
+ * A socket's whole allowance of fetching realtime frames: the default, or
743
+ * the realtime service's subscription ceiling where a deployment raised
744
+ * it, so a full socket still re-subscribes without being charged.
745
+ */
746
+ function fetchAllowanceCapacity() {
747
+ const ceiling = Reflect.get(realtimeService, "maxSubscriptionsPerSocket");
748
+ return typeof ceiling === "number" && Number.isFinite(ceiling) ? Math.max(SOCKET_FETCH_ALLOWANCE, ceiling) : SOCKET_FETCH_ALLOWANCE;
749
+ }
750
+ /**
751
+ * Take one fetching realtime frame from the socket's own allowance,
752
+ * topped up for the time since it was last asked. False when none is
753
+ * left: the frame then counts in the caller's data bucket.
754
+ */
755
+ function spendFetchAllowance(clientId) {
756
+ const session = clientSessions.get(clientId);
757
+ if (!session) return false;
758
+ const now = Date.now();
759
+ const capacity = fetchAllowanceCapacity();
760
+ const refilled = Math.max(0, now - session.fetchAllowanceAt) * capacity / SOCKET_FETCH_ALLOWANCE_REFILL_MS;
761
+ session.fetchAllowance = Math.min(capacity, session.fetchAllowance + refilled);
762
+ session.fetchAllowanceAt = now;
763
+ if (session.fetchAllowance < 1) return false;
764
+ session.fetchAllowance -= 1;
765
+ return true;
766
+ }
767
+ const identityRecheckMs = Math.max(250, options?.identityRecheckIntervalMs ?? DEFAULT_IDENTITY_RECHECK_MS);
768
+ /**
769
+ * Re-check every socket that is due, asking the auth store once per
770
+ * question.
771
+ *
772
+ * Each socket used to be asked about on its own, all in the same tick: S
773
+ * listening sockets were S concurrent lookups on the pool REST shares,
774
+ * and a person with five tabs was five identical lookups of one token.
775
+ * Sockets are grouped by what they would ask — their credential and the
776
+ * user they impersonate — and each group asks once, at most
777
+ * {@link IDENTITY_SWEEP_CONCURRENCY} groups at a time.
778
+ *
779
+ * The answer is the group's alone, made when the group's turn comes and
780
+ * dropped when it is done; nothing is kept from one sweep to the next, so
781
+ * a credential revoked after a sweep is asked about afresh, and refused,
782
+ * by the next one. A socket that authenticated again while its group
783
+ * waited is asked about the credential it holds now — see `answerFor`.
784
+ */
785
+ async function sweepIdentities() {
786
+ const now = Date.now();
787
+ const groups = /* @__PURE__ */ new Map();
788
+ for (const [id, session] of clientSessions) {
789
+ if (!session.user || !session.credential || now - session.checkedAt < identityRecheckMs / 2) continue;
790
+ const key = JSON.stringify([session.credential, session.impersonate ?? null]);
791
+ const group = groups.get(key);
792
+ if (group) group.clientIds.push(id);
793
+ else groups.set(key, {
794
+ credential: session.credential,
795
+ impersonate: session.impersonate,
796
+ clientIds: [id]
797
+ });
798
+ }
799
+ const queue = [...groups.values()];
800
+ const work = async () => {
801
+ for (let group = queue.shift(); group; group = queue.shift()) {
802
+ let answer;
803
+ const answerFor = (token, impersonate) => {
804
+ if (token !== group.credential || impersonate !== group.impersonate) return resolveIdentity(token, impersonate);
805
+ answer ??= resolveIdentity(token, impersonate);
806
+ return answer;
807
+ };
808
+ for (const id of group.clientIds) await recheckIdentity(id, answerFor).catch(() => void 0);
809
+ }
810
+ };
811
+ await Promise.all(Array.from({ length: Math.min(IDENTITY_SWEEP_CONCURRENCY, queue.length) }, work));
812
+ }
813
+ let sweeping = false;
814
+ const identitySweep = setInterval(() => {
815
+ if (sweeping) return;
816
+ sweeping = true;
817
+ sweepIdentities().catch(() => void 0).finally(() => {
818
+ sweeping = false;
819
+ });
820
+ }, identityRecheckMs);
821
+ identitySweep.unref?.();
822
+ const stopSweep = () => clearInterval(identitySweep);
823
+ wss.on("close", stopSweep);
824
+ if (typeof server.on === "function") server.on("close", stopSweep);
825
+ wss.on("connection", (ws, request) => {
826
+ const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
827
+ const origin = connectionOrigin(request);
828
+ wsDebug(`WebSocket client connected: ${clientId}`);
829
+ const optIns = requestedOptIns(request?.url);
830
+ if (optIns) {
831
+ ws.send(helloFrame(optIns, heartbeatIntervalMs, clientId));
832
+ realtimeService.setClientOptIns?.(clientId, optIns);
833
+ if (optIns.has("heartbeat")) {
834
+ heartbeatSockets.add(ws);
835
+ ws.once("close", () => heartbeatSockets.delete(ws));
836
+ }
837
+ }
838
+ clientSessions.set(clientId, {
839
+ ws,
840
+ authenticated: !requireAuth,
841
+ checkedAt: Date.now(),
842
+ messageCount: 0,
843
+ messageWindowStart: Date.now(),
844
+ channelMessageCount: 0,
845
+ channelWindowStart: Date.now(),
846
+ fetchAllowance: fetchAllowanceCapacity(),
847
+ fetchAllowanceAt: Date.now()
848
+ });
849
+ realtimeService.addClient(clientId, ws);
850
+ const sqlRuns = /* @__PURE__ */ new Map();
851
+ ws.on("close", () => {
852
+ wsDebug(`WebSocket client disconnected: ${clientId}`);
853
+ const session = clientSessions.get(clientId);
854
+ if (session?.expiryTimer) clearTimeout(session.expiryTimer);
855
+ clientSessions.delete(clientId);
856
+ });
857
+ ws.on("error", (error) => {
858
+ wsDebug(`WebSocket client ${clientId} error: ${error.message}`);
859
+ });
860
+ ws.on("message", async (message) => {
861
+ if (terminateIfBacklogged(ws, clientId)) return;
862
+ let requestId;
863
+ try {
864
+ if (!clientSessions.get(clientId)?.authenticated && frameBytes(message) > MAX_UNAUTHENTICATED_FRAME_BYTES) {
865
+ ws.close(1009, "Authenticate before sending anything larger than a token.");
866
+ return;
867
+ }
868
+ const { type, payload, requestId: reqId } = JSON.parse(message.toString());
869
+ requestId = reqId;
870
+ wsDebug(`[WS] ${clientId} → ${type}`, requestId ? `(${requestId})` : "");
871
+ const sendError = (errType, code, msg) => {
872
+ const channel = CHANNEL_MESSAGE_TYPES.has(type) ? payload?.channel : void 0;
873
+ ws.send(JSON.stringify({
874
+ type: errType,
875
+ requestId,
876
+ ...typeof channel === "string" && { channel },
877
+ payload: {
878
+ error: {
879
+ message: msg,
880
+ code
881
+ },
882
+ ...typeof channel === "string" && { channel }
883
+ }
884
+ }));
885
+ };
886
+ {
887
+ const session = clientSessions.get(clientId);
888
+ if (session) {
889
+ const now = Date.now();
890
+ if (CHANNEL_MESSAGE_TYPES.has(type)) {
891
+ if (now - session.channelWindowStart > WS_RATE_WINDOW_MS) {
892
+ session.channelMessageCount = 0;
893
+ session.channelWindowStart = now;
894
+ }
895
+ session.channelMessageCount++;
896
+ if (session.channelMessageCount > WS_CHANNEL_RATE_LIMIT) {
897
+ sendError("ERROR", "RATE_LIMITED", "Too many channel messages. Please slow down.");
898
+ return;
899
+ }
900
+ } else {
901
+ if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {
902
+ session.messageCount = 0;
903
+ session.messageWindowStart = now;
904
+ }
905
+ session.messageCount++;
906
+ if (session.messageCount > WS_RATE_LIMIT) {
907
+ sendError("ERROR", "RATE_LIMITED", "Too many requests. Please slow down.");
908
+ return;
909
+ }
910
+ }
911
+ }
912
+ }
913
+ if (type === "AUTHENTICATE") {
914
+ const { token, impersonate } = payload || {};
915
+ if (!token) {
916
+ sendError("AUTH_ERROR", "INVALID_INPUT", "Token is required");
917
+ return;
918
+ }
919
+ if (impersonate !== void 0 && typeof impersonate !== "string") {
920
+ sendError("AUTH_ERROR", "INVALID_INPUT", "impersonate must be the uid of the user to act as");
921
+ return;
922
+ }
923
+ let resolved;
924
+ try {
925
+ resolved = await resolveIdentity(token, impersonate, impersonate === void 0 ? void 0 : {
926
+ door: "socket",
927
+ clientId
928
+ });
929
+ } catch (error) {
930
+ const answer = impersonate === void 0 ? void 0 : declaredErrorAnswer(error);
931
+ if (answer) {
932
+ sendError("AUTH_ERROR", answer.code, answer.message);
933
+ return;
934
+ }
935
+ if (token.startsWith("rk_") || !authAdapter) throw error;
936
+ resolved = { refused: "Invalid or expired token" };
937
+ }
938
+ if ("refused" in resolved && (resolved.apiKey || resolved.code)) {
939
+ sendError("AUTH_ERROR", resolved.code ?? "INVALID_TOKEN", resolved.refused);
940
+ return;
941
+ }
942
+ const verifiedUser = "user" in resolved ? resolved.user : null;
943
+ if (verifiedUser) {
944
+ const session = clientSessions.get(clientId);
945
+ if (maxSocketsPerUser !== void 0 && session) {
946
+ let held = 0;
947
+ for (const other of clientSessions.values()) if (other !== session && other.user?.uid === verifiedUser.uid) held++;
948
+ if (held >= maxSocketsPerUser) {
949
+ logger.warn(`[WebSocket Server] Refused a socket for ${verifiedUser.uid}: ${held} already open, ceiling ${maxSocketsPerUser}`);
950
+ sendError("ERROR", "TOO_MANY_SOCKETS", `This user already has ${held} realtime connections open on this server, the most it allows (REALTIME_MAX_SOCKETS_PER_USER). Close another tab or client and try again.`);
951
+ ws.close(TOO_MANY_SOCKETS_CLOSE_CODE, "Too many realtime connections for this user");
952
+ return;
953
+ }
954
+ }
955
+ if (session) {
956
+ session.user = verifiedUser;
957
+ session.authenticated = true;
958
+ session.credential = token;
959
+ session.impersonate = impersonate;
960
+ session.checkedAt = Date.now();
961
+ scheduleExpiry(clientId, token);
962
+ await realtimeService.rescopeClient(clientId, sessionAuthContext(session));
963
+ }
964
+ wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);
965
+ ws.send(JSON.stringify({
966
+ type: "AUTH_SUCCESS",
967
+ requestId,
968
+ payload: {
969
+ uid: verifiedUser.uid,
970
+ roles: verifiedUser.roles
971
+ }
972
+ }));
973
+ wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.uid}`);
974
+ } else {
975
+ wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
976
+ sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
977
+ }
978
+ return;
979
+ }
980
+ {
981
+ const session = clientSessions.get(clientId);
982
+ if (session?.user && session.credential && Date.now() - session.checkedAt >= (CHANNEL_MESSAGE_TYPES.has(type) ? CHANNEL_RECHECK_MIN_MS : 0)) {
983
+ const outcome = await recheckIdentityForFrame(clientId);
984
+ if (outcome === "ended") return;
985
+ if (outcome === "unavailable") {
986
+ sendError("ERROR", "ROLE_LOOKUP_FAILED", "Could not verify your permissions. Please try again.");
987
+ return;
988
+ }
989
+ }
990
+ }
991
+ if (requireAuth) {
992
+ if (!clientSessions.get(clientId)?.authenticated) {
993
+ sendError("ERROR", "UNAUTHORIZED", "Authentication required");
994
+ return;
995
+ }
996
+ }
997
+ const adminScope = ADMIN_PLANE_TYPES.get(type);
998
+ if (adminScope) {
999
+ if (!scopeGrants(sessionScopes(clientSessions.get(clientId), accessModel()), adminScope)) {
1000
+ sendError("ERROR", "SCOPE_MISSING", `This operation needs the "${adminScope}" scope`);
1001
+ return;
1002
+ }
1003
+ }
1004
+ const narrowedScopes = clientSessions.get(clientId)?.user?.scopes;
1005
+ if (narrowedScopes) {
1006
+ const dataScope = DATA_VERB_SCOPES.get(type);
1007
+ if (dataScope) {
1008
+ const collection = frameCollection(type, payload);
1009
+ if (!scopeGrants(narrowedScopes, dataScope, collection)) {
1010
+ sendError("ERROR", "SCOPE_MISSING", `This API key does not hold the "${collection ? `${dataScope}:${collection}` : dataScope}" scope`);
1011
+ return;
1012
+ }
1013
+ } else if (CHANNEL_MESSAGE_TYPES.has(type)) {
1014
+ sendError("ERROR", "SCOPE_MISSING", "An API key cannot use channels");
1015
+ return;
1016
+ }
1017
+ }
1018
+ if (options?.dataRateLimit && (PUBLIC_TYPES.has(type) || FETCHING_REALTIME_TYPES.has(type) && !spendFetchAllowance(clientId))) {
1019
+ const decision = await options.dataRateLimit({
1020
+ ...origin,
1021
+ uid: clientSessions.get(clientId)?.user?.uid,
1022
+ apiKey: clientSessions.get(clientId)?.user?.apiKey
1023
+ });
1024
+ if (decision && !decision.allowed) {
1025
+ sendError("ERROR", "RATE_LIMITED", "Too many requests, please try again later.");
1026
+ return;
1027
+ }
1028
+ }
1029
+ /**
1030
+ * Apply the REST layer's write checks to a socket payload.
1031
+ *
1032
+ * Silent when the path names no registered collection: the
1033
+ * driver decides what a path means, and refusing here would
1034
+ * turn "unknown collection" into a validation error.
1035
+ */
1036
+ const assertWriteRequest = (path, values, status) => {
1037
+ if (!path || !values || typeof values !== "object") return;
1038
+ const collection = driver.registry?.getCollectionByPath(path);
1039
+ if (!collection) return;
1040
+ assertWriteRequestValid(values, collection, {
1041
+ viewer: { roles: clientSessions.get(clientId)?.user?.roles ?? ["anon"] },
1042
+ status
1043
+ });
1044
+ assertFieldOpsValid(values, collection);
1045
+ };
1046
+ /**
1047
+ * Apply the REST layer's read check to a socket request: no
1048
+ * filter, sort, projection or uniqueness probe over a field
1049
+ * this caller may not read. The strip keeps the value off the
1050
+ * wire; without this a `COUNT` still answers whether a hidden
1051
+ * value matches a pattern, one character at a time.
1052
+ *
1053
+ * Same viewer as `assertWriteRequest`, and silent in the same
1054
+ * case: a path that names no registered collection is the
1055
+ * driver's to answer.
1056
+ */
1057
+ const assertReadRequest = (path, request) => {
1058
+ if (!path) return;
1059
+ let collection;
1060
+ try {
1061
+ collection = driver.registry?.getCollectionByPath(path);
1062
+ } catch {
1063
+ return;
1064
+ }
1065
+ if (!collection) return;
1066
+ const session = clientSessions.get(clientId);
1067
+ assertReadRequestReadable(request, collection, { roles: session?.user?.roles ?? ["anon"] });
1068
+ };
1069
+ /**
1070
+ * Refuse a write through a nested path that sets a relation
1071
+ * this caller may not write — the rule REST's nested routes
1072
+ * apply, from the same function. The body is checked by
1073
+ * `assertWriteRequest`; this is what the address writes.
1074
+ *
1075
+ * Silent for a root path, and for a nested one the registry
1076
+ * cannot walk: the driver refuses that with its own message.
1077
+ */
1078
+ const assertNestedWrite = (path, kind) => {
1079
+ if (typeof path !== "string" || !isNestedPath(path) || !driver.registry) return;
1080
+ let hop;
1081
+ try {
1082
+ hop = resolveNestedPath(path, driver.registry);
1083
+ } catch {
1084
+ return;
1085
+ }
1086
+ if (!hop) return;
1087
+ const session = clientSessions.get(clientId);
1088
+ assertNestedWriteAllowed(hop, kind, path, { roles: session?.user?.roles ?? ["anon"] });
1089
+ };
1090
+ /** The auth collection at `path`, or `undefined` for any other. */
1091
+ const authCollectionAt = (path) => {
1092
+ if (typeof path !== "string") return void 0;
1093
+ let collection;
1094
+ try {
1095
+ collection = driver.registry?.getCollectionByPath(path);
1096
+ } catch {
1097
+ return;
1098
+ }
1099
+ const auth = collection?.auth;
1100
+ return auth === true || !!auth && typeof auth === "object" && auth.enabled === true ? collection : void 0;
1101
+ };
1102
+ /**
1103
+ * The auth collection's rows are the users, so a write to one
1104
+ * is user administration — the auth adapter's to check, as it
1105
+ * is on the REST routes and `/admin/users`. `undefined` for any
1106
+ * other collection, or an adapter with nothing to say.
1107
+ */
1108
+ const userAdminFor = (path) => authAdapter && authCollectionAt(path) ? authAdapter : void 0;
1109
+ /**
1110
+ * An upsert's options, under the rules `POST /api/data`
1111
+ * applies to `?on_conflict=`: refused on a nested path,
1112
+ * whose insert branch stamps the parent's key into the
1113
+ * DO UPDATE and so moves the row it matched under this
1114
+ * parent; and a target that is a declared unique key of
1115
+ * the registry's collection, or a 400 naming the ones
1116
+ * there are rather than Postgres's 42P10.
1117
+ */
1118
+ const upsertOptions = (request) => {
1119
+ if (request.upsert !== true) return {};
1120
+ if (typeof request.path === "string" && isNestedPath(request.path)) throw ApiError.badRequest("An upsert is not accepted on a nested path: a row it matched would be moved under this parent. Send the upsert to the collection directly.", "INVALID_CONFLICT_TARGET");
1121
+ const collection = driver.registry?.getCollectionByPath(request.path);
1122
+ const onConflict = collection ? resolveConflictTarget(request.onConflict, collection, { where: "`onConflict`" }) : void 0;
1123
+ return {
1124
+ upsert: true,
1125
+ ...onConflict && { onConflict }
1126
+ };
1127
+ };
1128
+ const getScopedDelegate = async () => {
1129
+ if (typeof driver.withAuth === "function") try {
1130
+ const who = sessionAuthContext(clientSessions.get(clientId));
1131
+ const userForAuth = {
1132
+ uid: who.uid,
1133
+ displayName: null,
1134
+ email: null,
1135
+ photoURL: null,
1136
+ providerId: "websocket",
1137
+ isAnonymous: who.isAnonymous === true,
1138
+ roles: who.roles,
1139
+ ...who.claims ? { claims: who.claims } : {}
1140
+ };
1141
+ return await driver.withAuth(userForAuth);
1142
+ } catch (e) {
1143
+ logger.error("Failed to create RLS scoped delegate for WS request", { error: e });
1144
+ throw new Error("Internal authentication error");
1145
+ }
1146
+ return driver;
1147
+ };
1148
+ if (PUBLIC_TYPES.has(type)) assertKnownCollection(driver.registry, type === "DELETE" ? payload?.row?.path : payload?.path);
1149
+ switch (type) {
1150
+ case "FETCH_COLLECTION":
1151
+ {
1152
+ wsDebug("📋 [WebSocket Server] Processing FETCH_COLLECTION request");
1153
+ const request = withDecodedFilter(payload);
1154
+ assertReadRequest(request.path, request);
1155
+ const rows = await (await getScopedDelegate()).fetchCollection({
1156
+ ...request,
1157
+ limit: resolveClientListLimit(request.limit, { vectorSearch: !!request.vectorSearch })
1158
+ });
1159
+ wsDebug("📋 [WebSocket Server] FETCH_COLLECTION result - rows count:", rows.length);
1160
+ const response = {
1161
+ type: "FETCH_COLLECTION_SUCCESS",
1162
+ payload: { rows },
1163
+ requestId
1164
+ };
1165
+ wsDebug("📋 [WebSocket Server] Sending FETCH_COLLECTION_SUCCESS response");
1166
+ ws.send(JSON.stringify(response));
1167
+ }
1168
+ break;
1169
+ case "FETCH_ONE":
1170
+ {
1171
+ wsDebug("📄 [WebSocket Server] Processing FETCH_ENTITY request");
1172
+ const request = payload;
1173
+ const row = await (await getScopedDelegate()).fetchOne(request);
1174
+ wsDebug("📄 [WebSocket Server] FETCH_ENTITY result:", row);
1175
+ const response = {
1176
+ type: "FETCH_ONE_SUCCESS",
1177
+ payload: { row: row ?? null },
1178
+ requestId
1179
+ };
1180
+ wsDebug("📄 [WebSocket Server] Sending FETCH_ENTITY_SUCCESS response");
1181
+ ws.send(JSON.stringify(response));
1182
+ }
1183
+ break;
1184
+ case "SAVE":
1185
+ {
1186
+ wsDebug("💾 [WebSocket Server] Processing SAVE_ENTITY request");
1187
+ const request = payload;
1188
+ wsDebug("💾 [WebSocket Server] Saving row with request:", inspect(request, {
1189
+ depth: null,
1190
+ colors: true
1191
+ }));
1192
+ const upsertedUsers = request.upsert === true ? authCollectionAt(request.path) : void 0;
1193
+ if (upsertedUsers) assertNoUserUpsert(authAdapter, upsertedUsers);
1194
+ const usersCollection = request.status !== "existing" && request.upsert !== true && authAdapter?.prepareUserCreation ? authCollectionAt(request.path) : void 0;
1195
+ const caller = { roles: clientSessions.get(clientId)?.user?.roles ?? ["anon"] };
1196
+ if (usersCollection) {
1197
+ const body = request.values ?? {};
1198
+ assertUserCreationBodyValid(authAdapter, usersCollection, body, caller);
1199
+ assertNoFieldOpsOnCreate(body, "A create", usersCollection);
1200
+ } else assertWriteRequest(request.path, request.values, request.status);
1201
+ assertNestedWrite(request.path, request.id === void 0 || request.status !== "existing" ? "create" : "update");
1202
+ const delegate = await getScopedDelegate();
1203
+ const userAdmin = request.status === "existing" && request.id !== void 0 ? userAdminFor(request.path) : void 0;
1204
+ const [values] = userAdmin?.prepareUserUpdates ? await userAdmin.prepareUserUpdates([{
1205
+ uid: String(request.id),
1206
+ values: request.values
1207
+ }]) : [request.values];
1208
+ const write = (toWrite) => delegate.save({
1209
+ path: request.path,
1210
+ ...request.id !== void 0 && { id: request.id },
1211
+ values: toWrite,
1212
+ status: request.status,
1213
+ ...upsertOptions(request)
1214
+ });
1215
+ const created = usersCollection ? await createUserThroughAuthCollection(authAdapter, usersCollection, values ?? {}, caller, write) : void 0;
1216
+ const row = created ? {
1217
+ ...created.row,
1218
+ ...created.delivery
1219
+ } : await write(values);
1220
+ wsDebug("💾 [WebSocket Server] SAVE_ENTITY result:", inspect(row, {
1221
+ depth: null,
1222
+ colors: true
1223
+ }));
1224
+ const response = {
1225
+ type: "SAVE_SUCCESS",
1226
+ payload: { row },
1227
+ requestId
1228
+ };
1229
+ wsDebug("💾 [WebSocket Server] Sending SAVE_ENTITY_SUCCESS response");
1230
+ ws.send(JSON.stringify(response));
1231
+ }
1232
+ break;
1233
+ case "DELETE":
1234
+ {
1235
+ wsDebug("🗑️ [WebSocket Server] Processing DELETE_ENTITY request");
1236
+ const request = payload;
1237
+ wsDebug("🗑️ [WebSocket Server] Deleting row:", request.row);
1238
+ assertNestedWrite(request.row?.path, "unlink");
1239
+ const delegate = await getScopedDelegate();
1240
+ const userAdmin = userAdminFor(request.row?.path);
1241
+ const uids = [String(request.row.id)];
1242
+ await userAdmin?.prepareUserDeletions?.(uids);
1243
+ await delegate.delete({
1244
+ row: {
1245
+ id: request.row.id,
1246
+ path: request.row.path
1247
+ },
1248
+ hard: request.hard
1249
+ });
1250
+ await userAdmin?.finalizeUserDeletions?.(uids);
1251
+ wsDebug("🗑️ [WebSocket Server] DELETE_ENTITY completed successfully");
1252
+ const response = {
1253
+ type: "DELETE_SUCCESS",
1254
+ payload: { success: true },
1255
+ requestId
1256
+ };
1257
+ wsDebug("🗑️ [WebSocket Server] Sending DELETE_ENTITY_SUCCESS response");
1258
+ ws.send(JSON.stringify(response));
1259
+ }
1260
+ break;
1261
+ case "CHECK_UNIQUE_FIELD":
1262
+ {
1263
+ wsDebug("🔍 [WebSocket Server] Processing CHECK_UNIQUE_FIELD request");
1264
+ const { path, name, value, id, collection } = payload;
1265
+ if (typeof name === "string") assertReadRequest(path, { filter: { [name]: ["==", value] } });
1266
+ const isUnique = await (await getScopedDelegate()).checkUniqueField(path, name, value, id, collection);
1267
+ wsDebug("🔍 [WebSocket Server] CHECK_UNIQUE_FIELD result:", isUnique);
1268
+ const response = {
1269
+ type: "CHECK_UNIQUE_FIELD_SUCCESS",
1270
+ payload: { isUnique },
1271
+ requestId
1272
+ };
1273
+ wsDebug("🔍 [WebSocket Server] Sending CHECK_UNIQUE_FIELD_SUCCESS response");
1274
+ ws.send(JSON.stringify(response));
1275
+ }
1276
+ break;
1277
+ case "COUNT":
1278
+ {
1279
+ const request = withDecodedFilter(payload);
1280
+ assertReadRequest(request.path, request);
1281
+ const response = {
1282
+ type: "COUNT_SUCCESS",
1283
+ payload: { count: await (await getScopedDelegate()).count(request) },
1284
+ requestId
1285
+ };
1286
+ ws.send(JSON.stringify(response));
1287
+ }
1288
+ break;
1289
+ case "EXECUTE_SQL":
1290
+ {
1291
+ const { sql, options, mode } = payload;
1292
+ const cancel = mode === "script" && typeof requestId === "string" && !sqlRuns.has(requestId) ? new AbortController() : void 0;
1293
+ if (cancel && requestId) sqlRuns.set(requestId, cancel);
1294
+ try {
1295
+ const admin = (await getScopedDelegate()).admin;
1296
+ if (!isSQLAdmin(admin)) {
1297
+ sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
1298
+ break;
1299
+ }
1300
+ const script = mode === "script" && typeof admin.runSqlScript === "function" ? await admin.runSqlScript(String(sql), {
1301
+ database: typeof options?.database === "string" ? options.database : void 0,
1302
+ role: typeof options?.role === "string" ? options.role : void 0,
1303
+ ...cancel ? { signal: cancel.signal } : {}
1304
+ }) : void 0;
1305
+ const result = script ? script.rows : await admin.executeSql(sql, {
1306
+ ...options,
1307
+ isolateSession: true,
1308
+ maxResultBytes: SQL_RESULT_BYTE_BUDGET
1309
+ });
1310
+ const ranAs = typeof options?.role === "string" && options.role ? await sqlRoleRanAs(admin, options.role, typeof options?.database === "string" ? options.database : void 0) : void 0;
1311
+ if (process.env.NODE_ENV !== "production") wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
1312
+ const auditSession = clientSessions.get(clientId);
1313
+ logger.info("[SQL Audit] WebSocket SQL execution", {
1314
+ sql: redactSqlLiterals(String(sql)).substring(0, 500),
1315
+ database: options?.database,
1316
+ role: options?.role,
1317
+ effectiveRole: effectiveSqlRole(options?.role),
1318
+ paramCount: Array.isArray(options?.params) ? options.params.length : 0,
1319
+ resultRows: Array.isArray(result) ? result.length : "unknown",
1320
+ uid: auditSession?.user?.uid ?? "unknown",
1321
+ roles: auditSession?.user?.roles ?? [],
1322
+ isAdmin: auditSession?.user?.isAdmin ?? false,
1323
+ ...auditSession?.user?.impersonator ? { impersonatedBy: auditSession.user.impersonator } : {},
1324
+ requestId
1325
+ });
1326
+ const response = {
1327
+ type: "EXECUTE_SQL_SUCCESS",
1328
+ payload: {
1329
+ ...script ? {
1330
+ ...scriptDescription(script),
1331
+ result
1332
+ } : { result },
1333
+ ...ranAs ? { effectiveRole: ranAs } : {}
1334
+ },
1335
+ requestId
1336
+ };
1337
+ ws.send(JSON.stringify(response));
1338
+ } catch (sqlError) {
1339
+ if (sqlError instanceof SqlResultTooLargeError) {
1340
+ ws.send(JSON.stringify({
1341
+ type: "ERROR",
1342
+ requestId,
1343
+ payload: { error: {
1344
+ message: sqlError.message,
1345
+ code: "RESULT_TOO_LARGE"
1346
+ } }
1347
+ }));
1348
+ break;
1349
+ }
1350
+ sendError("ERROR", "SQL_ERROR", extractErrorMessage(sqlError));
1351
+ } finally {
1352
+ if (cancel && requestId && sqlRuns.get(requestId) === cancel) sqlRuns.delete(requestId);
1353
+ }
1354
+ }
1355
+ break;
1356
+ case "CANCEL_SQL":
1357
+ {
1358
+ const target = payload?.requestId;
1359
+ const run = typeof target === "string" ? sqlRuns.get(target) : void 0;
1360
+ run?.abort();
1361
+ ws.send(JSON.stringify({
1362
+ type: "CANCEL_SQL_SUCCESS",
1363
+ requestId,
1364
+ payload: { cancelled: run !== void 0 }
1365
+ }));
1366
+ }
1367
+ break;
1368
+ case "FETCH_DATABASES":
1369
+ {
1370
+ wsDebug("📚 [WebSocket Server] Processing FETCH_DATABASES request");
1371
+ const admin = (await getScopedDelegate()).admin;
1372
+ let databases = [];
1373
+ if (isSQLAdmin(admin) && admin.fetchAvailableDatabases) databases = await admin.fetchAvailableDatabases();
1374
+ wsDebug(`📚 [WebSocket Server] Fetched ${databases.length} databases.`);
1375
+ const response = {
1376
+ type: "FETCH_DATABASES_SUCCESS",
1377
+ payload: { databases },
1378
+ requestId
1379
+ };
1380
+ ws.send(JSON.stringify(response));
1381
+ }
1382
+ break;
1383
+ case "FETCH_ROLES":
1384
+ {
1385
+ wsDebug("👤 [WebSocket Server] Processing FETCH_ROLES request");
1386
+ const admin = (await getScopedDelegate()).admin;
1387
+ let roles = [];
1388
+ if (isSQLAdmin(admin) && admin.fetchAvailableRoles) roles = await admin.fetchAvailableRoles();
1389
+ wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} roles.`);
1390
+ const response = {
1391
+ type: "FETCH_ROLES_SUCCESS",
1392
+ payload: { roles },
1393
+ requestId
1394
+ };
1395
+ ws.send(JSON.stringify(response));
1396
+ }
1397
+ break;
1398
+ case "FETCH_APPLICATION_ROLES":
1399
+ {
1400
+ wsDebug("👤 [WebSocket Server] Processing FETCH_APPLICATION_ROLES request");
1401
+ const admin = (await getScopedDelegate()).admin;
1402
+ let roles = [];
1403
+ if (isSQLAdmin(admin) && admin.fetchApplicationRoles) roles = await admin.fetchApplicationRoles();
1404
+ wsDebug(`👤 [WebSocket Server] Fetched ${roles.length} application roles.`);
1405
+ const response = {
1406
+ type: "FETCH_APPLICATION_ROLES_SUCCESS",
1407
+ payload: { roles },
1408
+ requestId
1409
+ };
1410
+ ws.send(JSON.stringify(response));
1411
+ }
1412
+ break;
1413
+ case "FETCH_CURRENT_DATABASE":
1414
+ {
1415
+ wsDebug("📚 [WebSocket Server] Processing FETCH_CURRENT_DATABASE request");
1416
+ const admin = (await getScopedDelegate()).admin;
1417
+ let database = void 0;
1418
+ if (isSQLAdmin(admin) && admin.fetchCurrentDatabase) database = await admin.fetchCurrentDatabase();
1419
+ const response = {
1420
+ type: "FETCH_CURRENT_DATABASE_SUCCESS",
1421
+ payload: { database },
1422
+ requestId
1423
+ };
1424
+ ws.send(JSON.stringify(response));
1425
+ }
1426
+ break;
1427
+ case "FETCH_UNMAPPED_TABLES":
1428
+ {
1429
+ wsDebug("📋 [WebSocket Server] Processing FETCH_UNMAPPED_TABLES request");
1430
+ const admin = (await getScopedDelegate()).admin;
1431
+ let tables = [];
1432
+ if (isSchemaAdmin(admin) && admin.fetchUnmappedTables) tables = await admin.fetchUnmappedTables(payload?.mappedPaths);
1433
+ wsDebug(`📋 [WebSocket Server] Fetched ${tables.length} unmapped tables.`);
1434
+ const response = {
1435
+ type: "FETCH_UNMAPPED_TABLES_SUCCESS",
1436
+ payload: { tables },
1437
+ requestId
1438
+ };
1439
+ ws.send(JSON.stringify(response));
1440
+ }
1441
+ break;
1442
+ case "FETCH_TABLE_METADATA":
1443
+ {
1444
+ wsDebug("📋 [WebSocket Server] Processing FETCH_TABLE_METADATA request");
1445
+ const { tableName } = payload;
1446
+ const admin = (await getScopedDelegate()).admin;
1447
+ let metadata;
1448
+ if (isSchemaAdmin(admin) && admin.fetchTableMetadata) metadata = await admin.fetchTableMetadata(tableName);
1449
+ wsDebug(`📋 [WebSocket Server] Fetched metadata for table '${tableName}'. (${metadata?.columns?.length ?? 0} columns)`);
1450
+ const response = {
1451
+ type: "FETCH_TABLE_METADATA_SUCCESS",
1452
+ payload: { metadata },
1453
+ requestId
1454
+ };
1455
+ ws.send(JSON.stringify(response));
1456
+ }
1457
+ break;
1458
+ case "CREATE_BRANCH":
1459
+ {
1460
+ wsDebug("🌿 [WebSocket Server] Processing CREATE_BRANCH request");
1461
+ const { name, options } = payload;
1462
+ const delegate = await getScopedDelegate();
1463
+ if (!delegate.admin?.createBranch) {
1464
+ sendError("ERROR", "NOT_SUPPORTED", "Database branching is not available. Configure adminConnectionString.");
1465
+ break;
1466
+ }
1467
+ const branch = await delegate.admin.createBranch(name, options);
1468
+ wsDebug(`🌿 [WebSocket Server] Branch created: ${branch.name}`);
1469
+ const response = {
1470
+ type: "CREATE_BRANCH_SUCCESS",
1471
+ payload: { branch },
1472
+ requestId
1473
+ };
1474
+ ws.send(JSON.stringify(response));
1475
+ }
1476
+ break;
1477
+ case "DELETE_BRANCH":
1478
+ {
1479
+ wsDebug("🗑️ [WebSocket Server] Processing DELETE_BRANCH request");
1480
+ const { name: branchName } = payload;
1481
+ const delegate = await getScopedDelegate();
1482
+ if (!delegate.admin?.deleteBranch) {
1483
+ sendError("ERROR", "NOT_SUPPORTED", "Database branching is not available.");
1484
+ break;
1485
+ }
1486
+ await delegate.admin.deleteBranch(branchName);
1487
+ wsDebug(`🗑️ [WebSocket Server] Branch deleted: ${branchName}`);
1488
+ const response = {
1489
+ type: "DELETE_BRANCH_SUCCESS",
1490
+ payload: { success: true },
1491
+ requestId
1492
+ };
1493
+ ws.send(JSON.stringify(response));
1494
+ }
1495
+ break;
1496
+ case "LIST_BRANCHES":
1497
+ {
1498
+ wsDebug("🌿 [WebSocket Server] Processing LIST_BRANCHES request");
1499
+ const delegate = await getScopedDelegate();
1500
+ let branches = [];
1501
+ if (delegate.admin?.listBranches) branches = await delegate.admin.listBranches();
1502
+ wsDebug(`🌿 [WebSocket Server] Listed ${branches.length} branches.`);
1503
+ const response = {
1504
+ type: "LIST_BRANCHES_SUCCESS",
1505
+ payload: { branches },
1506
+ requestId
1507
+ };
1508
+ ws.send(JSON.stringify(response));
1509
+ }
1510
+ break;
1511
+ case "subscribe_collection":
1512
+ case "subscribe_one":
1513
+ case "unsubscribe":
1514
+ case "join_channel":
1515
+ case "leave_channel":
1516
+ case "broadcast":
1517
+ case "presence_track":
1518
+ case "presence_untrack":
1519
+ case "presence_state":
1520
+ case "channel_history": {
1521
+ wsDebug("🔄 [WebSocket Server] Routing realtime message to RealtimeService:", type);
1522
+ const authContext = sessionAuthContext(clientSessions.get(clientId));
1523
+ await realtimeService.handleClientMessage(clientId, {
1524
+ type,
1525
+ payload,
1526
+ subscriptionId: payload?.subscriptionId
1527
+ }, authContext);
1528
+ break;
1529
+ }
1530
+ default:
1531
+ logger.warn("[WebSocket Server] Unknown message type", { detail: type });
1532
+ throw ApiError.badRequest(`Unknown message type '${String(type)}'.`, "UNKNOWN_MESSAGE_TYPE");
1533
+ }
1534
+ } catch (error) {
1535
+ if (error instanceof ListLimitError) {
1536
+ logger.warn(`[WebSocket Server] Refused a list read: ${error.message}`);
1537
+ ws.send(JSON.stringify({
1538
+ type: "ERROR",
1539
+ requestId,
1540
+ payload: { error: {
1541
+ message: error.message,
1542
+ code: "INVALID_LIMIT"
1543
+ } }
1544
+ }));
1545
+ return;
1546
+ }
1547
+ if (error instanceof BranchingUnsupportedError || error?.name === "BranchingUnsupportedError") {
1548
+ const refusal = error;
1549
+ logger.warn(`[WebSocket Server] Refused a branch operation: ${refusal.message}`);
1550
+ ws.send(JSON.stringify({
1551
+ type: "ERROR",
1552
+ requestId,
1553
+ payload: { error: {
1554
+ message: refusal.message,
1555
+ code: "BRANCHING_UNSUPPORTED"
1556
+ } }
1557
+ }));
1558
+ return;
1559
+ }
1560
+ const answer = declaredErrorAnswer(error);
1561
+ if (answer) {
1562
+ const line = `[WebSocket Server] Refused: ${answer.code} ${answer.message}`;
1563
+ if (answer.expected) logger.debug(line);
1564
+ else logger.warn(line);
1565
+ ws.send(JSON.stringify({
1566
+ type: "ERROR",
1567
+ requestId,
1568
+ payload: { error: {
1569
+ message: answer.message,
1570
+ code: answer.code,
1571
+ ...answer.details !== void 0 && { details: answer.details }
1572
+ } }
1573
+ }));
1574
+ return;
1575
+ }
1576
+ logger.error("💥 [WebSocket Server] Error handling message", { error });
1577
+ if (error instanceof Error) logger.error("Stack trace", { detail: error.stack });
1578
+ const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : extractErrorMessage(error);
1579
+ const errorResponse = {
1580
+ type: "ERROR",
1581
+ requestId,
1582
+ payload: { error: {
1583
+ message: errorMessage,
1584
+ code: "INTERNAL_ERROR"
1585
+ } }
1586
+ };
1587
+ ws.send(JSON.stringify(errorResponse));
1588
+ }
1589
+ });
1590
+ });
1591
+ const stopReaping = reapSilentSockets(wss);
1592
+ wss.on("close", stopReaping);
1593
+ if (typeof server.on === "function") server.on("close", stopReaping);
1594
+ const stopBudgeting = budgetSendBuffers(wss);
1595
+ wss.on("close", stopBudgeting);
1596
+ if (typeof server.on === "function") server.on("close", stopBudgeting);
1597
+ const stopHeartbeats = startHeartbeats(() => heartbeatSockets, heartbeatIntervalMs);
1598
+ wss.on("close", stopHeartbeats);
1599
+ if (typeof server.on === "function") server.on("close", stopHeartbeats);
1600
+ }
1601
+ //#endregion
1602
+ export { createPostgresWebSocket as a, websocket_exports as c, TOO_MANY_SOCKETS_CLOSE_CODE as i, PUBLIC_TYPES as n, isSocketRealtimeService as o, SESSION_ENDED_CLOSE_CODE as r, socketRealtimeMissing as s, ADMIN_PLANE_TYPES as t };
1603
+
1604
+ //# sourceMappingURL=websocket-BLGc5yhs.js.map