@prisma/streams-server 0.0.1 → 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.
Files changed (83) hide show
  1. package/CODE_OF_CONDUCT.md +45 -0
  2. package/CONTRIBUTING.md +68 -0
  3. package/LICENSE +201 -0
  4. package/README.md +39 -2
  5. package/SECURITY.md +33 -0
  6. package/bin/prisma-streams-server +2 -0
  7. package/package.json +29 -34
  8. package/src/app.ts +74 -0
  9. package/src/app_core.ts +1706 -0
  10. package/src/app_local.ts +46 -0
  11. package/src/backpressure.ts +66 -0
  12. package/src/bootstrap.ts +239 -0
  13. package/src/config.ts +251 -0
  14. package/src/db/db.ts +1386 -0
  15. package/src/db/schema.ts +625 -0
  16. package/src/expiry_sweeper.ts +44 -0
  17. package/src/hist.ts +169 -0
  18. package/src/index/binary_fuse.ts +379 -0
  19. package/src/index/indexer.ts +745 -0
  20. package/src/index/run_cache.ts +84 -0
  21. package/src/index/run_format.ts +213 -0
  22. package/src/ingest.ts +655 -0
  23. package/src/lens/lens.ts +501 -0
  24. package/src/manifest.ts +114 -0
  25. package/src/memory.ts +155 -0
  26. package/src/metrics.ts +161 -0
  27. package/src/metrics_emitter.ts +50 -0
  28. package/src/notifier.ts +64 -0
  29. package/src/objectstore/interface.ts +13 -0
  30. package/src/objectstore/mock_r2.ts +269 -0
  31. package/src/objectstore/null.ts +32 -0
  32. package/src/objectstore/r2.ts +128 -0
  33. package/src/offset.ts +70 -0
  34. package/src/reader.ts +454 -0
  35. package/src/runtime/hash.ts +156 -0
  36. package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
  37. package/src/runtime/hash_vendor/NOTICE.md +8 -0
  38. package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
  39. package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
  40. package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
  41. package/src/schema/lens_schema.ts +290 -0
  42. package/src/schema/proof.ts +547 -0
  43. package/src/schema/registry.ts +405 -0
  44. package/src/segment/cache.ts +179 -0
  45. package/src/segment/format.ts +331 -0
  46. package/src/segment/segmenter.ts +326 -0
  47. package/src/segment/segmenter_worker.ts +43 -0
  48. package/src/segment/segmenter_workers.ts +94 -0
  49. package/src/server.ts +326 -0
  50. package/src/sqlite/adapter.ts +164 -0
  51. package/src/stats.ts +205 -0
  52. package/src/touch/engine.ts +41 -0
  53. package/src/touch/interpreter_worker.ts +442 -0
  54. package/src/touch/live_keys.ts +118 -0
  55. package/src/touch/live_metrics.ts +827 -0
  56. package/src/touch/live_templates.ts +619 -0
  57. package/src/touch/manager.ts +1199 -0
  58. package/src/touch/spec.ts +456 -0
  59. package/src/touch/touch_journal.ts +671 -0
  60. package/src/touch/touch_key_id.ts +20 -0
  61. package/src/touch/worker_pool.ts +189 -0
  62. package/src/touch/worker_protocol.ts +56 -0
  63. package/src/types/proper-lockfile.d.ts +1 -0
  64. package/src/uploader.ts +317 -0
  65. package/src/util/base32_crockford.ts +81 -0
  66. package/src/util/bloom256.ts +67 -0
  67. package/src/util/cleanup.ts +22 -0
  68. package/src/util/crc32c.ts +29 -0
  69. package/src/util/ds_error.ts +15 -0
  70. package/src/util/duration.ts +17 -0
  71. package/src/util/endian.ts +53 -0
  72. package/src/util/json_pointer.ts +148 -0
  73. package/src/util/log.ts +25 -0
  74. package/src/util/lru.ts +45 -0
  75. package/src/util/retry.ts +35 -0
  76. package/src/util/siphash.ts +71 -0
  77. package/src/util/stream_paths.ts +31 -0
  78. package/src/util/time.ts +14 -0
  79. package/src/util/yield.ts +3 -0
  80. package/build/index.d.mts +0 -1
  81. package/build/index.d.ts +0 -1
  82. package/build/index.js +0 -0
  83. package/build/index.mjs +0 -1
