@supalive/core 0.1.4 → 0.1.5

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 (212) hide show
  1. package/README.md +17 -2
  2. package/dist/helper-CiacMqje.d.ts +14 -0
  3. package/dist/helper-CiacMqje.d.ts.map +1 -0
  4. package/dist/helper-zdJT5FUc.js +58 -0
  5. package/dist/helper-zdJT5FUc.js.map +1 -0
  6. package/dist/index-CTyXKcq_.d.ts +1736 -0
  7. package/dist/index-CTyXKcq_.d.ts.map +1 -0
  8. package/dist/mysql-C8SN443m.d.ts +104 -0
  9. package/dist/mysql-C8SN443m.d.ts.map +1 -0
  10. package/dist/mysql-DELSRrpY.js +569 -0
  11. package/dist/mysql-DELSRrpY.js.map +1 -0
  12. package/dist/overlap-checker-CVkbbuEQ.js +187 -0
  13. package/dist/overlap-checker-CVkbbuEQ.js.map +1 -0
  14. package/dist/postgres-DE-XyNAQ.js +823 -0
  15. package/dist/postgres-DE-XyNAQ.js.map +1 -0
  16. package/dist/postgres-D_gwCHx-.d.ts +108 -0
  17. package/dist/postgres-D_gwCHx-.d.ts.map +1 -0
  18. package/dist/query-CbQLjeME.js +713 -0
  19. package/dist/query-CbQLjeME.js.map +1 -0
  20. package/dist/realtime_db-D9p2X2PH.js +246 -0
  21. package/dist/realtime_db-D9p2X2PH.js.map +1 -0
  22. package/dist/router-DlTYWpop.js +81 -0
  23. package/dist/router-DlTYWpop.js.map +1 -0
  24. package/dist/schema-SKcIWHfo.js +167 -0
  25. package/dist/schema-SKcIWHfo.js.map +1 -0
  26. package/dist/src/client/index.d.ts +2 -17
  27. package/dist/src/client/index.js +1151 -18
  28. package/dist/src/client/index.js.map +1 -1
  29. package/dist/src/exports/mysql.d.ts +2 -2
  30. package/dist/src/exports/mysql.js +2 -2
  31. package/dist/src/exports/postgres.d.ts +2 -2
  32. package/dist/src/exports/postgres.js +2 -2
  33. package/dist/src/exports/procedure.d.ts +3 -5
  34. package/dist/src/exports/procedure.js +5 -5
  35. package/dist/src/exports/server.d.ts +368 -2
  36. package/dist/src/exports/server.d.ts.map +1 -1
  37. package/dist/src/exports/server.js +1346 -2
  38. package/dist/src/exports/server.js.map +1 -1
  39. package/dist/src/exports/subscription-manager-worker-entry.d.ts +1 -0
  40. package/dist/src/exports/subscription-manager-worker-entry.js +115 -0
  41. package/dist/src/exports/subscription-manager-worker-entry.js.map +1 -0
  42. package/dist/src/exports/types.d.ts +4 -6
  43. package/dist/src/exports/types.js +5 -6
  44. package/dist/subscription-worker-BurdPO8d.js +511 -0
  45. package/dist/subscription-worker-BurdPO8d.js.map +1 -0
  46. package/dist/types_client_rpc-CIsjQQGG.js +105 -0
  47. package/dist/types_client_rpc-CIsjQQGG.js.map +1 -0
  48. package/dist/types_db-Ctf-ksoy.js +168 -0
  49. package/dist/types_db-Ctf-ksoy.js.map +1 -0
  50. package/dist/types_server-Ch56y8L3.d.ts +216 -0
  51. package/dist/types_server-Ch56y8L3.d.ts.map +1 -0
  52. package/dist/types_server-Dk6o_B7N.js +24 -0
  53. package/dist/types_server-Dk6o_B7N.js.map +1 -0
  54. package/package.json +3 -3
  55. package/dist/src/client/caller_client.d.ts +0 -42
  56. package/dist/src/client/caller_client.d.ts.map +0 -1
  57. package/dist/src/client/caller_client.js +0 -77
  58. package/dist/src/client/caller_client.js.map +0 -1
  59. package/dist/src/client/client.d.ts +0 -149
  60. package/dist/src/client/client.d.ts.map +0 -1
  61. package/dist/src/client/client.js +0 -308
  62. package/dist/src/client/client.js.map +0 -1
  63. package/dist/src/client/index.d.ts.map +0 -1
  64. package/dist/src/client/sub_key.d.ts +0 -20
  65. package/dist/src/client/sub_key.d.ts.map +0 -1
  66. package/dist/src/client/sub_key.js +0 -37
  67. package/dist/src/client/sub_key.js.map +0 -1
  68. package/dist/src/client/ws_client_manager.d.ts +0 -268
  69. package/dist/src/client/ws_client_manager.d.ts.map +0 -1
  70. package/dist/src/client/ws_client_manager.js +0 -1013
  71. package/dist/src/client/ws_client_manager.js.map +0 -1
  72. package/dist/src/config.d.ts +0 -117
  73. package/dist/src/config.d.ts.map +0 -1
  74. package/dist/src/config.js +0 -2
  75. package/dist/src/config.js.map +0 -1
  76. package/dist/src/db/bytes.d.ts +0 -4
  77. package/dist/src/db/bytes.d.ts.map +0 -1
  78. package/dist/src/db/bytes.js +0 -24
  79. package/dist/src/db/bytes.js.map +0 -1
  80. package/dist/src/db/cache.d.ts +0 -30
  81. package/dist/src/db/cache.d.ts.map +0 -1
  82. package/dist/src/db/cache.js +0 -153
  83. package/dist/src/db/cache.js.map +0 -1
  84. package/dist/src/db/codec.d.ts +0 -10
  85. package/dist/src/db/codec.d.ts.map +0 -1
  86. package/dist/src/db/codec.js +0 -73
  87. package/dist/src/db/codec.js.map +0 -1
  88. package/dist/src/db/context.d.ts +0 -28
  89. package/dist/src/db/context.d.ts.map +0 -1
  90. package/dist/src/db/context.js +0 -147
  91. package/dist/src/db/context.js.map +0 -1
  92. package/dist/src/db/db.d.ts +0 -154
  93. package/dist/src/db/db.d.ts.map +0 -1
  94. package/dist/src/db/db.js +0 -23
  95. package/dist/src/db/db.js.map +0 -1
  96. package/dist/src/db/mysql.d.ts +0 -120
  97. package/dist/src/db/mysql.d.ts.map +0 -1
  98. package/dist/src/db/mysql.js +0 -708
  99. package/dist/src/db/mysql.js.map +0 -1
  100. package/dist/src/db/postgres.d.ts +0 -116
  101. package/dist/src/db/postgres.d.ts.map +0 -1
  102. package/dist/src/db/postgres.js +0 -998
  103. package/dist/src/db/postgres.js.map +0 -1
  104. package/dist/src/db/query.d.ts +0 -174
  105. package/dist/src/db/query.d.ts.map +0 -1
  106. package/dist/src/db/query.js +0 -790
  107. package/dist/src/db/query.js.map +0 -1
  108. package/dist/src/db/query_definition.d.ts +0 -54
  109. package/dist/src/db/query_definition.d.ts.map +0 -1
  110. package/dist/src/db/query_definition.js +0 -47
  111. package/dist/src/db/query_definition.js.map +0 -1
  112. package/dist/src/db/realtime_db.d.ts +0 -19
  113. package/dist/src/db/realtime_db.d.ts.map +0 -1
  114. package/dist/src/db/realtime_db.js +0 -98
  115. package/dist/src/db/realtime_db.js.map +0 -1
  116. package/dist/src/db/schema.d.ts +0 -110
  117. package/dist/src/db/schema.d.ts.map +0 -1
  118. package/dist/src/db/schema.js +0 -47
  119. package/dist/src/db/schema.js.map +0 -1
  120. package/dist/src/db/types_client_rpc.d.ts +0 -157
  121. package/dist/src/db/types_client_rpc.d.ts.map +0 -1
  122. package/dist/src/db/types_client_rpc.js +0 -106
  123. package/dist/src/db/types_client_rpc.js.map +0 -1
  124. package/dist/src/db/types_db.d.ts +0 -236
  125. package/dist/src/db/types_db.d.ts.map +0 -1
  126. package/dist/src/db/types_db.js +0 -188
  127. package/dist/src/db/types_db.js.map +0 -1
  128. package/dist/src/exports/mysql.d.ts.map +0 -1
  129. package/dist/src/exports/mysql.js.map +0 -1
  130. package/dist/src/exports/postgres.d.ts.map +0 -1
  131. package/dist/src/exports/postgres.js.map +0 -1
  132. package/dist/src/exports/procedure.d.ts.map +0 -1
  133. package/dist/src/exports/procedure.js.map +0 -1
  134. package/dist/src/exports/types.d.ts.map +0 -1
  135. package/dist/src/exports/types.js.map +0 -1
  136. package/dist/src/helper.d.ts +0 -11
  137. package/dist/src/helper.d.ts.map +0 -1
  138. package/dist/src/helper.js +0 -45
  139. package/dist/src/helper.js.map +0 -1
  140. package/dist/src/logger.d.ts +0 -7
  141. package/dist/src/logger.d.ts.map +0 -1
  142. package/dist/src/logger.js +0 -47
  143. package/dist/src/logger.js.map +0 -1
  144. package/dist/src/router/index.d.ts +0 -4
  145. package/dist/src/router/index.d.ts.map +0 -1
  146. package/dist/src/router/index.js +0 -4
  147. package/dist/src/router/index.js.map +0 -1
  148. package/dist/src/router/procedure.d.ts +0 -204
  149. package/dist/src/router/procedure.d.ts.map +0 -1
  150. package/dist/src/router/procedure.js +0 -122
  151. package/dist/src/router/procedure.js.map +0 -1
  152. package/dist/src/router/router.d.ts +0 -88
  153. package/dist/src/router/router.d.ts.map +0 -1
  154. package/dist/src/router/router.js +0 -88
  155. package/dist/src/router/router.js.map +0 -1
  156. package/dist/src/server/hash.d.ts +0 -12
  157. package/dist/src/server/hash.d.ts.map +0 -1
  158. package/dist/src/server/hash.js +0 -20
  159. package/dist/src/server/hash.js.map +0 -1
  160. package/dist/src/server/hash.test.d.ts +0 -2
  161. package/dist/src/server/hash.test.d.ts.map +0 -1
  162. package/dist/src/server/hash.test.js +0 -43
  163. package/dist/src/server/hash.test.js.map +0 -1
  164. package/dist/src/server/index.d.ts +0 -9
  165. package/dist/src/server/index.d.ts.map +0 -1
  166. package/dist/src/server/index.js +0 -8
  167. package/dist/src/server/index.js.map +0 -1
  168. package/dist/src/server/overlap-checker.d.ts +0 -18
  169. package/dist/src/server/overlap-checker.d.ts.map +0 -1
  170. package/dist/src/server/overlap-checker.js +0 -247
  171. package/dist/src/server/overlap-checker.js.map +0 -1
  172. package/dist/src/server/sub_hash.d.ts +0 -23
  173. package/dist/src/server/sub_hash.d.ts.map +0 -1
  174. package/dist/src/server/sub_hash.js +0 -38
  175. package/dist/src/server/sub_hash.js.map +0 -1
  176. package/dist/src/server/subscription-manager-client.d.ts +0 -120
  177. package/dist/src/server/subscription-manager-client.d.ts.map +0 -1
  178. package/dist/src/server/subscription-manager-client.js +0 -207
  179. package/dist/src/server/subscription-manager-client.js.map +0 -1
  180. package/dist/src/server/subscription-manager-types.d.ts +0 -168
  181. package/dist/src/server/subscription-manager-types.d.ts.map +0 -1
  182. package/dist/src/server/subscription-manager-types.js +0 -94
  183. package/dist/src/server/subscription-manager-types.js.map +0 -1
  184. package/dist/src/server/subscription-manager-worker-entry.d.ts +0 -2
  185. package/dist/src/server/subscription-manager-worker-entry.d.ts.map +0 -1
  186. package/dist/src/server/subscription-manager-worker-entry.js +0 -127
  187. package/dist/src/server/subscription-manager-worker-entry.js.map +0 -1
  188. package/dist/src/server/subscription-manager-worker-protocol.d.ts +0 -40
  189. package/dist/src/server/subscription-manager-worker-protocol.d.ts.map +0 -1
  190. package/dist/src/server/subscription-manager-worker-protocol.js +0 -20
  191. package/dist/src/server/subscription-manager-worker-protocol.js.map +0 -1
  192. package/dist/src/server/subscription-manager.d.ts +0 -32
  193. package/dist/src/server/subscription-manager.d.ts.map +0 -1
  194. package/dist/src/server/subscription-manager.js +0 -249
  195. package/dist/src/server/subscription-manager.js.map +0 -1
  196. package/dist/src/server/subscription-manager.test.d.ts +0 -2
  197. package/dist/src/server/subscription-manager.test.d.ts.map +0 -1
  198. package/dist/src/server/subscription-manager.test.js +0 -610
  199. package/dist/src/server/subscription-manager.test.js.map +0 -1
  200. package/dist/src/server/subscription-worker.d.ts +0 -32
  201. package/dist/src/server/subscription-worker.d.ts.map +0 -1
  202. package/dist/src/server/subscription-worker.js +0 -190
  203. package/dist/src/server/subscription-worker.js.map +0 -1
  204. package/dist/src/server/supalive-server.d.ts +0 -158
  205. package/dist/src/server/supalive-server.d.ts.map +0 -1
  206. package/dist/src/server/supalive-server.js +0 -1048
  207. package/dist/src/server/supalive-server.js.map +0 -1
  208. package/dist/src/server/types_server.d.ts +0 -96
  209. package/dist/src/server/types_server.d.ts.map +0 -1
  210. package/dist/src/server/types_server.js +0 -36
  211. package/dist/src/server/types_server.js.map +0 -1
  212. package/dist/tsconfig.tsbuildinfo +0 -1
