@supalive/core 1.0.0 → 1.2.0
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 +28 -0
- package/dist/db-ClCicssQ.js +28 -0
- package/dist/db-ClCicssQ.js.map +1 -0
- package/dist/{index-DouKwYL6.d.ts → index-E1_1iRI1.d.ts} +213 -9
- package/dist/index-E1_1iRI1.d.ts.map +1 -0
- package/dist/{mysql-Bxurg_PH.d.ts → mysql-BmkLy5oY.d.ts} +8 -3
- package/dist/mysql-BmkLy5oY.d.ts.map +1 -0
- package/dist/{mysql-bvnjYWvZ.js → mysql-Ca1B_0k_.js} +58 -25
- package/dist/mysql-Ca1B_0k_.js.map +1 -0
- package/dist/{overlap-checker-Ct_HP_iB.js → overlap-checker-ClJC0nxW.js} +52 -4
- package/dist/overlap-checker-ClJC0nxW.js.map +1 -0
- package/dist/{postgres-B8u1YN8X.js → postgres-CHDY1HPf.js} +59 -22
- package/dist/postgres-CHDY1HPf.js.map +1 -0
- package/dist/{postgres-Ck-xt72U.d.ts → postgres-v2ctNE6o.d.ts} +8 -3
- package/dist/postgres-v2ctNE6o.d.ts.map +1 -0
- package/dist/{procedure-DX87tyef.js → procedure-Ze1GE-N9.js} +33 -2
- package/dist/procedure-Ze1GE-N9.js.map +1 -0
- package/dist/{query-Bh0RNxDz.js → query-CrbJ_W0W.js} +21 -1
- package/dist/query-CrbJ_W0W.js.map +1 -0
- package/dist/{realtime_db-DOCgqbwi.js → realtime_db-BtwBABDY.js} +76 -51
- package/dist/realtime_db-BtwBABDY.js.map +1 -0
- package/dist/{router-DlTYWpop.js → router-CiWYeWUR.js} +8 -2
- package/dist/router-CiWYeWUR.js.map +1 -0
- package/dist/src/client/index.d.ts +1 -1
- package/dist/src/client/index.js +44 -5
- package/dist/src/client/index.js.map +1 -1
- package/dist/src/exports/mysql.d.ts +1 -1
- package/dist/src/exports/mysql.js +1 -1
- package/dist/src/exports/postgres.d.ts +1 -1
- package/dist/src/exports/postgres.js +1 -1
- package/dist/src/exports/procedure.d.ts +2 -2
- package/dist/src/exports/procedure.js +4 -4
- package/dist/src/exports/schema-sql.d.ts +35 -2
- package/dist/src/exports/schema-sql.d.ts.map +1 -1
- package/dist/src/exports/schema-sql.js +69 -19
- package/dist/src/exports/schema-sql.js.map +1 -1
- package/dist/src/exports/server.d.ts +64 -17
- package/dist/src/exports/server.d.ts.map +1 -1
- package/dist/src/exports/server.js +726 -64
- package/dist/src/exports/server.js.map +1 -1
- package/dist/src/exports/subscription-manager-worker-entry.js +8 -5
- package/dist/src/exports/subscription-manager-worker-entry.js.map +1 -1
- package/dist/src/exports/types.d.ts +3 -3
- package/dist/src/exports/types.js +1 -1
- package/dist/{subscription-worker-CqoWp6zB.js → subscription-worker-CAf6MUta.js} +306 -69
- package/dist/subscription-worker-CAf6MUta.js.map +1 -0
- package/dist/{types_db-DXj_l9n-.js → types_db-CTRm_awX.js} +7 -2
- package/dist/types_db-CTRm_awX.js.map +1 -0
- package/dist/types_server-CzAufcee.d.ts +420 -0
- package/dist/types_server-CzAufcee.d.ts.map +1 -0
- package/package.json +5 -4
- package/dist/index-CaWMDqq0.d.ts +0 -1754
- package/dist/index-CaWMDqq0.d.ts.map +0 -1
- package/dist/index-DouKwYL6.d.ts.map +0 -1
- package/dist/mysql-Bxurg_PH.d.ts.map +0 -1
- package/dist/mysql-Cxd4Uq5p.d.ts +0 -104
- package/dist/mysql-Cxd4Uq5p.d.ts.map +0 -1
- package/dist/mysql-bvnjYWvZ.js.map +0 -1
- package/dist/overlap-checker-Ct_HP_iB.js.map +0 -1
- package/dist/postgres-B8u1YN8X.js.map +0 -1
- package/dist/postgres-CfT_aDx3.d.ts +0 -108
- package/dist/postgres-CfT_aDx3.d.ts.map +0 -1
- package/dist/postgres-Ck-xt72U.d.ts.map +0 -1
- package/dist/procedure-DX87tyef.js.map +0 -1
- package/dist/query-Bh0RNxDz.js.map +0 -1
- package/dist/realtime_db-DOCgqbwi.js.map +0 -1
- package/dist/router-DlTYWpop.js.map +0 -1
- package/dist/src/exports/codegen.d.ts +0 -38
- package/dist/src/exports/codegen.d.ts.map +0 -1
- package/dist/src/exports/codegen.js +0 -134
- package/dist/src/exports/codegen.js.map +0 -1
- package/dist/subscription-worker-CqoWp6zB.js.map +0 -1
- package/dist/subscription-worker-PtmJWEj1.js +0 -511
- package/dist/subscription-worker-PtmJWEj1.js.map +0 -1
- package/dist/subscription-worker-pahX0zxg.js +0 -511
- package/dist/subscription-worker-pahX0zxg.js.map +0 -1
- package/dist/types_db-DXj_l9n-.js.map +0 -1
- package/dist/types_server-BW1ys_SK.d.ts +0 -216
- package/dist/types_server-BW1ys_SK.d.ts.map +0 -1
- package/dist/types_server-Dpsi0wpG.d.ts +0 -216
- package/dist/types_server-Dpsi0wpG.d.ts.map +0 -1
- package/dist/types_server-DzrIccyO.d.ts +0 -216
- package/dist/types_server-DzrIccyO.d.ts.map +0 -1
- package/dist/types_server-TFo6SWDH.d.ts +0 -216
- package/dist/types_server-TFo6SWDH.d.ts.map +0 -1
|
@@ -1,15 +1,17 @@
|
|
|
1
|
-
import { n as getContextRegistry } from "../../router-
|
|
1
|
+
import { n as getContextRegistry } from "../../router-CiWYeWUR.js";
|
|
2
2
|
import { n as stableStringify, r as supaliveStringify, t as groupByToMap } from "../../helper-wGbzz6zo.js";
|
|
3
|
+
import { r as evaluateCacheFreshness } from "../../overlap-checker-ClJC0nxW.js";
|
|
3
4
|
import { i as AuthenticationError, o as ClientMessageSchema } from "../../types_client_rpc-CIsjQQGG.js";
|
|
4
5
|
import { n as RemoteSubscriptionMessageSchema } from "../../types_server-Dk6o_B7N.js";
|
|
5
|
-
import { n as sleep } from "../../realtime_db-
|
|
6
|
-
import { C as
|
|
6
|
+
import { n as sleep } from "../../realtime_db-BtwBABDY.js";
|
|
7
|
+
import { C as RetentionWatermark, D as logger, E as logLevelOf, O as serializeMapValue, S as RETENTION_WATERMARK_CHANNEL, T as isProduction, _ as UnregisterSubscriptionResultSchema, a as createDatabase, b as UpdateSubscriptionReadSetParamsSchema, c as initDatabase, d as InvalidateWritesetResultSchema, g as UnregisterSubscriptionParamsSchema, h as RegisterSubscriptionResultSchema, i as bootstrapDbTypes, l as CacheLayer, m as RegisterSubscriptionParamsSchema, n as CORE_VERSION, o as initCacheLayer, p as RegisterSubscriptionBatchResultSchema, r as DB_QUERY_TIMEOUT_MS, s as initCore, u as InvalidateWritesetParamsSchema, w as appInstanceId, x as UpdateSubscriptionReadSetResultSchema, y as UnregisterSubscriptionsResultSchema } from "../../subscription-worker-CAf6MUta.js";
|
|
7
8
|
import { createServer } from "http";
|
|
8
9
|
import { WebSocket, WebSocketServer } from "ws";
|
|
9
10
|
import { v4 } from "uuid";
|
|
10
11
|
import { ExponentialBackoff, handleAll, retry } from "cockatiel";
|
|
11
12
|
import { sha1 } from "hash-wasm";
|
|
12
13
|
import { Client, Server } from "rpc-websockets";
|
|
14
|
+
import { Client as Client$1, Receiver } from "@upstash/qstash";
|
|
13
15
|
import { Worker } from "node:worker_threads";
|
|
14
16
|
import { fileURLToPath } from "node:url";
|
|
15
17
|
//#region src/server/sub_hash.ts
|
|
@@ -23,6 +25,33 @@ async function getHashOf(input) {
|
|
|
23
25
|
*/
|
|
24
26
|
const ANONYMOUS_IDENTITY = "anonymous";
|
|
25
27
|
/**
|
|
28
|
+
* Resolve the segmentation key that participates in the subId/cacheKey hash for
|
|
29
|
+
* a query. Shared by every query transport (WS subscribe, WS one-shot call, and
|
|
30
|
+
* HTTP RPC) so identity — and therefore cache segmentation — is computed
|
|
31
|
+
* identically regardless of how the query is invoked. Priority:
|
|
32
|
+
*
|
|
33
|
+
* 1. Procedure-level `queryIdentity` override:
|
|
34
|
+
* - `false` → `ANONYMOUS_IDENTITY` (cache shared across users)
|
|
35
|
+
* - `string` → that literal
|
|
36
|
+
* - `function` → serverCtx + input → string | null | undefined
|
|
37
|
+
* 2. `getUserId(serverCtx)` → falls back to `ANONYMOUS_IDENTITY` when it
|
|
38
|
+
* returns null/undefined or isn't configured.
|
|
39
|
+
*/
|
|
40
|
+
function resolveQueryIdentity(procedure, serverCtx, input, getUserId) {
|
|
41
|
+
const override = procedure.queryIdentity;
|
|
42
|
+
if (override !== void 0) {
|
|
43
|
+
if (override === false) return ANONYMOUS_IDENTITY;
|
|
44
|
+
if (typeof override === "string") return override;
|
|
45
|
+
if (typeof override === "function") {
|
|
46
|
+
const value = override(serverCtx, input);
|
|
47
|
+
if (value === null || value === void 0) return ANONYMOUS_IDENTITY;
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (!getUserId) return ANONYMOUS_IDENTITY;
|
|
52
|
+
return getUserId(serverCtx) ?? "anonymous";
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
26
55
|
* Compute a subscription id from hash of procedure + input + queryIdentity.
|
|
27
56
|
*
|
|
28
57
|
* `queryIdentity` is the segmentation key for cache/subscriptions. By default
|
|
@@ -42,8 +71,55 @@ async function generateCacheKey(queryName, input, queryIdentity) {
|
|
|
42
71
|
return `ck_${await getHashOf(`${queryName}_${typeof input === "string" ? input : stableStringify(input)}_${queryIdentity}`)}`;
|
|
43
72
|
}
|
|
44
73
|
//#endregion
|
|
74
|
+
//#region src/server/one-shot-query.ts
|
|
75
|
+
/**
|
|
76
|
+
* Execute a one-shot query (WS `call` or HTTP RPC) with the same caching model
|
|
77
|
+
* as a live subscription — but without registering a tracked subscription.
|
|
78
|
+
*
|
|
79
|
+
* Flow (mirrors `handleSubscribe` / worker `register`):
|
|
80
|
+
* 1. Resolve `queryIdentity` → `cacheKey` (identical segmentation to subs).
|
|
81
|
+
* 2. Freshness check via {@link evaluateCacheFreshness}: cached metadata +
|
|
82
|
+
* the retention watermark + a between-ts commit-log scan.
|
|
83
|
+
* 3. Fresh → return the cached data.
|
|
84
|
+
* Stale/miss → recompute via `queryInternal` and write BOTH the data and
|
|
85
|
+
* the metadata (readSet + snapshotTs) back, so a later one-shot or a fresh
|
|
86
|
+
* subscription can reuse it.
|
|
87
|
+
*
|
|
88
|
+
* The retention guard is what makes an untracked one-shot safe: nothing holds
|
|
89
|
+
* the prune watermark down for it, so an entry whose snapshot has fallen below
|
|
90
|
+
* the oldest retained commit log is treated as a miss (its freshness scan would
|
|
91
|
+
* be incomplete).
|
|
92
|
+
*
|
|
93
|
+
* `lastSnapshotTs` is the freshness upper bound — the latest snapshot ts as of
|
|
94
|
+
* this call. The caller loads it (via `db.getLatestSnapshotTimestamp()`) and
|
|
95
|
+
* passes it in, exactly like the subscribe path loads it before `register`, so
|
|
96
|
+
* the snapshot read lives at the caller seam rather than being hidden here.
|
|
97
|
+
*/
|
|
98
|
+
async function runCachedQuery(deps, procedure, procedureName, input, serverCtx, lastSnapshotTs) {
|
|
99
|
+
const queryIdentity = resolveQueryIdentity(procedure, serverCtx, input, deps.getUserId);
|
|
100
|
+
const cacheKey = await generateCacheKey(procedureName, stableStringify(input), queryIdentity);
|
|
101
|
+
const metadata = await deps.cache.getQueryCacheMetaData(cacheKey);
|
|
102
|
+
if ((await evaluateCacheFreshness(deps.db.impl, metadata, lastSnapshotTs, deps.getMinRetainedTs())).fresh) {
|
|
103
|
+
const cached = await deps.cache.getQueryCacheFor(cacheKey);
|
|
104
|
+
if (cached && metadata && cached.version === metadata.version) return cached.data;
|
|
105
|
+
}
|
|
106
|
+
const result = await deps.db.queryInternal(async (ctx) => {
|
|
107
|
+
const fn = procedure.fn;
|
|
108
|
+
return fn({
|
|
109
|
+
db: ctx,
|
|
110
|
+
serverCtx
|
|
111
|
+
}, input);
|
|
112
|
+
});
|
|
113
|
+
await deps.cache.setQueryCacheAndMetadataFor(cacheKey, result.data, {
|
|
114
|
+
lastSnapshotTs: result.ts,
|
|
115
|
+
version: String(result.ts),
|
|
116
|
+
readSet: result.readSet
|
|
117
|
+
}).catch((err) => logger.error(err, "one-shot query cache write failed"));
|
|
118
|
+
return result.data;
|
|
119
|
+
}
|
|
120
|
+
//#endregion
|
|
45
121
|
//#region src/server/subscription-manager-client.ts
|
|
46
|
-
const logger$
|
|
122
|
+
const logger$5 = logger.child({ name: "SUBS_MGR_CLIENT" }, { level: logLevelOf("SUB_MANAGER_CLIENT_LOG_LEVEL") });
|
|
47
123
|
const subscriptionManagerDataPack = {
|
|
48
124
|
encode: (data) => supaliveStringify(data),
|
|
49
125
|
decode: (data) => JSON.parse(data)
|
|
@@ -100,7 +176,7 @@ var SubManagerClient = class {
|
|
|
100
176
|
this.client.dataPack = subscriptionManagerDataPack;
|
|
101
177
|
this.client.on("open", this.handleOpen.bind(this));
|
|
102
178
|
this.client.on("close", this.handleClose.bind(this));
|
|
103
|
-
this.client.on("error", (err) => logger$
|
|
179
|
+
this.client.on("error", (err) => logger$5.warn(err, "sub-manager client socket error"));
|
|
104
180
|
}
|
|
105
181
|
/**
|
|
106
182
|
* Wire up the app-server-driven recovery. Must be called before any
|
|
@@ -161,7 +237,7 @@ var SubManagerClient = class {
|
|
|
161
237
|
appendToBuffer(writeSet) {
|
|
162
238
|
if (this.bufferedWriteSets.length + writeSet.length > this.bufferLimit) {
|
|
163
239
|
if (!this.bufferDroppedDuringOutage) {
|
|
164
|
-
logger$
|
|
240
|
+
logger$5.warn(`Invalidate buffer would exceed ${this.bufferLimit}; dropping buffered entries. Subscriptions will be caught up via commit-log replay on re-register.`);
|
|
165
241
|
this.bufferDroppedDuringOutage = true;
|
|
166
242
|
}
|
|
167
243
|
this.bufferedWriteSets = [];
|
|
@@ -178,7 +254,7 @@ var SubManagerClient = class {
|
|
|
178
254
|
const result = await this.call("invalidateWriteset", { writeSet }, InvalidateWritesetResultSchema);
|
|
179
255
|
if (this.affectedHandler && result.affected.length > 0) await this.affectedHandler(result.affected);
|
|
180
256
|
} catch (err) {
|
|
181
|
-
logger$
|
|
257
|
+
logger$5.error(err, "Failed to flush invalidate buffer after recovery");
|
|
182
258
|
}
|
|
183
259
|
}
|
|
184
260
|
markBusy() {
|
|
@@ -194,28 +270,28 @@ var SubManagerClient = class {
|
|
|
194
270
|
this.resolveReady();
|
|
195
271
|
}
|
|
196
272
|
handleClose() {
|
|
197
|
-
logger$
|
|
273
|
+
logger$5.info("sub-manager client: socket closed");
|
|
198
274
|
this.markBusy();
|
|
199
275
|
}
|
|
200
276
|
handleOpen() {
|
|
201
277
|
if (!this.hasConnectedOnce) {
|
|
202
278
|
this.hasConnectedOnce = true;
|
|
203
|
-
logger$
|
|
279
|
+
logger$5.info("sub-manager client: initial connect");
|
|
204
280
|
this.markReady();
|
|
205
281
|
return;
|
|
206
282
|
}
|
|
207
|
-
logger$
|
|
283
|
+
logger$5.info("sub-manager client: reconnected — starting recovery");
|
|
208
284
|
this.runRecovery();
|
|
209
285
|
}
|
|
210
286
|
async runRecovery() {
|
|
211
287
|
try {
|
|
212
288
|
if (this.recoveryDriver) await this.recoveryDriver();
|
|
213
|
-
else logger$
|
|
289
|
+
else logger$5.warn("sub-manager client: no recoveryDriver configured — skipping recovery step");
|
|
214
290
|
await this.flushBuffer();
|
|
215
291
|
} catch (err) {
|
|
216
|
-
logger$
|
|
292
|
+
logger$5.error(err, "Recovery failed; state will be marked ready anyway");
|
|
217
293
|
} finally {
|
|
218
|
-
logger$
|
|
294
|
+
logger$5.info("sub-manager client: recovery complete");
|
|
219
295
|
this.markReady();
|
|
220
296
|
}
|
|
221
297
|
}
|
|
@@ -232,6 +308,423 @@ var SubManagerClient = class {
|
|
|
232
308
|
}
|
|
233
309
|
};
|
|
234
310
|
//#endregion
|
|
311
|
+
//#region src/jobs/scheduler.ts
|
|
312
|
+
const logger$4 = logger.child({ name: "SCHEDULER" }, { level: logLevelOf("SCHEDULER_LOG_LEVEL") });
|
|
313
|
+
function delayMsFrom(opts) {
|
|
314
|
+
if (opts?.runAt) return Math.max(0, opts.runAt.getTime() - Date.now());
|
|
315
|
+
if (opts?.delaySeconds && opts.delaySeconds > 0) return opts.delaySeconds * 1e3;
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* In-process scheduler for local development and tests. Recurring jobs fire on
|
|
320
|
+
* a fixed interval; one-shots fire on an exact `setTimeout` (so precise timing
|
|
321
|
+
* is faithfully testable). Every fire POSTs to the real HTTP job endpoint, and
|
|
322
|
+
* `verify` accepts everything — there is no signing in dev.
|
|
323
|
+
*/
|
|
324
|
+
var DevScheduler = class {
|
|
325
|
+
intervalMs;
|
|
326
|
+
fetchImpl;
|
|
327
|
+
intervals = /* @__PURE__ */ new Map();
|
|
328
|
+
timeouts = /* @__PURE__ */ new Map();
|
|
329
|
+
constructor(opts = {}) {
|
|
330
|
+
this.intervalMs = opts.recurringIntervalMs ?? 6e4;
|
|
331
|
+
this.fetchImpl = opts.fetchImpl ?? fetch;
|
|
332
|
+
}
|
|
333
|
+
async verify(_req) {
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
async syncSchedules(jobs, endpointFor) {
|
|
337
|
+
for (const timer of this.intervals.values()) clearInterval(timer);
|
|
338
|
+
this.intervals.clear();
|
|
339
|
+
for (const job of jobs) {
|
|
340
|
+
const endpoint = endpointFor(job.name);
|
|
341
|
+
const timer = setInterval(() => {
|
|
342
|
+
this.post(endpoint, {}).catch((err) => logger$4.error(err, `dev recurring job "${job.name}" failed`));
|
|
343
|
+
}, this.intervalMs);
|
|
344
|
+
timer.unref?.();
|
|
345
|
+
this.intervals.set(job.name, timer);
|
|
346
|
+
logger$4.info(`dev schedule "${job.name}" (cron "${job.cron}") → firing every ${this.intervalMs}ms`);
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
async enqueue(name, body, endpoint, opts) {
|
|
350
|
+
const id = crypto.randomUUID();
|
|
351
|
+
const timer = setTimeout(() => {
|
|
352
|
+
this.timeouts.delete(id);
|
|
353
|
+
this.post(endpoint, body).catch((err) => logger$4.error(err, `dev one-shot job "${name}" failed`));
|
|
354
|
+
}, delayMsFrom(opts));
|
|
355
|
+
timer.unref?.();
|
|
356
|
+
this.timeouts.set(id, timer);
|
|
357
|
+
return { id };
|
|
358
|
+
}
|
|
359
|
+
async cancel(handleId) {
|
|
360
|
+
const timer = this.timeouts.get(handleId);
|
|
361
|
+
if (timer) {
|
|
362
|
+
clearTimeout(timer);
|
|
363
|
+
this.timeouts.delete(handleId);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/** Stop every dev timer (call on server shutdown / in test teardown). */
|
|
367
|
+
stop() {
|
|
368
|
+
for (const t of this.intervals.values()) clearInterval(t);
|
|
369
|
+
for (const t of this.timeouts.values()) clearTimeout(t);
|
|
370
|
+
this.intervals.clear();
|
|
371
|
+
this.timeouts.clear();
|
|
372
|
+
}
|
|
373
|
+
async post(endpoint, body) {
|
|
374
|
+
const res = await this.fetchImpl(endpoint, {
|
|
375
|
+
method: "POST",
|
|
376
|
+
headers: { "content-type": "application/json" },
|
|
377
|
+
body: JSON.stringify(body ?? {})
|
|
378
|
+
});
|
|
379
|
+
if (!res.ok) throw new Error(`job endpoint ${endpoint} returned ${res.status}`);
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
/**
|
|
383
|
+
* Production scheduler backed by Upstash QStash. Externalizes the timer so a
|
|
384
|
+
* horizontally-scaled deployment fires each cron tick exactly once (QStash
|
|
385
|
+
* calls one HTTP endpoint), with at-least-once retries and signed webhooks.
|
|
386
|
+
*/
|
|
387
|
+
var QStashScheduler = class {
|
|
388
|
+
client;
|
|
389
|
+
receiver;
|
|
390
|
+
constructor(opts) {
|
|
391
|
+
this.client = new Client$1({ token: opts.token });
|
|
392
|
+
this.receiver = new Receiver({
|
|
393
|
+
currentSigningKey: opts.currentSigningKey,
|
|
394
|
+
nextSigningKey: opts.nextSigningKey
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
async verify(req) {
|
|
398
|
+
const signature = req.headers["upstash-signature"];
|
|
399
|
+
if (!signature) return false;
|
|
400
|
+
try {
|
|
401
|
+
return await this.receiver.verify({
|
|
402
|
+
signature,
|
|
403
|
+
body: req.rawBody,
|
|
404
|
+
url: req.url
|
|
405
|
+
});
|
|
406
|
+
} catch (err) {
|
|
407
|
+
logger$4.warn(err, `signature verification failed for job "${req.name}"`);
|
|
408
|
+
return false;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
async syncSchedules(jobs, endpointFor) {
|
|
412
|
+
const desired = new Map(jobs.map((j) => [scheduleIdFor(j.name), j]));
|
|
413
|
+
let existing = [];
|
|
414
|
+
try {
|
|
415
|
+
existing = await this.client.schedules.list();
|
|
416
|
+
} catch (err) {
|
|
417
|
+
logger$4.error(err, "failed to list QStash schedules; skipping reconcile of stale schedules");
|
|
418
|
+
}
|
|
419
|
+
for (const schedule of existing) {
|
|
420
|
+
const id = schedule.scheduleId;
|
|
421
|
+
if (id?.startsWith(SCHEDULE_ID_PREFIX) && !desired.has(id)) try {
|
|
422
|
+
await this.client.schedules.delete(id);
|
|
423
|
+
logger$4.info(`removed stale QStash schedule ${id}`);
|
|
424
|
+
} catch (err) {
|
|
425
|
+
logger$4.error(err, `failed to remove stale schedule ${id}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
for (const [scheduleId, job] of desired) try {
|
|
429
|
+
await this.client.schedules.create({
|
|
430
|
+
scheduleId,
|
|
431
|
+
destination: endpointFor(job.name),
|
|
432
|
+
cron: job.cron
|
|
433
|
+
});
|
|
434
|
+
logger$4.info(`synced QStash schedule "${job.name}" (${job.cron})`);
|
|
435
|
+
} catch (err) {
|
|
436
|
+
logger$4.error(err, `failed to sync schedule for job "${job.name}"`);
|
|
437
|
+
throw err;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
async enqueue(name, body, endpoint, opts) {
|
|
441
|
+
const messageId = (await this.client.publishJSON({
|
|
442
|
+
url: endpoint,
|
|
443
|
+
body,
|
|
444
|
+
...opts?.runAt ? { notBefore: Math.floor(opts.runAt.getTime() / 1e3) } : opts?.delaySeconds ? { delay: opts.delaySeconds } : {},
|
|
445
|
+
...opts?.dedupeId ? { deduplicationId: opts.dedupeId } : {}
|
|
446
|
+
})).messageId;
|
|
447
|
+
if (!messageId) throw new Error(`QStash publish for job "${name}" returned no messageId`);
|
|
448
|
+
return { id: messageId };
|
|
449
|
+
}
|
|
450
|
+
async cancel(handleId) {
|
|
451
|
+
await this.client.messages.cancel(handleId);
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
const SCHEDULE_ID_PREFIX = "supalive-job-";
|
|
455
|
+
function scheduleIdFor(jobName) {
|
|
456
|
+
return `${SCHEDULE_ID_PREFIX}${jobName}`;
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Thin app-facing handle for scheduling precise one-shots from inside
|
|
460
|
+
* procedures. The server builds one (via `getJobClient()`) that already knows
|
|
461
|
+
* how to turn a job name into its endpoint, so callers just say
|
|
462
|
+
* `jobs.enqueue("expireSpendAuth", { id }, { runAt })`.
|
|
463
|
+
*/
|
|
464
|
+
var JobClient = class {
|
|
465
|
+
scheduler;
|
|
466
|
+
endpointFor;
|
|
467
|
+
constructor(scheduler, endpointFor) {
|
|
468
|
+
this.scheduler = scheduler;
|
|
469
|
+
this.endpointFor = endpointFor;
|
|
470
|
+
}
|
|
471
|
+
enqueue(name, body, opts) {
|
|
472
|
+
return this.scheduler.enqueue(name, body, this.endpointFor(name), opts);
|
|
473
|
+
}
|
|
474
|
+
cancel(handleId) {
|
|
475
|
+
return this.scheduler.cancel(handleId);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
//#endregion
|
|
479
|
+
//#region src/server/rate-limit.ts
|
|
480
|
+
var FixedWindowRateLimiter = class {
|
|
481
|
+
windowMs;
|
|
482
|
+
max;
|
|
483
|
+
buckets = /* @__PURE__ */ new Map();
|
|
484
|
+
sweepTimer;
|
|
485
|
+
constructor(windowMs, max) {
|
|
486
|
+
this.windowMs = windowMs;
|
|
487
|
+
this.max = max;
|
|
488
|
+
this.sweepTimer = setInterval(() => this.sweep(), Math.max(windowMs, 1e3));
|
|
489
|
+
this.sweepTimer.unref?.();
|
|
490
|
+
}
|
|
491
|
+
check(key, now = Date.now()) {
|
|
492
|
+
let bucket = this.buckets.get(key);
|
|
493
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
494
|
+
bucket = {
|
|
495
|
+
count: 0,
|
|
496
|
+
resetAt: now + this.windowMs
|
|
497
|
+
};
|
|
498
|
+
this.buckets.set(key, bucket);
|
|
499
|
+
}
|
|
500
|
+
bucket.count++;
|
|
501
|
+
if (bucket.count > this.max) return {
|
|
502
|
+
allowed: false,
|
|
503
|
+
retryAfterMs: bucket.resetAt - now,
|
|
504
|
+
remaining: 0
|
|
505
|
+
};
|
|
506
|
+
return {
|
|
507
|
+
allowed: true,
|
|
508
|
+
retryAfterMs: 0,
|
|
509
|
+
remaining: this.max - bucket.count
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
sweep(now = Date.now()) {
|
|
513
|
+
for (const [key, bucket] of this.buckets) if (bucket.resetAt <= now) this.buckets.delete(key);
|
|
514
|
+
}
|
|
515
|
+
/** Stop the background sweep (call on server shutdown / test teardown). */
|
|
516
|
+
stop() {
|
|
517
|
+
if (this.sweepTimer) clearInterval(this.sweepTimer);
|
|
518
|
+
this.sweepTimer = void 0;
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
//#endregion
|
|
522
|
+
//#region src/server/http-rpc.ts
|
|
523
|
+
const logger$3 = logger.child({ name: "HTTP_RPC" }, { level: logLevelOf("SERVER_LOG_LEVEL") });
|
|
524
|
+
/**
|
|
525
|
+
* Handles the one-shot HTTP RPC transport: `POST {path}/<procedureName>` for
|
|
526
|
+
* `query`/`mutation`/`action`. Subscriptions are not offered here (WS only).
|
|
527
|
+
* Kept independent of the WebSocket server so it can be unit-tested against a
|
|
528
|
+
* plain `http.Server` with a stub db/registry.
|
|
529
|
+
*
|
|
530
|
+
* Request flow: CORS → (OPTIONS preflight) → rate limit (pre-auth) → procedure
|
|
531
|
+
* lookup (internal/job rejected) → auth → validate input → run → respond.
|
|
532
|
+
*/
|
|
533
|
+
var HttpRpcHandler = class {
|
|
534
|
+
deps;
|
|
535
|
+
path;
|
|
536
|
+
rpc;
|
|
537
|
+
rateLimiter;
|
|
538
|
+
constructor(deps) {
|
|
539
|
+
this.deps = deps;
|
|
540
|
+
this.rpc = deps.rpc;
|
|
541
|
+
this.path = normalizeRpcPath(deps.rpc.path ?? "/_rpc");
|
|
542
|
+
if (deps.rpc.rateLimit !== false) {
|
|
543
|
+
const rl = deps.rpc.rateLimit ?? {};
|
|
544
|
+
this.rateLimiter = new FixedWindowRateLimiter(rl.windowMs ?? 6e4, rl.max ?? 120);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
/** True when this handler owns the given request path. */
|
|
548
|
+
handlesPath(path) {
|
|
549
|
+
return path === this.path || path.startsWith(`${this.path}/`);
|
|
550
|
+
}
|
|
551
|
+
stop() {
|
|
552
|
+
this.rateLimiter?.stop();
|
|
553
|
+
}
|
|
554
|
+
async handle(req, res, path) {
|
|
555
|
+
this.applyCors(req, res);
|
|
556
|
+
if (req.method === "OPTIONS") {
|
|
557
|
+
res.writeHead(204);
|
|
558
|
+
res.end();
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
if (req.method !== "POST") return this.send(res, 405, {
|
|
562
|
+
success: false,
|
|
563
|
+
error: "method not allowed",
|
|
564
|
+
code: "METHOD_NOT_ALLOWED"
|
|
565
|
+
});
|
|
566
|
+
const prefix = `${this.path}/`;
|
|
567
|
+
const name = path.startsWith(prefix) ? decodeURIComponent(path.slice(prefix.length)) : "";
|
|
568
|
+
if (!name || name.includes("/")) return this.send(res, 404, {
|
|
569
|
+
success: false,
|
|
570
|
+
error: "unknown procedure",
|
|
571
|
+
code: "NOT_FOUND"
|
|
572
|
+
});
|
|
573
|
+
if (this.rateLimiter) {
|
|
574
|
+
const keyBy = this.rpc.rateLimit ? this.rpc.rateLimit.keyBy : void 0;
|
|
575
|
+
const key = keyBy ? keyBy(req) : clientIp(req);
|
|
576
|
+
const rl = this.rateLimiter.check(key);
|
|
577
|
+
if (!rl.allowed) {
|
|
578
|
+
res.setHeader("retry-after", String(Math.ceil(rl.retryAfterMs / 1e3)));
|
|
579
|
+
return this.send(res, 429, {
|
|
580
|
+
success: false,
|
|
581
|
+
error: "rate limit exceeded",
|
|
582
|
+
code: "RATE_LIMITED"
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
const procedure = this.deps.getRegistry()?.getProcedure(name);
|
|
587
|
+
if (!procedure || procedure.internal || procedure.type === "job") return this.send(res, 404, {
|
|
588
|
+
success: false,
|
|
589
|
+
error: `Procedure "${name}" not found`,
|
|
590
|
+
code: "NOT_FOUND"
|
|
591
|
+
});
|
|
592
|
+
const auth = await this.resolveAuth(req);
|
|
593
|
+
if (!auth.ok) return this.send(res, 401, {
|
|
594
|
+
success: false,
|
|
595
|
+
error: auth.message,
|
|
596
|
+
code: "UNAUTHENTICATED"
|
|
597
|
+
});
|
|
598
|
+
const rawBody = await readBody(req);
|
|
599
|
+
let input;
|
|
600
|
+
try {
|
|
601
|
+
const parsed = rawBody.trim().length > 0 ? JSON.parse(rawBody) : {};
|
|
602
|
+
input = procedure.inputSchema.parse(parsed);
|
|
603
|
+
} catch (err) {
|
|
604
|
+
logger$3.warn(err, `RPC "${name}" received invalid input`);
|
|
605
|
+
return this.send(res, 400, {
|
|
606
|
+
success: false,
|
|
607
|
+
error: "invalid input",
|
|
608
|
+
code: "BAD_REQUEST"
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
try {
|
|
612
|
+
const data = await this.runProcedure(procedure, input, auth.ctx);
|
|
613
|
+
return this.send(res, 200, {
|
|
614
|
+
success: true,
|
|
615
|
+
data
|
|
616
|
+
});
|
|
617
|
+
} catch (err) {
|
|
618
|
+
logger$3.error(err, `RPC "${name}" failed`);
|
|
619
|
+
return this.send(res, 500, {
|
|
620
|
+
success: false,
|
|
621
|
+
error: err instanceof Error ? err.message : "execution failed",
|
|
622
|
+
code: "EXECUTION_ERROR"
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
async runProcedure(procedure, input, serverCtx) {
|
|
627
|
+
if (procedure.type === "query") {
|
|
628
|
+
const lastSnapshotTs = await this.deps.db.impl.getLatestSnapshotTimestamp();
|
|
629
|
+
return runCachedQuery(this.deps.oneShotDeps, procedure, procedure.name, input, serverCtx, lastSnapshotTs);
|
|
630
|
+
}
|
|
631
|
+
if (procedure.type === "mutation") return (await this.deps.db.mutation(async (ctx) => {
|
|
632
|
+
const fn = procedure.fn;
|
|
633
|
+
return fn({
|
|
634
|
+
db: ctx,
|
|
635
|
+
serverCtx
|
|
636
|
+
}, input);
|
|
637
|
+
})).result;
|
|
638
|
+
const fn = procedure.fn;
|
|
639
|
+
return fn({
|
|
640
|
+
db: this.deps.db,
|
|
641
|
+
serverCtx
|
|
642
|
+
}, input);
|
|
643
|
+
}
|
|
644
|
+
async resolveAuth(req) {
|
|
645
|
+
const data = (this.rpc.auth ?? defaultHttpAuth)(req);
|
|
646
|
+
if (!this.deps.verifyAuth || !data) return {
|
|
647
|
+
ok: true,
|
|
648
|
+
ctx: void 0
|
|
649
|
+
};
|
|
650
|
+
try {
|
|
651
|
+
const ctx = await this.deps.verifyAuth(data, `http-${crypto.randomUUID()}`);
|
|
652
|
+
if (!ctx) return {
|
|
653
|
+
ok: false,
|
|
654
|
+
message: "Invalid authentication"
|
|
655
|
+
};
|
|
656
|
+
return {
|
|
657
|
+
ok: true,
|
|
658
|
+
ctx
|
|
659
|
+
};
|
|
660
|
+
} catch (err) {
|
|
661
|
+
if (err instanceof AuthenticationError) return {
|
|
662
|
+
ok: false,
|
|
663
|
+
message: `Invalid authentication: ${err.message}`
|
|
664
|
+
};
|
|
665
|
+
return {
|
|
666
|
+
ok: false,
|
|
667
|
+
message: "Invalid authentication"
|
|
668
|
+
};
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
applyCors(req, res) {
|
|
672
|
+
const cors = this.rpc.cors;
|
|
673
|
+
if (!cors) return;
|
|
674
|
+
const originHeader = req.headers["origin"];
|
|
675
|
+
const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader;
|
|
676
|
+
if (cors.origins === "*") res.setHeader("access-control-allow-origin", "*");
|
|
677
|
+
else if (origin && cors.origins.includes(origin)) {
|
|
678
|
+
res.setHeader("access-control-allow-origin", origin);
|
|
679
|
+
res.setHeader("vary", "Origin");
|
|
680
|
+
} else return;
|
|
681
|
+
res.setHeader("access-control-allow-methods", "POST, OPTIONS");
|
|
682
|
+
res.setHeader("access-control-allow-headers", "authorization, content-type");
|
|
683
|
+
res.setHeader("access-control-max-age", "86400");
|
|
684
|
+
}
|
|
685
|
+
send(res, status, body) {
|
|
686
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
687
|
+
res.end(supaliveStringify(body));
|
|
688
|
+
}
|
|
689
|
+
};
|
|
690
|
+
function normalizeRpcPath(p) {
|
|
691
|
+
let s = p.startsWith("/") ? p : `/${p}`;
|
|
692
|
+
if (s.length > 1 && s.endsWith("/")) s = s.slice(0, -1);
|
|
693
|
+
return s;
|
|
694
|
+
}
|
|
695
|
+
function defaultHttpAuth(req) {
|
|
696
|
+
const header = req.headers["authorization"];
|
|
697
|
+
const value = Array.isArray(header) ? header[0] : header;
|
|
698
|
+
if (!value) return void 0;
|
|
699
|
+
const match = /^Bearer\s+(.+)$/i.exec(value);
|
|
700
|
+
return match ? { token: match[1] } : void 0;
|
|
701
|
+
}
|
|
702
|
+
function clientIp(req) {
|
|
703
|
+
const xff = req.headers["x-forwarded-for"];
|
|
704
|
+
if (xff) {
|
|
705
|
+
const first = (Array.isArray(xff) ? xff[0] : xff).split(",")[0]?.trim();
|
|
706
|
+
if (first) return first;
|
|
707
|
+
}
|
|
708
|
+
return req.socket?.remoteAddress ?? "unknown";
|
|
709
|
+
}
|
|
710
|
+
function readBody(req, maxBytes = 1024 * 1024) {
|
|
711
|
+
return new Promise((resolve, reject) => {
|
|
712
|
+
const chunks = [];
|
|
713
|
+
let size = 0;
|
|
714
|
+
req.on("data", (chunk) => {
|
|
715
|
+
size += chunk.length;
|
|
716
|
+
if (size > maxBytes) {
|
|
717
|
+
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
718
|
+
req.destroy();
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
chunks.push(chunk);
|
|
722
|
+
});
|
|
723
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
724
|
+
req.on("error", reject);
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
//#endregion
|
|
235
728
|
//#region src/server/supalive-server.ts
|
|
236
729
|
const redisRetryPolicy = retry(handleAll, {
|
|
237
730
|
maxAttempts: 3,
|
|
@@ -291,6 +784,12 @@ var SupaliveWebSocketServer = class {
|
|
|
291
784
|
db;
|
|
292
785
|
contextRegistry;
|
|
293
786
|
router;
|
|
787
|
+
retentionWatermark = new RetentionWatermark();
|
|
788
|
+
oneShotDeps;
|
|
789
|
+
scheduler;
|
|
790
|
+
jobPath;
|
|
791
|
+
jobClient;
|
|
792
|
+
rpcHandler;
|
|
294
793
|
sessions = /* @__PURE__ */ new Map();
|
|
295
794
|
serverSubscriptions = /* @__PURE__ */ new Map();
|
|
296
795
|
subManagerClient;
|
|
@@ -313,9 +812,39 @@ var SupaliveWebSocketServer = class {
|
|
|
313
812
|
this.cache = config.cacheLayer;
|
|
314
813
|
this.redisSub = config.redisSubClient;
|
|
315
814
|
this.db = config.database;
|
|
815
|
+
this.db.setOnAfterCommit((writeSet, commitTs) => {
|
|
816
|
+
this.handleMutationCommit(writeSet, commitTs).catch((err) => logger$2.error(err, "post-commit invalidation failed"));
|
|
817
|
+
});
|
|
818
|
+
this.scheduler = config.scheduler;
|
|
819
|
+
this.jobPath = normalizeJobPath(config.jobPath ?? "/_jobs");
|
|
820
|
+
if (this.scheduler) {
|
|
821
|
+
if (!this.config.publicUrl) throw new Error("publicUrl config is required when a scheduler is configured (used to build job endpoints)");
|
|
822
|
+
this.jobClient = new JobClient(this.scheduler, (name) => this.endpointFor(name));
|
|
823
|
+
}
|
|
824
|
+
this.oneShotDeps = {
|
|
825
|
+
db: this.db,
|
|
826
|
+
cache: this.cache,
|
|
827
|
+
getUserId: config.getUserId,
|
|
828
|
+
getMinRetainedTs: () => this.retentionWatermark.get()
|
|
829
|
+
};
|
|
830
|
+
if (config.rpc) this.rpcHandler = new HttpRpcHandler({
|
|
831
|
+
getRegistry: () => this.contextRegistry,
|
|
832
|
+
db: this.db,
|
|
833
|
+
oneShotDeps: this.oneShotDeps,
|
|
834
|
+
verifyAuth: config.verifyAuth,
|
|
835
|
+
rpc: config.rpc
|
|
836
|
+
});
|
|
316
837
|
this.subManagerClient.setRecoveryDriver(this.runSubManagerRecovery.bind(this));
|
|
317
838
|
this.subManagerClient.setAffectedHandler(this.runAffectedHandler.bind(this));
|
|
318
|
-
this.httpServer = createServer()
|
|
839
|
+
this.httpServer = createServer((req, res) => {
|
|
840
|
+
this.handleHttpRequest(req, res).catch((err) => {
|
|
841
|
+
logger$2.error(err, "HTTP request handler error");
|
|
842
|
+
if (!res.headersSent) {
|
|
843
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
844
|
+
res.end(JSON.stringify({ error: "internal error" }));
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
});
|
|
319
848
|
this.wss = new WebSocketServer({
|
|
320
849
|
server: this.httpServer,
|
|
321
850
|
autoPong: true,
|
|
@@ -362,16 +891,68 @@ var SupaliveWebSocketServer = class {
|
|
|
362
891
|
this.contextRegistry = getContextRegistry(router.contextName);
|
|
363
892
|
}
|
|
364
893
|
async start() {
|
|
365
|
-
|
|
894
|
+
try {
|
|
895
|
+
await this.retentionWatermark.loadFrom(this.db.impl);
|
|
896
|
+
} catch (err) {
|
|
897
|
+
logger$2.error(err, "failed to load initial retention watermark");
|
|
898
|
+
}
|
|
899
|
+
await new Promise((resolve) => {
|
|
366
900
|
this.httpServer.listen(this.config.port, this.config.host, () => {
|
|
367
901
|
logger$2.info(`Server: ${this.instanceId} listening on ${this.config.host}:${this.config.port}`);
|
|
368
902
|
resolve();
|
|
369
903
|
});
|
|
370
904
|
});
|
|
905
|
+
await this.syncJobSchedules();
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Reconcile every registered recurring (cron) job with the scheduler. Safe
|
|
909
|
+
* to call more than once. No-op when no scheduler is configured or the router
|
|
910
|
+
* declares no cron jobs. Runs after the HTTP listener is up so the endpoints
|
|
911
|
+
* the scheduler registers are already reachable.
|
|
912
|
+
*/
|
|
913
|
+
async syncJobSchedules() {
|
|
914
|
+
if (!this.scheduler || !this.contextRegistry) return;
|
|
915
|
+
const jobs = this.contextRegistry.getJobProcedures().filter((p) => !!p.cron).map((p) => ({
|
|
916
|
+
name: p.name,
|
|
917
|
+
cron: p.cron
|
|
918
|
+
}));
|
|
919
|
+
if (jobs.length === 0) return;
|
|
920
|
+
try {
|
|
921
|
+
await this.scheduler.syncSchedules(jobs, (name) => this.endpointFor(name));
|
|
922
|
+
logger$2.info(`Synced ${jobs.length} recurring job schedule(s)`);
|
|
923
|
+
} catch (err) {
|
|
924
|
+
logger$2.error(err, "Failed to sync job schedules");
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
/** Absolute URL the scheduler POSTs to for a given job name. */
|
|
928
|
+
endpointFor(name) {
|
|
929
|
+
return `${this.config.publicUrl}${this.jobPath}/${name}`;
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* Enqueue a precise, one-shot run of a job (e.g. "expire this record at
|
|
933
|
+
* exactly `expiresAt`"). Returns a handle whose `id` can be passed to
|
|
934
|
+
* {@link cancelJob}. Requires a configured scheduler.
|
|
935
|
+
*/
|
|
936
|
+
async scheduleJob(name, body, opts) {
|
|
937
|
+
if (!this.jobClient) throw new Error("No scheduler configured; cannot schedule jobs");
|
|
938
|
+
return this.jobClient.enqueue(name, body, opts);
|
|
939
|
+
}
|
|
940
|
+
/** Cancel a one-shot previously created by {@link scheduleJob}. */
|
|
941
|
+
async cancelJob(handleId) {
|
|
942
|
+
if (!this.jobClient) throw new Error("No scheduler configured; cannot cancel jobs");
|
|
943
|
+
return this.jobClient.cancel(handleId);
|
|
944
|
+
}
|
|
945
|
+
/**
|
|
946
|
+
* The app-facing {@link JobClient} — hand it to your service layer so
|
|
947
|
+
* procedures can schedule/cancel one-shots without reaching for the server.
|
|
948
|
+
*/
|
|
949
|
+
getJobClient() {
|
|
950
|
+
return this.jobClient;
|
|
371
951
|
}
|
|
372
952
|
async stop() {
|
|
373
953
|
this.wss.close();
|
|
374
954
|
for (const [ws] of this.sessions) ws.close(1001, "Server shutting down");
|
|
955
|
+
this.rpcHandler?.stop();
|
|
375
956
|
this.httpServer.close();
|
|
376
957
|
logger$2.info("Server stopped");
|
|
377
958
|
}
|
|
@@ -386,6 +967,64 @@ var SupaliveWebSocketServer = class {
|
|
|
386
967
|
getInstanceId() {
|
|
387
968
|
return this.instanceId;
|
|
388
969
|
}
|
|
970
|
+
/**
|
|
971
|
+
* Plain-HTTP entrypoint (non-WS). Serves a liveness probe and the job
|
|
972
|
+
* webhook: `POST {jobPath}/<jobName>`. Every job POST is authenticated by the
|
|
973
|
+
* configured scheduler before the (server-only) job handler runs.
|
|
974
|
+
*/
|
|
975
|
+
async handleHttpRequest(req, res) {
|
|
976
|
+
const path = (req.url ?? "/").split("?")[0];
|
|
977
|
+
if (req.method === "GET" && (path === "/_health" || path === `${this.jobPath}/_health`)) return sendJson(res, 200, {
|
|
978
|
+
status: "ok",
|
|
979
|
+
instanceId: this.instanceId
|
|
980
|
+
});
|
|
981
|
+
if (this.rpcHandler && this.rpcHandler.handlesPath(path)) return this.rpcHandler.handle(req, res, path);
|
|
982
|
+
const jobPrefix = `${this.jobPath}/`;
|
|
983
|
+
if (!path.startsWith(jobPrefix)) return sendJson(res, 404, { error: "not found" });
|
|
984
|
+
if (req.method !== "POST") return sendJson(res, 405, { error: "method not allowed" });
|
|
985
|
+
const name = decodeURIComponent(path.slice(jobPrefix.length));
|
|
986
|
+
if (!name || name.includes("/")) return sendJson(res, 404, { error: "unknown job" });
|
|
987
|
+
if (!this.scheduler) return sendJson(res, 503, { error: "jobs not enabled" });
|
|
988
|
+
const rawBody = await readRequestBody(req);
|
|
989
|
+
let verified = false;
|
|
990
|
+
try {
|
|
991
|
+
verified = await this.scheduler.verify({
|
|
992
|
+
name,
|
|
993
|
+
rawBody,
|
|
994
|
+
headers: lowercaseHeaders(req.headers),
|
|
995
|
+
url: this.endpointFor(name)
|
|
996
|
+
});
|
|
997
|
+
} catch (err) {
|
|
998
|
+
logger$2.warn(err, `job "${name}" verification threw`);
|
|
999
|
+
}
|
|
1000
|
+
if (!verified) return sendJson(res, 401, { error: "invalid signature" });
|
|
1001
|
+
const procedure = this.contextRegistry?.getProcedure(name);
|
|
1002
|
+
if (!procedure || procedure.type !== "job") return sendJson(res, 404, { error: "unknown job" });
|
|
1003
|
+
let input;
|
|
1004
|
+
try {
|
|
1005
|
+
const parsed = rawBody.trim().length > 0 ? JSON.parse(rawBody) : {};
|
|
1006
|
+
input = procedure.inputSchema.parse(parsed);
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
logger$2.warn(err, `job "${name}" received an invalid payload`);
|
|
1009
|
+
return sendJson(res, 400, { error: "invalid payload" });
|
|
1010
|
+
}
|
|
1011
|
+
try {
|
|
1012
|
+
const serverCtx = this.config.jobContext ? await this.config.jobContext() : void 0;
|
|
1013
|
+
const fn = procedure.fn;
|
|
1014
|
+
const result = await fn({
|
|
1015
|
+
db: this.db,
|
|
1016
|
+
serverCtx
|
|
1017
|
+
}, input);
|
|
1018
|
+
logger$2.info(`job "${name}" completed`);
|
|
1019
|
+
return sendJson(res, 200, {
|
|
1020
|
+
ok: true,
|
|
1021
|
+
result
|
|
1022
|
+
});
|
|
1023
|
+
} catch (err) {
|
|
1024
|
+
logger$2.error(err, `job "${name}" failed`);
|
|
1025
|
+
return sendJson(res, 500, { error: "job failed" });
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
389
1028
|
setupWebSocketHandlers() {
|
|
390
1029
|
this.wss.on("connection", (ws, req) => {
|
|
391
1030
|
this.handleConnection(ws, req).catch((err) => {
|
|
@@ -555,20 +1194,15 @@ var SupaliveWebSocketServer = class {
|
|
|
555
1194
|
return;
|
|
556
1195
|
}
|
|
557
1196
|
const procedure = this.contextRegistry.getProcedure(procedureName);
|
|
558
|
-
if (!procedure) {
|
|
1197
|
+
if (!procedure || procedure.type === "job") {
|
|
559
1198
|
this.sendError(session, id, `Procedure "${procedureName}" not found`, "NOT_FOUND");
|
|
560
1199
|
return;
|
|
561
1200
|
}
|
|
562
1201
|
try {
|
|
563
1202
|
const validatedInput = procedure.inputSchema.parse(input);
|
|
564
1203
|
if (procedure.type === "query") {
|
|
565
|
-
const
|
|
566
|
-
|
|
567
|
-
return fn({
|
|
568
|
-
db: ctx,
|
|
569
|
-
serverCtx: session.serverCtx
|
|
570
|
-
}, validatedInput);
|
|
571
|
-
});
|
|
1204
|
+
const lastSnapshotTs = await this.db.impl.getLatestSnapshotTimestamp();
|
|
1205
|
+
const result = await runCachedQuery(this.oneShotDeps, procedure, procedureName, validatedInput, session.serverCtx, lastSnapshotTs);
|
|
572
1206
|
this.sendMessage(session, {
|
|
573
1207
|
type: "response",
|
|
574
1208
|
id,
|
|
@@ -584,9 +1218,9 @@ var SupaliveWebSocketServer = class {
|
|
|
584
1218
|
this.sendMessage(session, {
|
|
585
1219
|
type: "response",
|
|
586
1220
|
id,
|
|
587
|
-
success: true
|
|
1221
|
+
success: true,
|
|
1222
|
+
data: mutationResult.result
|
|
588
1223
|
});
|
|
589
|
-
await this.handleMutationCommit(mutationResult.writeSet, mutationResult.commitTs);
|
|
590
1224
|
} else if (procedure.type === "action") {
|
|
591
1225
|
if (session.actionInFlight >= MAX_ACTION_CONCURRENCY) {
|
|
592
1226
|
this.sendError(session, id, `Too many concurrent actions on this connection (max ${MAX_ACTION_CONCURRENCY})`, "TOO_MANY_CONCURRENT_ACTIONS");
|
|
@@ -627,7 +1261,7 @@ var SupaliveWebSocketServer = class {
|
|
|
627
1261
|
}
|
|
628
1262
|
try {
|
|
629
1263
|
const validatedInput = procedure.inputSchema.parse(input);
|
|
630
|
-
const queryIdentity =
|
|
1264
|
+
const queryIdentity = resolveQueryIdentity(procedure, session.serverCtx, validatedInput, this.config.getUserId);
|
|
631
1265
|
const inputJsonStable = stableStringify(validatedInput);
|
|
632
1266
|
const subId = await generateSubscriptionId(procedureName, inputJsonStable, queryIdentity);
|
|
633
1267
|
const cacheKey = await generateCacheKey(procedureName, inputJsonStable, queryIdentity);
|
|
@@ -656,7 +1290,7 @@ var SupaliveWebSocketServer = class {
|
|
|
656
1290
|
lastSnapshotTs
|
|
657
1291
|
});
|
|
658
1292
|
const cached = await this.cache.getQueryCacheFor(subManagerResult.cacheKey);
|
|
659
|
-
if (cached && !subManagerResult.recompute) data = cached.data;
|
|
1293
|
+
if (cached && !subManagerResult.recompute && cached.version === subManagerResult.version) data = cached.data;
|
|
660
1294
|
else {
|
|
661
1295
|
liveResult = await this.executeLiveQuery(procedureName, validatedInput, session.serverCtx);
|
|
662
1296
|
data = liveResult.data;
|
|
@@ -675,7 +1309,7 @@ var SupaliveWebSocketServer = class {
|
|
|
675
1309
|
lastSnapshotTs: liveResult.ts,
|
|
676
1310
|
readSet: liveResult.readSet,
|
|
677
1311
|
instanceName: this.instanceId
|
|
678
|
-
}), this.cache.setQueryCacheFor(cacheKey, data)]);
|
|
1312
|
+
}), this.cache.setQueryCacheFor(cacheKey, data, String(liveResult.ts))]);
|
|
679
1313
|
if (!updateResult.tracked) logger$2.warn(`updateSubscriptionReadSet returned tracked:false immediately after register for ${subId} — possible sub-manager restart mid-subscribe`);
|
|
680
1314
|
}
|
|
681
1315
|
dataHash = await getHashOf(data);
|
|
@@ -922,7 +1556,7 @@ var SupaliveWebSocketServer = class {
|
|
|
922
1556
|
lastSnapshotTs: liveResult.ts,
|
|
923
1557
|
readSet: liveResult.readSet,
|
|
924
1558
|
instanceName: this.instanceId
|
|
925
|
-
}), this.cache.setQueryCacheFor(affected.cacheKey, liveResult.data)]);
|
|
1559
|
+
}), this.cache.setQueryCacheFor(affected.cacheKey, liveResult.data, String(liveResult.ts))]);
|
|
926
1560
|
if (!updateResult.tracked) {
|
|
927
1561
|
this.dropSubscriptionLocally(affected.subId, "no longer tracked by sub-manager");
|
|
928
1562
|
return;
|
|
@@ -950,31 +1584,6 @@ var SupaliveWebSocketServer = class {
|
|
|
950
1584
|
}, input);
|
|
951
1585
|
});
|
|
952
1586
|
}
|
|
953
|
-
/**
|
|
954
|
-
* Resolve the segmentation key used in the subId/cacheKey hash for a
|
|
955
|
-
* query subscription. Priority:
|
|
956
|
-
*
|
|
957
|
-
* 1. Procedure-level `queryIdentity` override:
|
|
958
|
-
* - `false` → identity = `ANONYMOUS_IDENTITY` (cache shared across users)
|
|
959
|
-
* - `string` → that literal
|
|
960
|
-
* - `function` → ctx + input → string | null | undefined
|
|
961
|
-
* 2. `config.getUserId(serverCtx)` → falls back to ANONYMOUS_IDENTITY if it
|
|
962
|
-
* returns null/undefined or isn't configured.
|
|
963
|
-
*/
|
|
964
|
-
resolveQueryIdentity(procedure, session, input) {
|
|
965
|
-
const override = procedure.queryIdentity;
|
|
966
|
-
if (override !== void 0) {
|
|
967
|
-
if (override === false) return ANONYMOUS_IDENTITY;
|
|
968
|
-
if (typeof override === "string") return override;
|
|
969
|
-
if (typeof override === "function") {
|
|
970
|
-
const value = override(session.serverCtx, input);
|
|
971
|
-
if (value === null || value === void 0) return ANONYMOUS_IDENTITY;
|
|
972
|
-
return value;
|
|
973
|
-
}
|
|
974
|
-
}
|
|
975
|
-
if (!this.config.getUserId) return ANONYMOUS_IDENTITY;
|
|
976
|
-
return this.config.getUserId(session.serverCtx) ?? "anonymous";
|
|
977
|
-
}
|
|
978
1587
|
sendMessage(session, message) {
|
|
979
1588
|
if (session.ws.readyState !== WebSocket.OPEN) return;
|
|
980
1589
|
session.ws.send(supaliveStringify(message));
|
|
@@ -995,9 +1604,10 @@ var SupaliveWebSocketServer = class {
|
|
|
995
1604
|
async subscribeToInstanceChannel() {
|
|
996
1605
|
const instanceChannel = `sub:instance:${this.instanceId}`;
|
|
997
1606
|
const schemaChannel = SCHEMA_RELOAD_CHANNEL;
|
|
998
|
-
|
|
1607
|
+
const watermarkChannel = RETENTION_WATERMARK_CHANNEL;
|
|
1608
|
+
await this.redisSub.subscribe(instanceChannel, schemaChannel, watermarkChannel);
|
|
999
1609
|
this.redisSub.on("connect", () => {
|
|
1000
|
-
logger$2.info(`Subscribed to Redis channels: ${instanceChannel}, ${schemaChannel}`);
|
|
1610
|
+
logger$2.info(`Subscribed to Redis channels: ${instanceChannel}, ${schemaChannel}, ${watermarkChannel}`);
|
|
1001
1611
|
});
|
|
1002
1612
|
this.redisSub.on("message", (channel, message) => {
|
|
1003
1613
|
if (channel === instanceChannel) this.handleRemoteInstanceMessage(message).catch((err) => {
|
|
@@ -1006,6 +1616,11 @@ var SupaliveWebSocketServer = class {
|
|
|
1006
1616
|
else if (channel === schemaChannel) this.handleSchemaReload().catch((err) => {
|
|
1007
1617
|
logger$2.error(err, "Error handling schema reload");
|
|
1008
1618
|
});
|
|
1619
|
+
else if (channel === watermarkChannel) try {
|
|
1620
|
+
this.retentionWatermark.set(BigInt(message));
|
|
1621
|
+
} catch (err) {
|
|
1622
|
+
logger$2.error(err, `Bad retention watermark message: ${message}`);
|
|
1623
|
+
}
|
|
1009
1624
|
});
|
|
1010
1625
|
this.redisSub.on("error", (err) => {
|
|
1011
1626
|
logger$2.error(err, `Subscriber error for channels: ${instanceChannel}, ${schemaChannel}`);
|
|
@@ -1100,6 +1715,40 @@ var SupaliveWebSocketServer = class {
|
|
|
1100
1715
|
await Promise.all(publishPromises);
|
|
1101
1716
|
}
|
|
1102
1717
|
};
|
|
1718
|
+
/** Normalize a job path to a leading-slash, no-trailing-slash prefix. */
|
|
1719
|
+
function normalizeJobPath(p) {
|
|
1720
|
+
let s = p.startsWith("/") ? p : `/${p}`;
|
|
1721
|
+
if (s.length > 1 && s.endsWith("/")) s = s.slice(0, -1);
|
|
1722
|
+
return s;
|
|
1723
|
+
}
|
|
1724
|
+
function sendJson(res, status, body) {
|
|
1725
|
+
const payload = supaliveStringify(body);
|
|
1726
|
+
res.writeHead(status, { "content-type": "application/json" });
|
|
1727
|
+
res.end(payload);
|
|
1728
|
+
}
|
|
1729
|
+
function lowercaseHeaders(headers) {
|
|
1730
|
+
const out = {};
|
|
1731
|
+
for (const [k, v] of Object.entries(headers)) out[k.toLowerCase()] = Array.isArray(v) ? v.join(",") : v;
|
|
1732
|
+
return out;
|
|
1733
|
+
}
|
|
1734
|
+
/** Buffer an incoming request body to a string, capped to guard against abuse. */
|
|
1735
|
+
function readRequestBody(req, maxBytes = 1024 * 1024) {
|
|
1736
|
+
return new Promise((resolve, reject) => {
|
|
1737
|
+
const chunks = [];
|
|
1738
|
+
let size = 0;
|
|
1739
|
+
req.on("data", (chunk) => {
|
|
1740
|
+
size += chunk.length;
|
|
1741
|
+
if (size > maxBytes) {
|
|
1742
|
+
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
1743
|
+
req.destroy();
|
|
1744
|
+
return;
|
|
1745
|
+
}
|
|
1746
|
+
chunks.push(chunk);
|
|
1747
|
+
});
|
|
1748
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
1749
|
+
req.on("error", reject);
|
|
1750
|
+
});
|
|
1751
|
+
}
|
|
1103
1752
|
//#endregion
|
|
1104
1753
|
//#region src/server/hash.ts
|
|
1105
1754
|
/**
|
|
@@ -1201,7 +1850,7 @@ var SubscriptionManager = class SubscriptionManager {
|
|
|
1201
1850
|
port;
|
|
1202
1851
|
workers;
|
|
1203
1852
|
pruneInterval = null;
|
|
1204
|
-
static PRUNE_INTERVAL_MS =
|
|
1853
|
+
static PRUNE_INTERVAL_MS = 10080 * 60 * 1e3;
|
|
1205
1854
|
constructor(config) {
|
|
1206
1855
|
this.port = config.port;
|
|
1207
1856
|
this.wss = new Server({ port: this.port }, subscriptionManagerDataPack);
|
|
@@ -1221,6 +1870,12 @@ var SubscriptionManager = class SubscriptionManager {
|
|
|
1221
1870
|
async start() {
|
|
1222
1871
|
await Promise.all(this.workers.map((w) => w.ready()));
|
|
1223
1872
|
logger$1.info(`Listening on port ${this.port}`);
|
|
1873
|
+
try {
|
|
1874
|
+
const minRetainedTs = await this.workers[0].send(12, null);
|
|
1875
|
+
await this.broadcastRetentionWatermark(minRetainedTs);
|
|
1876
|
+
} catch (err) {
|
|
1877
|
+
logger$1.error(err, "initial retention watermark seed failed");
|
|
1878
|
+
}
|
|
1224
1879
|
this.pruneInterval = setInterval(() => {
|
|
1225
1880
|
this.pruneCommitLogs().catch((err) => logger$1.error(err, "commit_logs prune failed"));
|
|
1226
1881
|
}, SubscriptionManager.PRUNE_INTERVAL_MS);
|
|
@@ -1237,18 +1892,25 @@ var SubscriptionManager = class SubscriptionManager {
|
|
|
1237
1892
|
}
|
|
1238
1893
|
async pruneCommitLogs() {
|
|
1239
1894
|
const minPerWorker = await Promise.all(this.workers.map((w) => w.send(8, null)));
|
|
1240
|
-
let
|
|
1895
|
+
let minRetainedTs = null;
|
|
1241
1896
|
for (const m of minPerWorker) {
|
|
1242
1897
|
if (m === null) continue;
|
|
1243
|
-
if (
|
|
1898
|
+
if (minRetainedTs === null || m < minRetainedTs) minRetainedTs = m;
|
|
1244
1899
|
}
|
|
1245
|
-
if (
|
|
1900
|
+
if (minRetainedTs === null) {
|
|
1246
1901
|
const latest = await this.workers[0].send(9, null);
|
|
1247
1902
|
if (latest <= 0n) return;
|
|
1248
|
-
|
|
1903
|
+
minRetainedTs = latest;
|
|
1249
1904
|
}
|
|
1250
|
-
const deleted = await this.workers[0].send(10,
|
|
1251
|
-
|
|
1905
|
+
const deleted = await this.workers[0].send(10, minRetainedTs);
|
|
1906
|
+
await this.broadcastRetentionWatermark(minRetainedTs);
|
|
1907
|
+
if (deleted > 0n) logger$1.info(`pruned ${deleted} commit_logs older than ts=${minRetainedTs}`);
|
|
1908
|
+
}
|
|
1909
|
+
/** Fan a watermark value out to every worker's in-memory copy. Worker 0 (the
|
|
1910
|
+
* pruner/seeder) already has it, but re-sending is harmless — {@link
|
|
1911
|
+
* SubscriptionWorker.setRetentionWatermark} is monotonic. */
|
|
1912
|
+
async broadcastRetentionWatermark(ts) {
|
|
1913
|
+
await Promise.all(this.workers.map((w) => w.send(13, ts).catch((err) => logger$1.error(err, "retention watermark broadcast to worker failed"))));
|
|
1252
1914
|
}
|
|
1253
1915
|
workerCount() {
|
|
1254
1916
|
return this.workers.length;
|
|
@@ -1337,6 +1999,6 @@ function resolveMaxConnectionsPerWorker(config, workers) {
|
|
|
1337
1999
|
return Math.max(1, Math.floor(total / workers));
|
|
1338
2000
|
}
|
|
1339
2001
|
//#endregion
|
|
1340
|
-
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 };
|
|
2002
|
+
export { ANONYMOUS_IDENTITY, CORE_VERSION, CacheLayer, DB_QUERY_TIMEOUT_MS, DevScheduler, InvalidateWritesetParamsSchema, JobClient, QStashScheduler, RegisterSubscriptionParamsSchema, SubManagerClient, SubscriptionManager, SupaliveWebSocketServer, UnregisterSubscriptionParamsSchema, UpdateSubscriptionReadSetParamsSchema, appInstanceId, bootstrapDbTypes, createDatabase, generateCacheKey, generateSubscriptionId, getHashOf, initCacheLayer, initCore, initDatabase, isProduction, logLevelOf, logger, serializeMapValue };
|
|
1341
2003
|
|
|
1342
2004
|
//# sourceMappingURL=server.js.map
|