@prisma/streams-server 0.1.0 → 0.1.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/src/app_core.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { mkdirSync } from "node:fs";
2
2
  import type { Config } from "./config";
3
- import { SqliteDurableStore, STREAM_FLAG_TOUCH } from "./db/db";
3
+ import { SqliteDurableStore } from "./db/db";
4
4
  import { IngestQueue, type ProducerInfo, type AppendRow } from "./ingest";
5
5
  import type { ObjectStore } from "./objectstore/interface";
6
6
  import type { StreamReader, ReadBatch, ReaderError } from "./reader";
@@ -20,8 +20,6 @@ import { BackpressureGate } from "./backpressure";
20
20
  import { MemoryGuard } from "./memory";
21
21
  import { TouchInterpreterManager } from "./touch/manager";
22
22
  import { isTouchEnabled } from "./touch/spec";
23
- import { resolveTouchStreamName } from "./touch/naming";
24
- import { RoutingKeyNotifier } from "./touch/routing_key_notifier";
25
23
  import { parseTouchCursor } from "./touch/touch_journal";
26
24
  import { touchKeyIdFromRoutingKeyResult } from "./touch/touch_key_id";
27
25
  import { tableKeyIdFor, templateKeyIdFor } from "./touch/live_keys";
@@ -205,7 +203,6 @@ export type App = {
205
203
  os: ObjectStore;
206
204
  ingest: IngestQueue;
207
205
  notifier: StreamNotifier;
208
- touchRoutingKeyNotifier: RoutingKeyNotifier;
209
206
  reader: StreamReader;
210
207
  segmenter: SegmenterController;
211
208
  uploader: UploaderController;
@@ -224,7 +221,6 @@ export type CreateAppRuntimeArgs = {
224
221
  db: SqliteDurableStore;
225
222
  ingest: IngestQueue;
226
223
  notifier: StreamNotifier;
227
- touchRoutingKeyNotifier: RoutingKeyNotifier;
228
224
  registry: SchemaRegistryStore;
229
225
  touch: TouchInterpreterManager;
230
226
  stats?: StatsCollector;
@@ -270,15 +266,13 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
270
266
  const metrics = new Metrics();
271
267
  const ingest = new IngestQueue(cfg, db, stats, backpressure, memory, metrics);
272
268
  const notifier = new StreamNotifier();
273
- const touchRoutingKeyNotifier = new RoutingKeyNotifier();
274
269
  const registry = new SchemaRegistryStore(db);
275
- const touch = new TouchInterpreterManager(cfg, db, ingest, notifier, registry, backpressure, touchRoutingKeyNotifier);
270
+ const touch = new TouchInterpreterManager(cfg, db, ingest, notifier, registry, backpressure);
276
271
  const runtime = opts.createRuntime({
277
272
  config: cfg,
278
273
  db,
279
274
  ingest,
280
275
  notifier,
281
- touchRoutingKeyNotifier,
282
276
  registry,
283
277
  touch,
284
278
  stats,
@@ -495,7 +489,6 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
495
489
  let pathKeyParam: string | null = null;
496
490
  let touchMode:
497
491
  | null
498
- | { kind: "read"; key: string | null }
499
492
  | { kind: "meta" }
500
493
  | { kind: "wait" }
501
494
  | { kind: "templates_activate" } = null;
@@ -516,12 +509,6 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
516
509
  } else if (segments.length >= 2 && segments[segments.length - 2] === "touch" && segments[segments.length - 1] === "wait") {
517
510
  touchMode = { kind: "wait" };
518
511
  segments.splice(segments.length - 2, 2);
519
- } else if (segments.length >= 3 && segments[segments.length - 3] === "touch" && segments[segments.length - 2] === "pk") {
520
- touchMode = { kind: "read", key: decodeURIComponent(segments[segments.length - 1]) };
521
- segments.splice(segments.length - 3, 3);
522
- } else if (segments[segments.length - 1] === "touch") {
523
- touchMode = { kind: "read", key: null };
524
- segments.pop();
525
512
  } else if (segments.length >= 2 && segments[segments.length - 2] === "pk") {
526
513
  pathKeyParam = decodeURIComponent(segments[segments.length - 1]);
527
514
  segments.splice(segments.length - 2, 2);
@@ -664,24 +651,6 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
664
651
  if (!isTouchEnabled(reg.interpreter)) return notFound("touch not enabled");
665
652
 
666
653
  const touchCfg = reg.interpreter.touch;
667
- const touchStorage = touchCfg.storage ?? "memory";
668
- const derived = resolveTouchStreamName(stream, touchCfg);
669
-
670
- const ensureTouchStream = (): Result<void, { kind: "touch_stream_content_type_mismatch"; message: string }> => {
671
- const existing = db.getStream(derived);
672
- if (existing) {
673
- if (String(existing.content_type) !== "application/json") {
674
- return Result.err({
675
- kind: "touch_stream_content_type_mismatch",
676
- message: `touch stream content-type mismatch: ${existing.content_type}`,
677
- });
678
- }
679
- if ((existing.stream_flags & STREAM_FLAG_TOUCH) === 0) db.addStreamFlags(derived, STREAM_FLAG_TOUCH);
680
- return Result.ok(undefined);
681
- }
682
- db.ensureStream(derived, { contentType: "application/json", streamFlags: STREAM_FLAG_TOUCH });
683
- return Result.ok(undefined);
684
- };
685
654
 
686
655
  if (touchMode.kind === "templates_activate") {
687
656
  if (req.method !== "POST") return badRequest("unsupported method");
@@ -728,16 +697,7 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
728
697
  maxActiveTemplatesPerEntity: touchCfg.templates?.maxActiveTemplatesPerEntity ?? 256,
729
698
  };
730
699
 
731
- let activeFromTouchOffset: string;
732
- if (touchStorage === "sqlite") {
733
- const touchRes = ensureTouchStream();
734
- if (Result.isError(touchRes)) return conflict(touchRes.error.message);
735
- const trow = db.getStream(derived)!;
736
- const tailSeq = trow.next_offset - 1n;
737
- activeFromTouchOffset = encodeOffset(trow.epoch, tailSeq);
738
- } else {
739
- activeFromTouchOffset = touch.getOrCreateJournal(derived, touchCfg).getCursor();
740
- }
700
+ const activeFromTouchOffset = touch.getOrCreateJournal(stream, touchCfg).getCursor();
741
701
 
742
702
  const res = touch.activateTemplates({
743
703
  stream,
@@ -760,40 +720,13 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
760
720
  } catch {
761
721
  activeTemplates = 0;
762
722
  }
763
- if (touchStorage === "sqlite") {
764
- const touchRes = ensureTouchStream();
765
- if (Result.isError(touchRes)) return conflict(touchRes.error.message);
766
- const trow = db.getStream(derived)!;
767
- const tailSeq = trow.next_offset - 1n;
768
- const currentTouchOffset = encodeOffset(trow.epoch, tailSeq);
769
- const oldestSeq = db.getWalOldestOffset(derived);
770
- const oldestCursorSeq = oldestSeq == null ? -1n : oldestSeq - 1n;
771
- const oldestAvailableTouchOffset = encodeOffset(trow.epoch, oldestCursorSeq);
772
- const clampBigInt = (v: bigint): number => {
773
- if (v <= 0n) return 0;
774
- const max = BigInt(Number.MAX_SAFE_INTEGER);
775
- return v > max ? Number.MAX_SAFE_INTEGER : Number(v);
776
- };
777
- return json(200, {
778
- mode: "sqlite",
779
- currentTouchOffset,
780
- oldestAvailableTouchOffset,
781
- coarseIntervalMs: touchCfg.coarseIntervalMs ?? 100,
782
- touchCoalesceWindowMs: touchCfg.touchCoalesceWindowMs ?? 100,
783
- touchRetentionMs: touchCfg.retention?.maxAgeMs ?? null,
784
- activeTemplates,
785
- touchWalRetainedRows: clampBigInt(trow.wal_rows),
786
- touchWalRetainedBytes: clampBigInt(trow.wal_bytes),
787
- });
788
- }
789
- const meta = touch.getOrCreateJournal(derived, touchCfg).getMeta();
723
+ const meta = touch.getOrCreateJournal(stream, touchCfg).getMeta();
790
724
  const runtime = touch.getTouchRuntimeSnapshot({ stream, touchCfg });
791
725
  const interp = db.getStreamInterpreter(stream);
792
726
  return json(200, {
793
727
  ...meta,
794
728
  coarseIntervalMs: touchCfg.coarseIntervalMs ?? 100,
795
729
  touchCoalesceWindowMs: touchCfg.touchCoalesceWindowMs ?? 100,
796
- touchRetentionMs: null,
797
730
  activeTemplates,
798
731
  lagSourceOffsets: runtime.lagSourceOffsets,
799
732
  touchMode: runtime.touchMode,
@@ -844,7 +777,6 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
844
777
  }
845
778
  const keysRaw = body?.keys;
846
779
  const cursorRaw = body?.cursor;
847
- const sinceRaw = body?.sinceTouchOffset;
848
780
  const timeoutMsRaw = body?.timeoutMs;
849
781
  if (keysRaw !== undefined && (!Array.isArray(keysRaw) || !keysRaw.every((k: any) => typeof k === "string" && k.trim() !== ""))) {
850
782
  return badRequest("wait.keys must be a non-empty string array when provided");
@@ -865,13 +797,8 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
865
797
  }
866
798
  if (keys.length === 0 && keyIds.length === 0) return badRequest("wait requires keys or keyIds");
867
799
  if (keyIds.length > 1024) return badRequest("wait.keyIds too large (max 1024)");
868
- if (touchStorage === "sqlite" && keys.length === 0) {
869
- return badRequest("wait.keys must be a non-empty string array in sqlite touch storage mode");
870
- }
871
- const cursorOrSince = typeof cursorRaw === "string" && cursorRaw.trim() !== "" ? cursorRaw : sinceRaw;
872
- if (typeof cursorOrSince !== "string" || cursorOrSince.trim() === "") {
873
- return badRequest(touchStorage === "memory" ? "wait.cursor must be a non-empty string" : "wait.sinceTouchOffset must be a non-empty string");
874
- }
800
+ if (typeof cursorRaw !== "string" || cursorRaw.trim() === "") return badRequest("wait.cursor must be a non-empty string");
801
+ const cursor = cursorRaw.trim();
875
802
 
876
803
  const timeoutMs =
877
804
  timeoutMsRaw === undefined ? 30_000 : typeof timeoutMsRaw === "number" && Number.isFinite(timeoutMsRaw) ? Math.max(0, Math.min(120_000, timeoutMsRaw)) : null;
@@ -923,16 +850,7 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
923
850
  templates.push({ entity, fields });
924
851
  }
925
852
  if (templates.length !== declareTemplatesRaw.length) return badRequest("wait.declareTemplates contains invalid template definitions");
926
- let activeFromTouchOffset: string;
927
- if (touchStorage === "sqlite") {
928
- const touchRes = ensureTouchStream();
929
- if (Result.isError(touchRes)) return conflict(touchRes.error.message);
930
- const trow = db.getStream(derived)!;
931
- const tailSeq = trow.next_offset - 1n;
932
- activeFromTouchOffset = encodeOffset(trow.epoch, tailSeq);
933
- } else {
934
- activeFromTouchOffset = touch.getOrCreateJournal(derived, touchCfg).getCursor();
935
- }
853
+ const activeFromTouchOffset = touch.getOrCreateJournal(stream, touchCfg).getCursor();
936
854
  touch.activateTemplates({
937
855
  stream,
938
856
  touchCfg,
@@ -943,344 +861,150 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
943
861
  });
944
862
  }
945
863
 
946
- if (touchStorage === "memory") {
947
- const j = touch.getOrCreateJournal(derived, touchCfg);
948
- const runtime = touch.getTouchRuntimeSnapshot({ stream, touchCfg });
949
- let rawFineKeyIds = keyIds;
950
- if (keyIds.length === 0) {
951
- const parsedKeyIds: number[] = [];
952
- for (const key of keys) {
953
- const keyIdRes = touchKeyIdFromRoutingKeyResult(key);
954
- if (Result.isError(keyIdRes)) return internalError();
955
- parsedKeyIds.push(keyIdRes.value);
956
- }
957
- rawFineKeyIds = parsedKeyIds;
958
- }
959
- const templateWaitKeyIds =
960
- templateIdsUsed.length > 0
961
- ? Array.from(new Set(templateIdsUsed.map((templateId) => templateKeyIdFor(templateId) >>> 0)))
962
- : [];
963
- let waitKeyIds = rawFineKeyIds;
964
- let effectiveWaitKind: "fineKey" | "templateKey" | "tableKey" = "fineKey";
965
-
966
- if (interestMode === "coarse") {
967
- effectiveWaitKind = "tableKey";
968
- } else if (runtime.touchMode === "restricted" && templateIdsUsed.length > 0) {
969
- effectiveWaitKind = "templateKey";
970
- } else if (runtime.touchMode === "coarseOnly" && templateIdsUsed.length > 0) {
971
- effectiveWaitKind = "tableKey";
972
- }
973
-
974
- if (effectiveWaitKind === "templateKey") {
975
- waitKeyIds = templateWaitKeyIds;
976
- } else if (effectiveWaitKind === "tableKey") {
977
- if (templateIdsUsed.length > 0) {
978
- const entities = touch.resolveTemplateEntitiesForWait({ stream, templateIdsUsed });
979
- waitKeyIds = Array.from(new Set(entities.map((entity) => tableKeyIdFor(entity) >>> 0)));
980
- }
981
- }
982
-
983
- // Keep fine waits resilient to runtime mode flips: include template-key
984
- // fallbacks even when the current mode is fine. This avoids starvation
985
- // when a long-poll starts in fine mode but DS degrades to restricted
986
- // before that waiter naturally re-issues.
987
- if (interestMode === "fine" && effectiveWaitKind === "fineKey" && templateWaitKeyIds.length > 0) {
988
- const merged = new Set<number>();
989
- for (const keyId of waitKeyIds) merged.add(keyId >>> 0);
990
- for (const keyId of templateWaitKeyIds) merged.add(keyId >>> 0);
991
- waitKeyIds = Array.from(merged);
992
- }
993
-
994
- if (waitKeyIds.length === 0) {
995
- waitKeyIds = rawFineKeyIds;
996
- effectiveWaitKind = "fineKey";
864
+ const j = touch.getOrCreateJournal(stream, touchCfg);
865
+ const runtime = touch.getTouchRuntimeSnapshot({ stream, touchCfg });
866
+ let rawFineKeyIds = keyIds;
867
+ if (keyIds.length === 0) {
868
+ const parsedKeyIds: number[] = [];
869
+ for (const key of keys) {
870
+ const keyIdRes = touchKeyIdFromRoutingKeyResult(key);
871
+ if (Result.isError(keyIdRes)) return internalError();
872
+ parsedKeyIds.push(keyIdRes.value);
997
873
  }
998
- const hotInterestKeyIds = interestMode === "fine" ? rawFineKeyIds : waitKeyIds;
999
- const releaseHotInterest = touch.beginHotWaitInterest({
1000
- stream,
1001
- touchCfg,
1002
- keyIds: hotInterestKeyIds,
1003
- templateIdsUsed,
1004
- interestMode,
1005
- });
1006
- try {
1007
- let sinceGen: number;
1008
- if (cursorOrSince === "now") {
1009
- sinceGen = j.getGeneration();
1010
- } else {
1011
- const parsed = parseTouchCursor(cursorOrSince);
1012
- if (!parsed) return badRequest("wait.cursor must be in the form <epochHex>:<generation> or 'now'");
1013
- if (parsed.epoch !== j.getEpoch()) {
1014
- const latencyMs = Date.now() - waitStartMs;
1015
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "stale", latencyMs });
1016
- return json(200, {
1017
- stale: true,
1018
- cursor: j.getCursor(),
1019
- epoch: j.getEpoch(),
1020
- generation: j.getGeneration(),
1021
- effectiveWaitKind,
1022
- bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
1023
- flushAtMs: j.getLastFlushAtMs(),
1024
- bucketStartMs: j.getLastBucketStartMs(),
1025
- error: { code: "stale", message: "cursor epoch mismatch; rerun/re-subscribe and start from cursor" },
1026
- });
1027
- }
1028
- sinceGen = parsed.generation;
1029
- }
874
+ rawFineKeyIds = parsedKeyIds;
875
+ }
876
+ const templateWaitKeyIds =
877
+ templateIdsUsed.length > 0
878
+ ? Array.from(new Set(templateIdsUsed.map((templateId) => templateKeyIdFor(templateId) >>> 0)))
879
+ : [];
880
+ let waitKeyIds = rawFineKeyIds;
881
+ let effectiveWaitKind: "fineKey" | "templateKey" | "tableKey" = "fineKey";
882
+
883
+ if (interestMode === "coarse") {
884
+ effectiveWaitKind = "tableKey";
885
+ } else if (runtime.touchMode === "restricted" && templateIdsUsed.length > 0) {
886
+ effectiveWaitKind = "templateKey";
887
+ } else if (runtime.touchMode === "coarseOnly" && templateIdsUsed.length > 0) {
888
+ effectiveWaitKind = "tableKey";
889
+ }
1030
890
 
1031
- // Clamp bogus future cursors (defensive).
1032
- const nowGen = j.getGeneration();
1033
- if (sinceGen > nowGen) sinceGen = nowGen;
891
+ if (effectiveWaitKind === "templateKey") {
892
+ waitKeyIds = templateWaitKeyIds;
893
+ } else if (effectiveWaitKind === "tableKey" && templateIdsUsed.length > 0) {
894
+ const entities = touch.resolveTemplateEntitiesForWait({ stream, templateIdsUsed });
895
+ waitKeyIds = Array.from(new Set(entities.map((entity) => tableKeyIdFor(entity) >>> 0)));
896
+ }
1034
897
 
1035
- // Fast path: already touched since cursor.
1036
- if (j.maybeTouchedSinceAny(waitKeyIds, sinceGen)) {
1037
- const latencyMs = Date.now() - waitStartMs;
1038
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "touched", latencyMs });
1039
- return json(200, {
1040
- touched: true,
1041
- cursor: j.getCursor(),
1042
- effectiveWaitKind,
1043
- bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
1044
- flushAtMs: j.getLastFlushAtMs(),
1045
- bucketStartMs: j.getLastBucketStartMs(),
1046
- });
1047
- }
898
+ // Keep fine waits resilient to runtime mode flips: include template-key
899
+ // fallbacks even when the current mode is fine. This avoids starvation
900
+ // when a long-poll starts in fine mode but DS degrades to restricted
901
+ // before that waiter naturally re-issues.
902
+ if (interestMode === "fine" && effectiveWaitKind === "fineKey" && templateWaitKeyIds.length > 0) {
903
+ const merged = new Set<number>();
904
+ for (const keyId of waitKeyIds) merged.add(keyId >>> 0);
905
+ for (const keyId of templateWaitKeyIds) merged.add(keyId >>> 0);
906
+ waitKeyIds = Array.from(merged);
907
+ }
1048
908
 
1049
- const deadline = Date.now() + timeoutMs;
1050
- const remaining = deadline - Date.now();
1051
- if (remaining <= 0) {
909
+ if (waitKeyIds.length === 0) {
910
+ waitKeyIds = rawFineKeyIds;
911
+ effectiveWaitKind = "fineKey";
912
+ }
913
+ const hotInterestKeyIds = interestMode === "fine" ? rawFineKeyIds : waitKeyIds;
914
+ const releaseHotInterest = touch.beginHotWaitInterest({
915
+ stream,
916
+ touchCfg,
917
+ keyIds: hotInterestKeyIds,
918
+ templateIdsUsed,
919
+ interestMode,
920
+ });
921
+ try {
922
+ let sinceGen: number;
923
+ if (cursor === "now") {
924
+ sinceGen = j.getGeneration();
925
+ } else {
926
+ const parsed = parseTouchCursor(cursor);
927
+ if (!parsed) return badRequest("wait.cursor must be in the form <epochHex>:<generation> or 'now'");
928
+ if (parsed.epoch !== j.getEpoch()) {
1052
929
  const latencyMs = Date.now() - waitStartMs;
1053
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "timeout", latencyMs });
930
+ touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "stale", latencyMs });
1054
931
  return json(200, {
1055
- touched: false,
932
+ stale: true,
1056
933
  cursor: j.getCursor(),
934
+ epoch: j.getEpoch(),
935
+ generation: j.getGeneration(),
1057
936
  effectiveWaitKind,
1058
937
  bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
1059
938
  flushAtMs: j.getLastFlushAtMs(),
1060
939
  bucketStartMs: j.getLastBucketStartMs(),
940
+ error: { code: "stale", message: "cursor epoch mismatch; rerun/re-subscribe and start from cursor" },
1061
941
  });
1062
942
  }
943
+ sinceGen = parsed.generation;
944
+ }
1063
945
 
1064
- // Avoid lost-wakeup races by capturing the current generation before waiting.
1065
- const afterGen = j.getGeneration();
1066
- const hit = await j.waitForAny({ keys: waitKeyIds, afterGeneration: afterGen, timeoutMs: remaining, signal: req.signal });
1067
- if (req.signal.aborted) return new Response(null, { status: 204 });
1068
-
1069
- if (hit == null) {
1070
- const latencyMs = Date.now() - waitStartMs;
1071
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "timeout", latencyMs });
1072
- return json(200, {
1073
- touched: false,
1074
- cursor: j.getCursor(),
1075
- effectiveWaitKind,
1076
- bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
1077
- flushAtMs: j.getLastFlushAtMs(),
1078
- bucketStartMs: j.getLastBucketStartMs(),
1079
- });
1080
- }
946
+ const nowGen = j.getGeneration();
947
+ if (sinceGen > nowGen) sinceGen = nowGen;
1081
948
 
