@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,449 @@
1
+ import type { LiveTemplateActivationInput, LiveTemplateStore } from "../store/touch_store";
2
+ import { canonicalizeTemplateFields, templateIdFor, type TemplateEncoding } from "./live_keys";
3
+
4
+ export type TemplateFieldSpec = { name: string; encoding: TemplateEncoding };
5
+ export type TemplateDecl = { entity: string; fields: TemplateFieldSpec[] };
6
+
7
+ export type ActivatedTemplate = {
8
+ templateId: string;
9
+ state: "active";
10
+ activeFromTouchOffset: string;
11
+ };
12
+
13
+ export type DeniedTemplate = {
14
+ templateId: string;
15
+ reason: "rate_limited" | "invalid";
16
+ };
17
+
18
+ export type TemplateLifecycleEvent =
19
+ | {
20
+ type: "live.template_activated";
21
+ ts: string;
22
+ stream: string;
23
+ templateId: string;
24
+ entity: string;
25
+ fields: string[];
26
+ encodings: TemplateEncoding[];
27
+ reason: "declared" | "heartbeat";
28
+ activeFromTouchOffset: string;
29
+ inactivityTtlMs: number;
30
+ }
31
+ | {
32
+ type: "live.template_retired";
33
+ ts: string;
34
+ stream: string;
35
+ templateId: string;
36
+ entity: string;
37
+ fields: string[];
38
+ encodings: TemplateEncoding[];
39
+ lastSeenAt: string;
40
+ inactiveForMs: number;
41
+ reason: "inactivity";
42
+ }
43
+ | {
44
+ type: "live.template_evicted";
45
+ ts: string;
46
+ stream: string;
47
+ templateId: string;
48
+ reason: "cap_exceeded";
49
+ cap: number;
50
+ };
51
+
52
+ type RateState = { tokens: number; lastRefillMs: number };
53
+
54
+ export type LiveTemplateRegistryMemoryStats = {
55
+ lastSeenEntries: number;
56
+ dirtyLastSeenEntries: number;
57
+ rateStateStreams: number;
58
+ };
59
+
60
+ function nowIso(ms: number): string {
61
+ return new Date(ms).toISOString();
62
+ }
63
+
64
+ export class LiveTemplateRegistry {
65
+ private readonly db: LiveTemplateStore;
66
+
67
+ // In-memory last-seen tracking to avoid sqlite writes on every wait call.
68
+ private readonly lastSeenMem = new Map<string, { lastSeenMs: number; lastPersistMs: number }>();
69
+ private readonly dirtyLastSeen = new Set<string>();
70
+
71
+ private readonly rate = new Map<string, RateState>();
72
+ private readonly activationLocks = new Map<string, Promise<void>>();
73
+
74
+ constructor(db: LiveTemplateStore) {
75
+ this.db = db;
76
+ }
77
+
78
+ private key(stream: string, templateId: string): string {
79
+ return `${stream}\n${templateId}`;
80
+ }
81
+
82
+ getMemoryStats(): LiveTemplateRegistryMemoryStats {
83
+ return {
84
+ lastSeenEntries: this.lastSeenMem.size,
85
+ dirtyLastSeenEntries: this.dirtyLastSeen.size,
86
+ rateStateStreams: this.rate.size,
87
+ };
88
+ }
89
+
90
+ private availableActivationTokens(stream: string, nowMs: number, limitPerMinute: number): number {
91
+ if (limitPerMinute <= 0) return Number.MAX_SAFE_INTEGER;
92
+ const ratePerMs = limitPerMinute / 60_000;
93
+ const st = this.rate.get(stream) ?? { tokens: limitPerMinute, lastRefillMs: nowMs };
94
+ const elapsed = Math.max(0, nowMs - st.lastRefillMs);
95
+ st.tokens = Math.min(limitPerMinute, st.tokens + elapsed * ratePerMs);
96
+ st.lastRefillMs = nowMs;
97
+ this.rate.set(stream, st);
98
+ return Math.max(0, Math.floor(st.tokens));
99
+ }
100
+
101
+ private consumeActivationTokens(stream: string, nowMs: number, limitPerMinute: number, count: number): void {
102
+ if (limitPerMinute <= 0 || count <= 0) return;
103
+ this.availableActivationTokens(stream, nowMs, limitPerMinute);
104
+ const st = this.rate.get(stream);
105
+ if (!st) return;
106
+ st.tokens = Math.max(0, st.tokens - count);
107
+ this.rate.set(stream, st);
108
+ }
109
+
110
+ private async withActivationLock<T>(stream: string, fn: () => Promise<T>): Promise<T> {
111
+ const previous = this.activationLocks.get(stream) ?? Promise.resolve();
112
+ let release!: () => void;
113
+ const current = new Promise<void>((resolve) => {
114
+ release = resolve;
115
+ });
116
+ const chained = previous.then(() => current, () => current);
117
+ this.activationLocks.set(stream, chained);
118
+ await previous.catch(() => {});
119
+ try {
120
+ return await fn();
121
+ } finally {
122
+ release();
123
+ if (this.activationLocks.get(stream) === chained) this.activationLocks.delete(stream);
124
+ }
125
+ }
126
+
127
+ async getActiveTemplateCount(stream: string): Promise<number> {
128
+ try {
129
+ return await this.db.countActiveLiveTemplates(stream);
130
+ } catch {
131
+ return 0;
132
+ }
133
+ }
134
+
135
+ async listActiveTemplates(stream: string): Promise<Array<{ templateId: string; entity: string; fields: string[]; encodings: TemplateEncoding[]; lastSeenAtMs: number }>> {
136
+ try {
137
+ const rows = await this.db.listActiveLiveTemplates(stream);
138
+ const out: Array<{ templateId: string; entity: string; fields: string[]; encodings: TemplateEncoding[]; lastSeenAtMs: number }> = [];
139
+ for (const row of rows) {
140
+ const templateId = row.template_id;
141
+ const entity = row.entity;
142
+ const fields = JSON.parse(row.fields_json);
143
+ const encodings = JSON.parse(row.encodings_json);
144
+ if (!Array.isArray(fields) || !Array.isArray(encodings) || fields.length !== encodings.length) continue;
145
+ const lastSeenAtMs = Number(row.last_seen_at_ms);
146
+ out.push({ templateId, entity, fields: fields.map(String), encodings: encodings.map(String) as any, lastSeenAtMs });
147
+ }
148
+ return out;
149
+ } catch {
150
+ return [];
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Activate templates (idempotent). Returns lifecycle events to be emitted.
156
+ *
157
+ * `baseStreamNextOffset` is used to set `active_from_source_offset` so we do
158
+ * not backfill fine touches for history when a template is activated while
159
+ * touch processing is behind.
160
+ */
161
+ async activate(args: {
162
+ stream: string;
163
+ activeFromTouchOffset: string;
164
+ baseStreamNextOffset: bigint;
165
+ templates: TemplateDecl[];
166
+ inactivityTtlMs: number;
167
+ limits: {
168
+ maxActiveTemplatesPerStream: number;
169
+ maxActiveTemplatesPerEntity: number;
170
+ activationRateLimitPerMinute: number;
171
+ };
172
+ nowMs: number;
173
+ }): Promise<{ activated: ActivatedTemplate[]; denied: DeniedTemplate[]; lifecycle: TemplateLifecycleEvent[] }> {
174
+ return this.withActivationLock(args.stream, () => this.activateLocked(args));
175
+ }
176
+
177
+ private async activateLocked(args: {
178
+ stream: string;
179
+ activeFromTouchOffset: string;
180
+ baseStreamNextOffset: bigint;
181
+ templates: TemplateDecl[];
182
+ inactivityTtlMs: number;
183
+ limits: {
184
+ maxActiveTemplatesPerStream: number;
185
+ maxActiveTemplatesPerEntity: number;
186
+ activationRateLimitPerMinute: number;
187
+ };
188
+ nowMs: number;
189
+ }): Promise<{ activated: ActivatedTemplate[]; denied: DeniedTemplate[]; lifecycle: TemplateLifecycleEvent[] }> {
190
+ const { stream, templates, inactivityTtlMs, nowMs } = args;
191
+ const { maxActiveTemplatesPerStream, maxActiveTemplatesPerEntity, activationRateLimitPerMinute } = args.limits;
192
+
193
+ const activated: ActivatedTemplate[] = [];
194
+ const denied: DeniedTemplate[] = [];
195
+ const lifecycle: TemplateLifecycleEvent[] = [];
196
+
197
+ const atomicInputs: LiveTemplateActivationInput[] = [];
198
+ const atomicLifecycleById = new Map<string, TemplateLifecycleEvent>();
199
+
200
+ for (const t of templates) {
201
+ const entity = typeof t?.entity === "string" ? t.entity.trim() : "";
202
+ if (entity === "") {
203
+ denied.push({ templateId: "0000000000000000", reason: "invalid" });
204
+ continue;
205
+ }
206
+ if (!Array.isArray(t.fields) || t.fields.length === 0 || t.fields.length > 3) {
207
+ denied.push({ templateId: "0000000000000000", reason: "invalid" });
208
+ continue;
209
+ }
210
+
211
+ const rawFields: Array<{ name: string; encoding: TemplateEncoding }> = [];
212
+ for (const f of t.fields) {
213
+ const name = typeof (f as any)?.name === "string" ? String((f as any).name).trim() : "";
214
+ const encoding = (f as any)?.encoding as TemplateEncoding;
215
+ if (name === "") continue;
216
+ if (encoding !== "string" && encoding !== "int64" && encoding !== "bool" && encoding !== "datetime" && encoding !== "bytes") continue;
217
+ rawFields.push({ name, encoding });
218
+ }
219
+ if (rawFields.length !== t.fields.length) {
220
+ denied.push({ templateId: "0000000000000000", reason: "invalid" });
221
+ continue;
222
+ }
223
+ {
224
+ const seen = new Set<string>();
225
+ let ok = true;
226
+ for (const f of rawFields) {
227
+ if (seen.has(f.name)) ok = false;
228
+ seen.add(f.name);
229
+ }
230
+ if (!ok) {
231
+ denied.push({ templateId: "0000000000000000", reason: "invalid" });
232
+ continue;
233
+ }
234
+ }
235
+
236
+ const fields = canonicalizeTemplateFields(rawFields);
237
+ const fieldNames = fields.map((f) => f.name);
238
+ const encodings = fields.map((f) => f.encoding);
239
+
240
+ const templateId = templateIdFor(entity, fieldNames);
241
+ const fieldsJson = JSON.stringify(fieldNames);
242
+ const encodingsJson = JSON.stringify(encodings);
243
+
244
+ if (atomicLifecycleById.has(templateId)) continue;
245
+
246
+ atomicInputs.push({
247
+ templateId,
248
+ entity,
249
+ fieldsJson,
250
+ encodingsJson,
251
+ nowMs,
252
+ inactivityTtlMs,
253
+ activeFromSourceOffset: args.baseStreamNextOffset,
254
+ });
255
+ atomicLifecycleById.set(templateId, {
256
+ type: "live.template_activated",
257
+ ts: nowIso(nowMs),
258
+ stream,
259
+ templateId,
260
+ entity,
261
+ fields: fieldNames,
262
+ encodings,
263
+ reason: "declared",
264
+ activeFromTouchOffset: args.activeFromTouchOffset,
265
+ inactivityTtlMs,
266
+ });
267
+ }
268
+
269
+ if (atomicInputs.length === 0) return { activated, denied, lifecycle };
270
+
271
+ const atomicRes = await this.db.activateLiveTemplates({
272
+ stream,
273
+ templates: atomicInputs,
274
+ maxActiveTemplatesPerStream,
275
+ maxActiveTemplatesPerEntity,
276
+ maxActivationTokens: this.availableActivationTokens(stream, nowMs, activationRateLimitPerMinute),
277
+ });
278
+ const invalidIds = new Set(atomicRes.invalid);
279
+ const rateLimitedIds = new Set(atomicRes.rateLimited);
280
+ for (const input of atomicInputs) {
281
+ if (invalidIds.has(input.templateId)) {
282
+ denied.push({ templateId: input.templateId, reason: "invalid" });
283
+ continue;
284
+ }
285
+ if (rateLimitedIds.has(input.templateId)) {
286
+ denied.push({ templateId: input.templateId, reason: "rate_limited" });
287
+ continue;
288
+ }
289
+ if (!atomicRes.activated.includes(input.templateId)) continue;
290
+ activated.push({ templateId: input.templateId, state: "active", activeFromTouchOffset: args.activeFromTouchOffset });
291
+ const event = atomicLifecycleById.get(input.templateId);
292
+ if (event) lifecycle.push(event);
293
+ this.markSeen(stream, input.templateId, nowMs);
294
+ }
295
+ this.consumeActivationTokens(stream, nowMs, activationRateLimitPerMinute, atomicRes.activationTokensUsed);
296
+ for (const evicted of atomicRes.evicted) {
297
+ lifecycle.push({
298
+ type: "live.template_evicted",
299
+ ts: nowIso(nowMs),
300
+ stream,
301
+ templateId: evicted.templateId,
302
+ reason: evicted.reason,
303
+ cap: evicted.cap,
304
+ });
305
+ this.lastSeenMem.delete(this.key(stream, evicted.templateId));
306
+ this.dirtyLastSeen.delete(this.key(stream, evicted.templateId));
307
+ }
308
+ return { activated, denied, lifecycle };
309
+ }
310
+
311
+ heartbeat(stream: string, templateIdsUsed: string[], nowMs: number): void {
312
+ for (const id of templateIdsUsed) {
313
+ const templateId = typeof id === "string" ? id.trim() : "";
314
+ if (!/^[0-9a-f]{16}$/.test(templateId)) continue;
315
+ this.markSeen(stream, templateId, nowMs);
316
+ }
317
+ }
318
+
319
+ async flushLastSeen(nowMs: number, persistIntervalMs: number): Promise<void> {
320
+ if (this.dirtyLastSeen.size === 0) return;
321
+
322
+ const updates: Array<{ key: string; item: { lastSeenMs: number; lastPersistMs: number }; stream: string; templateId: string }> = [];
323
+ for (const k of this.dirtyLastSeen) {
324
+ const item = this.lastSeenMem.get(k);
325
+ if (!item) {
326
+ this.dirtyLastSeen.delete(k);
327
+ continue;
328
+ }
329
+ if (nowMs - item.lastPersistMs < persistIntervalMs) continue;
330
+ const [stream, templateId] = k.split("\n");
331
+ updates.push({ key: k, item, stream, templateId });
332
+ }
333
+ if (updates.length === 0) return;
334
+
335
+ await this.db.updateLiveTemplateLastSeenBatch(
336
+ updates.map((update) => ({
337
+ stream: update.stream,
338
+ templateId: update.templateId,
339
+ lastSeenAtMs: update.item.lastSeenMs,
340
+ }))
341
+ );
342
+ for (const update of updates) {
343
+ update.item.lastPersistMs = nowMs;
344
+ this.dirtyLastSeen.delete(update.key);
345
+ }
346
+ }
347
+
348
+ async gcRetireExpired(stream: string, nowMs: number): Promise<{ retired: TemplateLifecycleEvent[] }> {
349
+ const expired: any[] = [];
350
+ try {
351
+ const rows = await this.db.listExpiredLiveTemplates(stream, nowMs, 1000);
352
+ expired.push(...rows);
353
+ } catch {
354
+ return { retired: [] };
355
+ }
356
+ if (expired.length === 0) return { retired: [] };
357
+
358
+ // If a client is heartbeating frequently but last-seen persistence is
359
+ // configured with a longer interval than the inactivity TTL, DB state can
360
+ // look expired even though in-memory last-seen is fresh. Prefer in-memory
361
+ // last-seen and opportunistically refresh DB to avoid incorrect retirement.
362
+ const effectiveExpired: any[] = [];
363
+ const refreshes: Array<{ key: string; item: { lastSeenMs: number; lastPersistMs: number }; stream: string; templateId: string }> = [];
364
+ for (const row of expired) {
365
+ const templateId = String(row.template_id);
366
+ const dbLastSeenAtMs = Number(row.last_seen_at_ms);
367
+ const ttlMs = Number(row.inactivity_ttl_ms);
368
+ const mem = this.lastSeenMem.get(this.key(stream, templateId));
369
+ const memLastSeen = mem ? mem.lastSeenMs : 0;
370
+ const lastSeenAtMs = Math.max(dbLastSeenAtMs, memLastSeen);
371
+ if (lastSeenAtMs + ttlMs >= nowMs) {
372
+ // Not expired when considering in-memory last-seen. Refresh DB so it
373
+ // doesn't get re-selected on the next GC tick.
374
+ if (mem && memLastSeen > dbLastSeenAtMs) {
375
+ refreshes.push({ key: this.key(stream, templateId), item: mem, stream, templateId });
376
+ }
377
+ continue;
378
+ }
379
+ effectiveExpired.push(row);
380
+ }
381
+
382
+ if (refreshes.length > 0) {
383
+ await this.db.updateLiveTemplateLastSeenBatch(
384
+ refreshes.map((refresh) => ({
385
+ stream: refresh.stream,
386
+ templateId: refresh.templateId,
387
+ lastSeenAtMs: refresh.item.lastSeenMs,
388
+ }))
389
+ );
390
+ for (const refresh of refreshes) {
391
+ refresh.item.lastPersistMs = nowMs;
392
+ this.dirtyLastSeen.delete(refresh.key);
393
+ }
394
+ }
395
+
396
+ if (effectiveExpired.length === 0) return { retired: [] };
397
+
398
+ const retired: TemplateLifecycleEvent[] = [];
399
+ const retiredIds: string[] = [];
400
+ for (const row of effectiveExpired) {
401
+ const templateId = String(row.template_id);
402
+ const entity = String(row.entity);
403
+ let fields: string[] = [];
404
+ let encodings: TemplateEncoding[] = [];
405
+ try {
406
+ const f = JSON.parse(String(row.fields_json));
407
+ const e = JSON.parse(String(row.encodings_json));
408
+ if (Array.isArray(f)) fields = f.map(String);
409
+ if (Array.isArray(e)) encodings = e.map(String) as any;
410
+ } catch {
411
+ // ignore
412
+ }
413
+ const dbLastSeenAtMs = Number(row.last_seen_at_ms);
414
+ const mem = this.lastSeenMem.get(this.key(stream, templateId));
415
+ const memLastSeen = mem ? mem.lastSeenMs : 0;
416
+ const lastSeenAtMs = Math.max(dbLastSeenAtMs, memLastSeen);
417
+ const inactiveForMs = Math.max(0, nowMs - lastSeenAtMs);
418
+ retiredIds.push(templateId);
419
+ retired.push({
420
+ type: "live.template_retired",
421
+ ts: nowIso(nowMs),
422
+ stream,
423
+ templateId,
424
+ entity,
425
+ fields,
426
+ encodings,
427
+ lastSeenAt: nowIso(lastSeenAtMs),
428
+ inactiveForMs,
429
+ reason: "inactivity",
430
+ });
431
+ }
432
+
433
+ await this.db.retireLiveTemplatesForInactivity(stream, retiredIds, nowMs);
434
+ for (const templateId of retiredIds) {
435
+ this.lastSeenMem.delete(this.key(stream, templateId));
436
+ this.dirtyLastSeen.delete(this.key(stream, templateId));
437
+ }
438
+
439
+ return { retired };
440
+ }
441
+
442
+ private markSeen(stream: string, templateId: string, nowMs: number): void {
443
+ const k = this.key(stream, templateId);
444
+ const item = this.lastSeenMem.get(k) ?? { lastSeenMs: 0, lastPersistMs: 0 };
445
+ if (nowMs > item.lastSeenMs) item.lastSeenMs = nowMs;
446
+ this.lastSeenMem.set(k, item);
447
+ this.dirtyLastSeen.add(k);
448
+ }
449
+ }