@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.
Files changed (85) hide show
  1. package/README.md +28 -0
  2. package/dist/db-ClCicssQ.js +28 -0
  3. package/dist/db-ClCicssQ.js.map +1 -0
  4. package/dist/{index-DouKwYL6.d.ts → index-DJYvJYR4.d.ts} +214 -9
  5. package/dist/index-DJYvJYR4.d.ts.map +1 -0
  6. package/dist/{mysql-Bxurg_PH.d.ts → mysql-53W9lRh9.d.ts} +8 -3
  7. package/dist/mysql-53W9lRh9.d.ts.map +1 -0
  8. package/dist/{mysql-bvnjYWvZ.js → mysql-C0mysDQ_.js} +57 -25
  9. package/dist/mysql-C0mysDQ_.js.map +1 -0
  10. package/dist/{overlap-checker-Ct_HP_iB.js → overlap-checker-ClJC0nxW.js} +52 -4
  11. package/dist/overlap-checker-ClJC0nxW.js.map +1 -0
  12. package/dist/{postgres-Ck-xt72U.d.ts → postgres-7d6R9P7w.d.ts} +8 -3
  13. package/dist/postgres-7d6R9P7w.d.ts.map +1 -0
  14. package/dist/{postgres-B8u1YN8X.js → postgres-SvdXfb0w.js} +55 -22
  15. package/dist/postgres-SvdXfb0w.js.map +1 -0
  16. package/dist/{procedure-DX87tyef.js → procedure-Ze1GE-N9.js} +33 -2
  17. package/dist/procedure-Ze1GE-N9.js.map +1 -0
  18. package/dist/{query-Bh0RNxDz.js → query-CrbJ_W0W.js} +21 -1
  19. package/dist/query-CrbJ_W0W.js.map +1 -0
  20. package/dist/{realtime_db-DOCgqbwi.js → realtime_db-BtwBABDY.js} +76 -51
  21. package/dist/realtime_db-BtwBABDY.js.map +1 -0
  22. package/dist/{router-DlTYWpop.js → router-CiWYeWUR.js} +8 -2
  23. package/dist/router-CiWYeWUR.js.map +1 -0
  24. package/dist/src/client/index.d.ts +1 -1
  25. package/dist/src/client/index.js +44 -5
  26. package/dist/src/client/index.js.map +1 -1
  27. package/dist/src/exports/mysql.d.ts +1 -1
  28. package/dist/src/exports/mysql.js +1 -1
  29. package/dist/src/exports/postgres.d.ts +1 -1
  30. package/dist/src/exports/postgres.js +1 -1
  31. package/dist/src/exports/procedure.d.ts +2 -2
  32. package/dist/src/exports/procedure.js +4 -4
  33. package/dist/src/exports/schema-sql.d.ts +36 -2
  34. package/dist/src/exports/schema-sql.d.ts.map +1 -1
  35. package/dist/src/exports/schema-sql.js +74 -19
  36. package/dist/src/exports/schema-sql.js.map +1 -1
  37. package/dist/src/exports/server.d.ts +65 -17
  38. package/dist/src/exports/server.d.ts.map +1 -1
  39. package/dist/src/exports/server.js +724 -63
  40. package/dist/src/exports/server.js.map +1 -1
  41. package/dist/src/exports/subscription-manager-worker-entry.js +8 -5
  42. package/dist/src/exports/subscription-manager-worker-entry.js.map +1 -1
  43. package/dist/src/exports/types.d.ts +3 -3
  44. package/dist/src/exports/types.js +1 -1
  45. package/dist/{subscription-worker-CqoWp6zB.js → subscription-worker-B2XtjFWY.js} +307 -69
  46. package/dist/subscription-worker-B2XtjFWY.js.map +1 -0
  47. package/dist/{types_db-DXj_l9n-.js → types_db-CTRm_awX.js} +7 -2
  48. package/dist/types_db-CTRm_awX.js.map +1 -0
  49. package/dist/types_server-wW3ZgE7l.d.ts +420 -0
  50. package/dist/types_server-wW3ZgE7l.d.ts.map +1 -0
  51. package/package.json +5 -4
  52. package/dist/index-CaWMDqq0.d.ts +0 -1754
  53. package/dist/index-CaWMDqq0.d.ts.map +0 -1
  54. package/dist/index-DouKwYL6.d.ts.map +0 -1
  55. package/dist/mysql-Bxurg_PH.d.ts.map +0 -1
  56. package/dist/mysql-Cxd4Uq5p.d.ts +0 -104
  57. package/dist/mysql-Cxd4Uq5p.d.ts.map +0 -1
  58. package/dist/mysql-bvnjYWvZ.js.map +0 -1
  59. package/dist/overlap-checker-Ct_HP_iB.js.map +0 -1
  60. package/dist/postgres-B8u1YN8X.js.map +0 -1
  61. package/dist/postgres-CfT_aDx3.d.ts +0 -108
  62. package/dist/postgres-CfT_aDx3.d.ts.map +0 -1
  63. package/dist/postgres-Ck-xt72U.d.ts.map +0 -1
  64. package/dist/procedure-DX87tyef.js.map +0 -1
  65. package/dist/query-Bh0RNxDz.js.map +0 -1
  66. package/dist/realtime_db-DOCgqbwi.js.map +0 -1
  67. package/dist/router-DlTYWpop.js.map +0 -1
  68. package/dist/src/exports/codegen.d.ts +0 -38
  69. package/dist/src/exports/codegen.d.ts.map +0 -1
  70. package/dist/src/exports/codegen.js +0 -134
  71. package/dist/src/exports/codegen.js.map +0 -1
  72. package/dist/subscription-worker-CqoWp6zB.js.map +0 -1
  73. package/dist/subscription-worker-PtmJWEj1.js +0 -511
  74. package/dist/subscription-worker-PtmJWEj1.js.map +0 -1
  75. package/dist/subscription-worker-pahX0zxg.js +0 -511
  76. package/dist/subscription-worker-pahX0zxg.js.map +0 -1
  77. package/dist/types_db-DXj_l9n-.js.map +0 -1
  78. package/dist/types_server-BW1ys_SK.d.ts +0 -216
  79. package/dist/types_server-BW1ys_SK.d.ts.map +0 -1
  80. package/dist/types_server-Dpsi0wpG.d.ts +0 -216
  81. package/dist/types_server-Dpsi0wpG.d.ts.map +0 -1
  82. package/dist/types_server-DzrIccyO.d.ts +0 -216
  83. package/dist/types_server-DzrIccyO.d.ts.map +0 -1
  84. package/dist/types_server-TFo6SWDH.d.ts +0 -216
  85. package/dist/types_server-TFo6SWDH.d.ts.map +0 -1
