@tungthedev/streams-server 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/CODE_OF_CONDUCT.md +45 -0
  2. package/CONTRIBUTING.md +76 -0
  3. package/LICENSE +201 -0
  4. package/README.md +58 -0
  5. package/SECURITY.md +42 -0
  6. package/bin/prisma-streams-server +2 -0
  7. package/package.json +46 -0
  8. package/src/app.ts +583 -0
  9. package/src/app_core.ts +3144 -0
  10. package/src/app_local.ts +206 -0
  11. package/src/auth.ts +124 -0
  12. package/src/auto_tune.ts +69 -0
  13. package/src/backpressure.ts +66 -0
  14. package/src/bootstrap.ts +613 -0
  15. package/src/compute/demo_entry.ts +415 -0
  16. package/src/compute/demo_site.ts +1242 -0
  17. package/src/compute/entry.ts +19 -0
  18. package/src/compute/package_entry.ts +4 -0
  19. package/src/compute/virtual-modules.d.ts +15 -0
  20. package/src/compute/worker_module_url.ts +9 -0
  21. package/src/concurrency_gate.ts +108 -0
  22. package/src/config.ts +402 -0
  23. package/src/db/bootstrap_store.ts +9 -0
  24. package/src/db/db.ts +2424 -0
  25. package/src/db/schema.ts +925 -0
  26. package/src/db/sqlite_manifest_snapshot.ts +81 -0
  27. package/src/db/sqlite_touch_store.ts +491 -0
  28. package/src/db/sqlite_wal_store.ts +472 -0
  29. package/src/details/full_mode_details.ts +568 -0
  30. package/src/expiry_sweeper.ts +47 -0
  31. package/src/foreground_activity.ts +55 -0
  32. package/src/hist.ts +169 -0
  33. package/src/index/binary_fuse.ts +379 -0
  34. package/src/index/indexer.ts +947 -0
  35. package/src/index/lexicon_file_cache.ts +261 -0
  36. package/src/index/lexicon_format.ts +93 -0
  37. package/src/index/lexicon_indexer.ts +863 -0
  38. package/src/index/run_cache.ts +84 -0
  39. package/src/index/run_format.ts +213 -0
  40. package/src/index/schedule.ts +28 -0
  41. package/src/index/secondary_indexer.ts +901 -0
  42. package/src/index/secondary_schema.ts +105 -0
  43. package/src/ingest.ts +309 -0
  44. package/src/lens/lens.ts +501 -0
  45. package/src/manifest.ts +249 -0
  46. package/src/memory.ts +334 -0
  47. package/src/metrics.ts +147 -0
  48. package/src/metrics_emitter.ts +83 -0
  49. package/src/notifier.ts +180 -0
  50. package/src/objectstore/accounting.ts +151 -0
  51. package/src/objectstore/interface.ts +13 -0
  52. package/src/objectstore/mock_r2.ts +269 -0
  53. package/src/objectstore/null.ts +32 -0
  54. package/src/objectstore/r2.ts +318 -0
  55. package/src/observe/pairing.ts +61 -0
  56. package/src/observe/request.ts +772 -0
  57. package/src/offset.ts +70 -0
  58. package/src/postgres/bootstrap.ts +269 -0
  59. package/src/postgres/companions.ts +197 -0
  60. package/src/postgres/control_restore.ts +109 -0
  61. package/src/postgres/details.ts +189 -0
  62. package/src/postgres/lexicon_index.ts +260 -0
  63. package/src/postgres/routing_index.ts +189 -0
  64. package/src/postgres/rows.ts +132 -0
  65. package/src/postgres/schema.ts +355 -0
  66. package/src/postgres/secondary_index.ts +238 -0
  67. package/src/postgres/segments.ts +900 -0
  68. package/src/postgres/stats.ts +103 -0
  69. package/src/postgres/store.ts +947 -0
  70. package/src/postgres/touch.ts +591 -0
  71. package/src/postgres/types.ts +32 -0
  72. package/src/profiles/evlog/schema.ts +234 -0
  73. package/src/profiles/evlog.ts +473 -0
  74. package/src/profiles/generic.ts +51 -0
  75. package/src/profiles/index.ts +237 -0
  76. package/src/profiles/metrics/block_format.ts +109 -0
  77. package/src/profiles/metrics/normalize.ts +366 -0
  78. package/src/profiles/metrics/schema.ts +319 -0
  79. package/src/profiles/metrics.ts +83 -0
  80. package/src/profiles/otelTraces/normalize.ts +955 -0
  81. package/src/profiles/otelTraces/otlp.ts +1002 -0
  82. package/src/profiles/otelTraces/schema.ts +408 -0
  83. package/src/profiles/otelTraces.ts +390 -0
  84. package/src/profiles/profile.ts +284 -0
  85. package/src/profiles/stateProtocol/change_event_conformance.typecheck.ts +35 -0
  86. package/src/profiles/stateProtocol/changes.ts +24 -0
  87. package/src/profiles/stateProtocol/ingest.ts +115 -0
  88. package/src/profiles/stateProtocol/routes.ts +511 -0
  89. package/src/profiles/stateProtocol/types.ts +6 -0
  90. package/src/profiles/stateProtocol/validation.ts +51 -0
  91. package/src/profiles/stateProtocol.ts +107 -0
  92. package/src/read_filter.ts +468 -0
  93. package/src/reader.ts +2986 -0
  94. package/src/runtime/hash.ts +156 -0
  95. package/src/runtime/hash_vendor/LICENSE.hash-wasm +38 -0
  96. package/src/runtime/hash_vendor/NOTICE.md +8 -0
  97. package/src/runtime/hash_vendor/xxhash3.umd.min.cjs +7 -0
  98. package/src/runtime/hash_vendor/xxhash32.umd.min.cjs +7 -0
  99. package/src/runtime/hash_vendor/xxhash64.umd.min.cjs +7 -0
  100. package/src/runtime/host_runtime.ts +5 -0
  101. package/src/runtime_memory.ts +200 -0
  102. package/src/runtime_memory_sampler.ts +237 -0
  103. package/src/schema/lens_schema.ts +290 -0
  104. package/src/schema/proof.ts +547 -0
  105. package/src/schema/read_json.ts +51 -0
  106. package/src/schema/registry.ts +966 -0
  107. package/src/search/agg_format.ts +638 -0
  108. package/src/search/aggregate.ts +409 -0
  109. package/src/search/binary/codec.ts +162 -0
  110. package/src/search/binary/docset.ts +67 -0
  111. package/src/search/binary/restart_strings.ts +181 -0
  112. package/src/search/binary/varint.ts +34 -0
  113. package/src/search/bitset.ts +19 -0
  114. package/src/search/col_format.ts +382 -0
  115. package/src/search/col_runtime.ts +59 -0
  116. package/src/search/column_encoding.ts +43 -0
  117. package/src/search/companion_file_cache.ts +319 -0
  118. package/src/search/companion_format.ts +327 -0
  119. package/src/search/companion_manager.ts +1305 -0
  120. package/src/search/companion_plan.ts +229 -0
  121. package/src/search/exact_format.ts +281 -0
  122. package/src/search/exact_runtime.ts +55 -0
  123. package/src/search/fts_format.ts +423 -0
  124. package/src/search/fts_runtime.ts +333 -0
  125. package/src/search/query.ts +875 -0
  126. package/src/search/schema.ts +245 -0
  127. package/src/segment/cache.ts +270 -0
  128. package/src/segment/cached_segment.ts +89 -0
  129. package/src/segment/format.ts +403 -0
  130. package/src/segment/segmenter.ts +412 -0
  131. package/src/segment/segmenter_worker.ts +72 -0
  132. package/src/segment/segmenter_workers.ts +130 -0
  133. package/src/server.ts +264 -0
  134. package/src/server_auto_tune.ts +158 -0
  135. package/src/sqlite/adapter.ts +335 -0
  136. package/src/sqlite/runtime_stats.ts +163 -0
  137. package/src/stats.ts +205 -0
  138. package/src/store/append.ts +50 -0
  139. package/src/store/bootstrap_restore_store.ts +71 -0
  140. package/src/store/capabilities.ts +86 -0
  141. package/src/store/full_mode_details_store.ts +71 -0
  142. package/src/store/index_store.ts +104 -0
  143. package/src/store/profile_touch_store.ts +1 -0
  144. package/src/store/rows.ts +144 -0
  145. package/src/store/schema_profile_store.ts +73 -0
  146. package/src/store/schema_publication.ts +6 -0
  147. package/src/store/segment_manifest_store.ts +129 -0
  148. package/src/store/segment_read_store.ts +22 -0
  149. package/src/store/stats_accounting_store.ts +83 -0
  150. package/src/store/touch_store.ts +98 -0
  151. package/src/store/wal_store.ts +21 -0
  152. package/src/stream_size_reconciler.ts +100 -0
  153. package/src/touch/canonical_change.ts +7 -0
  154. package/src/touch/live_keys.ts +158 -0
  155. package/src/touch/live_metrics.ts +841 -0
  156. package/src/touch/live_templates.ts +449 -0
  157. package/src/touch/manager.ts +1292 -0
  158. package/src/touch/process_batch.ts +576 -0
  159. package/src/touch/processor_worker.ts +85 -0
  160. package/src/touch/spec.ts +459 -0
  161. package/src/touch/touch_journal.ts +771 -0
  162. package/src/touch/touch_key_id.ts +20 -0
  163. package/src/touch/worker_pool.ts +191 -0
  164. package/src/touch/worker_protocol.ts +57 -0
  165. package/src/types/proper-lockfile.d.ts +1 -0
  166. package/src/uploader.ts +358 -0
  167. package/src/util/base32_crockford.ts +81 -0
  168. package/src/util/bloom256.ts +67 -0
  169. package/src/util/byte_lru.ts +73 -0
  170. package/src/util/cleanup.ts +22 -0
  171. package/src/util/crc32c.ts +29 -0
  172. package/src/util/ds_error.ts +15 -0
  173. package/src/util/duration.ts +17 -0
  174. package/src/util/endian.ts +53 -0
  175. package/src/util/json_pointer.ts +148 -0
  176. package/src/util/log.ts +25 -0
  177. package/src/util/lru.ts +53 -0
  178. package/src/util/retry.ts +35 -0
  179. package/src/util/siphash.ts +71 -0
  180. package/src/util/stream_paths.ts +50 -0
  181. package/src/util/time.ts +14 -0
  182. package/src/util/yield.ts +3 -0
  183. package/src/util/zstd.ts +24 -0