949
+ if (j.maybeTouchedSinceAny(waitKeyIds, sinceGen)) {
1082
950
  const latencyMs = Date.now() - waitStartMs;
1083
951
  touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "touched", latencyMs });
1084
952
  return json(200, {
1085
953
  touched: true,
1086
954
  cursor: j.getCursor(),
1087
955
  effectiveWaitKind,
1088
- bucketMaxSourceOffsetSeq: hit.bucketMaxSourceOffsetSeq.toString(),
1089
- flushAtMs: hit.flushAtMs,
1090
- bucketStartMs: hit.bucketStartMs,
1091
- });
1092
- } finally {
1093
- releaseHotInterest();
1094
- }
1095
- }
1096
-
1097
- // touchStorage === "sqlite"
1098
- const touchRes = ensureTouchStream();
1099
- if (Result.isError(touchRes)) return conflict(touchRes.error.message);
1100
- const trow = db.getStream(derived)!;
1101
- const tailSeq = trow.next_offset - 1n;
1102
- const currentTouchOffset = encodeOffset(trow.epoch, tailSeq);
1103
- const oldestSeq = db.getWalOldestOffset(derived);
1104
- const oldestCursorSeq = oldestSeq == null ? -1n : oldestSeq - 1n;
1105
- const oldestAvailableTouchOffset = encodeOffset(trow.epoch, oldestCursorSeq);
1106
-
1107
- const staleBody = () => ({
1108
- stale: true,
1109
- currentTouchOffset,
1110
- oldestAvailableTouchOffset,
1111
- error: {
1112
- code: "stale",
1113
- message:
1114
- "offset is older than oldestAvailableTouchOffset; rerun/re-subscribe and start from currentTouchOffset",
1115
- },
1116
- });
1117
-
1118
- let sinceSeq: bigint;
1119
- if (cursorOrSince === "now") {
1120
- sinceSeq = tailSeq;
1121
- } else {
1122
- const sinceRes = parseOffsetResult(cursorOrSince);
1123
- if (Result.isError(sinceRes)) return badRequest(sinceRes.error.message);
1124
- sinceSeq = offsetToSeqOrNeg1(sinceRes.value);
1125
- }
1126
-
1127
- if (sinceSeq < oldestCursorSeq) {
1128
- const latencyMs = Date.now() - waitStartMs;
1129
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: keys.length, outcome: "stale", latencyMs });
1130
- return json(200, staleBody());
1131
- }
1132
-
1133
- const encoder = new TextEncoder();
1134
- let keyBytes: Uint8Array[] | null = null;
1135
- const ensureKeyBytes = (): Uint8Array[] => {
1136
- if (keyBytes) return keyBytes;
1137
- keyBytes = keys.map((k) => encoder.encode(k));
1138
- return keyBytes;
1139
- };
1140
-
1141
- // Only use in-memory key notifications for small key sets; for huge key
1142
- // sets this would cause O(keysPerWait) register/unregister overhead.
1143
- const KEY_NOTIFIER_MAX_KEYS = 32;
1144
- const useKeyNotifier = keys.length <= KEY_NOTIFIER_MAX_KEYS;
1145
-
1146
- let cursorSeq = sinceSeq;
1147
- const deadline = Date.now() + timeoutMs;
1148
- for (;;) {
1149
- if (req.signal.aborted) return new Response(null, { status: 204 });
1150
-
1151
- const latest = db.getStream(derived);
1152
- if (!latest || db.isDeleted(latest)) return notFound();
1153
-
1154
- const endSeq = latest.next_offset - 1n;
1155
- const endTouchOffset = encodeOffset(latest.epoch, endSeq);
1156
-
1157
- const match = cursorSeq < endSeq ? db.findFirstWalOffsetForRoutingKeys(derived, cursorSeq, endSeq, ensureKeyBytes()) : null;
1158
- if (match != null) {
1159
- let touchedKey: string | null = null;
1160
- try {
1161
- const row = db.db.query(`SELECT routing_key FROM wal WHERE stream=? AND offset=? LIMIT 1;`).get(derived, match) as any;
1162
- if (row && row.routing_key) {
1163
- touchedKey = new TextDecoder().decode(row.routing_key as Uint8Array);
1164
- }
1165
- } catch {
1166
- touchedKey = null;
1167
- }
1168
- const latencyMs = Date.now() - waitStartMs;
1169
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: keys.length, outcome: "touched", latencyMs });
1170
- return json(200, {
1171
- touched: true,
1172
- touchOffset: encodeOffset(latest.epoch, match),
1173
- currentTouchOffset: endTouchOffset,
1174
- touchedKeys: touchedKey ? [touchedKey] : [],
956
+ bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
957
+ flushAtMs: j.getLastFlushAtMs(),
958
+ bucketStartMs: j.getLastBucketStartMs(),
1175
959
  });