@@ -1,15 +1,17 @@
1
- import { n as getContextRegistry } from "../../router-DlTYWpop.js";
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-DOCgqbwi.js";
6
- import { C as UpdateSubscriptionReadSetParamsSchema, S as UnregisterSubscriptionsResultSchema, _ as RegisterSubscriptionParamsSchema, a as initCacheLayer, b as UnregisterSubscriptionResultSchema, c as appInstanceId, d as logger, f as serializeMapValue, g as RegisterSubscriptionBatchResultSchema, i as createDatabase, l as isProduction, m as InvalidateWritesetResultSchema, n as DB_QUERY_TIMEOUT_MS, o as initDatabase, p as InvalidateWritesetParamsSchema, r as bootstrapDbTypes, s as CacheLayer, u as logLevelOf, v as RegisterSubscriptionResultSchema, w as UpdateSubscriptionReadSetResultSchema, y as UnregisterSubscriptionParamsSchema } from "../../subscription-worker-CqoWp6zB.js";
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$3 = logger.child({ name: "SUBS_MGR_CLIENT" }, { level: logLevelOf("SUB_MANAGER_CLIENT_LOG_LEVEL") });
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$3.warn(err, "sub-manager client socket error"));
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$3.warn(`Invalidate buffer would exceed ${this.bufferLimit}; dropping buffered entries. Subscriptions will be caught up via commit-log replay on re-register.`);
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$3.error(err, "Failed to flush invalidate buffer after recovery");
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$3.info("sub-manager client: socket closed");
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$3.info("sub-manager client: initial connect");
279
+ logger$5.info("sub-manager client: initial connect");
204
280
  this.markReady();
205
281
  return;
206
282
  }
207
- logger$3.info("sub-manager client: reconnected — starting recovery");
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$3.warn("sub-manager client: no recoveryDriver configured — skipping recovery step");
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$3.error(err, "Recovery failed; state will be marked ready anyway");
292
+ logger$5.error(err, "Recovery failed; state will be marked ready anyway");
217
293
  } finally {
218
- logger$3.info("sub-manager client: recovery complete");
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
- return new Promise((resolve) => {
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 result = await this.db.query(async (ctx) => {
566
- const fn = procedure.fn;
567
- return fn({
568
- db: ctx,
569
- serverCtx: session.serverCtx
570
- }, validatedInput);
571
- });
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 = this.resolveQueryIdentity(procedure, session, validatedInput);
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
- await this.redisSub.subscribe(instanceChannel, schemaChannel);
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 = 1800 * 1e3;
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 floor = null;
1895
+ let minRetainedTs = null;
1242
1896
  for (const m of minPerWorker) {
1243
1897
  if (m === null) continue;
1244
- if (floor === null || m < floor) floor = m;
1898
+ if (minRetainedTs === null || m < minRetainedTs) minRetainedTs = m;
1245
1899
  }
1246
- if (floor === null) {
1900
+ if (minRetainedTs === null) {
1247
1901
  const latest = await this.workers[0].send(9, null);
1248
1902
  if (latest <= 0n) return;
1249
- floor = latest;
1903
+ minRetainedTs = latest;
1250
1904
  }
1251
- const deleted = await this.workers[0].send(10, floor);
1252
- if (deleted > 0n) logger$1.info(`pruned ${deleted} commit_logs older than ts=${floor}`);
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