package/src/stats.ts ADDED
@@ -0,0 +1,205 @@
1
+ import type { SqliteDurableStore } from "./db/db";
2
+ import type { UploaderController } from "./uploader";
3
+ import type { MemoryGuard } from "./memory";
4
+ import type { BackpressureGate } from "./backpressure";
5
+ import type { IngestQueue } from "./ingest";
6
+
7
+ export type StatsSnapshot = {
8
+ ingestedBytes: number;
9
+ walBytes: number;
10
+ sealedPayloadBytes: number;
11
+ sealedBytes: number;
12
+ uploadedBytes: number;
13
+ segmentsSealed: number;
14
+ backpressureOverMs: number;
15
+ activeStreams: number;
16
+ };
17
+
18
+ export class StatsCollector {
19
+ private readonly backpressureBudgetMs: number;
20
+ private ingestedBytes = 0;
21
+ private walBytes = 0;
22
+ private sealedPayloadBytes = 0;
23
+ private sealedBytes = 0;
24
+ private uploadedBytes = 0;
25
+ private segmentsSealed = 0;
26
+ private backpressureOverMs = 0;
27
+ private readonly activeStreams = new Set<string>();
28
+
29
+ constructor(opts?: { backpressureBudgetMs?: number }) {
30
+ const raw = opts?.backpressureBudgetMs ?? 1;
31
+ this.backpressureBudgetMs = Number.isFinite(raw) ? Math.max(0, raw) : 1;
32
+ }
33
+
34
+ recordIngested(bytes: number): void {
35
+ this.ingestedBytes += bytes;
36
+ }
37
+
38
+ recordWalCommitBytes(bytes: number): void {
39
+ this.walBytes += bytes;
40
+ }
41
+
42
+ recordSegmentSealed(payloadBytes: number, segmentBytes: number): void {
43
+ this.sealedPayloadBytes += payloadBytes;
44
+ this.sealedBytes += segmentBytes;
45
+ this.segmentsSealed += 1;
46
+ }
47
+
48
+ recordUploadedBytes(bytes: number): void {
49
+ this.uploadedBytes += bytes;
50
+ }
51
+
52
+ getBackpressureBudgetMs(): number {
53
+ return this.backpressureBudgetMs;
54
+ }
55
+
56
+ recordBackpressureOverMs(overMs: number): void {
57
+ if (overMs <= 0) return;
58
+ this.backpressureOverMs += Math.max(0, overMs);
59
+ }
60
+
61
+ recordStreamTouched(stream: string): void {
62
+ this.activeStreams.add(stream);
63
+ }
64
+
65
+ snapshotAndReset(): StatsSnapshot {
66
+ const snapshot: StatsSnapshot = {
67
+ ingestedBytes: this.ingestedBytes,
68
+ walBytes: this.walBytes,
69
+ sealedPayloadBytes: this.sealedPayloadBytes,
70
+ sealedBytes: this.sealedBytes,
71
+ uploadedBytes: this.uploadedBytes,
72
+ segmentsSealed: this.segmentsSealed,
73
+ backpressureOverMs: this.backpressureOverMs,
74
+ activeStreams: this.activeStreams.size,
75
+ };
76
+ this.ingestedBytes = 0;
77
+ this.walBytes = 0;
78
+ this.sealedPayloadBytes = 0;
79
+ this.sealedBytes = 0;
80
+ this.uploadedBytes = 0;
81
+ this.segmentsSealed = 0;
82
+ this.backpressureOverMs = 0;
83
+ this.activeStreams.clear();
84
+ return snapshot;
85
+ }
86
+ }
87
+
88
+ function formatBytes(bytes: number): string {
89
+ const units = ["b", "kb", "mb", "gb"];
90
+ let value = bytes;
91
+ let idx = 0;
92
+ while (value >= 1024 && idx < units.length - 1) {
93
+ value /= 1024;
94
+ idx += 1;
95
+ }
96
+ const digits = idx === 0 ? 0 : 1;
97
+ return `${value.toFixed(digits)}${units[idx]}`;
98
+ }
99
+
100
+ export class StatsReporter {
101
+ private timer: any | null = null;
102
+ private sampleTimer: any | null = null;
103
+ private running = false;
104
+ private lastTickMs: number | null = null;
105
+ private lastSampleMs: number | null = null;
106
+ private rejectActiveMs = 0;
107
+ private readonly intervalMs: number;
108
+ private readonly stats: StatsCollector;
109
+ private readonly db: SqliteDurableStore;
110
+ private readonly uploader: UploaderController;
111
+ private readonly ingest?: IngestQueue;
112
+ private readonly backpressure?: BackpressureGate;
113
+ private readonly memory?: MemoryGuard;
114
+
115
+ constructor(
116
+ stats: StatsCollector,
117
+ db: SqliteDurableStore,
118
+ uploader: UploaderController,
119
+ ingest?: IngestQueue,
120
+ backpressure?: BackpressureGate,
121
+ memory?: MemoryGuard,
122
+ intervalMs = 60_000
123
+ ) {
124
+ this.stats = stats;
125
+ this.db = db;
126
+ this.uploader = uploader;
127
+ this.ingest = ingest;
128
+ this.backpressure = backpressure;
129
+ this.memory = memory;
130
+ this.intervalMs = intervalMs;
131
+ }
132
+
133
+ start(): void {
134
+ if (this.timer) return;
135
+ if (!this.sampleTimer) {
136
+ this.sampleTimer = setInterval(() => this.sample(), 250);
137
+ this.sample();
138
+ }
139
+ this.timer = setInterval(() => {
140
+ void this.tick();
141
+ }, this.intervalMs);
142
+ }
143
+
144
+ stop(): void {
145
+ if (this.timer) clearInterval(this.timer);
146
+ this.timer = null;
147
+ if (this.sampleTimer) clearInterval(this.sampleTimer);
148
+ this.sampleTimer = null;
149
+ }
150
+
151
+ private sample(): void {
152
+ const now = Date.now();
153
+ const last = this.lastSampleMs;
154
+ this.lastSampleMs = now;
155
+ if (!last) return;
156
+ const dt = Math.max(0, now - last);
157
+
158
+ const rejectActive =
159
+ (this.memory?.isOverLimit() ?? false) ||
160
+ (this.ingest?.isQueueFull() ?? false) ||
161
+ (this.backpressure?.isOverLimit() ?? false);
162
+ if (rejectActive) this.rejectActiveMs += dt;
163
+ }
164
+
165
+ private async tick(): Promise<void> {
166
+ if (this.running) return;
167
+ this.running = true;
168
+ try {
169
+ const nowMs = Date.now();
170
+ const windowMs = this.lastTickMs ? Math.max(1, nowMs - this.lastTickMs) : this.intervalMs;
171
+ this.lastTickMs = nowMs;
172
+ const snap = this.stats.snapshotAndReset();
173
+ const storedBytes = snap.walBytes + snap.sealedBytes;
174
+ const compression =
175
+ snap.sealedBytes > 0 ? `${(snap.sealedPayloadBytes / snap.sealedBytes).toFixed(2)}x` : "n/a";
176
+ const queueWaitPct = snap.backpressureOverMs > 0 ? (snap.backpressureOverMs / windowMs) * 100 : 0;
177
+ const rejectPct = this.rejectActiveMs > 0 ? (this.rejectActiveMs / windowMs) * 100 : 0;
178
+ const backpressurePct = Math.min(100, Math.max(queueWaitPct, rejectPct));
179
+ this.rejectActiveMs = 0;
180
+ const avgSegmentSize = snap.segmentsSealed > 0 ? formatBytes(snap.sealedBytes / snap.segmentsSealed) : "n/a";
181
+ const totalStreams = this.db.countStreams();
182
+ const segmentsWaiting = this.uploader.countSegmentsWaiting();
183
+ const walDbBytes = this.db.getWalDbSizeBytes();
184
+ const metaDbBytes = this.db.getMetaDbSizeBytes();
185
+ const maxRss = this.memory ? formatBytes(this.memory.snapshotMaxRssBytes(true)) : null;
186
+ const line =
187
+ `ingested=${formatBytes(snap.ingestedBytes)} ` +
188
+ `stored=${formatBytes(storedBytes)} ` +
189
+ `compression=${compression} ` +
190
+ `uploaded=${formatBytes(snap.uploadedBytes)} ` +
191
+ `streams-touched=${snap.activeStreams}/${totalStreams} ` +
192
+ `segments-sealed=${snap.segmentsSealed} ` +
193
+ `segments-waiting=${segmentsWaiting} ` +
194
+ `avg-segment-size=${avgSegmentSize} ` +
195
+ `wal-size=${formatBytes(walDbBytes)} ` +
196
+ `meta-size=${formatBytes(metaDbBytes)} ` +
197
+ `backpressure=${backpressurePct.toFixed(1)}%` +
198
+ (maxRss ? ` max-rss=${maxRss}` : "");
199
+ // eslint-disable-next-line no-console
200
+ console.log(line);
201
+ } finally {
202
+ this.running = false;
203
+ }
204
+ }
205
+ }
@@ -0,0 +1,41 @@
1
+ import type { StreamInterpreterConfig } from "./spec.ts";
2
+
3
+ export type CanonicalChange = {
4
+ entity: string;
5
+ key?: string;
6
+ op: "insert" | "update" | "delete";
7
+ before?: unknown;
8
+ after?: unknown;
9
+ };
10
+
11
+ export function interpretRecordToChanges(record: any, _cfg: StreamInterpreterConfig): CanonicalChange[] {
12
+ return interpretStateProtocolRecord(record);
13
+ }
14
+
15
+ function interpretStateProtocolRecord(record: any): CanonicalChange[] {
16
+ if (!record || typeof record !== "object" || Array.isArray(record)) return [];
17
+ const headers = (record as any).headers;
18
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) return [];
19
+
20
+ // Control messages are ignored by touch derivation.
21
+ if (typeof (headers as any).control === "string") return [];
22
+
23
+ const opRaw = (headers as any).operation;
24
+ if (typeof opRaw !== "string") return [];
25
+ const op = opRaw;
26
+ if (op !== "insert" && op !== "update" && op !== "delete") return [];
27
+
28
+ const type = (record as any).type;
29
+ const key = (record as any).key;
30
+ if (typeof type !== "string" || type.trim() === "") return [];
31
+ if (typeof key !== "string" || key.trim() === "") return [];
32
+
33
+ const before = Object.prototype.hasOwnProperty.call(record, "oldValue")
34
+ ? (record as any).oldValue
35
+ : Object.prototype.hasOwnProperty.call(record, "old_value")
36
+ ? (record as any).old_value
37
+ : undefined;
38
+ const after = Object.prototype.hasOwnProperty.call(record, "value") ? (record as any).value : undefined;
39
+
40
+ return [{ entity: type, key, op, before, after }];
41
+ }
@@ -0,0 +1,442 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ import { Result } from "better-result";
3
+ import type { Config } from "../config.ts";
4
+ import { SqliteDurableStore } from "../db/db.ts";
5
+ import { initConsoleLogging } from "../util/log.ts";
6
+ import type { ProcessRequest } from "./worker_protocol.ts";
7
+ import { interpretRecordToChanges } from "./engine.ts";
8
+ import { encodeTemplateArg, tableKeyIdFor, templateKeyIdFor, watchKeyIdFor, type TemplateEncoding } from "./live_keys.ts";
9
+ import { isTouchEnabled } from "./spec.ts";
10
+
11
+ initConsoleLogging();
12
+
13
+ const data = workerData as { config: Config };
14
+ const cfg = data.config;
15
+ // The main server process initializes/migrates schema; workers should avoid
16
+ // concurrent migrations on the same sqlite file.
17
+ const db = new SqliteDurableStore(cfg.dbPath, { cacheBytes: cfg.sqliteCacheBytes, skipMigrations: true });
18
+
19
+ const decoder = new TextDecoder();
20
+
21
+ type ActiveTemplate = {
22
+ templateId: string;
23
+ entity: string;
24
+ fields: string[];
25
+ encodings: TemplateEncoding[];
26
+ activeFromSourceOffset: bigint;
27
+ };
28
+
29
+ type InterpreterWorkerError = { kind: "missing_old_value"; message: string };
30
+
31
+ async function handleProcess(msg: ProcessRequest): Promise<void> {
32
+ const { stream, fromOffset, toOffset, interpreter, maxRows, maxBytes } = msg;
33
+ const failProcess = (message: string): void => {
34
+ const err = Result.err<never, InterpreterWorkerError>({ kind: "missing_old_value", message });
35
+ parentPort?.postMessage({
36
+ type: "error",
37
+ id: msg.id,
38
+ stream,
39
+ message: err.error.message,
40
+ });
41
+ };
42
+ if (!isTouchEnabled(interpreter)) {
43
+ parentPort?.postMessage({
44
+ type: "error",
45
+ id: msg.id,
46
+ stream,
47
+ message: "touch not enabled for interpreter",
48
+ });
49
+ return;
50
+ }
51
+ const touch = interpreter.touch;
52
+
53
+ const fineBudgetRaw = msg.fineTouchBudget ?? touch.fineTouchBudgetPerBatch;
54
+ const fineBudget = fineBudgetRaw == null ? null : Math.max(0, Math.floor(fineBudgetRaw));
55
+ const fineGranularity = msg.fineGranularity === "template" ? "template" : "key";
56
+ const interpretMode = msg.interpretMode === "hotTemplatesOnly" ? "hotTemplatesOnly" : "full";
57
+ const hotTemplatesOnly = fineGranularity === "template" && interpretMode === "hotTemplatesOnly";
58
+
59
+ const emitFineTouches = msg.emitFineTouches !== false && fineBudget !== 0;
60
+ let fineBudgetExhausted = fineBudget != null && fineBudget <= 0;
61
+ let fineKeysBudgetRemaining = fineBudget;
62
+ let fineTouchesSuppressedDueToBudget = false;
63
+ const filterHotTemplates = msg.filterHotTemplates === true;
64
+ const hotTemplateIdsRaw = filterHotTemplates ? msg.hotTemplateIds ?? [] : [];
65
+ const hotTemplateIds = filterHotTemplates ? new Set(hotTemplateIdsRaw.filter((x): x is string => typeof x === "string" && /^[0-9a-f]{16}$/.test(x))) : null;
66
+
67
+ const coarseIntervalMs = Math.max(1, Math.floor(touch.coarseIntervalMs ?? 100));
68
+ const coalesceWindowMs = Math.max(1, Math.floor(touch.touchCoalesceWindowMs ?? 100));
69
+ const onMissingBefore = touch.onMissingBefore ?? "coarse";
70
+
71
+ const templatesByEntity = new Map<string, ActiveTemplate[]>();
72
+ const coldTemplateCountByEntity = new Map<string, number>();
73
+ if (emitFineTouches) {
74
+ try {
75
+ const rows = db.db
76
+ .query(
77
+ `SELECT template_id, entity, fields_json, encodings_json, active_from_source_offset
78
+ FROM live_templates
79
+ WHERE stream=? AND state='active';`
80
+ )
81
+ .all(stream) as any[];
82
+ for (const row of rows) {
83
+ const templateId = String(row.template_id ?? "");
84
+ if (!/^[0-9a-f]{16}$/.test(templateId)) continue;
85
+ const entity = String(row.entity ?? "");
86
+ if (entity.trim() === "") continue;
87
+ let fields: any;
88
+ let encodings: any;
89
+ try {
90
+ fields = JSON.parse(String(row.fields_json ?? "[]"));
91
+ encodings = JSON.parse(String(row.encodings_json ?? "[]"));
92
+ } catch {
93
+ continue;
94
+ }
95
+ if (!Array.isArray(fields) || !Array.isArray(encodings) || fields.length !== encodings.length) continue;
96
+ const f = fields.map(String);
97
+ const e = encodings.map(String) as TemplateEncoding[];
98
+ if (f.length === 0 || f.length > 3) continue;
99
+ if (!e.every((x) => x === "string" || x === "int64" || x === "bool" || x === "datetime" || x === "bytes")) continue;
100
+ if (hotTemplateIds && !hotTemplateIds.has(templateId)) {
101
+ coldTemplateCountByEntity.set(entity, (coldTemplateCountByEntity.get(entity) ?? 0) + 1);
102
+ continue;
103
+ }
104
+ const activeFromSourceOffset = typeof row.active_from_source_offset === "bigint" ? row.active_from_source_offset : BigInt(row.active_from_source_offset ?? 0);
105
+ const tpl: ActiveTemplate = { templateId, entity, fields: f, encodings: e, activeFromSourceOffset };
106
+ const arr = templatesByEntity.get(entity) ?? [];
107
+ arr.push(tpl);
108
+ templatesByEntity.set(entity, arr);
109
+ }
110
+ } catch {
111
+ // If the live_templates table isn't available yet (old DB), treat as no templates.
112
+ }
113
+ }
114
+
115
+ let rowsRead = 0;
116
+ let bytesRead = 0;
117
+ let changes = 0;
118
+ let maxSourceTsMs = 0;
119
+
120
+ let processedThrough = fromOffset - 1n;
121
+
122
+ type PendingTouch = {
123
+ keyId: number;
124
+ windowStartMs: number;
125
+ watermark: string;
126
+ entity: string;
127
+ kind: "table" | "template";
128
+ templateId?: string;
129
+ };
130
+ type EntityTemplateOnlyTouch = { offset: bigint; tsMs: number; watermark: string };
131
+
132
+ const pending = new Map<string, PendingTouch>();
133
+ const templateOnlyEntityTouch = new Map<string, EntityTemplateOnlyTouch>();
134
+ const touches: Array<{ keyId: number; watermark: string; entity: string; kind: "table" | "template"; templateId?: string }> = [];
135
+ let fineTouchesDroppedDueToBudget = 0;
136
+ let fineTouchesSkippedColdTemplate = 0;
137
+
138
+ const flush = (_mapKey: string, p: PendingTouch) => {
139
+ touches.push({ keyId: p.keyId >>> 0, watermark: p.watermark, entity: p.entity, kind: p.kind, templateId: p.templateId });
140
+ };
141
+
142
+ const queueTouch = (args: {
143
+ keyId: number;
144
+ tsMs: number;
145
+ watermark: string;
146
+ entity: string;
147
+ kind: "table" | "template";
148
+ templateId?: string;
149
+ windowMs: number;
150
+ }) => {
151
+ const mapKey = `i:${args.keyId >>> 0}`;
152
+ const prev = pending.get(mapKey);
153
+
154
+ // Guardrail: cap fine/template touches (key cardinality) per batch.
155
+ // Table touches are always emitted for correctness.
156
+ if (args.kind !== "table" && fineBudget != null && !fineBudgetExhausted && !prev) {
157
+ const remaining = fineKeysBudgetRemaining ?? 0;
158
+ if (remaining <= 0) {
159
+ fineBudgetExhausted = true;
160
+ fineTouchesSuppressedDueToBudget = true;
161
+ fineTouchesDroppedDueToBudget += 1;
162
+ return;
163
+ }
164
+ fineKeysBudgetRemaining = remaining - 1;
165
+ } else if (args.kind !== "table" && fineBudget != null && !prev && fineBudgetExhausted) {
166
+ fineTouchesSuppressedDueToBudget = true;
167
+ fineTouchesDroppedDueToBudget += 1;
168
+ return;
169
+ }
170
+
171
+ if (!prev) {
172
+ pending.set(mapKey, {
173
+ keyId: args.keyId >>> 0,
174
+ windowStartMs: args.tsMs,
175
+ watermark: args.watermark,
176
+ entity: args.entity,
177
+ kind: args.kind,
178
+ templateId: args.templateId,
179
+ });
180
+ return;
181
+ }
182
+ if (args.tsMs - prev.windowStartMs < args.windowMs) {
183
+ // Coalesce within the window; keep the latest watermark for debugging.
184
+ prev.watermark = args.watermark;
185
+ return;
186
+ }
187
+ flush(mapKey, prev);
188
+ pending.set(mapKey, {
189
+ keyId: args.keyId >>> 0,
190
+ windowStartMs: args.tsMs,
191
+ watermark: args.watermark,
192
+ entity: args.entity,
193
+ kind: args.kind,
194
+ templateId: args.templateId,
195
+ });
196
+ };
197
+
198
+ for (const row of db.iterWalRange(stream, fromOffset, toOffset)) {
199
+ const payload = row.payload as Uint8Array;
200
+ const payloadLen = payload.byteLength;
201
+ if (rowsRead > 0 && (rowsRead >= maxRows || bytesRead + payloadLen > maxBytes)) break;
202
+
203
+ rowsRead++;
204
+ bytesRead += payloadLen;
205
+ const offset = typeof row.offset === "bigint" ? (row.offset as bigint) : BigInt(row.offset);
206
+ processedThrough = offset;
207
+ const tsMsRaw = row.ts_ms;
208
+ const tsMs = typeof tsMsRaw === "bigint" ? Number(tsMsRaw) : Number(tsMsRaw);
209
+ if (!Number.isFinite(tsMs)) continue;
210
+ if (tsMs > maxSourceTsMs) maxSourceTsMs = tsMs;
211
+
212
+ let value: any;
213
+ try {
214
+ value = JSON.parse(decoder.decode(payload));
215
+ } catch {
216
+ // Treat invalid JSON as "no changes".
217
+ continue;
218
+ }
219
+
220
+ const canonical = interpretRecordToChanges(value, interpreter);
221
+ changes += canonical.length;
222
+ if (canonical.length === 0) continue;
223
+ const watermark = offset.toString();
224
+
225
+ for (const ch of canonical) {
226
+ const entity = ch.entity;
227
+
228
+ // Always emit coarse table touches for correctness.
229
+ const coarseKeyId = tableKeyIdFor(entity);
230
+ queueTouch({
231
+ keyId: coarseKeyId,
232
+ tsMs,
233
+ watermark,
234
+ entity,
235
+ kind: "table",
236
+ windowMs: coarseIntervalMs,
237
+ });
238
+
239
+ if (!emitFineTouches) continue;
240
+ if (fineBudgetExhausted) continue;
241
+
242
+ const tpls = templatesByEntity.get(entity);
243
+ if (filterHotTemplates) {
244
+ fineTouchesSkippedColdTemplate += coldTemplateCountByEntity.get(entity) ?? 0;
245
+ }
246
+ if (!tpls || tpls.length === 0) continue;
247
+
248
+ if (hotTemplatesOnly) {
249
+ const prev = templateOnlyEntityTouch.get(entity);
250
+ if (!prev || offset > prev.offset) templateOnlyEntityTouch.set(entity, { offset, tsMs, watermark });
251
+ continue;
252
+ }
253
+
254
+ for (const tpl of tpls) {
255
+ if (fineBudgetExhausted) break;
256
+ if (offset < tpl.activeFromSourceOffset) continue;
257
+
258
+ if (fineGranularity === "template") {
259
+ queueTouch({
260
+ keyId: templateKeyIdFor(tpl.templateId) >>> 0,
261
+ tsMs,
262
+ watermark,
263
+ entity,
264
+ kind: "template",
265
+ templateId: tpl.templateId,
266
+ windowMs: coalesceWindowMs,
267
+ });
268
+ if (fineBudgetExhausted) break;
269
+ continue;
270
+ }
271
+
272
+ const afterObj = ch.after;
273
+ const beforeObj = ch.before;
274
+
275
+ const watchKeyIds = new Set<number>();
276
+
277
+ const compute = (obj: unknown): number | null => {
278
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
279
+ const args: string[] = [];
280
+ for (let i = 0; i < tpl.fields.length; i++) {
281
+ const name = tpl.fields[i];
282
+ const enc = tpl.encodings[i];
283
+ const v = (obj as any)[name];
284
+ const encoded = encodeTemplateArg(v, enc);
285
+ if (encoded == null) return null;
286
+ args.push(encoded);
287
+ }
288
+ return watchKeyIdFor(tpl.templateId, args) >>> 0;
289
+ };
290
+
291
+ if (ch.op === "insert") {
292
+ const k = compute(afterObj);
293
+ if (k != null) watchKeyIds.add(k >>> 0);
294
+ } else if (ch.op === "delete") {
295
+ const k = compute(beforeObj);
296
+ if (k != null) watchKeyIds.add(k >>> 0);
297
+ } else {
298
+ // update: compute touches from both before and after (when possible).
299
+ // Policy for missing/insufficient before image:
300
+ // - coarse: emit no fine touches (table touch already guarantees correctness)
301
+ // - skipBefore: emit after-only touch
302
+ // - error: fail the interpreter batch
303
+ const kAfter = compute(afterObj);
304
+ const kBefore = compute(beforeObj);
305
+
306
+ if (kBefore != null) {
307
+ watchKeyIds.add(kBefore >>> 0);
308
+ if (kAfter != null) watchKeyIds.add(kAfter >>> 0);
309
+ } else {
310
+ if (beforeObj === undefined) {
311
+ if (onMissingBefore === "error") {
312
+ failProcess(`missing oldValue for update (entity=${entity}, templateId=${tpl.templateId})`);
313
+ return;
314
+ }
315
+ } else {
316
+ // oldValue exists but missing fields / unsupported types.
317
+ if (onMissingBefore === "error") {
318
+ failProcess(`oldValue missing required fields for update (entity=${entity}, templateId=${tpl.templateId})`);
319
+ return;
320
+ }
321
+ }
322
+
323
+ if (onMissingBefore === "skipBefore") {
324
+ if (kAfter != null) watchKeyIds.add(kAfter >>> 0);
325
+ } else {
326
+ // coarse: no fine touches
327
+ }
328
+ }
329
+ }
330
+
331
+ for (const watchKeyId of watchKeyIds) {
332
+ queueTouch({
333
+ keyId: watchKeyId >>> 0,
334
+ tsMs,
335
+ watermark,
336
+ entity,
337
+ kind: "template",
338
+ templateId: tpl.templateId,
339
+ windowMs: coalesceWindowMs,
340
+ });
341
+ if (fineBudgetExhausted) break;
342
+ }
343
+ }
344
+ }
345
+ }
346
+
347
+ if (emitFineTouches && hotTemplatesOnly && !fineBudgetExhausted && templateOnlyEntityTouch.size > 0) {
348
+ for (const [entity, agg] of templateOnlyEntityTouch.entries()) {
349
+ if (fineBudgetExhausted) break;
350
+ const tpls = templatesByEntity.get(entity);
351
+ if (!tpls || tpls.length === 0) continue;
352
+ for (const tpl of tpls) {
353
+ if (fineBudgetExhausted) break;
354
+ if (agg.offset < tpl.activeFromSourceOffset) continue;
355
+ queueTouch({
356
+ keyId: templateKeyIdFor(tpl.templateId) >>> 0,
357
+ tsMs: agg.tsMs,
358
+ watermark: agg.watermark,
359
+ entity,
360
+ kind: "template",
361
+ templateId: tpl.templateId,
362
+ windowMs: coalesceWindowMs,
363
+ });
364
+ }
365
+ }
366
+ }
367
+
368
+ for (const [key, p] of pending.entries()) {
369
+ flush(key, p);
370
+ }
371
+
372
+ touches.sort((a, b) => {
373
+ const ak = a.keyId >>> 0;
374
+ const bk = b.keyId >>> 0;
375
+ if (ak < bk) return -1;
376
+ if (ak > bk) return 1;
377
+ const aw = BigInt(a.watermark);
378
+ const bw = BigInt(b.watermark);
379
+ if (aw < bw) return -1;
380
+ if (aw > bw) return 1;
381
+ return 0;
382
+ });
383
+
384
+ let tableTouchesEmitted = 0;
385
+ let templateTouchesEmitted = 0;
386
+ for (const t of touches) {
387
+ if (t.kind === "table") tableTouchesEmitted++;
388
+ else templateTouchesEmitted++;
389
+ }
390
+
391
+ parentPort?.postMessage({
392
+ type: "result",
393
+ id: msg.id,
394
+ stream,
395
+ processedThrough,
396
+ touches,
397
+ stats: {
398
+ rowsRead,
399
+ bytesRead,
400
+ changes,
401
+ touchesEmitted: touches.length,
402
+ tableTouchesEmitted,
403
+ templateTouchesEmitted,
404
+ maxSourceTsMs,
405
+ fineTouchesDroppedDueToBudget,
406
+ fineTouchesSuppressedDueToBudget,
407
+ fineTouchesSkippedColdTemplate,
408
+ },
409
+ });
410
+ }
411
+
412
+ parentPort?.on("message", (msg: any) => {
413
+ if (!msg || typeof msg !== "object") return;
414
+ if (msg.type === "stop") {
415
+ try {
416
+ db.close();
417
+ } catch {
418
+ // ignore
419
+ }
420
+ try {
421
+ parentPort?.postMessage({ type: "stopped" });
422
+ } catch {
423
+ // ignore
424
+ }
425
+ return;
426
+ }
427
+ if (msg.type === "process") {
428
+ void handleProcess(msg as ProcessRequest).catch((e: any) => {
429
+ try {
430
+ parentPort?.postMessage({
431
+ type: "error",
432
+ id: (msg as any).id,
433
+ stream: (msg as any).stream,
434
+ message: String(e?.message ?? e),
435
+ stack: e?.stack ? String(e.stack) : undefined,
436
+ });
437
+ } catch {
438
+ // ignore
439
+ }
440
+ });
441
+ }
442
+ });