@supalive/core 0.1.4 → 0.1.6

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 (222) hide show
  1. package/README.md +17 -2
  2. package/dist/bytes-AuEkyxeU.js +19 -0
  3. package/dist/bytes-AuEkyxeU.js.map +1 -0
  4. package/dist/codec-BHAmGEIQ.js +62 -0
  5. package/dist/codec-BHAmGEIQ.js.map +1 -0
  6. package/dist/helper-CiacMqje.d.ts +14 -0
  7. package/dist/helper-CiacMqje.d.ts.map +1 -0
  8. package/dist/helper-wGbzz6zo.js +42 -0
  9. package/dist/helper-wGbzz6zo.js.map +1 -0
  10. package/dist/index-CaWMDqq0.d.ts +1754 -0
  11. package/dist/index-CaWMDqq0.d.ts.map +1 -0
  12. package/dist/mysql-Cxd4Uq5p.d.ts +104 -0
  13. package/dist/mysql-Cxd4Uq5p.d.ts.map +1 -0
  14. package/dist/mysql-bvnjYWvZ.js +570 -0
  15. package/dist/mysql-bvnjYWvZ.js.map +1 -0
  16. package/dist/overlap-checker-Ct_HP_iB.js +187 -0
  17. package/dist/overlap-checker-Ct_HP_iB.js.map +1 -0
  18. package/dist/postgres-B8u1YN8X.js +824 -0
  19. package/dist/postgres-B8u1YN8X.js.map +1 -0
  20. package/dist/postgres-Ck-xt72U.d.ts +108 -0
  21. package/dist/postgres-Ck-xt72U.d.ts.map +1 -0
  22. package/dist/procedure-DX87tyef.js +124 -0
  23. package/dist/procedure-DX87tyef.js.map +1 -0
  24. package/dist/query-Bh0RNxDz.js +673 -0
  25. package/dist/query-Bh0RNxDz.js.map +1 -0
  26. package/dist/realtime_db-DOCgqbwi.js +247 -0
  27. package/dist/realtime_db-DOCgqbwi.js.map +1 -0
  28. package/dist/router-DlTYWpop.js +81 -0
  29. package/dist/router-DlTYWpop.js.map +1 -0
  30. package/dist/schema-Dd7u0A1L.js +109 -0
  31. package/dist/schema-Dd7u0A1L.js.map +1 -0
  32. package/dist/src/client/index.d.ts +2 -17
  33. package/dist/src/client/index.js +1152 -18
  34. package/dist/src/client/index.js.map +1 -1
  35. package/dist/src/exports/mysql.d.ts +2 -2
  36. package/dist/src/exports/mysql.js +2 -2
  37. package/dist/src/exports/postgres.d.ts +2 -2
  38. package/dist/src/exports/postgres.js +2 -2
  39. package/dist/src/exports/procedure.d.ts +3 -5
  40. package/dist/src/exports/procedure.js +6 -5
  41. package/dist/src/exports/schema-sql.d.ts +18 -0
  42. package/dist/src/exports/schema-sql.d.ts.map +1 -0
  43. package/dist/src/exports/schema-sql.js +161 -0
  44. package/dist/src/exports/schema-sql.js.map +1 -0
  45. package/dist/src/exports/server.d.ts +368 -2
  46. package/dist/src/exports/server.d.ts.map +1 -1
  47. package/dist/src/exports/server.js +1346 -2
  48. package/dist/src/exports/server.js.map +1 -1
  49. package/dist/src/exports/subscription-manager-worker-entry.d.ts +1 -0
  50. package/dist/src/exports/subscription-manager-worker-entry.js +115 -0
  51. package/dist/src/exports/subscription-manager-worker-entry.js.map +1 -0
  52. package/dist/src/exports/types.d.ts +4 -6
  53. package/dist/src/exports/types.js +5 -6
  54. package/dist/subscription-worker-pahX0zxg.js +511 -0
  55. package/dist/subscription-worker-pahX0zxg.js.map +1 -0
  56. package/dist/types_client_rpc-CIsjQQGG.js +105 -0
  57. package/dist/types_client_rpc-CIsjQQGG.js.map +1 -0
  58. package/dist/types_db-DXj_l9n-.js +168 -0
  59. package/dist/types_db-DXj_l9n-.js.map +1 -0
  60. package/dist/types_server-Dk6o_B7N.js +24 -0
  61. package/dist/types_server-Dk6o_B7N.js.map +1 -0
  62. package/dist/types_server-TFo6SWDH.d.ts +216 -0
  63. package/dist/types_server-TFo6SWDH.d.ts.map +1 -0
  64. package/package.json +7 -3
  65. package/dist/src/client/caller_client.d.ts +0 -42
  66. package/dist/src/client/caller_client.d.ts.map +0 -1
  67. package/dist/src/client/caller_client.js +0 -77
  68. package/dist/src/client/caller_client.js.map +0 -1
  69. package/dist/src/client/client.d.ts +0 -149
  70. package/dist/src/client/client.d.ts.map +0 -1
  71. package/dist/src/client/client.js +0 -308
  72. package/dist/src/client/client.js.map +0 -1
  73. package/dist/src/client/index.d.ts.map +0 -1
  74. package/dist/src/client/sub_key.d.ts +0 -20
  75. package/dist/src/client/sub_key.d.ts.map +0 -1
  76. package/dist/src/client/sub_key.js +0 -37
  77. package/dist/src/client/sub_key.js.map +0 -1
  78. package/dist/src/client/ws_client_manager.d.ts +0 -268
  79. package/dist/src/client/ws_client_manager.d.ts.map +0 -1
  80. package/dist/src/client/ws_client_manager.js +0 -1013
  81. package/dist/src/client/ws_client_manager.js.map +0 -1
  82. package/dist/src/config.d.ts +0 -117
  83. package/dist/src/config.d.ts.map +0 -1
  84. package/dist/src/config.js +0 -2
  85. package/dist/src/config.js.map +0 -1
  86. package/dist/src/db/bytes.d.ts +0 -4
  87. package/dist/src/db/bytes.d.ts.map +0 -1
  88. package/dist/src/db/bytes.js +0 -24
  89. package/dist/src/db/bytes.js.map +0 -1
  90. package/dist/src/db/cache.d.ts +0 -30
  91. package/dist/src/db/cache.d.ts.map +0 -1
  92. package/dist/src/db/cache.js +0 -153
  93. package/dist/src/db/cache.js.map +0 -1
  94. package/dist/src/db/codec.d.ts +0 -10
  95. package/dist/src/db/codec.d.ts.map +0 -1
  96. package/dist/src/db/codec.js +0 -73
  97. package/dist/src/db/codec.js.map +0 -1
  98. package/dist/src/db/context.d.ts +0 -28
  99. package/dist/src/db/context.d.ts.map +0 -1
  100. package/dist/src/db/context.js +0 -147
  101. package/dist/src/db/context.js.map +0 -1
  102. package/dist/src/db/db.d.ts +0 -154
  103. package/dist/src/db/db.d.ts.map +0 -1
  104. package/dist/src/db/db.js +0 -23
  105. package/dist/src/db/db.js.map +0 -1
  106. package/dist/src/db/mysql.d.ts +0 -120
  107. package/dist/src/db/mysql.d.ts.map +0 -1
  108. package/dist/src/db/mysql.js +0 -708
  109. package/dist/src/db/mysql.js.map +0 -1
  110. package/dist/src/db/postgres.d.ts +0 -116
  111. package/dist/src/db/postgres.d.ts.map +0 -1
  112. package/dist/src/db/postgres.js +0 -998
  113. package/dist/src/db/postgres.js.map +0 -1
  114. package/dist/src/db/query.d.ts +0 -174
  115. package/dist/src/db/query.d.ts.map +0 -1
  116. package/dist/src/db/query.js +0 -790
  117. package/dist/src/db/query.js.map +0 -1
  118. package/dist/src/db/query_definition.d.ts +0 -54
  119. package/dist/src/db/query_definition.d.ts.map +0 -1
  120. package/dist/src/db/query_definition.js +0 -47
  121. package/dist/src/db/query_definition.js.map +0 -1
  122. package/dist/src/db/realtime_db.d.ts +0 -19
  123. package/dist/src/db/realtime_db.d.ts.map +0 -1
  124. package/dist/src/db/realtime_db.js +0 -98
  125. package/dist/src/db/realtime_db.js.map +0 -1
  126. package/dist/src/db/schema.d.ts +0 -110
  127. package/dist/src/db/schema.d.ts.map +0 -1
  128. package/dist/src/db/schema.js +0 -47
  129. package/dist/src/db/schema.js.map +0 -1
  130. package/dist/src/db/types_client_rpc.d.ts +0 -157
  131. package/dist/src/db/types_client_rpc.d.ts.map +0 -1
  132. package/dist/src/db/types_client_rpc.js +0 -106
  133. package/dist/src/db/types_client_rpc.js.map +0 -1
  134. package/dist/src/db/types_db.d.ts +0 -236
  135. package/dist/src/db/types_db.d.ts.map +0 -1
  136. package/dist/src/db/types_db.js +0 -188
  137. package/dist/src/db/types_db.js.map +0 -1
  138. package/dist/src/exports/mysql.d.ts.map +0 -1
  139. package/dist/src/exports/mysql.js.map +0 -1
  140. package/dist/src/exports/postgres.d.ts.map +0 -1
  141. package/dist/src/exports/postgres.js.map +0 -1
  142. package/dist/src/exports/procedure.d.ts.map +0 -1
  143. package/dist/src/exports/procedure.js.map +0 -1
  144. package/dist/src/exports/types.d.ts.map +0 -1
  145. package/dist/src/exports/types.js.map +0 -1
  146. package/dist/src/helper.d.ts +0 -11
  147. package/dist/src/helper.d.ts.map +0 -1
  148. package/dist/src/helper.js +0 -45
  149. package/dist/src/helper.js.map +0 -1
  150. package/dist/src/logger.d.ts +0 -7
  151. package/dist/src/logger.d.ts.map +0 -1
  152. package/dist/src/logger.js +0 -47
  153. package/dist/src/logger.js.map +0 -1
  154. package/dist/src/router/index.d.ts +0 -4
  155. package/dist/src/router/index.d.ts.map +0 -1
  156. package/dist/src/router/index.js +0 -4
  157. package/dist/src/router/index.js.map +0 -1
  158. package/dist/src/router/procedure.d.ts +0 -204
  159. package/dist/src/router/procedure.d.ts.map +0 -1
  160. package/dist/src/router/procedure.js +0 -122
  161. package/dist/src/router/procedure.js.map +0 -1
  162. package/dist/src/router/router.d.ts +0 -88
  163. package/dist/src/router/router.d.ts.map +0 -1
  164. package/dist/src/router/router.js +0 -88
  165. package/dist/src/router/router.js.map +0 -1
  166. package/dist/src/server/hash.d.ts +0 -12
  167. package/dist/src/server/hash.d.ts.map +0 -1
  168. package/dist/src/server/hash.js +0 -20
  169. package/dist/src/server/hash.js.map +0 -1
  170. package/dist/src/server/hash.test.d.ts +0 -2
  171. package/dist/src/server/hash.test.d.ts.map +0 -1
  172. package/dist/src/server/hash.test.js +0 -43
  173. package/dist/src/server/hash.test.js.map +0 -1
  174. package/dist/src/server/index.d.ts +0 -9
  175. package/dist/src/server/index.d.ts.map +0 -1
  176. package/dist/src/server/index.js +0 -8
  177. package/dist/src/server/index.js.map +0 -1
  178. package/dist/src/server/overlap-checker.d.ts +0 -18
  179. package/dist/src/server/overlap-checker.d.ts.map +0 -1
  180. package/dist/src/server/overlap-checker.js +0 -247
  181. package/dist/src/server/overlap-checker.js.map +0 -1
  182. package/dist/src/server/sub_hash.d.ts +0 -23
  183. package/dist/src/server/sub_hash.d.ts.map +0 -1
  184. package/dist/src/server/sub_hash.js +0 -38
  185. package/dist/src/server/sub_hash.js.map +0 -1
  186. package/dist/src/server/subscription-manager-client.d.ts +0 -120
  187. package/dist/src/server/subscription-manager-client.d.ts.map +0 -1
  188. package/dist/src/server/subscription-manager-client.js +0 -207
  189. package/dist/src/server/subscription-manager-client.js.map +0 -1
  190. package/dist/src/server/subscription-manager-types.d.ts +0 -168
  191. package/dist/src/server/subscription-manager-types.d.ts.map +0 -1
  192. package/dist/src/server/subscription-manager-types.js +0 -94
  193. package/dist/src/server/subscription-manager-types.js.map +0 -1
  194. package/dist/src/server/subscription-manager-worker-entry.d.ts +0 -2
  195. package/dist/src/server/subscription-manager-worker-entry.d.ts.map +0 -1
  196. package/dist/src/server/subscription-manager-worker-entry.js +0 -127
  197. package/dist/src/server/subscription-manager-worker-entry.js.map +0 -1
  198. package/dist/src/server/subscription-manager-worker-protocol.d.ts +0 -40
  199. package/dist/src/server/subscription-manager-worker-protocol.d.ts.map +0 -1
  200. package/dist/src/server/subscription-manager-worker-protocol.js +0 -20
  201. package/dist/src/server/subscription-manager-worker-protocol.js.map +0 -1
  202. package/dist/src/server/subscription-manager.d.ts +0 -32
  203. package/dist/src/server/subscription-manager.d.ts.map +0 -1
  204. package/dist/src/server/subscription-manager.js +0 -249
  205. package/dist/src/server/subscription-manager.js.map +0 -1
  206. package/dist/src/server/subscription-manager.test.d.ts +0 -2
  207. package/dist/src/server/subscription-manager.test.d.ts.map +0 -1
  208. package/dist/src/server/subscription-manager.test.js +0 -610
  209. package/dist/src/server/subscription-manager.test.js.map +0 -1
  210. package/dist/src/server/subscription-worker.d.ts +0 -32
  211. package/dist/src/server/subscription-worker.d.ts.map +0 -1
  212. package/dist/src/server/subscription-worker.js +0 -190
  213. package/dist/src/server/subscription-worker.js.map +0 -1
  214. package/dist/src/server/supalive-server.d.ts +0 -158
  215. package/dist/src/server/supalive-server.d.ts.map +0 -1
  216. package/dist/src/server/supalive-server.js +0 -1048
  217. package/dist/src/server/supalive-server.js.map +0 -1
  218. package/dist/src/server/types_server.d.ts +0 -96
  219. package/dist/src/server/types_server.d.ts.map +0 -1
  220. package/dist/src/server/types_server.js +0 -36
  221. package/dist/src/server/types_server.js.map +0 -1
  222. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -1,1048 +0,0 @@