1176
960
  }
1177
961
 
962
+ const deadline = Date.now() + timeoutMs;
1178
963
  const remaining = deadline - Date.now();
1179
964
  if (remaining <= 0) {
1180
- // Return the tail as-of the timeout moment, not as-of the last scan.
1181
- const latest2 = db.getStream(derived);
1182
- if (!latest2 || db.isDeleted(latest2)) return notFound();
1183
- const endSeq2 = latest2.next_offset - 1n;
1184
- const endTouchOffset2 = encodeOffset(latest2.epoch, endSeq2);
1185
965
  const latencyMs = Date.now() - waitStartMs;
1186
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: keys.length, outcome: "timeout", latencyMs });
1187
- return json(200, { touched: false, currentTouchOffset: endTouchOffset2 });
1188
- }
1189
-
1190
- if (useKeyNotifier) {
1191
- const hit = await touchRoutingKeyNotifier.waitForAny({
1192
- stream: derived,
1193
- keys,
1194
- afterSeq: endSeq,
1195
- timeoutMs: remaining,
1196
- signal: req.signal,
966
+ touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "timeout", latencyMs });
967
+ return json(200, {
968
+ touched: false,
969
+ cursor: j.getCursor(),
970
+ effectiveWaitKind,
971
+ bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
972
+ flushAtMs: j.getLastFlushAtMs(),
973
+ bucketStartMs: j.getLastBucketStartMs(),
1197
974
  });
1198
- if (req.signal.aborted) return new Response(null, { status: 204 });
1199
-
1200
- const latest2 = db.getStream(derived);
1201
- if (!latest2 || db.isDeleted(latest2)) return notFound();
1202
- const endSeq2 = latest2.next_offset - 1n;
1203
- const endTouchOffset2 = encodeOffset(latest2.epoch, endSeq2);
975
+ }
1204
976
 
