@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.
- package/README.md +17 -2
- package/dist/helper-CiacMqje.d.ts +14 -0
- package/dist/helper-CiacMqje.d.ts.map +1 -0
- package/dist/helper-zdJT5FUc.js +58 -0
- package/dist/helper-zdJT5FUc.js.map +1 -0
- package/dist/index-CTyXKcq_.d.ts +1736 -0
- package/dist/index-CTyXKcq_.d.ts.map +1 -0
- package/dist/mysql-C8SN443m.d.ts +104 -0
- package/dist/mysql-C8SN443m.d.ts.map +1 -0
- package/dist/mysql-DELSRrpY.js +569 -0
- package/dist/mysql-DELSRrpY.js.map +1 -0
- package/dist/overlap-checker-CVkbbuEQ.js +187 -0
- package/dist/overlap-checker-CVkbbuEQ.js.map +1 -0
- package/dist/postgres-DE-XyNAQ.js +823 -0
- package/dist/postgres-DE-XyNAQ.js.map +1 -0
- package/dist/postgres-D_gwCHx-.d.ts +108 -0
- package/dist/postgres-D_gwCHx-.d.ts.map +1 -0
- package/dist/query-CbQLjeME.js +713 -0
- package/dist/query-CbQLjeME.js.map +1 -0
- package/dist/realtime_db-D9p2X2PH.js +246 -0
- package/dist/realtime_db-D9p2X2PH.js.map +1 -0
- package/dist/router-DlTYWpop.js +81 -0
- package/dist/router-DlTYWpop.js.map +1 -0
- package/dist/schema-SKcIWHfo.js +167 -0
- package/dist/schema-SKcIWHfo.js.map +1 -0
- package/dist/src/client/index.d.ts +2 -17
- package/dist/src/client/index.js +1151 -18
- package/dist/src/client/index.js.map +1 -1
- package/dist/src/exports/mysql.d.ts +2 -2
- package/dist/src/exports/mysql.js +2 -2
- package/dist/src/exports/postgres.d.ts +2 -2
- package/dist/src/exports/postgres.js +2 -2
- package/dist/src/exports/procedure.d.ts +3 -5
- package/dist/src/exports/procedure.js +5 -5
- package/dist/src/exports/server.d.ts +368 -2
- package/dist/src/exports/server.d.ts.map +1 -1
- package/dist/src/exports/server.js +1346 -2
- package/dist/src/exports/server.js.map +1 -1
- package/dist/src/exports/subscription-manager-worker-entry.d.ts +1 -0
- package/dist/src/exports/subscription-manager-worker-entry.js +115 -0
- package/dist/src/exports/subscription-manager-worker-entry.js.map +1 -0
- package/dist/src/exports/types.d.ts +4 -6
- package/dist/src/exports/types.js +5 -6
- package/dist/subscription-worker-BurdPO8d.js +511 -0
- package/dist/subscription-worker-BurdPO8d.js.map +1 -0
- package/dist/types_client_rpc-CIsjQQGG.js +105 -0
- package/dist/types_client_rpc-CIsjQQGG.js.map +1 -0
- package/dist/types_db-Ctf-ksoy.js +168 -0
- package/dist/types_db-Ctf-ksoy.js.map +1 -0
- package/dist/types_server-Ch56y8L3.d.ts +216 -0
- package/dist/types_server-Ch56y8L3.d.ts.map +1 -0
- package/dist/types_server-Dk6o_B7N.js +24 -0
- package/dist/types_server-Dk6o_B7N.js.map +1 -0
- package/package.json +3 -3
- package/dist/src/client/caller_client.d.ts +0 -42
- package/dist/src/client/caller_client.d.ts.map +0 -1
- package/dist/src/client/caller_client.js +0 -77
- package/dist/src/client/caller_client.js.map +0 -1
- package/dist/src/client/client.d.ts +0 -149
- package/dist/src/client/client.d.ts.map +0 -1
- package/dist/src/client/client.js +0 -308
- package/dist/src/client/client.js.map +0 -1
- package/dist/src/client/index.d.ts.map +0 -1
- package/dist/src/client/sub_key.d.ts +0 -20
- package/dist/src/client/sub_key.d.ts.map +0 -1
- package/dist/src/client/sub_key.js +0 -37
- package/dist/src/client/sub_key.js.map +0 -1
- package/dist/src/client/ws_client_manager.d.ts +0 -268
- package/dist/src/client/ws_client_manager.d.ts.map +0 -1
- package/dist/src/client/ws_client_manager.js +0 -1013
- package/dist/src/client/ws_client_manager.js.map +0 -1
- package/dist/src/config.d.ts +0 -117
- package/dist/src/config.d.ts.map +0 -1
- package/dist/src/config.js +0 -2
- package/dist/src/config.js.map +0 -1
- package/dist/src/db/bytes.d.ts +0 -4
- package/dist/src/db/bytes.d.ts.map +0 -1
- package/dist/src/db/bytes.js +0 -24
- package/dist/src/db/bytes.js.map +0 -1
- package/dist/src/db/cache.d.ts +0 -30
- package/dist/src/db/cache.d.ts.map +0 -1
- package/dist/src/db/cache.js +0 -153
- package/dist/src/db/cache.js.map +0 -1
- package/dist/src/db/codec.d.ts +0 -10
- package/dist/src/db/codec.d.ts.map +0 -1
- package/dist/src/db/codec.js +0 -73
- package/dist/src/db/codec.js.map +0 -1
- package/dist/src/db/context.d.ts +0 -28
- package/dist/src/db/context.d.ts.map +0 -1
- package/dist/src/db/context.js +0 -147
- package/dist/src/db/context.js.map +0 -1
- package/dist/src/db/db.d.ts +0 -154
- package/dist/src/db/db.d.ts.map +0 -1
- package/dist/src/db/db.js +0 -23
- package/dist/src/db/db.js.map +0 -1
- package/dist/src/db/mysql.d.ts +0 -120
- package/dist/src/db/mysql.d.ts.map +0 -1
- package/dist/src/db/mysql.js +0 -708
- package/dist/src/db/mysql.js.map +0 -1
- package/dist/src/db/postgres.d.ts +0 -116
- package/dist/src/db/postgres.d.ts.map +0 -1
- package/dist/src/db/postgres.js +0 -998
- package/dist/src/db/postgres.js.map +0 -1
- package/dist/src/db/query.d.ts +0 -174
- package/dist/src/db/query.d.ts.map +0 -1
- package/dist/src/db/query.js +0 -790
- package/dist/src/db/query.js.map +0 -1
- package/dist/src/db/query_definition.d.ts +0 -54
- package/dist/src/db/query_definition.d.ts.map +0 -1
- package/dist/src/db/query_definition.js +0 -47
- package/dist/src/db/query_definition.js.map +0 -1
- package/dist/src/db/realtime_db.d.ts +0 -19
- package/dist/src/db/realtime_db.d.ts.map +0 -1
- package/dist/src/db/realtime_db.js +0 -98
- package/dist/src/db/realtime_db.js.map +0 -1
- package/dist/src/db/schema.d.ts +0 -110
- package/dist/src/db/schema.d.ts.map +0 -1
- package/dist/src/db/schema.js +0 -47
- package/dist/src/db/schema.js.map +0 -1
- package/dist/src/db/types_client_rpc.d.ts +0 -157
- package/dist/src/db/types_client_rpc.d.ts.map +0 -1
- package/dist/src/db/types_client_rpc.js +0 -106
- package/dist/src/db/types_client_rpc.js.map +0 -1
- package/dist/src/db/types_db.d.ts +0 -236
- package/dist/src/db/types_db.d.ts.map +0 -1
- package/dist/src/db/types_db.js +0 -188
- package/dist/src/db/types_db.js.map +0 -1
- package/dist/src/exports/mysql.d.ts.map +0 -1
- package/dist/src/exports/mysql.js.map +0 -1
- package/dist/src/exports/postgres.d.ts.map +0 -1
- package/dist/src/exports/postgres.js.map +0 -1
- package/dist/src/exports/procedure.d.ts.map +0 -1
- package/dist/src/exports/procedure.js.map +0 -1
- package/dist/src/exports/types.d.ts.map +0 -1
- package/dist/src/exports/types.js.map +0 -1
- package/dist/src/helper.d.ts +0 -11
- package/dist/src/helper.d.ts.map +0 -1
- package/dist/src/helper.js +0 -45
- package/dist/src/helper.js.map +0 -1
- package/dist/src/logger.d.ts +0 -7
- package/dist/src/logger.d.ts.map +0 -1
- package/dist/src/logger.js +0 -47
- package/dist/src/logger.js.map +0 -1
- package/dist/src/router/index.d.ts +0 -4
- package/dist/src/router/index.d.ts.map +0 -1
- package/dist/src/router/index.js +0 -4
- package/dist/src/router/index.js.map +0 -1
- package/dist/src/router/procedure.d.ts +0 -204
- package/dist/src/router/procedure.d.ts.map +0 -1
- package/dist/src/router/procedure.js +0 -122
- package/dist/src/router/procedure.js.map +0 -1
- package/dist/src/router/router.d.ts +0 -88
- package/dist/src/router/router.d.ts.map +0 -1
- package/dist/src/router/router.js +0 -88
- package/dist/src/router/router.js.map +0 -1
- package/dist/src/server/hash.d.ts +0 -12
- package/dist/src/server/hash.d.ts.map +0 -1
- package/dist/src/server/hash.js +0 -20
- package/dist/src/server/hash.js.map +0 -1
- package/dist/src/server/hash.test.d.ts +0 -2
- package/dist/src/server/hash.test.d.ts.map +0 -1
- package/dist/src/server/hash.test.js +0 -43
- package/dist/src/server/hash.test.js.map +0 -1
- package/dist/src/server/index.d.ts +0 -9
- package/dist/src/server/index.d.ts.map +0 -1
- package/dist/src/server/index.js +0 -8
- package/dist/src/server/index.js.map +0 -1
- package/dist/src/server/overlap-checker.d.ts +0 -18
- package/dist/src/server/overlap-checker.d.ts.map +0 -1
- package/dist/src/server/overlap-checker.js +0 -247
- package/dist/src/server/overlap-checker.js.map +0 -1
- package/dist/src/server/sub_hash.d.ts +0 -23
- package/dist/src/server/sub_hash.d.ts.map +0 -1
- package/dist/src/server/sub_hash.js +0 -38
- package/dist/src/server/sub_hash.js.map +0 -1
- package/dist/src/server/subscription-manager-client.d.ts +0 -120
- package/dist/src/server/subscription-manager-client.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-client.js +0 -207
- package/dist/src/server/subscription-manager-client.js.map +0 -1
- package/dist/src/server/subscription-manager-types.d.ts +0 -168
- package/dist/src/server/subscription-manager-types.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-types.js +0 -94
- package/dist/src/server/subscription-manager-types.js.map +0 -1
- package/dist/src/server/subscription-manager-worker-entry.d.ts +0 -2
- package/dist/src/server/subscription-manager-worker-entry.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-worker-entry.js +0 -127
- package/dist/src/server/subscription-manager-worker-entry.js.map +0 -1
- package/dist/src/server/subscription-manager-worker-protocol.d.ts +0 -40
- package/dist/src/server/subscription-manager-worker-protocol.d.ts.map +0 -1
- package/dist/src/server/subscription-manager-worker-protocol.js +0 -20
- package/dist/src/server/subscription-manager-worker-protocol.js.map +0 -1
- package/dist/src/server/subscription-manager.d.ts +0 -32
- package/dist/src/server/subscription-manager.d.ts.map +0 -1
- package/dist/src/server/subscription-manager.js +0 -249
- package/dist/src/server/subscription-manager.js.map +0 -1
- package/dist/src/server/subscription-manager.test.d.ts +0 -2
- package/dist/src/server/subscription-manager.test.d.ts.map +0 -1
- package/dist/src/server/subscription-manager.test.js +0 -610
- package/dist/src/server/subscription-manager.test.js.map +0 -1
- package/dist/src/server/subscription-worker.d.ts +0 -32
- package/dist/src/server/subscription-worker.d.ts.map +0 -1
- package/dist/src/server/subscription-worker.js +0 -190
- package/dist/src/server/subscription-worker.js.map +0 -1
- package/dist/src/server/supalive-server.d.ts +0 -158
- package/dist/src/server/supalive-server.d.ts.map +0 -1
- package/dist/src/server/supalive-server.js +0 -1048
- package/dist/src/server/supalive-server.js.map +0 -1
- package/dist/src/server/types_server.d.ts +0 -96
- package/dist/src/server/types_server.d.ts.map +0 -1
- package/dist/src/server/types_server.js +0 -36
- package/dist/src/server/types_server.js.map +0 -1
- package/dist/tsconfig.tsbuildinfo +0 -1
|
@@ -1,3 +1,1347 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|