@supalive/core 1.1.0 → 1.2.1
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-DJYvJYR4.d.ts} +214 -9
- package/dist/index-DJYvJYR4.d.ts.map +1 -0
- package/dist/{mysql-Bxurg_PH.d.ts → mysql-53W9lRh9.d.ts} +8 -3
- package/dist/mysql-53W9lRh9.d.ts.map +1 -0
- package/dist/{mysql-bvnjYWvZ.js → mysql-C0mysDQ_.js} +57 -25
- package/dist/mysql-C0mysDQ_.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-Ck-xt72U.d.ts → postgres-7d6R9P7w.d.ts} +8 -3
- package/dist/postgres-7d6R9P7w.d.ts.map +1 -0
- package/dist/{postgres-B8u1YN8X.js → postgres-SvdXfb0w.js} +55 -22
- package/dist/postgres-SvdXfb0w.js.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 +36 -2
- package/dist/src/exports/schema-sql.d.ts.map +1 -1
- package/dist/src/exports/schema-sql.js +74 -19
- package/dist/src/exports/schema-sql.js.map +1 -1
- package/dist/src/exports/server.d.ts +65 -17
- package/dist/src/exports/server.d.ts.map +1 -1
- package/dist/src/exports/server.js +724 -63
- 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-B2XtjFWY.js} +307 -69
- package/dist/subscription-worker-B2XtjFWY.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-wW3ZgE7l.d.ts +420 -0
- package/dist/types_server-wW3ZgE7l.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-B2XtjFWY.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,
|
|
@@ -587,7 +1221,6 @@ var SupaliveWebSocketServer = class {
|
|
|
587
1221
|
success: true,
|
|
588
1222
|
data: mutationResult.result
|
|
589
1223
|
});
|
|
590
|
-
await this.handleMutationCommit(mutationResult.writeSet, mutationResult.commitTs);
|
|
591
1224
|
} else if (procedure.type === "action") {
|
|
592
1225
|
if (session.actionInFlight >= MAX_ACTION_CONCURRENCY) {
|
|
593
1226
|
this.sendError(session, id, `Too many concurrent actions on this connection (max ${MAX_ACTION_CONCURRENCY})`, "TOO_MANY_CONCURRENT_ACTIONS");
|
|
@@ -628,7 +1261,7 @@ var SupaliveWebSocketServer = class {
|
|
|
628
1261
|
}
|
|
629
1262
|
try {
|
|
630
1263
|
const validatedInput = procedure.inputSchema.parse(input);
|
|
631
|
-
const queryIdentity =
|
|
1264
|
+
const queryIdentity = resolveQueryIdentity(procedure, session.serverCtx, validatedInput, this.config.getUserId);
|
|
632
1265
|
const inputJsonStable = stableStringify(validatedInput);
|
|
633
1266
|
const subId = await generateSubscriptionId(procedureName, inputJsonStable, queryIdentity);
|
|
634
1267
|
const cacheKey = await generateCacheKey(procedureName, inputJsonStable, queryIdentity);
|
|
@@ -657,7 +1290,7 @@ var SupaliveWebSocketServer = class {
|
|
|
657
1290
|
lastSnapshotTs
|
|
658
1291
|
});
|
|
659
1292
|
const cached = await this.cache.getQueryCacheFor(subManagerResult.cacheKey);
|
|
660
|
-
if (cached && !subManagerResult.recompute) data = cached.data;
|
|
1293
|
+
if (cached && !subManagerResult.recompute && cached.version === subManagerResult.version) data = cached.data;
|
|
661
1294
|
else {
|
|
662
1295
|
liveResult = await this.executeLiveQuery(procedureName, validatedInput, session.serverCtx);
|
|
663
1296
|
data = liveResult.data;
|
|
@@ -676,7 +1309,7 @@ var SupaliveWebSocketServer = class {
|
|
|
676
1309
|
lastSnapshotTs: liveResult.ts,
|
|
677
1310
|
readSet: liveResult.readSet,
|
|
678
1311
|
instanceName: this.instanceId
|
|
679
|
-
}), this.cache.setQueryCacheFor(cacheKey, data)]);
|
|
1312
|
+
}), this.cache.setQueryCacheFor(cacheKey, data, String(liveResult.ts))]);
|
|
680
1313
|
if (!updateResult.tracked) logger$2.warn(`updateSubscriptionReadSet returned tracked:false immediately after register for ${subId} — possible sub-manager restart mid-subscribe`);
|
|
681
1314
|
}
|
|
682
1315
|
dataHash = await getHashOf(data);
|
|
@@ -923,7 +1556,7 @@ var SupaliveWebSocketServer = class {
|
|
|
923
1556
|
lastSnapshotTs: liveResult.ts,
|
|
924
1557
|
readSet: liveResult.readSet,
|
|
925
1558
|
instanceName: this.instanceId
|
|
926
|
-
}), this.cache.setQueryCacheFor(affected.cacheKey, liveResult.data)]);
|
|
1559
|
+
}), this.cache.setQueryCacheFor(affected.cacheKey, liveResult.data, String(liveResult.ts))]);
|
|
927
1560
|
if (!updateResult.tracked) {
|
|
928
1561
|
this.dropSubscriptionLocally(affected.subId, "no longer tracked by sub-manager");
|
|
929
1562
|
return;
|
|
@@ -951,31 +1584,6 @@ var SupaliveWebSocketServer = class {
|
|
|
951
1584
|
}, input);
|
|
952
1585
|
});
|
|
953
1586
|
}
|
|
954
|
-
/**
|
|
955
|
-
* Resolve the segmentation key used in the subId/cacheKey hash for a
|
|
956
|
-
* query subscription. Priority:
|
|
957
|
-
*
|
|
958
|
-
* 1. Procedure-level `queryIdentity` override:
|
|
959
|
-
* - `false` → identity = `ANONYMOUS_IDENTITY` (cache shared across users)
|
|
960
|
-
* - `string` → that literal
|
|
961
|
-
* - `function` → ctx + input → string | null | undefined
|
|
962
|
-
* 2. `config.getUserId(serverCtx)` → falls back to ANONYMOUS_IDENTITY if it
|
|
963
|
-
* returns null/undefined or isn't configured.
|
|
964
|
-
*/
|
|
965
|
-
resolveQueryIdentity(procedure, session, input) {
|
|
966
|
-
const override = procedure.queryIdentity;
|
|
967
|
-
if (override !== void 0) {
|
|
968
|
-
if (override === false) return ANONYMOUS_IDENTITY;
|
|
969
|
-
if (typeof override === "string") return override;
|
|
970
|
-
if (typeof override === "function") {
|
|
971
|
-
const value = override(session.serverCtx, input);
|
|
972
|
-
if (value === null || value === void 0) return ANONYMOUS_IDENTITY;
|
|
973
|
-
return value;
|
|
974
|
-
}
|
|
975
|
-
}
|
|
976
|
-
if (!this.config.getUserId) return ANONYMOUS_IDENTITY;
|
|
977
|
-
return this.config.getUserId(session.serverCtx) ?? "anonymous";
|
|
978
|
-
}
|
|
979
1587
|
sendMessage(session, message) {
|
|
980
1588
|
if (session.ws.readyState !== WebSocket.OPEN) return;
|
|
981
1589
|
session.ws.send(supaliveStringify(message));
|
|
@@ -996,9 +1604,10 @@ var SupaliveWebSocketServer = class {
|
|
|
996
1604
|
async subscribeToInstanceChannel() {
|
|
997
1605
|
const instanceChannel = `sub:instance:${this.instanceId}`;
|
|
998
1606
|
const schemaChannel = SCHEMA_RELOAD_CHANNEL;
|
|
999
|
-
|
|
1607
|
+
const watermarkChannel = RETENTION_WATERMARK_CHANNEL;
|
|
1608
|
+
await this.redisSub.subscribe(instanceChannel, schemaChannel, watermarkChannel);
|
|
1000
1609
|
this.redisSub.on("connect", () => {
|
|
1001
|
-
logger$2.info(`Subscribed to Redis channels: ${instanceChannel}, ${schemaChannel}`);
|
|
1610
|
+
logger$2.info(`Subscribed to Redis channels: ${instanceChannel}, ${schemaChannel}, ${watermarkChannel}`);
|
|
1002
1611
|
});
|
|
1003
1612
|
this.redisSub.on("message", (channel, message) => {
|
|
1004
1613
|
if (channel === instanceChannel) this.handleRemoteInstanceMessage(message).catch((err) => {
|
|
@@ -1007,6 +1616,11 @@ var SupaliveWebSocketServer = class {
|
|
|
1007
1616
|
else if (channel === schemaChannel) this.handleSchemaReload().catch((err) => {
|
|
1008
1617
|
logger$2.error(err, "Error handling schema reload");
|
|
1009
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
|
+
}
|
|
1010
1624
|
});
|
|
1011
1625
|
this.redisSub.on("error", (err) => {
|
|
1012
1626
|
logger$2.error(err, `Subscriber error for channels: ${instanceChannel}, ${schemaChannel}`);
|
|
@@ -1101,6 +1715,40 @@ var SupaliveWebSocketServer = class {
|
|
|
1101
1715
|
await Promise.all(publishPromises);
|
|
1102
1716
|
}
|
|
1103
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
|
+
}
|
|
1104
1752
|
//#endregion
|
|
1105
1753
|
//#region src/server/hash.ts
|
|
1106
1754
|
/**
|
|
@@ -1202,7 +1850,7 @@ var SubscriptionManager = class SubscriptionManager {
|
|
|
1202
1850
|
port;
|
|
1203
1851
|
workers;
|
|
1204
1852
|
pruneInterval = null;
|
|
1205
|
-
static PRUNE_INTERVAL_MS =
|
|
1853
|
+
static PRUNE_INTERVAL_MS = 10080 * 60 * 1e3;
|
|
1206
1854
|
constructor(config) {
|
|
1207
1855
|
this.port = config.port;
|
|
1208
1856
|
this.wss = new Server({ port: this.port }, subscriptionManagerDataPack);
|
|
@@ -1222,6 +1870,12 @@ var SubscriptionManager = class SubscriptionManager {
|
|
|
1222
1870
|
async start() {
|
|
1223
1871
|
await Promise.all(this.workers.map((w) => w.ready()));
|
|
1224
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
|
+
}
|
|
1225
1879
|
this.pruneInterval = setInterval(() => {
|
|
1226
1880
|
this.pruneCommitLogs().catch((err) => logger$1.error(err, "commit_logs prune failed"));
|
|
1227
1881
|
}, SubscriptionManager.PRUNE_INTERVAL_MS);
|
|
@@ -1238,18 +1892,25 @@ var SubscriptionManager = class SubscriptionManager {
|
|
|
1238
1892
|
}
|
|
1239
1893
|
async pruneCommitLogs() {
|
|
1240
1894
|
const minPerWorker = await Promise.all(this.workers.map((w) => w.send(8, null)));
|
|
1241
|
-
let
|
|
1895
|
+
let minRetainedTs = null;
|
|
1242
1896
|
for (const m of minPerWorker) {
|
|
1243
1897
|
if (m === null) continue;
|
|
1244
|
-
if (
|
|
1898
|
+
if (minRetainedTs === null || m < minRetainedTs) minRetainedTs = m;
|
|
1245
1899
|
}
|
|
1246
|
-
if (
|
|
1900
|
+
if (minRetainedTs === null) {
|
|
1247
1901
|
const latest = await this.workers[0].send(9, null);
|
|
1248
1902
|
if (latest <= 0n) return;
|
|
1249
|
-
|
|
1903
|
+
minRetainedTs = latest;
|
|
1250
1904
|
}
|
|
1251
|
-
const deleted = await this.workers[0].send(10,
|
|
1252
|
-
|
|
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"))));
|
|
1253
1914
|
}
|
|
1254
1915
|
workerCount() {
|
|
1255
1916
|
return this.workers.length;
|
|
@@ -1338,6 +1999,6 @@ function resolveMaxConnectionsPerWorker(config, workers) {
|
|
|
1338
1999
|
return Math.max(1, Math.floor(total / workers));
|
|
1339
2000
|
}
|
|
1340
2001
|
//#endregion
|
|
1341
|
-
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 };
|
|
1342
2003
|
|
|
1343
2004
|
//# sourceMappingURL=server.js.map
|