1205
- if (hit == null) {
1206
- const latencyMs = Date.now() - waitStartMs;
1207
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: keys.length, outcome: "timeout", latencyMs });
1208
- return json(200, { touched: false, currentTouchOffset: endTouchOffset2 });
1209
- }
977
+ const afterGen = j.getGeneration();
978
+ const hit = await j.waitForAny({ keys: waitKeyIds, afterGeneration: afterGen, timeoutMs: remaining, signal: req.signal });
979
+ if (req.signal.aborted) return new Response(null, { status: 204 });
1210
980
 
981
+ if (hit == null) {
1211
982
  const latencyMs = Date.now() - waitStartMs;
1212
- touch.recordWaitMetrics({ stream, touchCfg, keysCount: keys.length, outcome: "touched", latencyMs });
983
+ touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "timeout", latencyMs });
1213
984
  return json(200, {
1214
- touched: true,
1215
- touchOffset: encodeOffset(latest2.epoch, hit.seq),
1216
- currentTouchOffset: endTouchOffset2,
1217
- touchedKeys: [hit.key],
985
+ touched: false,
986
+ cursor: j.getCursor(),
987
+ effectiveWaitKind,
988
+ bucketMaxSourceOffsetSeq: j.getLastFlushedSourceOffsetSeq().toString(),
989
+ flushAtMs: j.getLastFlushAtMs(),
990
+ bucketStartMs: j.getLastBucketStartMs(),
1218
991
  });