@@ -1,3 +1,1347 @@
1
- export * from "../server/index";
2
- export { SubscriptionManager } from "../server/subscription-manager";
1
+ import { n as getContextRegistry } from "../../router-DlTYWpop.js";
2
+ import { n as stableStringify, r as supaliveStringify, t as groupByToMap } from "../../helper-zdJT5FUc.js";
3
+ import { i as AuthenticationError, o as ClientMessageSchema } from "../../types_client_rpc-CIsjQQGG.js";
4
+ import { n as RemoteSubscriptionMessageSchema } from "../../types_server-Dk6o_B7N.js";
5
+ import { n as sleep } from "../../realtime_db-D9p2X2PH.js";
6
+ import { C as UpdateSubscriptionReadSetParamsSchema, S as UnregisterSubscriptionsResultSchema, _ as RegisterSubscriptionParamsSchema, a as initCacheLayer, b as UnregisterSubscriptionResultSchema, c as appInstanceId, d as logger, f as serializeMapValue, g as RegisterSubscriptionBatchResultSchema, i as createDatabase, l as isProduction, m as InvalidateWritesetResultSchema, n as DB_QUERY_TIMEOUT_MS, o as initDatabase, p as InvalidateWritesetParamsSchema, r as bootstrapDbTypes, s as CacheLayer, u as logLevelOf, v as RegisterSubscriptionResultSchema, w as UpdateSubscriptionReadSetResultSchema, y as UnregisterSubscriptionParamsSchema } from "../../subscription-worker-BurdPO8d.js";
7
+ import { createServer } from "http";
8
+ import { WebSocket, WebSocketServer } from "ws";
9
+ import { v4 } from "uuid";
10
+ import { ExponentialBackoff, handleAll, retry } from "cockatiel";
11
+ import { sha1 } from "hash-wasm";
12
+ import { Client, Server } from "rpc-websockets";
13
+ import { Worker } from "node:worker_threads";
14
+ import { fileURLToPath } from "node:url";
15
+ //#region src/server/sub_hash.ts
16
+ /** Compute stable hash using json-stable-stringify and SHA1. */
17
+ async function getHashOf(input) {
18
+ return sha1((typeof input === "string" ? input : stableStringify(input)) ?? "");
19
+ }
20
+ /**
21
+ * Default identity literal used when neither a procedure-level
22
+ * `queryIdentity` override nor a per-session user is available.
23
+ */
24
+ const ANONYMOUS_IDENTITY = "anonymous";
25
+ /**
26
+ * Compute a subscription id from hash of procedure + input + queryIdentity.
27
+ *
28
+ * `queryIdentity` is the segmentation key for cache/subscriptions. By default
29
+ * it is the authenticated user's id (so two users get distinct subscriptions
30
+ * for the same query), but a procedure may override it to share cache/subs
31
+ * across users (e.g. for a public, user-independent query).
32
+ */
33
+ async function generateSubscriptionId(procedure, input, queryIdentity) {
34
+ return `sub_${await getHashOf(`${procedure}_${typeof input === "string" ? input : stableStringify(input)}_${queryIdentity}`)}`;
35
+ }
36
+ /**
37
+ * Compute cache key from query name + args + queryIdentity. The identity
38
+ * participates so that user-specific results don't poison each other's
39
+ * cache when a procedure does authorize per-user.
40
+ */
41
+ async function generateCacheKey(queryName, input, queryIdentity) {
42
+ return `ck_${await getHashOf(`${queryName}_${typeof input === "string" ? input : stableStringify(input)}_${queryIdentity}`)}`;
43
+ }
44
+ //#endregion
45
+ //#region src/server/subscription-manager-client.ts
46
+ const logger$3 = logger.child({ name: "SUBS_MGR_CLIENT" }, { level: logLevelOf("SUB_MANAGER_CLIENT_LOG_LEVEL") });
47
+ const subscriptionManagerDataPack = {
48
+ encode: (data) => supaliveStringify(data),
49
+ decode: (data) => JSON.parse(data)
50
+ };
51
+ /**
52
+ * RPC client for the (single) sub-manager process.
53
+ *
54
+ * Design (Convex-inspired):
55
+ * • The sub-manager is in-memory and ephemeral. There is no version
56
+ * protocol, no client-side registration registry, no OUT_OF_SYNC
57
+ * replay logic. All recovery is driven from the app server using
58
+ * state it already has (`serverSubscriptions`) plus registration
59
+ * records persisted to Redis at `sl:reg:<subId>`.
60
+ *
61
+ * • Out-of-order `updateSubscriptionReadSet` calls are filtered server
62
+ * side using `lastSnapshotTs` (monotonic DB commit timestamp).
63
+ *
64
+ * • On rpc-websockets reconnect, the client:
65
+ * 1. Transitions to `busy` (any in-flight `invalidateWriteset`
66
+ * buffers its writeSet).
67
+ * 2. Invokes the app server's `recoveryDriver` to re-register all
68
+ * known subs.
69
+ * 3. Flushes the buffer as one batched `invalidateWriteset` RPC and
70
+ * hands the resulting `affected[]` to `affectedHandler`.
71
+ * 4. Transitions to `ready`.
72
+ *
73
+ * • During an extended outage, the buffer is bounded; if exceeded, it
74
+ * is dropped and we rely on the commit-log replay built into
75
+ * `SubscriptionWorker.register()` to catch each sub up on the next
76
+ * re-register.
77
+ */
78
+ var SubManagerClient = class {
79
+ client;
80
+ state = "busy";
81
+ hasConnectedOnce = false;
82
+ /** Resolves whenever state transitions to 'ready'. Replaced on each busy→ready cycle. */
83
+ readyPromise;
84
+ resolveReady;
85
+ /** Writeset entries accumulated while state === 'busy'. Flushed as one RPC after recovery. */
86
+ bufferedWriteSets = [];
87
+ bufferLimit;
88
+ bufferDroppedDuringOutage = false;
89
+ recoveryDriver;
90
+ affectedHandler;
91
+ constructor(urlOrOptions) {
92
+ const opts = typeof urlOrOptions === "string" ? { url: urlOrOptions } : urlOrOptions;
93
+ this.bufferLimit = opts.invalidateBufferLimit ?? 5e4;
94
+ this.readyPromise = new Promise((r) => {
95
+ this.resolveReady = r;
96
+ });
97
+ this.client = new Client(opts.url, { dataPack: subscriptionManagerDataPack });
98
+ this.client.setAutoReconnect(true);
99
+ this.client.setMaxReconnects(0);
100
+ this.client.dataPack = subscriptionManagerDataPack;
101
+ this.client.on("open", this.handleOpen.bind(this));
102
+ this.client.on("close", this.handleClose.bind(this));
103
+ this.client.on("error", (err) => logger$3.warn(err, "sub-manager client socket error"));
104
+ }
105
+ /**
106
+ * Wire up the app-server-driven recovery. Must be called before any
107
+ * disconnect/reconnect cycle for buffered writeSets to be flushed and
108
+ * subscriptions re-registered.
109
+ */
110
+ setRecoveryDriver(driver) {
111
+ this.recoveryDriver = driver;
112
+ }
113
+ /**
114
+ * Wire up the handler invoked when batched invalidate results land
115
+ * after recovery. The handler is expected to drive
116
+ * `reexecuteAffectedQuery` for each entry.
117
+ */
118
+ setAffectedHandler(handler) {
119
+ this.affectedHandler = handler;
120
+ }
121
+ async registerSubscription(params) {
122
+ return this.call("registerSubscription", params, RegisterSubscriptionResultSchema);
123
+ }
124
+ async registerSubscriptionBatch(params) {
125
+ return this.call("registerSubscriptionBatch", params, RegisterSubscriptionBatchResultSchema);
126
+ }
127
+ async updateSubscriptionReadSet(params) {
128
+ return this.call("updateSubscriptionReadSet", params, UpdateSubscriptionReadSetResultSchema);
129
+ }
130
+ async unregisterSubscription(params) {
131
+ return this.call("unregisterSubscription", params, UnregisterSubscriptionResultSchema);
132
+ }
133
+ async unregisterSubscriptions(params) {
134
+ return this.call("unregisterSubscriptions", params, UnregisterSubscriptionsResultSchema);
135
+ }
136
+ /**
137
+ * Sends the writeSet to the sub-manager for invalidation, OR buffers it
138
+ * if the client is currently disconnected / recovering. Returns
139
+ * `{ affected: [] }` while buffering — the actual `affected` from the
140
+ * buffered batch is delivered via the registered `affectedHandler`
141
+ * after recovery completes.
142
+ */
143
+ async invalidateWriteset(params) {
144
+ if (this.state === "busy") {
145
+ this.appendToBuffer(params.writeSet);
146
+ return { affected: [] };
147
+ }
148
+ return this.call("invalidateWriteset", params, InvalidateWritesetResultSchema);
149
+ }
150
+ /**
151
+ * Single point through which RPCs go. Throws on transport / server error.
152
+ * `registerSubscription`, `updateSubscriptionReadSet`, and
153
+ * `unregisterSubscription` are NOT gated on the ready state: during a
154
+ * disconnect they will fail at the socket layer, and during recovery
155
+ * (which itself uses these methods) gating would deadlock.
156
+ */
157
+ async call(procName, params, resType) {
158
+ const result = await this.client.call(procName, params);
159
+ return resType.parse(result);
160
+ }
161
+ appendToBuffer(writeSet) {
162
+ if (this.bufferedWriteSets.length + writeSet.length > this.bufferLimit) {
163
+ if (!this.bufferDroppedDuringOutage) {
164
+ logger$3.warn(`Invalidate buffer would exceed ${this.bufferLimit}; dropping buffered entries. Subscriptions will be caught up via commit-log replay on re-register.`);
165
+ this.bufferDroppedDuringOutage = true;
166
+ }
167
+ this.bufferedWriteSets = [];
168
+ return;
169
+ }
170
+ this.bufferedWriteSets.push(...writeSet);
171
+ }
172
+ async flushBuffer() {
173
+ if (this.bufferedWriteSets.length === 0) return;
174
+ const writeSet = this.bufferedWriteSets;
175
+ this.bufferedWriteSets = [];
176
+ this.bufferDroppedDuringOutage = false;
177
+ try {
178
+ const result = await this.call("invalidateWriteset", { writeSet }, InvalidateWritesetResultSchema);
179
+ if (this.affectedHandler && result.affected.length > 0) await this.affectedHandler(result.affected);
180
+ } catch (err) {
181
+ logger$3.error(err, "Failed to flush invalidate buffer after recovery");
182
+ }
183
+ }
184
+ markBusy() {
185
+ if (this.state === "busy") return;
186
+ this.state = "busy";
187
+ this.readyPromise = new Promise((r) => {
188
+ this.resolveReady = r;
189
+ });
190
+ }
191
+ markReady() {
192
+ if (this.state === "ready") return;
193
+ this.state = "ready";
194
+ this.resolveReady();
195
+ }
196
+ handleClose() {
197
+ logger$3.info("sub-manager client: socket closed");
198
+ this.markBusy();
199
+ }
200
+ handleOpen() {
201
+ if (!this.hasConnectedOnce) {
202
+ this.hasConnectedOnce = true;
203
+ logger$3.info("sub-manager client: initial connect");
204
+ this.markReady();
205
+ return;
206
+ }
207
+ logger$3.info("sub-manager client: reconnected — starting recovery");
208
+ this.runRecovery();
209
+ }
210
+ async runRecovery() {
211
+ try {
212
+ if (this.recoveryDriver) await this.recoveryDriver();
213
+ else logger$3.warn("sub-manager client: no recoveryDriver configured — skipping recovery step");
214
+ await this.flushBuffer();
215
+ } catch (err) {
216
+ logger$3.error(err, "Recovery failed; state will be marked ready anyway");
217
+ } finally {
218
+ logger$3.info("sub-manager client: recovery complete");
219
+ this.markReady();
220
+ }
221
+ }
222
+ /**
223
+ * Test/inspection helpers.
224
+ */
225
+ /** @internal */
226
+ isReady() {
227
+ return this.state === "ready";
228
+ }
229
+ /** @internal */
230
+ bufferSize() {
231
+ return this.bufferedWriteSets.length;
232
+ }
233
+ };
234
+ //#endregion
235
+ //#region src/server/supalive-server.ts
236
+ const redisRetryPolicy = retry(handleAll, {
237
+ maxAttempts: 3,
238
+ backoff: new ExponentialBackoff({
239
+ initialDelay: 100,
240
+ maxDelay: 5e3,
241
+ exponent: 2
242
+ })
243
+ });
244
+ /**
245
+ * Global broadcast channel used to signal column-type cache invalidation
246
+ * across every running server instance. Publishers (migration tooling,
247
+ * the publishSchemaReload helper below) send a JSON message of shape
248
+ * `{ type: "schema:reload"; tables?: string[]; originInstance?: string }`.
249
+ * Subscribers re-bootstrap from PG (or Redis) so the next batched write
250
+ * sees up-to-date column metadata without a process restart.
251
+ */
252
+ const SCHEMA_RELOAD_CHANNEL = "supalive:schema:reload";
253
+ /**
254
+ * Per-socket cap on pending mutations. The Nth+1 mutation from a single
255
+ * client gets rejected with TOO_MANY_CONCURRENT_MUTATIONS rather than
256
+ * being enqueued — this is rate-limit pressure on a misbehaving client,
257
+ * not a hard system limit.
258
+ *
259
+ * Mirrors Convex's OPERATION_QUEUE_BUFFER_SIZE (1000). Mutations drain
260
+ * one-at-a-time per socket, so a healthy client never approaches this.
261
+ */
262
+ const MAX_MUTATION_QUEUE_BUFFER = 1e3;
263
+ /**
264
+ * Per-socket cap on in-flight actions. Actions run in parallel (unlike
265
+ * mutations) because they may do long external work (HTTP, queue calls);
266
+ * the cap exists so one socket can't open thousands of outbound
267
+ * connections from inside the server.
268
+ */
269
+ const MAX_ACTION_CONCURRENCY = 1e3;
270
+ const HEARTBEAT_INTERVAL_MS = Number(process.env.SUPALIVE_HEARTBEAT_INTERVAL_MS ?? 15e3);
271
+ const CLIENT_TIMEOUT_MS = 12e4;
272
+ /**
273
+ * TTL on the Redis lock that elects the recompute winner when the writer
274
+ * doesn't hold the sub locally. Each (subId, commitTs) gets its own lock
275
+ * key, so this only needs to bound the worst-case recompute duration —
276
+ * if the winner crashes mid-recompute, the lock expires and the next
277
+ * mutation triggers a new race.
278
+ */
279
+ const RECOMPUTE_LOCK_TTL_SECONDS = Number(process.env.SUPALIVE_RECOMPUTE_LOCK_TTL_SECONDS ?? 10);
280
+ const logger$2 = logger.child({ "name": "SUPALIVE" }, { level: logLevelOf("SERVER_LOG_LEVEL") });
281
+ /**
282
+ * Supalive WebSocket Server
283
+ */
284
+ var SupaliveWebSocketServer = class {
285
+ config;
286
+ instanceId;
287
+ httpServer;
288
+ wss;
289
+ cache;
290
+ redisSub;
291
+ db;
292
+ contextRegistry;
293
+ router;
294
+ sessions = /* @__PURE__ */ new Map();
295
+ serverSubscriptions = /* @__PURE__ */ new Map();
296
+ subManagerClient;
297
+ stats = {
298
+ activeConnections: 0,
299
+ totalConnections: 0,
300
+ activeSubscriptions: 0
301
+ };
302
+ constructor(config) {
303
+ this.config = {
304
+ port: 3e3,
305
+ host: "0.0.0.0",
306
+ contextName: "default",
307
+ sessionTtlSeconds: 3600,
308
+ ...config
309
+ };
310
+ this.instanceId = appInstanceId;
311
+ if (!config.subManagerUrl) throw new Error("subManagerUrl config is required (WebSocket URL of the sub-manager process)");
312
+ this.subManagerClient = new SubManagerClient(config.subManagerUrl);
313
+ this.cache = config.cacheLayer;
314
+ this.redisSub = config.redisSubClient;
315
+ this.db = config.database;
316
+ this.subManagerClient.setRecoveryDriver(this.runSubManagerRecovery.bind(this));
317
+ this.subManagerClient.setAffectedHandler(this.runAffectedHandler.bind(this));
318
+ this.httpServer = createServer();
319
+ this.wss = new WebSocketServer({
320
+ server: this.httpServer,
321
+ autoPong: true,
322
+ maxPayload: 100 * 1024 * 1024
323
+ });
324
+ this.setupWebSocketHandlers();
325
+ this.subscribeToInstanceChannel();
326
+ }
327
+ /**
328
+ * Per-session heartbeat. Fires every `HEARTBEAT_INTERVAL_MS` and does
329
+ * both jobs on the same tick:
330
+ *
331
+ * 1. Inactivity check + ws-protocol ping (dead-TCP detection).
332
+ * 2. App-level `{type:"ping"}` if the channel has been idle for at
333
+ * least one heartbeat interval (client-watchdog refresh).
334
+ *
335
+ * The `lastSentAt` gate on (2) preserves the original "only ping when
336
+ * idle" semantics: a session producing sub:updates faster than the
337
+ * heartbeat interval never emits a redundant app-ping.
338
+ */
339
+ startSessionHeartbeat(session) {
340
+ session.heartbeatTimer = setInterval(() => {
341
+ if (Date.now() - session.lastReceivedAt > CLIENT_TIMEOUT_MS) {
342
+ logger$2.info(`ws inactivity → terminate session ${session.id}`);
343
+ try {
344
+ session.ws.terminate();
345
+ } catch {}
346
+ return;
347
+ }
348
+ try {
349
+ session.ws.ping();
350
+ } catch {}
351
+ if (Date.now() - session.lastSentAt >= HEARTBEAT_INTERVAL_MS) this.sendMessage(session, { type: "ping" });
352
+ }, HEARTBEAT_INTERVAL_MS);
353
+ }
354
+ clearSessionTimers(session) {
355
+ if (session.heartbeatTimer) {
356
+ clearInterval(session.heartbeatTimer);
357
+ session.heartbeatTimer = null;
358
+ }
359
+ }
360
+ registerRouter(router) {
361
+ this.router = router;
362
+ this.contextRegistry = getContextRegistry(router.contextName);
363
+ }
364
+ async start() {
365
+ return new Promise((resolve) => {
366
+ this.httpServer.listen(this.config.port, this.config.host, () => {
367
+ logger$2.info(`Server: ${this.instanceId} listening on ${this.config.host}:${this.config.port}`);
368
+ resolve();
369
+ });
370
+ });
371
+ }
372
+ async stop() {
373
+ this.wss.close();
374
+ for (const [ws] of this.sessions) ws.close(1001, "Server shutting down");
375
+ this.httpServer.close();
376
+ logger$2.info("Server stopped");
377
+ }
378
+ getStats() {
379
+ return {
380
+ instanceId: this.instanceId,
381
+ activeConnections: this.stats.activeConnections,
382
+ totalConnections: this.stats.totalConnections,
383
+ activeSubscriptions: this.stats.activeSubscriptions
384
+ };
385
+ }
386
+ getInstanceId() {
387
+ return this.instanceId;
388
+ }
389
+ setupWebSocketHandlers() {
390
+ this.wss.on("connection", (ws, req) => {
391
+ this.handleConnection(ws, req).catch((err) => {
392
+ logger$2.error(err, "Connection handler error");
393
+ });
394
+ });
395
+ }
396
+ async handleConnection(ws, req) {
397
+ this.stats.totalConnections++;
398
+ this.stats.activeConnections++;
399
+ const session = {
400
+ id: v4(),
401
+ ws,
402
+ serverCtx: {},
403
+ subIds: /* @__PURE__ */ new Set(),
404
+ mutationQueue: [],
405
+ mutationInFlight: false,
406
+ actionInFlight: 0,
407
+ lastReceivedAt: Date.now(),
408
+ lastSentAt: Date.now(),
409
+ heartbeatTimer: null
410
+ };
411
+ logger$2.info(`New session: ${session.id}`);
412
+ this.sessions.set(ws, session);
413
+ this.sendMessage(session, {
414
+ type: "connected",
415
+ sessionId: session.id
416
+ });
417
+ ws.on("message", (data) => {
418
+ session.lastReceivedAt = Date.now();
419
+ this.handleMessage(session, data.toString()).catch((err) => {
420
+ logger$2.error(err, "Message handler error");
421
+ });
422
+ });
423
+ ws.on("ping", () => {
424
+ session.lastReceivedAt = Date.now();
425
+ });
426
+ ws.on("pong", () => {
427
+ session.lastReceivedAt = Date.now();
428
+ });
429
+ this.startSessionHeartbeat(session);
430
+ ws.on("close", (code) => {
431
+ logger$2.info(`Connection closed: ${session.id}, code: ${code}`);
432
+ this.clearSessionTimers(session);
433
+ this.handleDisconnect(session);
434
+ this.sessions.delete(ws);
435
+ this.stats.activeConnections--;
436
+ });
437
+ ws.on("error", (err) => {
438
+ logger$2.error(err, "Websocket error");
439
+ });
440
+ }
441
+ async handleMessage(session, message) {
442
+ try {
443
+ const rawMsg = JSON.parse(message);
444
+ const parseResult = ClientMessageSchema.safeParse(rawMsg);
445
+ if (!parseResult.success) {
446
+ this.sendError(session, "", "Invalid message format", "INVALID_MESSAGE");
447
+ return;
448
+ }
449
+ await this.processMessage(session, parseResult.data);
450
+ } catch (err) {
451
+ logger$2.error(err, "Message handling error");
452
+ this.sendError(session, "", "Parse error", "PARSE_ERROR");
453
+ }
454
+ }
455
+ async processMessage(session, msg) {
456
+ switch (msg.type) {
457
+ case "auth":
458
+ await this.handleAuth(session, msg.data);
459
+ break;
460
+ case "call":
461
+ await this.handleCall(session, msg.id, msg.procedure, msg.input);
462
+ break;
463
+ case "subscribe":
464
+ await this.handleSubscribe(session, msg.id, msg.procedure, msg.input);
465
+ break;
466
+ case "unsubscribe":
467
+ await this.handleUnsubscribe(session, msg.subId);
468
+ break;
469
+ }
470
+ }
471
+ async handleAuth(session, data) {
472
+ if (!this.config.verifyAuth || !data) {
473
+ this.sendMessage(session, {
474
+ type: "auth:success",
475
+ userId: "anonymous"
476
+ });
477
+ return;
478
+ }
479
+ try {
480
+ const authResult = await this.config.verifyAuth(data, session.id);
481
+ if (!authResult) {
482
+ this.sendMessage(session, {
483
+ type: "auth:failed",
484
+ message: "Invalid authentication"
485
+ });
486
+ return;
487
+ }
488
+ session.serverCtx = authResult;
489
+ this.sendMessage(session, {
490
+ type: "auth:success",
491
+ userId: authResult.user.userId
492
+ });
493
+ } catch (e) {
494
+ if (e instanceof AuthenticationError) this.sendMessage(session, {
495
+ type: "auth:failed",
496
+ message: "Invalid authentication: " + e.message,
497
+ extra: e.extra
498
+ });
499
+ else this.sendMessage(session, {
500
+ type: "auth:failed",
501
+ message: "Invalid authentication"
502
+ });
503
+ }
504
+ }
505
+ /**
506
+ * Park a mutation on this session's FIFO queue and resolve when it
507
+ * eventually drains. The drainer is fire-and-forget: it picks up any
508
+ * newly enqueued op as long as one drain loop is running on this
509
+ * session. Callers (handleCall) own the response/error for their own
510
+ * op via the returned promise.
511
+ */
512
+ enqueueMutation(session, procedure, validatedInput) {
513
+ return new Promise((resolve, reject) => {
514
+ const pending = {
515
+ procedure,
516
+ validatedInput,
517
+ resolve,
518
+ reject
519
+ };
520
+ session.mutationQueue.push(pending);
521
+ this.drainMutationQueue(session).catch((err) => logger$2.error(err, "drainMutationQueue crashed"));
522
+ });
523
+ }
524
+ /**
525
+ * Drain the per-session mutation queue serially. Only one drain loop
526
+ * runs at a time per session — re-entrant calls bail immediately, the
527
+ * loop already in progress will pick up the new op before exiting.
528
+ */
529
+ async drainMutationQueue(session) {
530
+ if (session.mutationInFlight) return;
531
+ session.mutationInFlight = true;
532
+ try {
533
+ while (session.mutationQueue.length > 0) {
534
+ const op = session.mutationQueue.shift();
535
+ try {
536
+ const fn = op.procedure.fn;
537
+ const result = await this.db.mutation(async (ctx) => {
538
+ return fn({
539
+ db: ctx,
540
+ serverCtx: session.serverCtx
541
+ }, op.validatedInput);
542
+ });
543
+ op.resolve(result);
544
+ } catch (err) {
545
+ op.reject(err);
546
+ }
547
+ }
548
+ } finally {
549
+ session.mutationInFlight = false;
550
+ }
551
+ }
552
+ async handleCall(session, id, procedureName, input) {
553
+ if (!this.router) {
554
+ this.sendError(session, id, "Router not registered", "SERVER_ERROR");
555
+ return;
556
+ }
557
+ const procedure = this.contextRegistry.getProcedure(procedureName);
558
+ if (!procedure) {
559
+ this.sendError(session, id, `Procedure "${procedureName}" not found`, "NOT_FOUND");
560
+ return;
561
+ }
562
+ try {
563
+ const validatedInput = procedure.inputSchema.parse(input);
564
+ if (procedure.type === "query") {
565
+ const result = await this.db.query(async (ctx) => {
566
+ const fn = procedure.fn;
567
+ return fn({
568
+ db: ctx,
569
+ serverCtx: session.serverCtx
570
+ }, validatedInput);
571
+ });
572
+ this.sendMessage(session, {
573
+ type: "response",
574
+ id,
575
+ success: true,
576
+ data: result
577
+ });
578
+ } else if (procedure.type === "mutation") {
579
+ if (session.mutationQueue.length >= MAX_MUTATION_QUEUE_BUFFER) {
580
+ this.sendError(session, id, `Too many concurrent mutations on this connection (max ${MAX_MUTATION_QUEUE_BUFFER})`, "TOO_MANY_CONCURRENT_MUTATIONS");
581
+ return;
582
+ }
583
+ const mutationResult = await this.enqueueMutation(session, procedure, validatedInput);
584
+ this.sendMessage(session, {
585
+ type: "response",
586
+ id,
587
+ success: true
588
+ });
589
+ await this.handleMutationCommit(mutationResult.writeSet, mutationResult.commitTs);
590
+ } else if (procedure.type === "action") {
591
+ if (session.actionInFlight >= MAX_ACTION_CONCURRENCY) {
592
+ this.sendError(session, id, `Too many concurrent actions on this connection (max ${MAX_ACTION_CONCURRENCY})`, "TOO_MANY_CONCURRENT_ACTIONS");
593
+ return;
594
+ }
595
+ session.actionInFlight++;
596
+ let result;
597
+ try {
598
+ const fn = procedure.fn;
599
+ result = await fn({
600
+ db: this.db,
601
+ serverCtx: session.serverCtx
602
+ }, validatedInput);
603
+ } finally {
604
+ session.actionInFlight--;
605
+ }
606
+ this.sendMessage(session, {
607
+ type: "response",
608
+ id,
609
+ success: true,
610
+ data: result
611
+ });
612
+ }
613
+ } catch (err) {
614
+ logger$2.error(err, `Error executing "${procedureName}"`);
615
+ this.sendError(session, id, err instanceof Error ? err.message : "Execution failed", "EXECUTION_ERROR");
616
+ }
617
+ }
618
+ async handleSubscribe(session, id, procedureName, input) {
619
+ if (!this.router) {
620
+ this.sendError(session, id, "Router not registered", "SERVER_ERROR");
621
+ return;
622
+ }
623
+ const procedure = this.contextRegistry.getProcedure(procedureName);
624
+ if (!procedure || procedure.type !== "query") {
625
+ this.sendError(session, id, `Procedure "${procedureName}" not found or not a query`, "NOT_FOUND");
626
+ return;
627
+ }
628
+ try {
629
+ const validatedInput = procedure.inputSchema.parse(input);
630
+ const queryIdentity = this.resolveQueryIdentity(procedure, session, validatedInput);
631
+ const inputJsonStable = stableStringify(validatedInput);
632
+ const subId = await generateSubscriptionId(procedureName, inputJsonStable, queryIdentity);
633
+ const cacheKey = await generateCacheKey(procedureName, inputJsonStable, queryIdentity);
634
+ const existingServerSub = this.serverSubscriptions.get(subId);
635
+ let data;
636
+ let dataHash;
637
+ let liveResult = null;
638
+ session.subIds.add(subId);
639
+ if (existingServerSub) {
640
+ existingServerSub.subscribedTimes += 1;
641
+ const cached = await this.cache.getQueryCacheFor(existingServerSub.cacheKey);
642
+ if (cached) data = cached.data;
643
+ else {
644
+ liveResult = await this.executeLiveQuery(procedureName, validatedInput, session.serverCtx);
645
+ data = liveResult.data;
646
+ }
647
+ } else {
648
+ const lastSnapshotTs = await this.db.impl.getLatestSnapshotTimestamp();
649
+ const subManagerResult = await this.subManagerClient.registerSubscription({
650
+ subId,
651
+ cacheKey,
652
+ queryName: procedureName,
653
+ args: validatedInput,
654
+ queryIdentity,
655
+ instanceName: this.instanceId,
656
+ lastSnapshotTs
657
+ });
658
+ const cached = await this.cache.getQueryCacheFor(subManagerResult.cacheKey);
659
+ if (cached && !subManagerResult.recompute) data = cached.data;
660
+ else {
661
+ liveResult = await this.executeLiveQuery(procedureName, validatedInput, session.serverCtx);
662
+ data = liveResult.data;
663
+ }
664
+ this.serverSubscriptions.set(subId, {
665
+ subId,
666
+ cacheKey,
667
+ subscribedTimes: 1
668
+ });
669
+ this.stats.activeSubscriptions++;
670
+ }
671
+ if (liveResult) {
672
+ const [updateResult] = await Promise.all([this.subManagerClient.updateSubscriptionReadSet({
673
+ cacheKey,
674
+ subId,
675
+ lastSnapshotTs: liveResult.ts,
676
+ readSet: liveResult.readSet,
677
+ instanceName: this.instanceId
678
+ }), this.cache.setQueryCacheFor(cacheKey, data)]);
679
+ if (!updateResult.tracked) logger$2.warn(`updateSubscriptionReadSet returned tracked:false immediately after register for ${subId} — possible sub-manager restart mid-subscribe`);
680
+ }
681
+ dataHash = await getHashOf(data);
682
+ this.sendMessage(session, {
683
+ type: "response",
684
+ id,
685
+ success: true,
686
+ subId
687
+ });
688
+ this.sendMessage(session, {
689
+ type: "sub:update",
690
+ subId,
691
+ data,
692
+ dataHash
693
+ });
694
+ logger$2.info(`Subscribed: ${subId} for session ${session.id}`);
695
+ } catch (err) {
696
+ logger$2.error(err, `Error subscribing to "${procedureName}"`);
697
+ this.sendMessage(session, {
698
+ type: "response",
699
+ id,
700
+ success: false
701
+ });
702
+ this.sendError(session, id, err instanceof Error ? err.message : "Subscribe failed", "SUBSCRIBE_ERROR");
703
+ }
704
+ }
705
+ async handleUnsubscribe(session, subId) {
706
+ const serverSub = this.serverSubscriptions.get(subId);
707
+ if (!serverSub) {
708
+ session.subIds.delete(subId);
709
+ return;
710
+ }
711
+ serverSub.subscribedTimes -= 1;
712
+ if (serverSub.subscribedTimes <= 0) {
713
+ session.subIds.delete(subId);
714
+ this.serverSubscriptions.delete(subId);
715
+ this.stats.activeSubscriptions--;
716
+ try {
717
+ await this.subManagerClient.unregisterSubscription({
718
+ subId,
719
+ instanceName: this.instanceId
720
+ });
721
+ } catch (err) {
722
+ logger$2.error(err, "Failed to unregister from sub-manager:");
723
+ }
724
+ }
725
+ logger$2.info(`Unsubscribed: ${subId} for session ${session.id}, remaining server subs: ${serverSub.subscribedTimes}`);
726
+ }
727
+ async handleDisconnect(session) {
728
+ logger$2.info(`Session disconnected: ${session.id}`);
729
+ let unregisterSubs = void 0;
730
+ for (const subId of session.subIds) {
731
+ const sub = this.serverSubscriptions.get(subId);
732
+ if (!sub) continue;
733
+ sub.subscribedTimes -= 1;
734
+ if (sub.subscribedTimes <= 0) {
735
+ if (!unregisterSubs) unregisterSubs = [];
736
+ unregisterSubs.push(sub);
737
+ }
738
+ }
739
+ if (!unregisterSubs || unregisterSubs.length === 0) return;
740
+ try {
741
+ await this.subManagerClient.unregisterSubscriptions(unregisterSubs.map((sub) => ({
742
+ subId: sub.subId,
743
+ instanceName: this.instanceId
744
+ })));
745
+ } catch (err) {
746
+ logger$2.error(err, "Failed to unregister from sub-manager:");
747
+ }
748
+ for (const sub of unregisterSubs) if (sub.subscribedTimes <= 0) {
749
+ this.serverSubscriptions.delete(sub.subId);
750
+ this.stats.activeSubscriptions--;
751
+ }
752
+ logger$2.info(`All Subscription sessions is disconnected: ${session.id}, subs: ${unregisterSubs.length}`);
753
+ }
754
+ /**
755
+ * Drives recovery on a sub-manager reconnect. Iterates this app server's
756
+ * `serverSubscriptions`, reads each subId's registration record from
757
+ * Redis (`sl:reg:<subId>`), and re-registers it with the sub-manager.
758
+ *
759
+ * • If the Redis record is missing, another app-server instance fully
760
+ * unregistered the sub during our disconnect. We drop it locally and
761
+ * send `sub:gone` to every session that was holding it.
762
+ * • If the sub-manager reports `recompute: true` (cache-metadata + commit
763
+ * log replay detected staleness), we kick a reexec for that sub.
764
+ *
765
+ * Returns after all subs have been processed. Only then does the
766
+ * SubManagerClient flush its buffered invalidate writeSets.
767
+ */
768
+ async runSubManagerRecovery() {
769
+ const snapshot = Array.from(this.serverSubscriptions.values());
770
+ if (snapshot.length === 0) {
771
+ logger$2.info("Sub-manager recovery: no subscriptions to restore");
772
+ return;
773
+ }
774
+ logger$2.info(`Sub-manager recovery: restoring ${snapshot.length} subscription(s)`);
775
+ const lastSnapshotTs = await this.db.impl.getLatestSnapshotTimestamp();
776
+ const subIds = snapshot.map((e) => e.subId);
777
+ const recordsMap = await this.cache.fetchRegistrationRecordsBatch(subIds);
778
+ const registerParams = [];
779
+ for (const entry of snapshot) {
780
+ const record = recordsMap.get(entry.subId) ?? null;
781
+ if (!record) {
782
+ this.dropSubscriptionLocally(entry.subId, "registration not found after recovery");
783
+ continue;
784
+ }
785
+ registerParams.push({
786
+ entry,
787
+ record
788
+ });
789
+ }
790
+ const recompute = [];
791
+ const BATCH_SIZE = 50;
792
+ for (let i = 0; i < registerParams.length; i += BATCH_SIZE) {
793
+ const batch = registerParams.slice(i, i + BATCH_SIZE);
794
+ const params = batch.map(({ entry, record }) => ({
795
+ subId: entry.subId,
796
+ cacheKey: entry.cacheKey,
797
+ queryName: record.queryName,
798
+ args: record.args,
799
+ queryIdentity: record.queryIdentity,
800
+ instanceName: this.instanceId,
801
+ lastSnapshotTs
802
+ }));
803
+ try {
804
+ const results = await this.subManagerClient.registerSubscriptionBatch(params);
805
+ for (let j = 0; j < batch.length; j++) {
806
+ const { entry, record } = batch[j];
807
+ if (results[j].recompute) recompute.push({
808
+ subId: entry.subId,
809
+ queryName: record.queryName,
810
+ args: record.args,
811
+ cacheKey: entry.cacheKey,
812
+ notifyInstances: []
813
+ });
814
+ }
815
+ } catch (err) {
816
+ logger$2.error(err, `Sub-manager recovery: failed to batch register ${i}-${i + batch.length}`);
817
+ }
818
+ }
819
+ await Promise.allSettled(recompute.map((a) => this.reexecuteAffectedQuery(a)));
820
+ logger$2.info(`Sub-manager recovery: done (${recompute.length} reexec'd)`);
821
+ }
822
+ async runAffectedHandler(affected) {
823
+ await Promise.allSettled(affected.map((a) => this.reexecuteAffectedQuery(a)));
824
+ }
825
+ /**
826
+ * Removes a subscription from this app server's state and notifies every
827
+ * session that was holding it. Used during recovery when the central
828
+ * registration record is gone, or after a `tracked: false` reply from the
829
+ * sub-manager that we can't reconcile.
830
+ */
831
+ dropSubscriptionLocally(subId, reason) {
832
+ const serverSub = this.serverSubscriptions.get(subId);
833
+ let notified = 0;
834
+ if (serverSub) {
835
+ this.serverSubscriptions.delete(subId);
836
+ this.stats.activeSubscriptions = Math.max(0, this.stats.activeSubscriptions - 1);
837
+ for (const [, session] of this.sessions) if (session.subIds.has(subId)) {
838
+ notified++;
839
+ session.subIds.delete(subId);
840
+ this.sendMessage(session, {
841
+ type: "sub:gone",
842
+ subId,
843
+ reason
844
+ });
845
+ }
846
+ }
847
+ logger$2.warn(`Dropped subscription ${subId} locally (${reason}); notified ${notified} session(s)`);
848
+ }
849
+ async handleMutationCommit(writeSet, commitTs) {
850
+ if (writeSet.length === 0) return;
851
+ const tables = [...new Set(writeSet.map((w) => w.table))];
852
+ logger$2.debug(`Mutation committed for tables: [${tables.join(", ")}]`);
853
+ try {
854
+ const result = await this.subManagerClient.invalidateWriteset({ writeSet });
855
+ logger$2.debug({ affected: result }, "Sub-managers invalidation result");
856
+ if (result?.affected?.length > 0) await Promise.allSettled(result.affected.map((affected) => this.dispatchAffected(affected, commitTs)));
857
+ } catch (err) {
858
+ logger$2.error(err, "Failed to notify sub-managers");
859
+ }
860
+ }
861
+ /**
862
+ * Route an affected sub to whichever instance can actually run the
863
+ * recompute:
864
+ * • If this instance has a local session for the sub, recompute here
865
+ * (fast path) and broadcast the result to every other instance.
866
+ * • Otherwise broadcast a `recompute-race` to every instance in
867
+ * `notifyInstances`. Each receiver tries to acquire a Redis lock
868
+ * keyed by (subId, commitTs); the winner recomputes and publishes
869
+ * an `update` via the normal broadcast path, losers no-op.
870
+ *
871
+ * If `notifyInstances` is empty (the sub has no live holders), we drop
872
+ * the message — the next subscribe will recompute fresh state.
873
+ */
874
+ async dispatchAffected(affected, commitTs) {
875
+ if (this.hasLocalSessionFor(affected.subId)) {
876
+ await this.reexecuteAffectedQuery(affected);
877
+ return;
878
+ }
879
+ if (affected.notifyInstances.length === 0) {
880
+ logger$2.debug(`dispatchAffected: no live instances hold ${affected.subId} — dropping`);
881
+ return;
882
+ }
883
+ await this.publishRecomputeRace(affected, commitTs);
884
+ }
885
+ hasLocalSessionFor(subId) {
886
+ for (const [, session] of this.sessions) if (session.subIds.has(subId)) return true;
887
+ return false;
888
+ }
889
+ async publishRecomputeRace(affected, commitTs) {
890
+ const payload = supaliveStringify({
891
+ type: "recompute-race",
892
+ subId: affected.subId,
893
+ cacheKey: affected.cacheKey,
894
+ queryName: affected.queryName,
895
+ args: affected.args,
896
+ notifyInstances: affected.notifyInstances,
897
+ commitTs: commitTs.toString(),
898
+ originInstance: this.instanceId
899
+ });
900
+ const publishPromises = affected.notifyInstances.map(async (instance) => {
901
+ if (instance === this.instanceId) return;
902
+ const channel = `sub:instance:${instance}`;
903
+ try {
904
+ await redisRetryPolicy.execute(() => this.cache.redis.publish(channel, payload));
905
+ } catch (err) {
906
+ logger$2.error(err, `Failed to publish recompute-race to ${channel} after many retries`);
907
+ }
908
+ });
909
+ await Promise.all(publishPromises);
910
+ }
911
+ async reexecuteAffectedQuery(affected) {
912
+ let foundSession = null;
913
+ for (const [, session] of this.sessions) if (session.subIds.has(affected.subId)) {
914
+ foundSession = session;
915
+ break;
916
+ }
917
+ if (!foundSession) {
918
+ logger$2.debug(`Session not found for subId: ${affected.subId}`);
919
+ return;
920
+ }
921
+ try {
922
+ const liveResult = await this.executeLiveQuery(affected.queryName, affected.args, foundSession.serverCtx);
923
+ const dataHash = await getHashOf(liveResult.data);
924
+ const [updateResult] = await Promise.all([this.subManagerClient.updateSubscriptionReadSet({
925
+ cacheKey: affected.cacheKey,
926
+ subId: affected.subId,
927
+ lastSnapshotTs: liveResult.ts,
928
+ readSet: liveResult.readSet,
929
+ instanceName: this.instanceId
930
+ }), this.cache.setQueryCacheFor(affected.cacheKey, liveResult.data)]);
931
+ if (!updateResult.tracked) {
932
+ this.dropSubscriptionLocally(affected.subId, "no longer tracked by sub-manager");
933
+ return;
934
+ }
935
+ for (const [, session] of this.sessions) if (session.subIds.has(affected.subId)) this.sendMessage(session, {
936
+ type: "sub:update",
937
+ subId: affected.subId,
938
+ data: liveResult.data,
939
+ dataHash
940
+ });
941
+ if (affected.notifyInstances.length > 0) await this.publishSubscriptionUpdate(affected.subId, liveResult.data, dataHash, affected.notifyInstances);
942
+ logger$2.debug(`Re-executed affected query for subId: ${affected.subId}`);
943
+ } catch (err) {
944
+ logger$2.error(err, `Failed to re-execute query for subId ${affected.subId}`);
945
+ }
946
+ }
947
+ async executeLiveQuery(procedureName, input, serverCtx) {
948
+ const procedure = this.contextRegistry.getProcedure(procedureName);
949
+ if (!procedure || procedure.type !== "query") throw new Error(`Procedure not defined: ${procedureName}`);
950
+ return this.db.liveQuery(async (ctx) => {
951
+ const fn = procedure.fn;
952
+ return fn({
953
+ db: ctx,
954
+ serverCtx
955
+ }, input);
956
+ });
957
+ }
958
+ /**
959
+ * Resolve the segmentation key used in the subId/cacheKey hash for a
960
+ * query subscription. Priority:
961
+ *
962
+ * 1. Procedure-level `queryIdentity` override:
963
+ * - `false` → identity = `ANONYMOUS_IDENTITY` (cache shared across users)
964
+ * - `string` → that literal
965
+ * - `function` → ctx + input → string | null | undefined
966
+ * 2. `config.getUserId(serverCtx)` → falls back to ANONYMOUS_IDENTITY if it
967
+ * returns null/undefined or isn't configured.
968
+ */
969
+ resolveQueryIdentity(procedure, session, input) {
970
+ const override = procedure.queryIdentity;
971
+ if (override !== void 0) {
972
+ if (override === false) return ANONYMOUS_IDENTITY;
973
+ if (typeof override === "string") return override;
974
+ if (typeof override === "function") {
975
+ const value = override(session.serverCtx, input);
976
+ if (value === null || value === void 0) return ANONYMOUS_IDENTITY;
977
+ return value;
978
+ }
979
+ }
980
+ if (!this.config.getUserId) return ANONYMOUS_IDENTITY;
981
+ return this.config.getUserId(session.serverCtx) ?? "anonymous";
982
+ }
983
+ sendMessage(session, message) {
984
+ if (session.ws.readyState !== WebSocket.OPEN) return;
985
+ session.ws.send(supaliveStringify(message));
986
+ session.lastSentAt = Date.now();
987
+ }
988
+ sendMessageRaw(session, message) {
989
+ if (session.ws.readyState === WebSocket.OPEN) session.ws.send(message);
990
+ }
991
+ sendError(session, id, error, code) {
992
+ this.sendMessage(session, {
993
+ type: "response",
994
+ id,
995
+ success: false,
996
+ error,
997
+ code
998
+ });
999
+ }
1000
+ async subscribeToInstanceChannel() {
1001
+ const instanceChannel = `sub:instance:${this.instanceId}`;
1002
+ const schemaChannel = SCHEMA_RELOAD_CHANNEL;
1003
+ await this.redisSub.subscribe(instanceChannel, schemaChannel);
1004
+ this.redisSub.on("connect", () => {
1005
+ logger$2.info(`Subscribed to Redis channels: ${instanceChannel}, ${schemaChannel}`);
1006
+ });
1007
+ this.redisSub.on("message", (channel, message) => {
1008
+ if (channel === instanceChannel) this.handleRemoteInstanceMessage(message).catch((err) => {
1009
+ logger$2.error(err, "Error handling remote instance message");
1010
+ });
1011
+ else if (channel === schemaChannel) this.handleSchemaReload().catch((err) => {
1012
+ logger$2.error(err, "Error handling schema reload");
1013
+ });
1014
+ });
1015
+ this.redisSub.on("error", (err) => {
1016
+ logger$2.error(err, `Subscriber error for channels: ${instanceChannel}, ${schemaChannel}`);
1017
+ });
1018
+ }
1019
+ /**
1020
+ * React to a `schema:reload` broadcast: invalidate the in-memory column
1021
+ * type cache for the named tables (or all if omitted) and re-bootstrap
1022
+ * from Postgres so subsequent batched writes see the fresh types. Skips
1023
+ * messages published by this instance (we already have the new types).
1024
+ */
1025
+ async handleSchemaReload() {
1026
+ logger$2.info(`Schema reload request received in instance ${this.instanceId} — reloading schema...`);
1027
+ try {
1028
+ await sleep(1e3 + Math.random() * 5e3);
1029
+ await bootstrapDbTypes(this.db, this.cache, true);
1030
+ logger$2.info(`Schema reload applied in instance ${this.instanceId}`);
1031
+ } catch (err) {
1032
+ logger$2.error(err, "Failed to reload schema");
1033
+ return;
1034
+ }
1035
+ }
1036
+ /**
1037
+ * Broadcast a schema-reload signal to every server instance (including
1038
+ * this one — the handler skips self-originated messages, so calling
1039
+ * after a local migration is safe). Pass `tables` to scope the reload;
1040
+ * omit to flush everyone's cache.
1041
+ */
1042
+ async publishSchemaReload(tables) {
1043
+ const payload = JSON.stringify({
1044
+ type: "schema:reload",
1045
+ tables,
1046
+ originInstance: this.instanceId
1047
+ });
1048
+ await redisRetryPolicy.execute(() => this.cache.redis.publish(SCHEMA_RELOAD_CHANNEL, payload));
1049
+ }
1050
+ async handleRemoteInstanceMessage(message) {
1051
+ let parsed;
1052
+ try {
1053
+ parsed = RemoteSubscriptionMessageSchema.parse(JSON.parse(message));
1054
+ } catch (err) {
1055
+ logger$2.error(err, "Failed to parse remote instance message");
1056
+ return;
1057
+ }
1058
+ if (parsed.originInstance === this.instanceId) return;
1059
+ if (parsed.type === "update") {
1060
+ for (const [, session] of this.sessions) {
1061
+ if (!session.subIds.has(parsed.subId)) continue;
1062
+ if (!this.serverSubscriptions.get(parsed.subId)) continue;
1063
+ this.sendMessage(session, {
1064
+ type: "sub:update",
1065
+ subId: parsed.subId,
1066
+ data: parsed.data,
1067
+ dataHash: parsed.dataHash
1068
+ });
1069
+ }
1070
+ return;
1071
+ }
1072
+ await this.handleRemoteRecomputeRace(parsed);
1073
+ }
1074
+ async handleRemoteRecomputeRace(req) {
1075
+ if (!this.hasLocalSessionFor(req.subId)) return;
1076
+ if (!await this.cache.tryAcquireRecomputeLock(this.instanceId, req.subId, req.commitTs, RECOMPUTE_LOCK_TTL_SECONDS)) {
1077
+ logger$2.debug(`recompute-race: lost lock for ${req.subId}@${req.commitTs}, waiting for winner's update`);
1078
+ return;
1079
+ }
1080
+ await this.reexecuteAffectedQuery({
1081
+ subId: req.subId,
1082
+ queryName: req.queryName,
1083
+ args: req.args,
1084
+ cacheKey: req.cacheKey,
1085
+ notifyInstances: req.notifyInstances
1086
+ });
1087
+ }
1088
+ async publishSubscriptionUpdate(subId, data, dataHash, notifyInstances) {
1089
+ const payload = supaliveStringify({
1090
+ type: "update",
1091
+ subId,
1092
+ data,
1093
+ dataHash,
1094
+ originInstance: this.instanceId
1095
+ });
1096
+ const publishPromises = notifyInstances.map(async (instance) => {
1097
+ if (instance === this.instanceId) return;
1098
+ const channel = `sub:instance:${instance}`;
1099
+ try {
1100
+ await redisRetryPolicy.execute(() => this.cache.redis.publish(channel, payload));
1101
+ } catch (err) {
1102
+ logger$2.error(err, `Failed to publish to ${channel} after many retries`);
1103
+ }
1104
+ });
1105
+ await Promise.all(publishPromises);
1106
+ }
1107
+ };
1108
+ //#endregion
1109
+ //#region src/server/hash.ts
1110
+ /**
1111
+ * FNV-1a 32-bit hash.
1112
+ *
1113
+ * Used for routing subscription operations to logical workers inside the
1114
+ * sub-manager process. Same input always returns the same 32-bit unsigned
1115
+ * integer in both JavaScript and Go, so a future Go port of the sub-manager
1116
+ * can use the identical routing without rebalancing keys.
1117
+ *
1118
+ * Reference: http://www.isthe.com/chongo/tech/comp/fnv/
1119
+ */
1120
+ function fnv1a32(s) {
1121
+ let h = 2166136261;
1122
+ for (let i = 0; i < s.length; i++) {
1123
+ h ^= s.charCodeAt(i);
1124
+ h = h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) >>> 0;
1125
+ }
1126
+ return h >>> 0;
1127
+ }
1128
+ //#endregion
1129
+ //#region src/server/subscription-manager.ts
1130
+ const logger$1 = logger.child({ name: "SUBS_MGR" }, { level: logLevelOf("SUBS_MANAGER_SERVER_LOG_LEVEL") });
1131
+ function resolveWorkerEntry() {
1132
+ const isTs = new URL(import.meta.url).pathname.endsWith(".ts");
1133
+ return {
1134
+ url: new URL(`./subscription-manager-worker-entry${isTs ? ".ts" : ".js"}`, import.meta.url),
1135
+ isTs
1136
+ };
1137
+ }
1138
+ function workerExecArgv(isTs) {
1139
+ if (!isTs) return void 0;
1140
+ if (typeof globalThis.Bun !== "undefined") return void 0;
1141
+ return ["--import", "tsx"];
1142
+ }
1143
+ var SubscriptionWorkerHandle = class {
1144
+ id;
1145
+ worker;
1146
+ pending = /* @__PURE__ */ new Map();
1147
+ nextReqId = 1;
1148
+ readyPromise;
1149
+ constructor(id, init) {
1150
+ this.id = id;
1151
+ const entry = resolveWorkerEntry();
1152
+ this.worker = new Worker(fileURLToPath(entry.url), { execArgv: workerExecArgv(entry.isTs) });
1153
+ this.worker.on("message", (res) => {
1154
+ const pending = this.pending.get(res.reqId);
1155
+ if (!pending) return;
1156
+ this.pending.delete(res.reqId);
1157
+ if (res.ok) pending.resolve(res.result);
1158
+ else {
1159
+ const err = new Error(res.error.message);
1160
+ if (res.error.stack) err.stack = res.error.stack;
1161
+ pending.reject(err);
1162
+ }
1163
+ });
1164
+ this.worker.on("error", (err) => {
1165
+ logger$1.error(err, `[worker ${this.id}] worker thread errored`);
1166
+ const e = err instanceof Error ? err : new Error(String(err));
1167
+ for (const [, p] of this.pending) p.reject(e);
1168
+ this.pending.clear();
1169
+ });
1170
+ this.readyPromise = this.send(1, init).then(() => {});
1171
+ }
1172
+ ready() {
1173
+ return this.readyPromise;
1174
+ }
1175
+ send(type, payload) {
1176
+ const reqId = this.nextReqId++;
1177
+ return new Promise((resolve, reject) => {
1178
+ this.pending.set(reqId, {
1179
+ resolve,
1180
+ reject
1181
+ });
1182
+ const req = {
1183
+ type,
1184
+ reqId,
1185
+ payload
1186
+ };
1187
+ this.worker.postMessage(req);
1188
+ });
1189
+ }
1190
+ async shutdown() {
1191
+ try {
1192
+ await this.send(11, null);
1193
+ } catch (_) {}
1194
+ await this.worker.terminate();
1195
+ }
1196
+ };
1197
+ /**
1198
+ * One process per deployment. Routes each subscription operation to one of N
1199
+ * worker threads by hashing subId. Worker threads run in parallel on separate
1200
+ * CPU cores; this dispatcher does no Zod parsing — it reads `subId` directly
1201
+ * from the decoded payload (the internal channel is trusted) and forwards the
1202
+ * raw payload. The worker validates and parses with Zod before executing.
1203
+ */
1204
+ var SubscriptionManager = class SubscriptionManager {
1205
+ wss;
1206
+ port;
1207
+ workers;
1208
+ pruneInterval = null;
1209
+ static PRUNE_INTERVAL_MS = 1800 * 1e3;
1210
+ constructor(config) {
1211
+ this.port = config.port;
1212
+ this.wss = new Server({ port: this.port }, subscriptionManagerDataPack);
1213
+ this.wss.dataPack = subscriptionManagerDataPack;
1214
+ const numWorkers = resolveWorkerCount(config.workers);
1215
+ const maxConnectionsPerWorker = resolveMaxConnectionsPerWorker(config, numWorkers);
1216
+ logger$1.info(`Spawning ${numWorkers} subscription worker${numWorkers === 1 ? "" : "s"}`);
1217
+ this.workers = Array.from({ length: numWorkers }, (_, i) => new SubscriptionWorkerHandle(i + 1, {
1218
+ workerId: i + 1,
1219
+ database: config.database,
1220
+ upstash: config.upstash,
1221
+ cacheTTLSeconds: config.cacheTTLSeconds,
1222
+ maxConnectionsPerWorker
1223
+ }));
1224
+ this.registerMethods();
1225
+ }
1226
+ async start() {
1227
+ await Promise.all(this.workers.map((w) => w.ready()));
1228
+ logger$1.info(`Listening on port ${this.port}`);
1229
+ this.pruneInterval = setInterval(() => {
1230
+ this.pruneCommitLogs().catch((err) => logger$1.error(err, "commit_logs prune failed"));
1231
+ }, SubscriptionManager.PRUNE_INTERVAL_MS);
1232
+ if (this.pruneInterval && typeof this.pruneInterval.unref === "function") this.pruneInterval.unref();
1233
+ }
1234
+ async stop() {
1235
+ if (this.pruneInterval) {
1236
+ clearInterval(this.pruneInterval);
1237
+ this.pruneInterval = null;
1238
+ }
1239
+ this.wss.close();
1240
+ await Promise.all(this.workers.map((w) => w.shutdown()));
1241
+ logger$1.info("Stopped");
1242
+ }
1243
+ async pruneCommitLogs() {
1244
+ const minPerWorker = await Promise.all(this.workers.map((w) => w.send(8, null)));
1245
+ let floor = null;
1246
+ for (const m of minPerWorker) {
1247
+ if (m === null) continue;
1248
+ if (floor === null || m < floor) floor = m;
1249
+ }
1250
+ if (floor === null) {
1251
+ const latest = await this.workers[0].send(9, null);
1252
+ if (latest <= 0n) return;
1253
+ floor = latest;
1254
+ }
1255
+ const deleted = await this.workers[0].send(10, floor);
1256
+ if (deleted > 0n) logger$1.info(`pruned ${deleted} commit_logs older than ts=${floor}`);
1257
+ }
1258
+ workerCount() {
1259
+ return this.workers.length;
1260
+ }
1261
+ workerIndexFor(subId) {
1262
+ return fnv1a32(subId) % this.workers.length;
1263
+ }
1264
+ workerFor(subId) {
1265
+ return this.workers[this.workerIndexFor(subId)];
1266
+ }
1267
+ registerMethods() {
1268
+ this.wss.register("registerSubscription", this.registerSubscription.bind(this));
1269
+ this.wss.register("registerSubscriptionBatch", this.registerSubscriptionBatch.bind(this));
1270
+ this.wss.register("updateSubscriptionReadSet", this.updateSubscriptionReadSet.bind(this));
1271
+ this.wss.register("invalidateWriteset", this.invalidateWriteset.bind(this));
1272
+ this.wss.register("unregisterSubscription", this.unregisterSubscription.bind(this));
1273
+ this.wss.register("unregisterSubscriptions", this.unregisterSubscriptions.bind(this));
1274
+ }
1275
+ readSubId(params) {
1276
+ return params.subId;
1277
+ }
1278
+ async registerSubscription(params) {
1279
+ const subId = this.readSubId(params);
1280
+ return this.workerFor(subId).send(2, params);
1281
+ }
1282
+ async registerSubscriptionBatch(params) {
1283
+ const arr = params;
1284
+ const byWorker = groupByToMap(arr.map((p, index) => ({
1285
+ index,
1286
+ p,
1287
+ worker: this.workerFor(p.subId)
1288
+ })), (t) => t.worker);
1289
+ const settled = await Promise.all([...byWorker.entries()].map(async ([worker, items]) => {
1290
+ const results = await worker.send(3, items.map((t) => t.p));
1291
+ return items.map((t, i) => ({
1292
+ index: t.index,
1293
+ result: results[i]
1294
+ }));
1295
+ }));
1296
+ const result = new Array(arr.length);
1297
+ for (const { index, result: r } of settled.flat()) result[index] = r;
1298
+ return result;
1299
+ }
1300
+ async updateSubscriptionReadSet(params) {
1301
+ const subId = this.readSubId(params);
1302
+ return this.workerFor(subId).send(4, params);
1303
+ }
1304
+ async unregisterSubscription(params) {
1305
+ const subId = this.readSubId(params);
1306
+ return this.workerFor(subId).send(5, params);
1307
+ }
1308
+ async unregisterSubscriptions(params) {
1309
+ const arr = params;
1310
+ const byWorker = groupByToMap(arr.map((p, index) => ({
1311
+ index,
1312
+ p,
1313
+ worker: this.workerFor(p.subId)
1314
+ })), (t) => t.worker);
1315
+ const settled = await Promise.all([...byWorker.entries()].map(async ([worker, items]) => {
1316
+ const results = await worker.send(6, items.map((t) => t.p));
1317
+ return items.map((t, i) => ({
1318
+ index: t.index,
1319
+ result: results[i]
1320
+ }));
1321
+ }));
1322
+ const result = new Array(arr.length);
1323
+ for (const { index, result: r } of settled.flat()) result[index] = r;
1324
+ return result;
1325
+ }
1326
+ async invalidateWriteset(params) {
1327
+ return { affected: (await Promise.all(this.workers.map((w) => w.send(7, params)))).flat() };
1328
+ }
1329
+ };
1330
+ function resolveWorkerCount(configured) {
1331
+ if (typeof configured === "number" && configured > 0) return Math.floor(configured);
1332
+ const envVal = process.env.SUPALIVE_SUB_MANAGER_WORKERS;
1333
+ if (envVal) {
1334
+ const n = parseInt(envVal, 10);
1335
+ if (Number.isFinite(n) && n > 0) return n;
1336
+ }
1337
+ return 1;
1338
+ }
1339
+ function resolveMaxConnectionsPerWorker(config, workers) {
1340
+ if (config.maxConnectionsPerWorker && config.maxConnectionsPerWorker > 0) return Math.floor(config.maxConnectionsPerWorker);
1341
+ const total = config.database.maxConnections ?? (config.database.type === "mysql" ? 20 : 10);
1342
+ return Math.max(1, Math.floor(total / workers));
1343
+ }
1344
+ //#endregion
1345
+ export { ANONYMOUS_IDENTITY, CacheLayer, DB_QUERY_TIMEOUT_MS, InvalidateWritesetParamsSchema, RegisterSubscriptionParamsSchema, SubManagerClient, SubscriptionManager, SupaliveWebSocketServer, UnregisterSubscriptionParamsSchema, UpdateSubscriptionReadSetParamsSchema, appInstanceId, bootstrapDbTypes, createDatabase, generateCacheKey, generateSubscriptionId, getHashOf, initCacheLayer, initDatabase, isProduction, logLevelOf, logger, serializeMapValue };
1346
+
3
1347
  //# sourceMappingURL=server.js.map