@@ -0,0 +1,576 @@
1
+ import { Result } from "better-result";
2
+ import { resolveEnabledTouchCapability } from "../profiles/index.ts";
3
+ import type { StreamProfileSpec } from "../profiles/profile.ts";
4
+ import type { LiveTemplateStoreRow, TouchProcessorStore } from "../store/touch_store.ts";
5
+ import type { ProcessResult } from "./worker_protocol.ts";
6
+ import {
7
+ encodeTemplateArg,
8
+ membershipKeyFor,
9
+ membershipKeyIdFor,
10
+ projectedFieldKeyFor,
11
+ projectedFieldKeyIdFor,
12
+ tableKeyIdFor,
13
+ templateKeyIdFor,
14
+ watchKeyFor,
15
+ watchKeyIdFor,
16
+ type TemplateEncoding,
17
+ } from "./live_keys.ts";
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
+ export type TouchProcessorBatchError = { kind: "missing_old_value"; message: string };
30
+
31
+ export type ProcessTouchBatchRequest = {
32
+ db: TouchProcessorStore;
33
+ stream: string;
34
+ fromOffset: bigint;
35
+ toOffset: bigint;
36
+ profile: StreamProfileSpec;
37
+ maxRows: number;
38
+ maxBytes: number;
39
+ emitFineTouches?: boolean;
40
+ fineTouchBudget?: number | null;
41
+ fineGranularity?: "key" | "template";
42
+ processingMode?: "full" | "hotTemplatesOnly";
43
+ filterHotTemplates?: boolean;
44
+ hotTemplateIds?: string[] | null;
45
+ };
46
+
47
+ const VALID_TEMPLATE_ENCODINGS = new Set<string>(["string", "int64", "bool", "datetime", "bytes"]);
48
+
49
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
50
+ return !!value && typeof value === "object" && !Array.isArray(value);
51
+ }
52
+
53
+ function invalidLiveTemplateMetadata(message: string): Result<ActiveTemplate, TouchProcessorBatchError> {
54
+ return Result.err({ kind: "missing_old_value", message: `invalid live template metadata: ${message}` });
55
+ }
56
+
57
+ function parseJsonArray(raw: unknown, field: string, templateId: string): Result<unknown[], TouchProcessorBatchError> {
58
+ try {
59
+ const value = JSON.parse(String(raw ?? "[]"));
60
+ if (!Array.isArray(value)) {
61
+ return Result.err({
62
+ kind: "missing_old_value",
63
+ message: `invalid live template metadata: ${field} must be an array (templateId=${templateId})`,
64
+ });
65
+ }
66
+ return Result.ok(value);
67
+ } catch {
68
+ return Result.err({
69
+ kind: "missing_old_value",
70
+ message: `invalid live template metadata: ${field} must be valid JSON (templateId=${templateId})`,
71
+ });
72
+ }
73
+ }
74
+
75
+ function parseActiveTemplateRow(row: LiveTemplateStoreRow): Result<ActiveTemplate, TouchProcessorBatchError> {
76
+ const templateId = String(row.template_id ?? "");
77
+ if (!/^[0-9a-f]{16}$/.test(templateId)) {
78
+ return invalidLiveTemplateMetadata(`template_id must be 16 lowercase hex characters (templateId=${templateId || "<empty>"})`);
79
+ }
80
+
81
+ const entity = String(row.entity ?? "");
82
+ if (entity.trim() === "") {
83
+ return invalidLiveTemplateMetadata(`entity must be non-empty (templateId=${templateId})`);
84
+ }
85
+
86
+ const fieldsRes = parseJsonArray(row.fields_json, "fields_json", templateId);
87
+ if (Result.isError(fieldsRes)) return fieldsRes;
88
+ const encodingsRes = parseJsonArray(row.encodings_json, "encodings_json", templateId);
89
+ if (Result.isError(encodingsRes)) return encodingsRes;
90
+
91
+ const rawFields = fieldsRes.value;
92
+ const rawEncodings = encodingsRes.value;
93
+ if (rawFields.length === 0 || rawFields.length > 3) {
94
+ return invalidLiveTemplateMetadata(`fields_json must contain 1 to 3 fields (templateId=${templateId})`);
95
+ }
96
+ if (rawFields.length !== rawEncodings.length) {
97
+ return invalidLiveTemplateMetadata(`fields_json and encodings_json lengths differ (templateId=${templateId})`);
98
+ }
99
+
100
+ const fields: string[] = [];
101
+ const encodings: TemplateEncoding[] = [];
102
+ for (let i = 0; i < rawFields.length; i++) {
103
+ const field = rawFields[i];
104
+ if (typeof field !== "string" || field.trim() === "") {
105
+ return invalidLiveTemplateMetadata(`fields_json[${i}] must be a non-empty string (templateId=${templateId})`);
106
+ }
107
+ const encoding = rawEncodings[i];
108
+ if (typeof encoding !== "string" || !VALID_TEMPLATE_ENCODINGS.has(encoding)) {
109
+ return invalidLiveTemplateMetadata(`encodings_json[${i}] is not supported (templateId=${templateId})`);
110
+ }
111
+ fields.push(field);
112
+ encodings.push(encoding as TemplateEncoding);
113
+ }
114
+
115
+ let activeFromSourceOffset: bigint;
116
+ try {
117
+ activeFromSourceOffset =
118
+ typeof row.active_from_source_offset === "bigint" ? row.active_from_source_offset : BigInt(row.active_from_source_offset ?? 0);
119
+ } catch {
120
+ return invalidLiveTemplateMetadata(`active_from_source_offset must be a bigint-compatible value (templateId=${templateId})`);
121
+ }
122
+
123
+ return Result.ok({ templateId, entity, fields, encodings, activeFromSourceOffset });
124
+ }
125
+
126
+ function isProjectedFieldValue(value: unknown): boolean {
127
+ return (
128
+ value === undefined ||
129
+ value === null ||
130
+ typeof value === "string" ||
131
+ typeof value === "boolean" ||
132
+ typeof value === "bigint" ||
133
+ (typeof value === "number" && Number.isFinite(value))
134
+ );
135
+ }
136
+
137
+ function projectedFieldValueEquals(a: unknown, b: unknown): boolean {
138
+ if (a === b) return true;
139
+ return typeof a === "number" && typeof b === "number" && Number.isNaN(a) && Number.isNaN(b);
140
+ }
141
+
142
+ function changedProjectedFieldNames(args: {
143
+ before: Record<string, unknown>;
144
+ after: Record<string, unknown>;
145
+ excluded: ReadonlySet<string>;
146
+ }): string[] {
147
+ const names = new Set<string>([...Object.keys(args.before), ...Object.keys(args.after)]);
148
+ const out: string[] = [];
149
+ for (const name of names) {
150
+ if (args.excluded.has(name)) continue;
151
+ const beforeValue = Object.prototype.hasOwnProperty.call(args.before, name) ? args.before[name] : undefined;
152
+ const afterValue = Object.prototype.hasOwnProperty.call(args.after, name) ? args.after[name] : undefined;
153
+ if (!isProjectedFieldValue(beforeValue) || !isProjectedFieldValue(afterValue)) continue;
154
+ if (!projectedFieldValueEquals(beforeValue, afterValue)) out.push(name);
155
+ }
156
+ return out;
157
+ }
158
+
159
+ function projectedFieldNamesFromAfter(args: { after: Record<string, unknown>; excluded: ReadonlySet<string> }): string[] {
160
+ const out: string[] = [];
161
+ for (const name of Object.keys(args.after)) {
162
+ if (args.excluded.has(name)) continue;
163
+ if (!isProjectedFieldValue(args.after[name])) continue;
164
+ out.push(name);
165
+ }
166
+ return out;
167
+ }
168
+
169
+ export async function processTouchBatch(req: ProcessTouchBatchRequest): Promise<Result<ProcessResult, TouchProcessorBatchError>> {
170
+ const { db, stream, fromOffset, toOffset, profile, maxRows, maxBytes } = req;
171
+ const failProcess = (message: string): Result<ProcessResult, TouchProcessorBatchError> => {
172
+ return Result.err({ kind: "missing_old_value", message });
173
+ };
174
+ const enabledTouch = resolveEnabledTouchCapability(profile);
175
+ if (!enabledTouch) return failProcess("touch not enabled for profile");
176
+
177
+ const { capability: touchCapability, touchCfg: touch } = enabledTouch;
178
+ const fineBudgetRaw = req.fineTouchBudget ?? touch.fineTouchBudgetPerBatch;
179
+ const fineBudget = fineBudgetRaw == null ? null : Math.max(0, Math.floor(fineBudgetRaw));
180
+ const fineGranularity = req.fineGranularity === "template" ? "template" : "key";
181
+ const processingMode = req.processingMode === "hotTemplatesOnly" ? "hotTemplatesOnly" : "full";
182
+ const hotTemplatesOnly = fineGranularity === "template" && processingMode === "hotTemplatesOnly";
183
+
184
+ const emitFineTouches = req.emitFineTouches !== false && fineBudget !== 0;
185
+ let fineBudgetExhausted = fineBudget != null && fineBudget <= 0;
186
+ let fineKeysBudgetRemaining = fineBudget;
187
+ let fineTouchesSuppressedDueToBudget = false;
188
+ const filterHotTemplates = req.filterHotTemplates === true;
189
+ const hotTemplateIdsRaw = filterHotTemplates ? req.hotTemplateIds ?? [] : [];
190
+ const hotTemplateIds = filterHotTemplates ? new Set(hotTemplateIdsRaw.filter((x): x is string => typeof x === "string" && /^[0-9a-f]{16}$/.test(x))) : null;
191
+
192
+ const coarseIntervalMs = Math.max(1, Math.floor(touch.coarseIntervalMs ?? 100));
193
+ const coalesceWindowMs = Math.max(1, Math.floor(touch.touchCoalesceWindowMs ?? 100));
194
+ const onMissingBefore = touch.onMissingBefore ?? "coarse";
195
+
196
+ const templatesByEntity = new Map<string, ActiveTemplate[]>();
197
+ const coldTemplateCountByEntity = new Map<string, number>();
198
+ if (emitFineTouches) {
199
+ try {
200
+ const rows = await db.listActiveLiveTemplates(stream);
201
+ for (const row of rows) {
202
+ const tplRes = parseActiveTemplateRow(row);
203
+ if (Result.isError(tplRes)) return tplRes;
204
+ const tpl = tplRes.value;
205
+ if (hotTemplateIds && !hotTemplateIds.has(tpl.templateId)) {
206
+ coldTemplateCountByEntity.set(tpl.entity, (coldTemplateCountByEntity.get(tpl.entity) ?? 0) + 1);
207
+ continue;
208
+ }
209
+ const arr = templatesByEntity.get(tpl.entity) ?? [];
210
+ arr.push(tpl);
211
+ templatesByEntity.set(tpl.entity, arr);
212
+ }
213
+ } catch (error) {
214
+ return failProcess(`failed to load active live templates: ${String((error as { message?: unknown })?.message ?? error)}`);
215
+ }
216
+ }
217
+
218
+ let rowsRead = 0;
219
+ let bytesRead = 0;
220
+ let changes = 0;
221
+ let maxSourceTsMs = 0;
222
+ let processedThrough = fromOffset - 1n;
223
+
224
+ type PendingTouch = {
225
+ keyId: number;
226
+ routingKey?: string;
227
+ windowStartMs: number;
228
+ watermark: string;
229
+ entity: string;
230
+ kind: "table" | "template";
231
+ templateId?: string;
232
+ };
233
+ type EntityTemplateOnlyTouch = { offset: bigint; tsMs: number; watermark: string };
234
+
235
+ const pending = new Map<string, PendingTouch>();
236
+ const templateOnlyEntityTouch = new Map<string, EntityTemplateOnlyTouch>();
237
+ const touches: Array<{ keyId: number; routingKey?: string; watermark: string; entity: string; kind: "table" | "template"; templateId?: string }> = [];
238
+ let fineTouchesDroppedDueToBudget = 0;
239
+ let fineTouchesSkippedColdTemplate = 0;
240
+
241
+ const flush = (_mapKey: string, p: PendingTouch): void => {
242
+ touches.push({
243
+ keyId: p.keyId >>> 0,
244
+ routingKey: p.routingKey,
245
+ watermark: p.watermark,
246
+ entity: p.entity,
247
+ kind: p.kind,
248
+ templateId: p.templateId,
249
+ });
250
+ };
251
+
252
+ const queueTouch = (args: {
253
+ keyId: number;
254
+ routingKey?: string;
255
+ tsMs: number;
256
+ watermark: string;
257
+ entity: string;
258
+ kind: "table" | "template";
259
+ templateId?: string;
260
+ windowMs: number;
261
+ }): void => {
262
+ const mapKey = args.routingKey ? `r:${args.routingKey}` : `i:${args.keyId >>> 0}`;
263
+ const prev = pending.get(mapKey);
264
+ if (args.kind !== "table" && fineBudget != null && !fineBudgetExhausted && !prev) {
265
+ const remaining = fineKeysBudgetRemaining ?? 0;
266
+ if (remaining <= 0) {
267
+ fineBudgetExhausted = true;
268
+ fineTouchesSuppressedDueToBudget = true;
269
+ fineTouchesDroppedDueToBudget += 1;
270
+ return;
271
+ }
272
+ fineKeysBudgetRemaining = remaining - 1;
273
+ } else if (args.kind !== "table" && fineBudget != null && !prev && fineBudgetExhausted) {
274
+ fineTouchesSuppressedDueToBudget = true;
275
+ fineTouchesDroppedDueToBudget += 1;
276
+ return;
277
+ }
278
+
279
+ if (!prev) {
280
+ pending.set(mapKey, {
281
+ keyId: args.keyId >>> 0,
282
+ routingKey: args.routingKey,
283
+ windowStartMs: args.tsMs,
284
+ watermark: args.watermark,
285
+ entity: args.entity,
286
+ kind: args.kind,
287
+ templateId: args.templateId,
288
+ });
289
+ return;
290
+ }
291
+ if (args.tsMs - prev.windowStartMs < args.windowMs) {
292
+ prev.watermark = args.watermark;
293
+ return;
294
+ }
295
+ flush(mapKey, prev);
296
+ pending.set(mapKey, {
297
+ keyId: args.keyId >>> 0,
298
+ routingKey: args.routingKey,
299
+ windowStartMs: args.tsMs,
300
+ watermark: args.watermark,
301
+ entity: args.entity,
302
+ kind: args.kind,
303
+ templateId: args.templateId,
304
+ });
305
+ };
306
+
307
+ for await (const row of db.readWalRange(stream, fromOffset, toOffset)) {
308
+ const payload = row.payload as Uint8Array;
309
+ const payloadLen = payload.byteLength;
310
+ if (rowsRead > 0 && (rowsRead >= maxRows || bytesRead + payloadLen > maxBytes)) break;
311
+
312
+ rowsRead++;
313
+ bytesRead += payloadLen;
314
+ const offset = typeof row.offset === "bigint" ? (row.offset as bigint) : BigInt(row.offset);
315
+ processedThrough = offset;
316
+ const tsMsRaw = row.tsMs;
317
+ const tsMs = typeof tsMsRaw === "bigint" ? Number(tsMsRaw) : Number(tsMsRaw);
318
+ if (!Number.isFinite(tsMs)) continue;
319
+ if (tsMs > maxSourceTsMs) maxSourceTsMs = tsMs;
320
+
321
+ let value: any;
322
+ try {
323
+ value = JSON.parse(decoder.decode(payload));
324
+ } catch {
325
+ continue;
326
+ }
327
+
328
+ const canonical = touchCapability.deriveCanonicalChanges(value, profile);
329
+ changes += canonical.length;
330
+ if (canonical.length === 0) continue;
331
+ const watermark = offset.toString();
332
+
333
+ for (const ch of canonical) {
334
+ const entity = ch.entity;
335
+ queueTouch({
336
+ keyId: tableKeyIdFor(entity),
337
+ tsMs,
338
+ watermark,
339
+ entity,
340
+ kind: "table",
341
+ windowMs: coarseIntervalMs,
342
+ });
343
+
344
+ if (!emitFineTouches) continue;
345
+ if (fineBudgetExhausted) continue;
346
+
347
+ const tpls = templatesByEntity.get(entity);
348
+ if (filterHotTemplates) fineTouchesSkippedColdTemplate += coldTemplateCountByEntity.get(entity) ?? 0;
349
+ if (!tpls || tpls.length === 0) continue;
350
+
351
+ if (hotTemplatesOnly) {
352
+ const prev = templateOnlyEntityTouch.get(entity);
353
+ if (!prev || offset > prev.offset) templateOnlyEntityTouch.set(entity, { offset, tsMs, watermark });
354
+ continue;
355
+ }
356
+
357
+ for (const tpl of tpls) {
358
+ if (fineBudgetExhausted) break;
359
+ if (offset < tpl.activeFromSourceOffset) continue;
360
+
361
+ if (fineGranularity === "template") {
362
+ queueTouch({
363
+ keyId: templateKeyIdFor(tpl.templateId) >>> 0,
364
+ tsMs,
365
+ watermark,
366
+ entity,
367
+ kind: "template",
368
+ templateId: tpl.templateId,
369
+ windowMs: coalesceWindowMs,
370
+ });
371
+ if (fineBudgetExhausted) break;
372
+ continue;
373
+ }
374
+
375
+ const afterObj = ch.after;
376
+ const beforeObj = ch.before;
377
+
378
+ const watchKeys = new Map<number, string>();
379
+ const membershipKeys = new Map<number, string>();
380
+ const projectedFieldKeys = new Map<number, string>();
381
+
382
+ const computeArgs = (obj: unknown): string[] | null => {
383
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) return null;
384
+ const args: string[] = [];
385
+ for (let i = 0; i < tpl.fields.length; i++) {
386
+ const name = tpl.fields[i];
387
+ const enc = tpl.encodings[i];
388
+ const v = (obj as any)[name];
389
+ const encoded = encodeTemplateArg(v, enc);
390
+ if (encoded == null) return null;
391
+ args.push(encoded);
392
+ }
393
+ return args;
394
+ };
395
+ const computeWatch = (args: string[]): { keyId: number; routingKey: string } => {
396
+ const routingKey = watchKeyFor(tpl.templateId, args);
397
+ return { keyId: watchKeyIdFor(tpl.templateId, args) >>> 0, routingKey };
398
+ };
399
+ const computeMembership = (args: string[]): { keyId: number; routingKey: string } => {
400
+ const routingKey = membershipKeyFor(tpl.templateId, args);
401
+ return { keyId: membershipKeyIdFor(tpl.templateId, args) >>> 0, routingKey };
402
+ };
403
+ const computeProjectedField = (fieldName: string, args: string[]): { keyId: number; routingKey: string } => {
404
+ const routingKey = projectedFieldKeyFor(tpl.templateId, fieldName, args);
405
+ return { keyId: projectedFieldKeyIdFor(tpl.templateId, fieldName, args) >>> 0, routingKey };
406
+ };
407
+ const afterArgs = computeArgs(afterObj);
408
+ const beforeArgs = computeArgs(beforeObj);
409
+ const watchAfter = afterArgs != null ? computeWatch(afterArgs) : null;
410
+ const watchBefore = beforeArgs != null ? computeWatch(beforeArgs) : null;
411
+ const membershipAfter = afterArgs != null ? computeMembership(afterArgs) : null;
412
+ const membershipBefore = beforeArgs != null ? computeMembership(beforeArgs) : null;
413
+ const sameTuple = watchBefore != null && watchAfter != null && watchBefore.routingKey === watchAfter.routingKey;
414
+ const excludedProjectedFields = new Set(tpl.fields);
415
+
416
+ if (ch.op === "insert") {
417
+ if (watchAfter != null) watchKeys.set(watchAfter.keyId >>> 0, watchAfter.routingKey);
418
+ if (membershipAfter != null) membershipKeys.set(membershipAfter.keyId >>> 0, membershipAfter.routingKey);
419
+ } else if (ch.op === "delete") {
420
+ if (watchBefore != null) watchKeys.set(watchBefore.keyId >>> 0, watchBefore.routingKey);
421
+ if (membershipBefore != null) membershipKeys.set(membershipBefore.keyId >>> 0, membershipBefore.routingKey);
422
+ } else {
423
+ if (watchBefore != null) {
424
+ watchKeys.set(watchBefore.keyId >>> 0, watchBefore.routingKey);
425
+ if (watchAfter != null) watchKeys.set(watchAfter.keyId >>> 0, watchAfter.routingKey);
426
+ if (membershipBefore != null && membershipAfter != null) {
427
+ if (membershipBefore.routingKey !== membershipAfter.routingKey) {
428
+ membershipKeys.set(membershipBefore.keyId >>> 0, membershipBefore.routingKey);
429
+ membershipKeys.set(membershipAfter.keyId >>> 0, membershipAfter.routingKey);
430
+ } else if (sameTuple && isPlainObject(beforeObj) && isPlainObject(afterObj) && afterArgs != null) {
431
+ for (const fieldName of changedProjectedFieldNames({
432
+ before: beforeObj,
433
+ after: afterObj,
434
+ excluded: excludedProjectedFields,
435
+ })) {
436
+ const projected = computeProjectedField(fieldName, afterArgs);
437
+ projectedFieldKeys.set(projected.keyId >>> 0, projected.routingKey);
438
+ }
439
+ }
440
+ } else {
441
+ if (membershipBefore != null) membershipKeys.set(membershipBefore.keyId >>> 0, membershipBefore.routingKey);
442
+ if (membershipAfter != null) membershipKeys.set(membershipAfter.keyId >>> 0, membershipAfter.routingKey);
443
+ }
444
+ } else {
445
+ if (beforeObj === undefined) {
446
+ if (onMissingBefore === "error") {
447
+ return failProcess(`missing old_value for update (entity=${entity}, templateId=${tpl.templateId})`);
448
+ }
449
+ } else if (onMissingBefore === "error") {
450
+ return failProcess(`old_value missing required fields for update (entity=${entity}, templateId=${tpl.templateId})`);
451
+ }
452
+
453
+ if (onMissingBefore === "skipBefore") {
454
+ if (watchAfter != null) watchKeys.set(watchAfter.keyId >>> 0, watchAfter.routingKey);
455
+ if (membershipAfter != null) membershipKeys.set(membershipAfter.keyId >>> 0, membershipAfter.routingKey);
456
+ if (afterArgs != null && isPlainObject(afterObj)) {
457
+ for (const fieldName of projectedFieldNamesFromAfter({
458
+ after: afterObj,
459
+ excluded: excludedProjectedFields,
460
+ })) {
461
+ const projected = computeProjectedField(fieldName, afterArgs);
462
+ projectedFieldKeys.set(projected.keyId >>> 0, projected.routingKey);
463
+ }
464
+ }
465
+ }
466
+ }
467
+ }
468
+
469
+ for (const [watchKeyId, routingKey] of watchKeys) {
470
+ queueTouch({
471
+ keyId: watchKeyId >>> 0,
472
+ routingKey,
473
+ tsMs,
474
+ watermark,
475
+ entity,
476
+ kind: "template",
477
+ templateId: tpl.templateId,
478
+ windowMs: coalesceWindowMs,
479
+ });
480
+ if (fineBudgetExhausted) break;
481
+ }
482
+ for (const [membershipKeyId, routingKey] of membershipKeys) {
483
+ queueTouch({
484
+ keyId: membershipKeyId >>> 0,
485
+ routingKey,
486
+ tsMs,
487
+ watermark,
488
+ entity,
489
+ kind: "template",
490
+ templateId: tpl.templateId,
491
+ windowMs: coalesceWindowMs,
492
+ });
493
+ if (fineBudgetExhausted) break;
494
+ }
495
+ for (const [projectedFieldKeyId, routingKey] of projectedFieldKeys) {
496
+ queueTouch({
497
+ keyId: projectedFieldKeyId >>> 0,
498
+ routingKey,
499
+ tsMs,
500
+ watermark,
501
+ entity,
502
+ kind: "template",
503
+ templateId: tpl.templateId,
504
+ windowMs: coalesceWindowMs,
505
+ });
506
+ if (fineBudgetExhausted) break;
507
+ }
508
+ }
509
+ }
510
+ }
511
+
512
+ if (emitFineTouches && hotTemplatesOnly && !fineBudgetExhausted && templateOnlyEntityTouch.size > 0) {
513
+ for (const [entity, t] of templateOnlyEntityTouch.entries()) {
514
+ if (fineBudgetExhausted) break;
515
+ const tpls = templatesByEntity.get(entity) ?? [];
516
+ for (const tpl of tpls) {
517
+ if (fineBudgetExhausted) break;
518
+ if (t.offset < tpl.activeFromSourceOffset) continue;
519
+ queueTouch({
520
+ keyId: templateKeyIdFor(tpl.templateId) >>> 0,
521
+ tsMs: t.tsMs,
522
+ watermark: t.watermark,
523
+ entity,
524
+ kind: "template",
525
+ templateId: tpl.templateId,
526
+ windowMs: coalesceWindowMs,
527
+ });
528
+ }
529
+ }
530
+ }
531
+
532
+ for (const [k, p] of pending) flush(k, p);
533
+
534
+ touches.sort((a, b) => {
535
+ const ak = a.keyId >>> 0;
536
+ const bk = b.keyId >>> 0;
537
+ if (ak < bk) return -1;
538
+ if (ak > bk) return 1;
539
+ const ar = a.routingKey ?? "";
540
+ const br = b.routingKey ?? "";
541
+ if (ar < br) return -1;
542
+ if (ar > br) return 1;
543
+ const aw = BigInt(a.watermark);
544
+ const bw = BigInt(b.watermark);
545
+ if (aw < bw) return -1;
546
+ if (aw > bw) return 1;
547
+ return 0;
548
+ });
549
+
550
+ let tableTouchesEmitted = 0;
551
+ let templateTouchesEmitted = 0;
552
+ for (const t of touches) {
553
+ if (t.kind === "table") tableTouchesEmitted++;
554
+ else templateTouchesEmitted++;
555
+ }
556
+
557
+ return Result.ok({
558
+ type: "result",
559
+ id: 0,
560
+ stream,
561
+ processedThrough,
562
+ touches,
563
+ stats: {
564
+ rowsRead,
565
+ bytesRead,
566
+ changes,
567
+ touchesEmitted: touches.length,
568
+ tableTouchesEmitted,
569
+ templateTouchesEmitted,
570
+ maxSourceTsMs,
571
+ fineTouchesDroppedDueToBudget,
572
+ fineTouchesSuppressedDueToBudget,
573
+ fineTouchesSkippedColdTemplate,
574
+ },
575
+ });
576
+ }
@@ -0,0 +1,85 @@
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 type { HostRuntime } from "../runtime/host_runtime.ts";
6
+ import { setSqliteRuntimeOverride } from "../sqlite/adapter.ts";
7
+ import { initConsoleLogging } from "../util/log.ts";
8
+ import { processTouchBatch } from "./process_batch.ts";
9
+ import type { ProcessRequest } from "./worker_protocol.ts";
10
+
11
+ initConsoleLogging();
12
+
13
+ const data = workerData as { config: Config; hostRuntime?: HostRuntime };
14
+ const cfg = data.config;
15
+ // Bun worker_threads can miss the Bun globals that the main thread sees.
16
+ // Use the parent host runtime hint before the worker opens SQLite.
17
+ setSqliteRuntimeOverride(data.hostRuntime ?? null);
18
+ // The main server process initializes/migrates schema; workers should avoid
19
+ // concurrent migrations on the same sqlite file.
20
+ const db = new SqliteDurableStore(cfg.dbPath, { cacheBytes: cfg.workerSqliteCacheBytes, skipMigrations: true });
21
+ const touchStore = db.touch;
22
+
23
+ async function handleProcess(msg: ProcessRequest): Promise<void> {
24
+ const res = await processTouchBatch({
25
+ db: touchStore,
26
+ stream: msg.stream,
27
+ fromOffset: msg.fromOffset,
28
+ toOffset: msg.toOffset,
29
+ profile: msg.profile,
30
+ maxRows: msg.maxRows,
31
+ maxBytes: msg.maxBytes,
32
+ emitFineTouches: msg.emitFineTouches,
33
+ fineTouchBudget: msg.fineTouchBudget,
34
+ fineGranularity: msg.fineGranularity,
35
+ processingMode: msg.processingMode,
36
+ filterHotTemplates: msg.filterHotTemplates,
37
+ hotTemplateIds: msg.hotTemplateIds,
38
+ });
39
+ if (Result.isError(res)) {
40
+ parentPort?.postMessage({
41
+ type: "error",
42
+ id: msg.id,
43
+ stream: msg.stream,
44
+ message: res.error.message,
45
+ });
46
+ return;
47
+ }
48
+ parentPort?.postMessage({
49
+ ...res.value,
50
+ id: msg.id,
51
+ stream: msg.stream,
52
+ });
53
+ }
54
+
55
+ parentPort?.on("message", (msg: any) => {
56
+ if (!msg || typeof msg !== "object") return;
57
+ if (msg.type === "stop") {
58
+ try {
59
+ db.close();
60
+ } catch {
61
+ // ignore
62
+ }
63
+ try {
64
+ parentPort?.postMessage({ type: "stopped" });
65
+ } catch {
66
+ // ignore
67
+ }
68
+ return;
69
+ }
70
+ if (msg.type === "process") {
71
+ void handleProcess(msg as ProcessRequest).catch((e: any) => {
72
+ try {
73
+ parentPort?.postMessage({
74
+ type: "error",
75
+ id: (msg as any).id,
76
+ stream: (msg as any).stream,
77
+ message: String(e?.message ?? e),
78
+ stack: e?.stack ? String(e.stack) : undefined,
79
+ });
80
+ } catch {
81
+ // ignore
82
+ }
83
+ });
84
+ }
85
+ });