1219
992
  }
1220
993
 
1221
- // Fallback: wait for any new touch rows. Cursor advances to the tail we already scanned.
1222
- await notifier.waitFor(derived, endSeq, remaining, req.signal);
1223
- if (req.signal.aborted) return new Response(null, { status: 204 });
1224
- cursorSeq = endSeq;
1225
- }
1226
- }
1227
-
1228
- // touchMode.kind === "read"
1229
- if (req.method !== "GET") return badRequest("unsupported method");
1230
- if (touchStorage === "memory") {
1231
- return notFound("touch stream read not supported in memory mode; use /touch/wait");
1232
- }
1233
- const touchRes = ensureTouchStream();
1234
- if (Result.isError(touchRes)) return conflict(touchRes.error.message);
1235
- const trow = db.getStream(derived)!;
1236
- const tailSeq = trow.next_offset - 1n;
1237
- const currentTouchOffset = encodeOffset(trow.epoch, tailSeq);
1238
- const oldestSeq = db.getWalOldestOffset(derived);
1239
- const oldestCursorSeq = oldestSeq == null ? -1n : oldestSeq - 1n;
1240
- const oldestAvailableTouchOffset = encodeOffset(trow.epoch, oldestCursorSeq);
1241
-
1242
- const staleBody = () => ({
1243
- stale: true,
1244
- currentTouchOffset,
1245
- oldestAvailableTouchOffset,
1246
- error: {
1247
- code: "stale",
1248
- message:
1249
- "offset is older than oldestAvailableTouchOffset; rerun/re-subscribe and start from currentTouchOffset",
1250
- },
1251
- });
1252
-
1253
- // Default to "subscribe from now" for companion touches.
1254
- const nextUrl = new URL(req.url, "http://localhost");
1255
- if (!nextUrl.searchParams.has("offset")) {
1256
- const sinceParam = nextUrl.searchParams.get("since");
1257
- if (sinceParam) {
1258
- const sinceRes = parseTimestampMsResult(sinceParam);
1259
- if (Result.isError(sinceRes)) return badRequest(sinceRes.error.message);
1260
- const key = touchMode.key ?? nextUrl.searchParams.get("key");
1261
- const computedRes = await reader.seekOffsetByTimestampResult(derived, sinceRes.value, key ?? null);
1262
- if (Result.isError(computedRes)) return readerErrorResponse(computedRes.error);
1263
- nextUrl.searchParams.set("offset", computedRes.value);
1264
- nextUrl.searchParams.delete("since");
1265
- } else {
1266
- nextUrl.searchParams.set("offset", "now");
994
+ const latencyMs = Date.now() - waitStartMs;
995
+ touch.recordWaitMetrics({ stream, touchCfg, keysCount: waitKeyIds.length, outcome: "touched", latencyMs });
996
+ return json(200, {
997
+ touched: true,
998
+ cursor: j.getCursor(),
999
+ effectiveWaitKind,
1000
+ bucketMaxSourceOffsetSeq: hit.bucketMaxSourceOffsetSeq.toString(),
1001
+ flushAtMs: hit.flushAtMs,
1002
+ bucketStartMs: hit.bucketStartMs,
1003
+ });
1004
+ } finally {
1005
+ releaseHotInterest();
1267
1006
  }
1268
1007
  }