1
- import { createServer } from "http";
2
- import { WebSocketServer, WebSocket } from "ws";
3
- import { v4 as uuidv4 } from "uuid";
4
- import Redis from "ioredis";
5
- import { retry, handleAll, ExponentialBackoff } from "cockatiel";
6
- import { stableStringify, supaliveStringify } from "../helper";
7
- import { getContextRegistry, } from "../router";
8
- import { sleep, SupaliveDb } from "../db/realtime_db";
9
- import { AuthenticationError, ClientMessageSchema } from "../db/types_client_rpc";
10
- import { RemoteSubscriptionMessageSchema, } from "./types_server";
11
- import { PgDatabase } from "../db/postgres";
12
- import { MySqlDatabase } from "../db/mysql";
13
- import { ANONYMOUS_IDENTITY, generateSubscriptionId, generateCacheKey, getHashOf, } from "./sub_hash";
14
- import { CacheLayer } from "../db/cache";
15
- import { SubManagerClient } from "./subscription-manager-client";
16
- import { appInstanceId, logger as baseLogger, logLevelOf } from "../logger";
17
- const redisRetryPolicy = retry(handleAll, {
18
- maxAttempts: 3,
19
- backoff: new ExponentialBackoff({
20
- initialDelay: 100,
21
- maxDelay: 5000,
22
- exponent: 2,
23
- }),
24
- });
25
- /**
26
- * Global broadcast channel used to signal column-type cache invalidation
27
- * across every running server instance. Publishers (migration tooling,
28
- * the publishSchemaReload helper below) send a JSON message of shape
29
- * `{ type: "schema:reload"; tables?: string[]; originInstance?: string }`.
30
- * Subscribers re-bootstrap from PG (or Redis) so the next batched write
31
- * sees up-to-date column metadata without a process restart.
32
- */
33
- const SCHEMA_RELOAD_CHANNEL = "supalive:schema:reload";
34
- /**
35
- * Per-socket cap on pending mutations. The Nth+1 mutation from a single
36
- * client gets rejected with TOO_MANY_CONCURRENT_MUTATIONS rather than
37
- * being enqueued — this is rate-limit pressure on a misbehaving client,
38
- * not a hard system limit.
39
- *
40
- * Mirrors Convex's OPERATION_QUEUE_BUFFER_SIZE (1000). Mutations drain
41
- * one-at-a-time per socket, so a healthy client never approaches this.
42
- */
43
- const MAX_MUTATION_QUEUE_BUFFER = 1000;
44
- /**
45
- * Per-socket cap on in-flight actions. Actions run in parallel (unlike
46
- * mutations) because they may do long external work (HTTP, queue calls);
47
- * the cap exists so one socket can't open thousands of outbound
48
- * connections from inside the server.
49
- */
50
- const MAX_ACTION_CONCURRENCY = 1000;
51
- /**
52
- * Default and env override for the per-statement DB timeout. PG enforces
53
- * this via `statement_timeout` (server cancels), MySQL via the per-query
54
- * `timeout` option (driver issues KILL QUERY). Set 0 to disable.
55
- */
56
- const DB_QUERY_TIMEOUT_MS = Number(process.env.SUPALIVE_DB_QUERY_TIMEOUT_MS ?? 30_000);
57
- // ─── Heartbeat constants ─────────────────────────────────────
58
- //
59
- // Two independent jobs run on a single per-session interval:
60
- //
61
- // 1. Dead-TCP detection: terminate sockets that have gone fully silent
62
- // (no inbound text/ping/pong) past `CLIENT_TIMEOUT_MS`. Otherwise
63
- // emit a ws-protocol ping (browser auto-pongs, refreshing `session.lastReceivedAt`).
64
- // 2. Client-watchdog refresh: if the application channel has been idle
65
- // for at least `HEARTBEAT_INTERVAL_MS`, send a `{type:"ping"}` JSON
66
- // frame so the client's inactivity watchdog stays fresh. Busy
67
- // sockets skip this (the `lastSentAt` check makes it idle-only).
68
- //
69
- // Both fire on the same `heartbeatTimer` — one `setInterval` per session.
70
- const HEARTBEAT_INTERVAL_MS = Number(process.env.SUPALIVE_HEARTBEAT_INTERVAL_MS ?? 15_000);
71
- const CLIENT_TIMEOUT_MS = 120_000;
72
- /**
73
- * TTL on the Redis lock that elects the recompute winner when the writer
74
- * doesn't hold the sub locally. Each (subId, commitTs) gets its own lock
75
- * key, so this only needs to bound the worst-case recompute duration —
76
- * if the winner crashes mid-recompute, the lock expires and the next
77
- * mutation triggers a new race.
78
- */
79
- const RECOMPUTE_LOCK_TTL_SECONDS = Number(process.env.SUPALIVE_RECOMPUTE_LOCK_TTL_SECONDS ?? 10);
80
- const logger = baseLogger.child({
81
- "name": "SUPALIVE",
82
- }, { level: logLevelOf("SERVER_LOG_LEVEL") });
83
- /**
84
- * Supalive WebSocket Server
85
- */
86
- export class SupaliveWebSocketServer {
87
- config;
88
- instanceId;
89
- httpServer;
90
- wss;
91
- cache;
92
- db;
93
- contextRegistry;
94
- router;
95
- sessions = new Map();
96
- serverSubscriptions = new Map();
97
- subManagerClient;
98
- redisSub;
99
- stats = {
100
- activeConnections: 0,
101
- totalConnections: 0,
102
- activeSubscriptions: 0,
103
- };
104
- constructor(config) {
105
- this.config = {
106
- port: 3000,
107
- host: "0.0.0.0",
108
- contextName: "default",
109
- sessionTtlSeconds: 3600,
110
- ...config,
111
- };
112
- this.instanceId = appInstanceId;
113
- if (!config.subManagerUrl) {
114
- throw new Error("subManagerUrl config is required (WebSocket URL of the sub-manager process)");
115
- }
116
- this.subManagerClient = new SubManagerClient(config.subManagerUrl);
117
- this.cache = new CacheLayer(new Redis(config.upstash.redisUrl, {
118
- connectTimeout: 15_000,
119
- maxRetriesPerRequest: 1,
120
- keepAlive: 3000,
121
- }), this.config.cacheTtlSeconds ?? 3600);
122
- this.redisSub = new Redis(config.upstash.redisUrl, {
123
- connectTimeout: 15_000,
124
- maxRetriesPerRequest: 5,
125
- retryStrategy: (times) => {
126
- if (times > 5)
127
- return null;
128
- return Math.min(times * 200, 2000);
129
- }
130
- });
131
- // Wire the sub-manager client's recovery callbacks to drive re-registration
132
- // from this app server's serverSubscriptions whenever the RPC reconnects,
133
- // and to process the resulting batched invalidate flush.
134
- this.subManagerClient.setRecoveryDriver(this.runSubManagerRecovery.bind(this));
135
- this.subManagerClient.setAffectedHandler(this.runAffectedHandler.bind(this));
136
- this.httpServer = createServer();
137
- this.wss = new WebSocketServer({
138
- server: this.httpServer,
139
- autoPong: true,
140
- maxPayload: 100 * 1024 * 1024,
141
- // backlog: //max pending connections queue (default: 511)
142
- });
143
- this.setupWebSocketHandlers();
144
- this.subscribeToInstanceChannel();
145
- }
146
- /**
147
- * Per-session heartbeat. Fires every `HEARTBEAT_INTERVAL_MS` and does
148
- * both jobs on the same tick:
149
- *
150
- * 1. Inactivity check + ws-protocol ping (dead-TCP detection).
151
- * 2. App-level `{type:"ping"}` if the channel has been idle for at
152
- * least one heartbeat interval (client-watchdog refresh).
153
- *
154
- * The `lastSentAt` gate on (2) preserves the original "only ping when
155
- * idle" semantics: a session producing sub:updates faster than the
156
- * heartbeat interval never emits a redundant app-ping.
157
- */
158
- startSessionHeartbeat(session) {
159
- session.heartbeatTimer = setInterval(() => {
160
- // (1) Dead-TCP detection.
161
- if (Date.now() - session.lastReceivedAt > CLIENT_TIMEOUT_MS) {
162
- logger.info(`ws inactivity → terminate session ${session.id}`);
163
- try {
164
- session.ws.terminate();
165
- }
166
- catch { /* close handler cleans up */ }
167
- return;
168
- }
169
- try {
170
- session.ws.ping();
171
- }
172
- catch {
173
- // socket already broken — close handler will clean up
174
- }
175
- // (2) Client-watchdog refresh, idle-only.
176
- if (Date.now() - session.lastSentAt >= HEARTBEAT_INTERVAL_MS) {
177
- this.sendMessage(session, { type: "ping" });
178
- }
179
- }, HEARTBEAT_INTERVAL_MS);
180
- }
181
- clearSessionTimers(session) {
182
- if (session.heartbeatTimer) {
183
- clearInterval(session.heartbeatTimer);
184
- session.heartbeatTimer = null;
185
- }
186
- }
187
- registerRouter(router) {
188
- this.router = router;
189
- this.contextRegistry = getContextRegistry(router.contextName);
190
- }
191
- async initDatabase() {
192
- const db = this.createDatabase(this.config.database);
193
- this.db = SupaliveDb.create({ db });
194
- await this.bootstrapDbTypes();
195
- }
196
- /**
197
- * Eagerly load column types into the apply-path cache at server start.
198
- * In production, the result is cached in Redis under
199
- * `supalive:schema:types:${SUPALIVE_SCHEMA_VERSION ?? "v1"}` so other
200
- * instances starting cold can skip the pg_attribute round-trip. In any
201
- * other env we go straight to PG every time so local schema iteration
202
- * is never blocked by stale Redis state. Migrations should publish a
203
- * `schema:reload` message (see {@link publishSchemaReload}) after
204
- * applying DDL — every instance picks that up and re-bootstraps.
205
- */
206
- async bootstrapDbTypes(forceReload = false) {
207
- // TODO: remove this check if we needs mysql column types
208
- if (this.db.impl.dbType === "mysql") {
209
- // currently we don't use mysql column type
210
- return;
211
- }
212
- const inProd = process.env.NODE_ENV === "production";
213
- const version = process.env.SUPALIVE_SCHEMA_VERSION ?? "v1";
214
- const key = `supalive:schema:types:${version}`;
215
- try {
216
- await this.db.impl.bootstrapColumnTypes({
217
- cacheKey: key,
218
- redis: inProd && !forceReload ? this.cache : undefined,
219
- });
220
- logger.info({ columnsTypes: this.db.impl.columnTypes }, `Bootstrapped db column types (redis=${inProd}, key=${key})`);
221
- }
222
- catch (err) {
223
- logger.error(err, "Failed to bootstrap db column types");
224
- throw err;
225
- }
226
- }
227
- createDatabase(config) {
228
- if (config.type === "postgres") {
229
- return new PgDatabase(logger.child({ database: "postgres" }), {
230
- ...config,
231
- connectionString: config.connectionString,
232
- min: config.minConnections ?? 0,
233
- max: config.maxConnections ?? 20,
234
- connectionTimeoutMillis: config.connectionTimeoutMillis ?? 15_000,
235
- idleTimeoutMillis: config.idleTimeoutMillis ?? 30_000,
236
- // server-enforced per-statement timeout; pg cancels the running
237
- // query when it fires. 0 disables it.
238
- statement_timeout: DB_QUERY_TIMEOUT_MS > 0 ? DB_QUERY_TIMEOUT_MS : undefined,
239
- });
240
- }
241
- else {
242
- return new MySqlDatabase(logger.child({ database: "mysql" }), {
243
- ...config,
244
- connectionLimit: config.maxConnections ?? 20,
245
- connectTimeout: config.connectionTimeoutMillis ?? 15_000,
246
- // mysql2 pool: 0 = wait forever for a free conn (default). queueLimit
247
- // bounds the wait queue length so backpressure surfaces as an error
248
- // rather than unbounded growth.
249
- queueLimit: config.queueLimit ?? 0,
250
- // driver-side per-statement timeout applied uniformly in
251
- // MySqlDatabase.query.
252
- queryTimeoutMs: DB_QUERY_TIMEOUT_MS,
253
- });
254
- }
255
- }
256
- async start() {
257
- await this.initDatabase();
258
- return new Promise((resolve) => {
259
- this.httpServer.listen(this.config.port, this.config.host, () => {
260
- logger.info(`Server: ${this.instanceId} listening on ${this.config.host}:${this.config.port}`);
261
- resolve();
262
- });
263
- });
264
- }
265
- async stop() {
266
- this.wss.close();
267
- for (const [ws] of this.sessions) {
268
- ws.close(1001, "Server shutting down");
269
- }
270
- this.httpServer.close();
271
- logger.info("Server stopped");
272
- }
273
- getStats() {
274
- return {
275
- instanceId: this.instanceId,
276
- activeConnections: this.stats.activeConnections,
277
- totalConnections: this.stats.totalConnections,
278
- activeSubscriptions: this.stats.activeSubscriptions,
279
- };
280
- }
281
- getInstanceId() {
282
- return this.instanceId;
283
- }
284
- setupWebSocketHandlers() {
285
- this.wss.on("connection", (ws, req) => {
286
- this.handleConnection(ws, req).catch((err) => {
287
- logger.error(err, "Connection handler error");
288
- });
289
- });
290
- }
291
- async handleConnection(ws, req) {
292
- this.stats.totalConnections++;
293
- this.stats.activeConnections++;
294
- const session = {
295
- id: uuidv4(),
296
- ws,
297
- serverCtx: {},
298
- subIds: new Set(),
299
- mutationQueue: [],
300
- mutationInFlight: false,
301
- actionInFlight: 0,
302
- lastReceivedAt: Date.now(),
303
- lastSentAt: Date.now(),
304
- heartbeatTimer: null,
305
- };
306
- logger.info(`New session: ${session.id}`);
307
- this.sessions.set(ws, session);
308
- this.sendMessage(session, { type: "connected", sessionId: session.id });
309
- // Any inbound frame (text, ws-ping, ws-pong) refreshes the liveness
310
- // clock. This is the only thing keeping `CLIENT_TIMEOUT_MS` from
311
- // tripping on a silent-but-live client, since the browser auto-pong
312
- // is the only thing such a client emits.
313
- ws.on("message", (data) => {
314
- session.lastReceivedAt = Date.now();
315
- this.handleMessage(session, data.toString()).catch((err) => {
316
- logger.error(err, "Message handler error");
317
- });
318
- });
319
- ws.on("ping", () => { session.lastReceivedAt = Date.now(); });
320
- ws.on("pong", () => { session.lastReceivedAt = Date.now(); });
321
- this.startSessionHeartbeat(session);
322
- ws.on("close", (code) => {
323
- logger.info(`Connection closed: ${session.id}, code: ${code}`);
324
- this.clearSessionTimers(session);
325
- this.handleDisconnect(session);
326
- this.sessions.delete(ws);
327
- this.stats.activeConnections--;
328
- });
329
- ws.on("error", (err) => {
330
- logger.error(err, "Websocket error");
331
- });
332
- }
333
- async handleMessage(session, message) {
334
- try {
335
- const rawMsg = JSON.parse(message);
336
- const parseResult = ClientMessageSchema.safeParse(rawMsg);
337
- if (!parseResult.success) {
338
- this.sendError(session, "", "Invalid message format", "INVALID_MESSAGE");
339
- return;
340
- }
341
- await this.processMessage(session, parseResult.data);
342
- }
343
- catch (err) {
344
- logger.error(err, "Message handling error");
345
- this.sendError(session, "", "Parse error", "PARSE_ERROR");
346
- }
347
- }
348
- async processMessage(session, msg) {
349
- switch (msg.type) {
350
- case "auth":
351
- await this.handleAuth(session, msg.data);
352
- break;
353
- case "call":
354
- await this.handleCall(session, msg.id, msg.procedure, msg.input);
355
- break;
356
- case "subscribe":
357
- await this.handleSubscribe(session, msg.id, msg.procedure, msg.input);
358
- break;
359
- case "unsubscribe":
360
- await this.handleUnsubscribe(session, msg.subId);
361
- break;
362
- }
363
- }
364
- async handleAuth(session, data) {
365
- if (!this.config.verifyAuth || !data) {
366
- this.sendMessage(session, { type: "auth:success", userId: "anonymous" });
367
- return;
368
- }
369
- try {
370
- const authResult = await this.config.verifyAuth(data, session.id);
371
- if (!authResult) {
372
- this.sendMessage(session, { type: "auth:failed", message: "Invalid authentication" });
373
- return;
374
- }
375
- session.serverCtx = authResult;
376
- this.sendMessage(session, { type: "auth:success", userId: authResult.user.userId });
377
- }
378
- catch (e) {
379
- if (e instanceof AuthenticationError) {
380
- this.sendMessage(session, {
381
- type: "auth:failed",
382
- message: "Invalid authentication: " + e.message,
383
- extra: e.extra,
384
- });
385
- }
386
- else {
387
- this.sendMessage(session, { type: "auth:failed", message: "Invalid authentication" });
388
- }
389
- }
390
- }
391
- /**
392
- * Park a mutation on this session's FIFO queue and resolve when it
393
- * eventually drains. The drainer is fire-and-forget: it picks up any
394
- * newly enqueued op as long as one drain loop is running on this
395
- * session. Callers (handleCall) own the response/error for their own
396
- * op via the returned promise.
397
- */
398
- enqueueMutation(session, procedure, validatedInput) {
399
- return new Promise((resolve, reject) => {
400
- const pending = {
401
- procedure,
402
- validatedInput,
403
- resolve,
404
- reject,
405
- };
406
- session.mutationQueue.push(pending);
407
- this.drainMutationQueue(session).catch((err) => logger.error(err, "drainMutationQueue crashed"));
408
- });
409
- }
410
- /**
411
- * Drain the per-session mutation queue serially. Only one drain loop
412
- * runs at a time per session — re-entrant calls bail immediately, the
413
- * loop already in progress will pick up the new op before exiting.
414
- */
415
- async drainMutationQueue(session) {
416
- if (session.mutationInFlight)
417
- return;
418
- session.mutationInFlight = true;
419
- try {
420
- while (session.mutationQueue.length > 0) {
421
- const op = session.mutationQueue.shift();
422
- try {
423
- const fn = op.procedure.fn;
424
- const result = await this.db.mutation(async (ctx) => {
425
- return fn({ db: ctx, serverCtx: session.serverCtx }, op.validatedInput);
426
- });
427
- op.resolve(result);
428
- }
429
- catch (err) {
430
- op.reject(err);
431
- }
432
- }
433
- }
434
- finally {
435
- session.mutationInFlight = false;
436
- }
437
- }
438
- async handleCall(session, id, procedureName, input) {
439
- if (!this.router) {
440
- this.sendError(session, id, "Router not registered", "SERVER_ERROR");
441
- return;
442
- }
443
- const procedure = this.contextRegistry.getProcedure(procedureName);
444
- if (!procedure) {
445
- this.sendError(session, id, `Procedure "${procedureName}" not found`, "NOT_FOUND");
446
- return;
447
- }
448
- try {
449
- const validatedInput = procedure.inputSchema.parse(input);
450
- if (procedure.type === "query") {
451
- const result = await this.db.query(async (ctx) => {
452
- const fn = procedure.fn;
453
- return fn({ db: ctx, serverCtx: session.serverCtx }, validatedInput);
454
- });
455
- this.sendMessage(session, {
456
- type: "response",
457
- id: id,
458
- success: true,
459
- data: result,
460
- });
461
- }
462
- else if (procedure.type === "mutation") {
463
- if (session.mutationQueue.length >= MAX_MUTATION_QUEUE_BUFFER) {
464
- this.sendError(session, id, `Too many concurrent mutations on this connection (max ${MAX_MUTATION_QUEUE_BUFFER})`, "TOO_MANY_CONCURRENT_MUTATIONS");
465
- return;
466
- }
467
- const mutationResult = await this.enqueueMutation(session, procedure, validatedInput);
468
- this.sendMessage(session, {
469
- type: "response",
470
- id: id,
471
- success: true,
472
- });
473
- await this.handleMutationCommit(mutationResult.writeSet, mutationResult.commitTs);
474
- }
475
- else if (procedure.type === "action") {
476
- if (session.actionInFlight >= MAX_ACTION_CONCURRENCY) {
477
- this.sendError(session, id, `Too many concurrent actions on this connection (max ${MAX_ACTION_CONCURRENCY})`, "TOO_MANY_CONCURRENT_ACTIONS");
478
- return;
479
- }
480
- session.actionInFlight++;
481
- let result;
482
- try {
483
- const fn = procedure.fn;
484
- result = await fn({ db: this.db, serverCtx: session.serverCtx }, validatedInput);
485
- }
486
- finally {
487
- session.actionInFlight--;
488
- }
489
- this.sendMessage(session, {
490
- type: "response",
491
- id: id,
492
- success: true,
493
- data: result,
494
- });
495
- }
496
- }
497
- catch (err) {
498
- logger.error(err, `Error executing "${procedureName}"`);
499
- this.sendError(session, id, err instanceof Error ? err.message : "Execution failed", "EXECUTION_ERROR");
500
- }
501
- }
502
- async handleSubscribe(session, id, procedureName, input) {
503
- if (!this.router) {
504
- this.sendError(session, id, "Router not registered", "SERVER_ERROR");
505
- return;
506
- }
507
- const procedure = this.contextRegistry.getProcedure(procedureName);
508
- if (!procedure || procedure.type !== "query") {
509
- this.sendError(session, id, `Procedure "${procedureName}" not found or not a query`, "NOT_FOUND");
510
- return;
511
- }
512
- try {
513
- const validatedInput = procedure.inputSchema.parse(input);
514
- const queryIdentity = this.resolveQueryIdentity(procedure, session, validatedInput);
515
- const inputJsonStable = stableStringify(validatedInput);
516
- const subId = await generateSubscriptionId(procedureName, inputJsonStable, queryIdentity);
517
- const cacheKey = await generateCacheKey(procedureName, inputJsonStable, queryIdentity);
518
- const existingServerSub = this.serverSubscriptions.get(subId);
519
- let data;
520
- let dataHash;
521
- let liveResult = null;
522
- // track this session subscribed this subscription
523
- session.subIds.add(subId);
524
- if (existingServerSub) {
525
- existingServerSub.subscribedTimes += 1;
526
- const cached = await this.cache.getQueryCacheFor(existingServerSub.cacheKey);
527
- if (cached) {
528
- data = cached.data;
529
- }
530
- else {
531
- liveResult = await this.executeLiveQuery(procedureName, validatedInput, session.serverCtx);
532
- data = liveResult.data;
533
- }
534
- }
535
- else {
536
- const lastSnapshotTs = await this.db.impl.getLatestSnapshotTimestamp();
537
- const subManagerResult = await this.subManagerClient.registerSubscription({
538
- subId,
539
- cacheKey,
540
- queryName: procedureName,
541
- args: validatedInput,
542
- queryIdentity,
543
- instanceName: this.instanceId,
544
- lastSnapshotTs: lastSnapshotTs,
545
- });
546
- const cached = await this.cache.getQueryCacheFor(subManagerResult.cacheKey);
547
- if (cached && !subManagerResult.recompute) {
548
- data = cached.data;
549
- }
550
- else {
551
- liveResult = await this.executeLiveQuery(procedureName, validatedInput, session.serverCtx);
552
- data = liveResult.data;
553
- }
554
- this.serverSubscriptions.set(subId, {
555
- subId,
556
- cacheKey,
557
- subscribedTimes: 1,
558
- });
559
- this.stats.activeSubscriptions++;
560
- }
561
- if (liveResult) {
562
- const [updateResult] = await Promise.all([
563
- this.subManagerClient.updateSubscriptionReadSet({
564
- cacheKey,
565
- subId,
566
- lastSnapshotTs: liveResult.ts,
567
- readSet: liveResult.readSet,
568
- instanceName: this.instanceId,
569
- }),
570
- this.cache.setQueryCacheFor(cacheKey, data),
571
- ]);
572
- // If the sub-manager just told us this subId isn't tracked (we hit
573
- // a window between its register and a separate restart), our register
574
- // above should have rebuilt the state — log loudly if not.
575
- if (!updateResult.tracked) {
576
- logger.warn(`updateSubscriptionReadSet returned tracked:false immediately after register for ${subId} ` +
577
- `— possible sub-manager restart mid-subscribe`);
578
- }
579
- }
580
- dataHash = await getHashOf(data);
581
- // Hand the canonical subId back to the client so it doesn't need to
582
- // recompute the hash locally (which would require pulling in wasm and
583
- // wouldn't reflect per-procedure `queryIdentity` overrides).
584
- this.sendMessage(session, { type: "response", id, success: true, subId });
585
- this.sendMessage(session, { type: "sub:update", subId, data, dataHash });
586
- logger.info(`Subscribed: ${subId} for session ${session.id}`);
587
- }
588
- catch (err) {
589
- logger.error(err, `Error subscribing to "${procedureName}"`);
590
- this.sendMessage(session, { type: "response", id, success: false });
591
- this.sendError(session, id, err instanceof Error ? err.message : "Subscribe failed", "SUBSCRIBE_ERROR");
592
- }
593
- }
594
- async handleUnsubscribe(session, subId) {
595
- const serverSub = this.serverSubscriptions.get(subId);
596
- if (!serverSub) {
597
- session.subIds.delete(subId);
598
- return;
599
- }
600
- serverSub.subscribedTimes -= 1;
601
- if (serverSub.subscribedTimes <= 0) {
602
- session.subIds.delete(subId);
603
- this.serverSubscriptions.delete(subId);
604
- this.stats.activeSubscriptions--;
605
- try {
606
- await this.subManagerClient.unregisterSubscription({ subId, instanceName: this.instanceId });
607
- }
608
- catch (err) {
609
- logger.error(err, "Failed to unregister from sub-manager:");
610
- }
611
- }
612
- logger.info(`Unsubscribed: ${subId} for session ${session.id}, remaining server subs: ${serverSub.subscribedTimes}`);
613
- }
614
- async handleDisconnect(session) {
615
- logger.info(`Session disconnected: ${session.id}`);
616
- let unregisterSubs = undefined;
617
- for (const subId of session.subIds) {
618
- const sub = this.serverSubscriptions.get(subId);
619
- if (!sub)
620
- continue;
621
- sub.subscribedTimes -= 1;
622
- if (sub.subscribedTimes <= 0) {
623
- if (!unregisterSubs) {
624
- unregisterSubs = [];
625
- }
626
- unregisterSubs.push(sub);
627
- }
628
- }
629
- if (!unregisterSubs || unregisterSubs.length === 0)
630
- return;
631
- try {
632
- await this.subManagerClient.unregisterSubscriptions(unregisterSubs.map((sub) => ({ subId: sub.subId, instanceName: this.instanceId })));
633
- }
634
- catch (err) {
635
- logger.error(err, "Failed to unregister from sub-manager:");
636
- }
637
- for (const sub of unregisterSubs) {
638
- // if still not subscribing
639
- if (sub.subscribedTimes <= 0) {
640
- this.serverSubscriptions.delete(sub.subId);
641
- this.stats.activeSubscriptions--;
642
- }
643
- }
644
- logger.info(`All Subscription sessions is disconnected: ${session.id}, subs: ${unregisterSubs.length}`);
645
- }
646
- /**
647
- * Drives recovery on a sub-manager reconnect. Iterates this app server's
648
- * `serverSubscriptions`, reads each subId's registration record from
649
- * Redis (`sl:reg:<subId>`), and re-registers it with the sub-manager.
650
- *
651
- * • If the Redis record is missing, another app-server instance fully
652
- * unregistered the sub during our disconnect. We drop it locally and
653
- * send `sub:gone` to every session that was holding it.
654
- * • If the sub-manager reports `recompute: true` (cache-metadata + commit
655
- * log replay detected staleness), we kick a reexec for that sub.
656
- *
657
- * Returns after all subs have been processed. Only then does the
658
- * SubManagerClient flush its buffered invalidate writeSets.
659
- */
660
- async runSubManagerRecovery() {
661
- const snapshot = Array.from(this.serverSubscriptions.values());
662
- if (snapshot.length === 0) {
663
- logger.info("Sub-manager recovery: no subscriptions to restore");
664
- return;
665
- }
666
- logger.info(`Sub-manager recovery: restoring ${snapshot.length} subscription(s)`);
667
- const lastSnapshotTs = await this.db.impl.getLatestSnapshotTimestamp();
668
- const subIds = snapshot.map((e) => e.subId);
669
- const recordsMap = await this.cache.fetchRegistrationRecordsBatch(subIds);
670
- const registerParams = [];
671
- for (const entry of snapshot) {
672
- const record = recordsMap.get(entry.subId) ?? null;
673
- if (!record) {
674
- this.dropSubscriptionLocally(entry.subId, "registration not found after recovery");
675
- continue;
676
- }
677
- registerParams.push({ entry, record });
678
- }
679
- const recompute = [];
680
- const BATCH_SIZE = 50;
681
- for (let i = 0; i < registerParams.length; i += BATCH_SIZE) {
682
- const batch = registerParams.slice(i, i + BATCH_SIZE);
683
- const params = batch.map(({ entry, record }) => ({
684
- subId: entry.subId,
685
- cacheKey: entry.cacheKey,
686
- queryName: record.queryName,
687
- args: record.args,
688
- queryIdentity: record.queryIdentity,
689
- instanceName: this.instanceId,
690
- lastSnapshotTs,
691
- }));
692
- try {
693
- const results = await this.subManagerClient.registerSubscriptionBatch(params);
694
- for (let j = 0; j < batch.length; j++) {
695
- const { entry, record } = batch[j];
696
- const result = results[j];
697
- if (result.recompute) {
698
- recompute.push({
699
- subId: entry.subId,
700
- queryName: record.queryName,
701
- args: record.args,
702
- cacheKey: entry.cacheKey,
703
- notifyInstances: [],
704
- });
705
- }
706
- }
707
- }
708
- catch (err) {
709
- logger.error(err, `Sub-manager recovery: failed to batch register ${i}-${i + batch.length}`);
710
- }
711
- }
712
- await Promise.allSettled(recompute.map((a) => this.reexecuteAffectedQuery(a)));
713
- logger.info(`Sub-manager recovery: done (${recompute.length} reexec'd)`);
714
- }
715
- async runAffectedHandler(affected) {
716
- await Promise.allSettled(affected.map((a) => this.reexecuteAffectedQuery(a)));
717
- }
718
- /**
719
- * Removes a subscription from this app server's state and notifies every
720
- * session that was holding it. Used during recovery when the central
721
- * registration record is gone, or after a `tracked: false` reply from the
722
- * sub-manager that we can't reconcile.
723
- */
724
- dropSubscriptionLocally(subId, reason) {
725
- const serverSub = this.serverSubscriptions.get(subId);
726
- let notified = 0;
727
- if (serverSub) {
728
- this.serverSubscriptions.delete(subId);
729
- this.stats.activeSubscriptions = Math.max(0, this.stats.activeSubscriptions - 1);
730
- for (const [, session] of this.sessions) {
731
- if (session.subIds.has(subId)) {
732
- notified++;
733
- session.subIds.delete(subId);
734
- this.sendMessage(session, { type: "sub:gone", subId, reason });
735
- }
736
- }
737
- }
738
- logger.warn(`Dropped subscription ${subId} locally (${reason}); notified ${notified} session(s)`);
739
- }
740
- async handleMutationCommit(writeSet, commitTs) {
741
- if (writeSet.length === 0)
742
- return;
743
- const tables = [...new Set(writeSet.map((w) => w.table))];
744
- logger.debug(`Mutation committed for tables: [${tables.join(", ")}]`);
745
- try {
746
- const result = await this.subManagerClient.invalidateWriteset({ writeSet });
747
- logger.debug({ affected: result }, "Sub-managers invalidation result");
748
- if (result?.affected?.length > 0) {
749
- await Promise.allSettled(result.affected.map((affected) => this.dispatchAffected(affected, commitTs)));
750
- }
751
- }
752
- catch (err) {
753
- logger.error(err, "Failed to notify sub-managers");
754
- }
755
- }
756
- /**
757
- * Route an affected sub to whichever instance can actually run the
758
- * recompute:
759
- * • If this instance has a local session for the sub, recompute here
760
- * (fast path) and broadcast the result to every other instance.
761
- * • Otherwise broadcast a `recompute-race` to every instance in
762
- * `notifyInstances`. Each receiver tries to acquire a Redis lock
763
- * keyed by (subId, commitTs); the winner recomputes and publishes
764
- * an `update` via the normal broadcast path, losers no-op.
765
- *
766
- * If `notifyInstances` is empty (the sub has no live holders), we drop
767
- * the message — the next subscribe will recompute fresh state.
768
- */
769
- async dispatchAffected(affected, commitTs) {
770
- if (this.hasLocalSessionFor(affected.subId)) {
771
- await this.reexecuteAffectedQuery(affected);
772
- return;
773
- }
774
- if (affected.notifyInstances.length === 0) {
775
- logger.debug(`dispatchAffected: no live instances hold ${affected.subId} — dropping`);
776
- return;
777
- }
778
- await this.publishRecomputeRace(affected, commitTs);
779
- }
780
- hasLocalSessionFor(subId) {
781
- for (const [, session] of this.sessions) {
782
- if (session.subIds.has(subId))
783
- return true;
784
- }
785
- return false;
786
- }
787
- async publishRecomputeRace(affected, commitTs) {
788
- const payload = supaliveStringify({
789
- type: "recompute-race",
790
- subId: affected.subId,
791
- cacheKey: affected.cacheKey,
792
- queryName: affected.queryName,
793
- args: affected.args,
794
- notifyInstances: affected.notifyInstances,
795
- commitTs: commitTs.toString(),
796
- originInstance: this.instanceId,
797
- });
798
- const publishPromises = affected.notifyInstances.map(async (instance) => {
799
- if (instance === this.instanceId)
800
- return;
801
- const channel = `sub:instance:${instance}`;
802
- try {
803
- await redisRetryPolicy.execute(() => this.cache.redis.publish(channel, payload));
804
- }
805
- catch (err) {
806
- logger.error(err, `Failed to publish recompute-race to ${channel} after many retries`);
807
- }
808
- });
809
- await Promise.all(publishPromises);
810
- }
811
- async reexecuteAffectedQuery(affected) {
812
- let foundSession = null;
813
- for (const [, session] of this.sessions) {
814
- if (session.subIds.has(affected.subId)) {
815
- foundSession = session;
816
- break;
817
- }
818
- }
819
- if (!foundSession) {
820
- // Should only happen if every local session unsubscribed between
821
- // dispatchAffected's check and now. dispatchAffected forwards to
822
- // another instance when no local session exists, so we'd lose the
823
- // update for everyone in this rare race. Accept the gap.
824
- logger.debug(`Session not found for subId: ${affected.subId}`);
825
- return;
826
- }
827
- try {
828
- const liveResult = await this.executeLiveQuery(affected.queryName, affected.args, foundSession.serverCtx);
829
- const dataHash = await getHashOf(liveResult.data);
830
- const [updateResult] = await Promise.all([
831
- this.subManagerClient.updateSubscriptionReadSet({
832
- cacheKey: affected.cacheKey,
833
- subId: affected.subId,
834
- lastSnapshotTs: liveResult.ts,
835
- readSet: liveResult.readSet,
836
- instanceName: this.instanceId,
837
- }),
838
- this.cache.setQueryCacheFor(affected.cacheKey, liveResult.data),
839
- ]);
840
- // `tracked: false` means the sub-manager has no record of this subId
841
- // (it restarted before we recovered, or a concurrent unregister wiped
842
- // it). Drop the sub locally and let clients re-subscribe.
843
- if (!updateResult.tracked) {
844
- this.dropSubscriptionLocally(affected.subId, "no longer tracked by sub-manager");
845
- return;
846
- }
847
- // find any session with same subId and send update
848
- for (const [, session] of this.sessions) {
849
- if (session.subIds.has(affected.subId)) {
850
- this.sendMessage(session, {
851
- type: "sub:update",
852
- subId: affected.subId,
853
- data: liveResult.data,
854
- dataHash,
855
- });
856
- }
857
- }
858
- if (affected.notifyInstances.length > 0) {
859
- await this.publishSubscriptionUpdate(affected.subId, liveResult.data, dataHash, affected.notifyInstances);
860
- }
861
- logger.debug(`Re-executed affected query for subId: ${affected.subId}`);
862
- }
863
- catch (err) {
864
- logger.error(err, `Failed to re-execute query for subId ${affected.subId}`);
865
- }
866
- }
867
- async executeLiveQuery(procedureName, input, serverCtx) {
868
- const procedure = this.contextRegistry.getProcedure(procedureName);
869
- if (!procedure || procedure.type !== "query") {
870
- throw new Error(`Procedure not defined: ${procedureName}`);
871
- }
872
- return this.db.liveQuery(async (ctx) => {
873
- const fn = procedure.fn;
874
- return fn({ db: ctx, serverCtx }, input);
875
- });
876
- }
877
- /**
878
- * Resolve the segmentation key used in the subId/cacheKey hash for a
879
- * query subscription. Priority:
880
- *
881
- * 1. Procedure-level `queryIdentity` override:
882
- * - `false` → identity = `ANONYMOUS_IDENTITY` (cache shared across users)
883
- * - `string` → that literal
884
- * - `function` → ctx + input → string | null | undefined
885
- * 2. `config.getUserId(serverCtx)` → falls back to ANONYMOUS_IDENTITY if it
886
- * returns null/undefined or isn't configured.
887
- */
888
- resolveQueryIdentity(procedure, session, input) {
889
- const override = procedure.queryIdentity;
890
- if (override !== undefined) {
891
- if (override === false)
892
- return ANONYMOUS_IDENTITY;
893
- if (typeof override === "string")
894
- return override;
895
- if (typeof override === "function") {
896
- const value = override(session.serverCtx, input);
897
- if (value === null || value === undefined)
898
- return ANONYMOUS_IDENTITY;
899
- return value;
900
- }
901
- }
902
- if (!this.config.getUserId)
903
- return ANONYMOUS_IDENTITY;
904
- return this.config.getUserId(session.serverCtx) ?? ANONYMOUS_IDENTITY;
905
- }
906
- sendMessage(session, message) {
907
- if (session.ws.readyState !== WebSocket.OPEN)
908
- return;
909
- session.ws.send(supaliveStringify(message));
910
- // Mark the channel as having had recent outbound traffic. The
911
- // heartbeat tick uses this to skip the idle-only app-level ping
912
- // for busy sessions (no point pinging a client that just got a
913
- // sub:update).
914
- session.lastSentAt = Date.now();
915
- }
916
- sendMessageRaw(session, message) {
917
- if (session.ws.readyState === WebSocket.OPEN) {
918
- session.ws.send(message);
919
- }
920
- }
921
- sendError(session, id, error, code) {
922
- this.sendMessage(session, { type: "response", id, success: false, error, code });
923
- }
924
- async subscribeToInstanceChannel() {
925
- const instanceChannel = `sub:instance:${this.instanceId}`;
926
- const schemaChannel = SCHEMA_RELOAD_CHANNEL;
927
- await this.redisSub.subscribe(instanceChannel, schemaChannel);
928
- this.redisSub.on("message", (channel, message) => {
929
- if (channel === instanceChannel) {
930
- this.handleRemoteInstanceMessage(message).catch((err) => {
931
- logger.error(err, "Error handling remote instance message");
932
- });
933
- }
934
- else if (channel === schemaChannel) {
935
- this.handleSchemaReload().catch((err) => {
936
- logger.error(err, "Error handling schema reload");
937
- });
938
- }
939
- });
940
- this.redisSub.on("error", (err) => logger.error(err, `Subscriber error for channels: ${instanceChannel}, ${schemaChannel}`));
941
- logger.info(`Subscribed to Redis channels: ${instanceChannel}, ${schemaChannel}`);
942
- }
943
- /**
944
- * React to a `schema:reload` broadcast: invalidate the in-memory column
945
- * type cache for the named tables (or all if omitted) and re-bootstrap
946
- * from Postgres so subsequent batched writes see the fresh types. Skips
947
- * messages published by this instance (we already have the new types).
948
- */
949
- async handleSchemaReload() {
950
- logger.info(`Schema reload request received in instance ${this.instanceId} — reloading schema...`);
951
- try {
952
- await sleep(1000 + (Math.random() * 5000)); // jitter to avoid thundering herd if many instances
953
- await this.bootstrapDbTypes(true);
954
- logger.info(`Schema reload applied in instance ${this.instanceId}`);
955
- }
956
- catch (err) {
957
- logger.error(err, "Failed to reload schema");
958
- return;
959
- }
960
- }
961
- /**
962
- * Broadcast a schema-reload signal to every server instance (including
963
- * this one — the handler skips self-originated messages, so calling
964
- * after a local migration is safe). Pass `tables` to scope the reload;
965
- * omit to flush everyone's cache.
966
- */
967
- async publishSchemaReload(tables) {
968
- const payload = JSON.stringify({
969
- type: "schema:reload",
970
- tables,
971
- originInstance: this.instanceId,
972
- });
973
- await redisRetryPolicy.execute(() => this.cache.redis.publish(SCHEMA_RELOAD_CHANNEL, payload));
974
- }
975
- async handleRemoteInstanceMessage(message) {
976
- let parsed;
977
- try {
978
- parsed = RemoteSubscriptionMessageSchema.parse(JSON.parse(message));
979
- }
980
- catch (err) {
981
- logger.error(err, "Failed to parse remote instance message");
982
- return;
983
- }
984
- if (parsed.originInstance === this.instanceId)
985
- return;
986
- if (parsed.type === "update") {
987
- for (const [, session] of this.sessions) {
988
- if (!session.subIds.has(parsed.subId))
989
- continue;
990
- const subEntry = this.serverSubscriptions.get(parsed.subId);
991
- if (!subEntry)
992
- continue;
993
- this.sendMessage(session, {
994
- type: "sub:update",
995
- subId: parsed.subId,
996
- data: parsed.data,
997
- dataHash: parsed.dataHash,
998
- });
999
- }
1000
- return;
1001
- }
1002
- // type === "recompute-race" — writer didn't hold the sub locally and
1003
- // broadcast a race hint to every notifyInstance. The first to acquire
1004
- // the lock recomputes; the rest no-op.
1005
- await this.handleRemoteRecomputeRace(parsed);
1006
- }
1007
- async handleRemoteRecomputeRace(req) {
1008
- if (!this.hasLocalSessionFor(req.subId)) {
1009
- // We're in notifyInstances per sub-manager but the sub has since
1010
- // been dropped here. Another holder may still pick it up — no warn.
1011
- return;
1012
- }
1013
- const won = await this.cache.tryAcquireRecomputeLock(this.instanceId, req.subId, req.commitTs, RECOMPUTE_LOCK_TTL_SECONDS);
1014
- if (!won) {
1015
- logger.debug(`recompute-race: lost lock for ${req.subId}@${req.commitTs}, waiting for winner's update`);
1016
- return;
1017
- }
1018
- await this.reexecuteAffectedQuery({
1019
- subId: req.subId,
1020
- queryName: req.queryName,
1021
- args: req.args,
1022
- cacheKey: req.cacheKey,
1023
- notifyInstances: req.notifyInstances,
1024
- });
1025
- }
1026
- async publishSubscriptionUpdate(subId, data, dataHash, notifyInstances) {
1027
- const payload = supaliveStringify({
1028
- type: "update",
1029
- subId,
1030
- data,
1031
- dataHash,
1032
- originInstance: this.instanceId,
1033
- });
1034
- const publishPromises = notifyInstances.map(async (instance) => {
1035
- if (instance === this.instanceId)
1036
- return;
1037
- const channel = `sub:instance:${instance}`;
1038
- try {
1039
- await redisRetryPolicy.execute(() => this.cache.redis.publish(channel, payload));
1040
- }
1041
- catch (err) {
1042
- logger.error(err, `Failed to publish to ${channel} after many retries`);
1043
- }
1044
- });
1045
- await Promise.all(publishPromises);
1046
- }
1047
- }
1048
- //# sourceMappingURL=supalive-server.js.map