1269
-
1270
- const requestedOffset = nextUrl.searchParams.get("offset") ?? "";
1271
- if (requestedOffset !== "now") {
1272
- const requestedRes = parseOffsetResult(requestedOffset);
1273
- if (Result.isError(requestedRes)) return badRequest(requestedRes.error.message);
1274
- const seq = offsetToSeqOrNeg1(requestedRes.value);
1275
- if (seq < oldestCursorSeq) return json(409, staleBody());
1276
- }
1277
-
1278
- // Delegate to the standard stream read path for the internal derived stream.
1279
- const touchPath = touchMode.key
1280
- ? `${streamPrefix}${encodeURIComponent(derived)}/pk/${encodeURIComponent(touchMode.key)}`
1281
- : `${streamPrefix}${encodeURIComponent(derived)}`;
1282
- nextUrl.pathname = touchPath;
1283
- return fetch(new Request(nextUrl.toString(), req));
1284
1008
  }
1285
1009
 
1286
1010
  // Stream lifecycle.
@@ -1967,7 +1691,6 @@ export function createAppCore(cfg: Config, opts: CreateAppCoreOptions): App {
1967
1691
  os: store,
1968
1692
  ingest,
1969
1693
  notifier,
1970
- touchRoutingKeyNotifier,
1971
1694
  reader,
1972
1695
  segmenter,
1973
1696
  uploader,