@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
@@ -0,0 +1,619 @@
1
+ import type { SqliteDurableStore } from "../db/db";
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 LiveTemplateRow = {
19
+ stream: string;
20
+ template_id: string;
21
+ entity: string;
22
+ fields_json: string;
23
+ encodings_json: string;
24
+ state: string;
25
+ created_at_ms: bigint;
26
+ last_seen_at_ms: bigint;
27
+ inactivity_ttl_ms: bigint;
28
+ active_from_source_offset: bigint;
29
+ retired_at_ms: bigint | null;
30
+ retired_reason: string | null;
31
+ };
32
+
33
+ export type TemplateLifecycleEvent =
34
+ | {
35
+ type: "live.template_activated";
36
+ ts: string;
37
+ stream: string;
38
+ templateId: string;
39
+ entity: string;
40
+ fields: string[];
41
+ encodings: TemplateEncoding[];
42
+ reason: "declared" | "heartbeat";
43
+ activeFromTouchOffset: string;
44
+ inactivityTtlMs: number;
45
+ }
46
+ | {
47
+ type: "live.template_retired";
48
+ ts: string;
49
+ stream: string;
50
+ templateId: string;
51
+ entity: string;
52
+ fields: string[];
53
+ encodings: TemplateEncoding[];
54
+ lastSeenAt: string;
55
+ inactiveForMs: number;
56
+ reason: "inactivity";
57
+ }
58
+ | {
59
+ type: "live.template_evicted";
60
+ ts: string;
61
+ stream: string;
62
+ templateId: string;
63
+ reason: "cap_exceeded";
64
+ cap: number;
65
+ };
66
+
67
+ type RateState = { tokens: number; lastRefillMs: number };
68
+
69
+ function nowIso(ms: number): string {
70
+ return new Date(ms).toISOString();
71
+ }
72
+
73
+ function parseTemplateRow(row: any): LiveTemplateRow {
74
+ return {
75
+ stream: String(row.stream),
76
+ template_id: String(row.template_id),
77
+ entity: String(row.entity),
78
+ fields_json: String(row.fields_json),
79
+ encodings_json: String(row.encodings_json),
80
+ state: String(row.state),
81
+ created_at_ms: typeof row.created_at_ms === "bigint" ? row.created_at_ms : BigInt(row.created_at_ms),
82
+ last_seen_at_ms: typeof row.last_seen_at_ms === "bigint" ? row.last_seen_at_ms : BigInt(row.last_seen_at_ms),
83
+ inactivity_ttl_ms: typeof row.inactivity_ttl_ms === "bigint" ? row.inactivity_ttl_ms : BigInt(row.inactivity_ttl_ms),
84
+ active_from_source_offset:
85
+ typeof row.active_from_source_offset === "bigint" ? row.active_from_source_offset : BigInt(row.active_from_source_offset),
86
+ retired_at_ms: row.retired_at_ms == null ? null : typeof row.retired_at_ms === "bigint" ? row.retired_at_ms : BigInt(row.retired_at_ms),
87
+ retired_reason: row.retired_reason == null ? null : String(row.retired_reason),
88
+ };
89
+ }
90
+
91
+ export class LiveTemplateRegistry {
92
+ private readonly db: SqliteDurableStore;
93
+
94
+ // In-memory last-seen tracking to avoid sqlite writes on every wait call.
95
+ private readonly lastSeenMem = new Map<string, { lastSeenMs: number; lastPersistMs: number }>();
96
+ private readonly dirtyLastSeen = new Set<string>();
97
+
98
+ private readonly rate = new Map<string, RateState>();
99
+
100
+ constructor(db: SqliteDurableStore) {
101
+ this.db = db;
102
+ }
103
+
104
+ private key(stream: string, templateId: string): string {
105
+ return `${stream}\n${templateId}`;
106
+ }
107
+
108
+ private allowActivation(stream: string, nowMs: number, limitPerMinute: number): boolean {
109
+ if (limitPerMinute <= 0) return true;
110
+ const ratePerMs = limitPerMinute / 60_000;
111
+ const st = this.rate.get(stream) ?? { tokens: limitPerMinute, lastRefillMs: nowMs };
112
+ const elapsed = Math.max(0, nowMs - st.lastRefillMs);
113
+ st.tokens = Math.min(limitPerMinute, st.tokens + elapsed * ratePerMs);
114
+ st.lastRefillMs = nowMs;
115
+ if (st.tokens < 1) {
116
+ this.rate.set(stream, st);
117
+ return false;
118
+ }
119
+ st.tokens -= 1;
120
+ this.rate.set(stream, st);
121
+ return true;
122
+ }
123
+
124
+ getActiveTemplateCount(stream: string): number {
125
+ try {
126
+ const row = this.db.db
127
+ .query(`SELECT COUNT(*) as cnt FROM live_templates WHERE stream=? AND state='active';`)
128
+ .get(stream) as any;
129
+ return Number(row?.cnt ?? 0);
130
+ } catch {
131
+ return 0;
132
+ }
133
+ }
134
+
135
+ listActiveTemplates(stream: string): Array<{ templateId: string; entity: string; fields: string[]; encodings: TemplateEncoding[]; lastSeenAtMs: number }> {
136
+ try {
137
+ const rows = this.db.db
138
+ .query(
139
+ `SELECT template_id, entity, fields_json, encodings_json, last_seen_at_ms
140
+ FROM live_templates
141
+ WHERE stream=? AND state='active'
142
+ ORDER BY entity ASC, template_id ASC;`
143
+ )
144
+ .all(stream) as any[];
145
+ const out: Array<{ templateId: string; entity: string; fields: string[]; encodings: TemplateEncoding[]; lastSeenAtMs: number }> = [];
146
+ for (const row of rows) {
147
+ const templateId = String(row.template_id);
148
+ const entity = String(row.entity);
149
+ const fields = JSON.parse(String(row.fields_json));
150
+ const encodings = JSON.parse(String(row.encodings_json));
151
+ if (!Array.isArray(fields) || !Array.isArray(encodings) || fields.length !== encodings.length) continue;
152
+ const lastSeenAtMs = Number(row.last_seen_at_ms);
153
+ out.push({ templateId, entity, fields: fields.map(String), encodings: encodings.map(String) as any, lastSeenAtMs });
154
+ }
155
+ return out;
156
+ } catch {
157
+ return [];
158
+ }
159
+ }
160
+
161
+ /**
162
+ * Activate templates (idempotent). Returns lifecycle events to be emitted.
163
+ *
164
+ * `baseStreamNextOffset` is used to set `active_from_source_offset` so we do
165
+ * not backfill fine touches for history when a template is activated while
166
+ * the interpreter is behind.
167
+ */
168
+ activate(args: {
169
+ stream: string;
170
+ activeFromTouchOffset: string;
171
+ baseStreamNextOffset: bigint;
172
+ templates: TemplateDecl[];
173
+ inactivityTtlMs: number;
174
+ limits: {
175
+ maxActiveTemplatesPerStream: number;
176
+ maxActiveTemplatesPerEntity: number;
177
+ activationRateLimitPerMinute: number;
178
+ };
179
+ nowMs: number;
180
+ }): { activated: ActivatedTemplate[]; denied: DeniedTemplate[]; lifecycle: TemplateLifecycleEvent[] } {
181
+ const { stream, templates, inactivityTtlMs, nowMs } = args;
182
+ const { maxActiveTemplatesPerStream, maxActiveTemplatesPerEntity, activationRateLimitPerMinute } = args.limits;
183
+
184
+ const activated: ActivatedTemplate[] = [];
185
+ const denied: DeniedTemplate[] = [];
186
+ const lifecycle: TemplateLifecycleEvent[] = [];
187
+
188
+ const protectedIds = new Set<string>();
189
+
190
+ for (const t of templates) {
191
+ const entity = typeof t?.entity === "string" ? t.entity.trim() : "";
192
+ if (entity === "") {
193
+ denied.push({ templateId: "0000000000000000", reason: "invalid" });
194
+ continue;
195
+ }
196
+ if (!Array.isArray(t.fields) || t.fields.length === 0 || t.fields.length > 3) {
197
+ denied.push({ templateId: "0000000000000000", reason: "invalid" });
198
+ continue;
199
+ }
200
+
201
+ const rawFields: Array<{ name: string; encoding: TemplateEncoding }> = [];
202
+ for (const f of t.fields) {
203
+ const name = typeof (f as any)?.name === "string" ? String((f as any).name).trim() : "";
204
+ const encoding = (f as any)?.encoding as TemplateEncoding;
205
+ if (name === "") continue;
206
+ if (encoding !== "string" && encoding !== "int64" && encoding !== "bool" && encoding !== "datetime" && encoding !== "bytes") continue;
207
+ rawFields.push({ name, encoding });
208
+ }
209
+ if (rawFields.length !== t.fields.length) {
210
+ denied.push({ templateId: "0000000000000000", reason: "invalid" });
211
+ continue;
212
+ }
213
+ {
214
+ const seen = new Set<string>();
215
+ let ok = true;
216
+ for (const f of rawFields) {
217
+ if (seen.has(f.name)) ok = false;
218
+ seen.add(f.name);
219
+ }
220
+ if (!ok) {
221
+ denied.push({ templateId: "0000000000000000", reason: "invalid" });
222
+ continue;
223
+ }
224
+ }
225
+
226
+ const fields = canonicalizeTemplateFields(rawFields);
227
+ const fieldNames = fields.map((f) => f.name);
228
+ const encodings = fields.map((f) => f.encoding);
229
+
230
+ const templateId = templateIdFor(entity, fieldNames);
231
+
232
+ const existing = this.db.db
233
+ .query(
234
+ `SELECT stream, template_id, entity, fields_json, encodings_json, state, created_at_ms, last_seen_at_ms,
235
+ inactivity_ttl_ms, active_from_source_offset, retired_at_ms, retired_reason
236
+ FROM live_templates
237
+ WHERE stream=? AND template_id=? LIMIT 1;`
238
+ )
239
+ .get(stream, templateId) as any;
240
+
241
+ const alreadyActive = existing && String(existing.state) === "active";
242
+ const needsToken = !alreadyActive;
243
+
244
+ if (needsToken && !this.allowActivation(stream, nowMs, activationRateLimitPerMinute)) {
245
+ denied.push({ templateId, reason: "rate_limited" });
246
+ continue;
247
+ }
248
+
249
+ if (existing) {
250
+ const row = parseTemplateRow(existing);
251
+ if (row.entity !== entity) {
252
+ denied.push({ templateId, reason: "invalid" });
253
+ continue;
254
+ }
255
+ let storedFields: any;
256
+ let storedEnc: any;
257
+ try {
258
+ storedFields = JSON.parse(row.fields_json);
259
+ storedEnc = JSON.parse(row.encodings_json);
260
+ } catch {
261
+ denied.push({ templateId, reason: "invalid" });
262
+ continue;
263
+ }
264
+ if (!Array.isArray(storedFields) || !Array.isArray(storedEnc) || storedFields.length !== storedEnc.length) {
265
+ denied.push({ templateId, reason: "invalid" });
266
+ continue;
267
+ }
268
+ const sf = storedFields.map(String);
269
+ const se = storedEnc.map(String);
270
+ if (sf.join("\0") !== fieldNames.join("\0")) {
271
+ denied.push({ templateId, reason: "invalid" });
272
+ continue;
273
+ }
274
+ if (se.join("\0") !== encodings.join("\0")) {
275
+ denied.push({ templateId, reason: "invalid" });
276
+ continue;
277
+ }
278
+
279
+ if (row.state === "active") {
280
+ this.db.db
281
+ .query(
282
+ `UPDATE live_templates
283
+ SET last_seen_at_ms=?, inactivity_ttl_ms=?
284
+ WHERE stream=? AND template_id=?;`
285
+ )
286
+ .run(nowMs, inactivityTtlMs, stream, templateId);
287
+ } else {
288
+ this.db.db
289
+ .query(
290
+ `UPDATE live_templates
291
+ SET state='active',
292
+ last_seen_at_ms=?,
293
+ inactivity_ttl_ms=?,
294
+ active_from_source_offset=?,
295
+ retired_at_ms=NULL,
296
+ retired_reason=NULL
297
+ WHERE stream=? AND template_id=?;`
298
+ )
299
+ .run(nowMs, inactivityTtlMs, args.baseStreamNextOffset, stream, templateId);
300
+ }
301
+ } else {
302
+ this.db.db
303
+ .query(
304
+ `INSERT INTO live_templates(
305
+ stream, template_id, entity, fields_json, encodings_json,
306
+ state, created_at_ms, last_seen_at_ms, inactivity_ttl_ms, active_from_source_offset,
307
+ retired_at_ms, retired_reason
308
+ ) VALUES(?, ?, ?, ?, ?, 'active', ?, ?, ?, ?, NULL, NULL);`
309
+ )
310
+ .run(stream, templateId, entity, JSON.stringify(fieldNames), JSON.stringify(encodings), nowMs, nowMs, inactivityTtlMs, args.baseStreamNextOffset);
311
+ }
312
+
313
+ protectedIds.add(templateId);
314
+ activated.push({ templateId, state: "active", activeFromTouchOffset: args.activeFromTouchOffset });
315
+ lifecycle.push({
316
+ type: "live.template_activated",
317
+ ts: nowIso(nowMs),
318
+ stream,
319
+ templateId,
320
+ entity,
321
+ fields: fieldNames,
322
+ encodings,
323
+ reason: "declared",
324
+ activeFromTouchOffset: args.activeFromTouchOffset,
325
+ inactivityTtlMs,
326
+ });
327
+ this.markSeen(stream, templateId, nowMs);
328
+ }
329
+
330
+ // Enforce caps with LRU eviction.
331
+ const evicted = this.evictToCaps(stream, nowMs, { maxActiveTemplatesPerStream, maxActiveTemplatesPerEntity }, protectedIds);
332
+ for (const e of evicted) lifecycle.push(e);
333
+
334
+ return { activated, denied, lifecycle };
335
+ }
336
+
337
+ heartbeat(stream: string, templateIdsUsed: string[], nowMs: number): void {
338
+ for (const id of templateIdsUsed) {
339
+ const templateId = typeof id === "string" ? id.trim() : "";
340
+ if (!/^[0-9a-f]{16}$/.test(templateId)) continue;
341
+ this.markSeen(stream, templateId, nowMs);
342
+ }
343
+ }
344
+
345
+ flushLastSeen(nowMs: number, persistIntervalMs: number): void {
346
+ if (this.dirtyLastSeen.size === 0) return;
347
+
348
+ const stmt = this.db.db.query(
349
+ `UPDATE live_templates
350
+ SET last_seen_at_ms=?
351
+ WHERE stream=? AND template_id=? AND state='active';`
352
+ );
353
+ try {
354
+ for (const k of this.dirtyLastSeen) {
355
+ const item = this.lastSeenMem.get(k);
356
+ if (!item) {
357
+ this.dirtyLastSeen.delete(k);
358
+ continue;
359
+ }
360
+ if (nowMs - item.lastPersistMs < persistIntervalMs) continue;
361
+ const [stream, templateId] = k.split("\n");
362
+ stmt.run(item.lastSeenMs, stream, templateId);
363
+ item.lastPersistMs = nowMs;
364
+ this.dirtyLastSeen.delete(k);
365
+ }
366
+ } finally {
367
+ try {
368
+ stmt.finalize?.();
369
+ } catch {
370
+ // ignore
371
+ }
372
+ }
373
+ }
374
+
375
+ gcRetireExpired(stream: string, nowMs: number): { retired: TemplateLifecycleEvent[] } {
376
+ const expired: any[] = [];
377
+ try {
378
+ const rows = this.db.db
379
+ .query(
380
+ `SELECT template_id, entity, fields_json, encodings_json, last_seen_at_ms, inactivity_ttl_ms
381
+ FROM live_templates
382
+ WHERE stream=? AND state='active' AND (last_seen_at_ms + inactivity_ttl_ms) < ?
383
+ ORDER BY last_seen_at_ms ASC
384
+ LIMIT 1000;`
385
+ )
386
+ .all(stream, nowMs) as any[];
387
+ expired.push(...rows);
388
+ } catch {
389
+ return { retired: [] };
390
+ }
391
+ if (expired.length === 0) return { retired: [] };
392
+
393
+ // If a client is heartbeating frequently but last-seen persistence is
394
+ // configured with a longer interval than the inactivity TTL, DB state can
395
+ // look expired even though in-memory last-seen is fresh. Prefer in-memory
396
+ // last-seen and opportunistically refresh DB to avoid incorrect retirement.
397
+ const effectiveExpired: any[] = [];
398
+ const refreshLastSeen = this.db.db.query(
399
+ `UPDATE live_templates
400
+ SET last_seen_at_ms=?
401
+ WHERE stream=? AND template_id=? AND state='active';`
402
+ );
403
+ try {
404
+ for (const row of expired) {
405
+ const templateId = String(row.template_id);
406
+ const dbLastSeenAtMs = Number(row.last_seen_at_ms);
407
+ const ttlMs = Number(row.inactivity_ttl_ms);
408
+ const mem = this.lastSeenMem.get(this.key(stream, templateId));
409
+ const memLastSeen = mem ? mem.lastSeenMs : 0;
410
+ const lastSeenAtMs = Math.max(dbLastSeenAtMs, memLastSeen);
411
+ if (lastSeenAtMs + ttlMs >= nowMs) {
412
+ // Not expired when considering in-memory last-seen. Refresh DB so it
413
+ // doesn't get re-selected on the next GC tick.
414
+ if (mem && memLastSeen > dbLastSeenAtMs) {
415
+ refreshLastSeen.run(memLastSeen, stream, templateId);
416
+ mem.lastPersistMs = nowMs;
417
+ this.dirtyLastSeen.delete(this.key(stream, templateId));
418
+ }
419
+ continue;
420
+ }
421
+ effectiveExpired.push(row);
422
+ }
423
+ } finally {
424
+ try {
425
+ refreshLastSeen.finalize?.();
426
+ } catch {
427
+ // ignore
428
+ }
429
+ }
430
+
431
+ if (effectiveExpired.length === 0) return { retired: [] };
432
+
433
+ const retired: TemplateLifecycleEvent[] = [];
434
+ const update = this.db.db.query(
435
+ `UPDATE live_templates
436
+ SET state='retired', retired_reason='inactivity', retired_at_ms=?
437
+ WHERE stream=? AND template_id=? AND state='active';`
438
+ );
439
+ try {
440
+ for (const row of effectiveExpired) {
441
+ const templateId = String(row.template_id);
442
+ const entity = String(row.entity);
443
+ let fields: string[] = [];
444
+ let encodings: TemplateEncoding[] = [];
445
+ try {
446
+ const f = JSON.parse(String(row.fields_json));
447
+ const e = JSON.parse(String(row.encodings_json));
448
+ if (Array.isArray(f)) fields = f.map(String);
449
+ if (Array.isArray(e)) encodings = e.map(String) as any;
450
+ } catch {
451
+ // ignore
452
+ }
453
+ const dbLastSeenAtMs = Number(row.last_seen_at_ms);
454
+ const mem = this.lastSeenMem.get(this.key(stream, templateId));
455
+ const memLastSeen = mem ? mem.lastSeenMs : 0;
456
+ const lastSeenAtMs = Math.max(dbLastSeenAtMs, memLastSeen);
457
+ const inactiveForMs = Math.max(0, nowMs - lastSeenAtMs);
458
+ update.run(nowMs, stream, templateId);
459
+ retired.push({
460
+ type: "live.template_retired",
461
+ ts: nowIso(nowMs),
462
+ stream,
463
+ templateId,
464
+ entity,
465
+ fields,
466
+ encodings,
467
+ lastSeenAt: nowIso(lastSeenAtMs),
468
+ inactiveForMs,
469
+ reason: "inactivity",
470
+ });
471
+ this.lastSeenMem.delete(this.key(stream, templateId));
472
+ this.dirtyLastSeen.delete(this.key(stream, templateId));
473
+ }
474
+ } finally {
475
+ try {
476
+ update.finalize?.();
477
+ } catch {
478
+ // ignore
479
+ }
480
+ }
481
+
482
+ return { retired };
483
+ }
484
+
485
+ private markSeen(stream: string, templateId: string, nowMs: number): void {
486
+ const k = this.key(stream, templateId);
487
+ const item = this.lastSeenMem.get(k) ?? { lastSeenMs: 0, lastPersistMs: 0 };
488
+ if (nowMs > item.lastSeenMs) item.lastSeenMs = nowMs;
489
+ this.lastSeenMem.set(k, item);
490
+ this.dirtyLastSeen.add(k);
491
+ }
492
+
493
+ private evictToCaps(
494
+ stream: string,
495
+ nowMs: number,
496
+ caps: { maxActiveTemplatesPerStream: number; maxActiveTemplatesPerEntity: number },
497
+ protectedIds: Set<string>
498
+ ): TemplateLifecycleEvent[] {
499
+ const out: TemplateLifecycleEvent[] = [];
500
+ const { maxActiveTemplatesPerStream, maxActiveTemplatesPerEntity } = caps;
501
+
502
+ // Per-entity cap.
503
+ let entities: Array<{ entity: string; cnt: number }> = [];
504
+ try {
505
+ const rows = this.db.db
506
+ .query(
507
+ `SELECT entity, COUNT(*) as cnt
508
+ FROM live_templates
509
+ WHERE stream=? AND state='active'
510
+ GROUP BY entity;`
511
+ )
512
+ .all(stream) as any[];
513
+ entities = rows.map((r) => ({ entity: String(r.entity), cnt: Number(r.cnt) }));
514
+ } catch {
515
+ // ignore
516
+ }
517
+
518
+ for (const e of entities) {
519
+ if (e.cnt <= maxActiveTemplatesPerEntity) continue;
520
+ const extra = e.cnt - maxActiveTemplatesPerEntity;
521
+ const evicted = this.evictLru(stream, nowMs, extra, { entity: e.entity, cap: maxActiveTemplatesPerEntity }, protectedIds);
522
+ out.push(...evicted);
523
+ }
524
+
525
+ // Per-stream cap.
526
+ let activeCount = 0;
527
+ try {
528
+ const row = this.db.db.query(`SELECT COUNT(*) as cnt FROM live_templates WHERE stream=? AND state='active';`).get(stream) as any;
529
+ activeCount = Number(row?.cnt ?? 0);
530
+ } catch {
531
+ activeCount = 0;
532
+ }
533
+ if (activeCount > maxActiveTemplatesPerStream) {
534
+ const extra = activeCount - maxActiveTemplatesPerStream;
535
+ const evicted = this.evictLru(stream, nowMs, extra, { cap: maxActiveTemplatesPerStream }, protectedIds);
536
+ out.push(...evicted);
537
+ }
538
+
539
+ return out;
540
+ }
541
+
542
+ private evictLru(
543
+ stream: string,
544
+ nowMs: number,
545
+ count: number,
546
+ scope: { entity?: string; cap: number },
547
+ protectedIds: Set<string>
548
+ ): TemplateLifecycleEvent[] {
549
+ if (count <= 0) return [];
550
+ const out: TemplateLifecycleEvent[] = [];
551
+
552
+ const pick = (excludeProtected: boolean): string[] => {
553
+ const params: any[] = [stream];
554
+ let where = `stream=? AND state='active'`;
555
+ if (scope.entity) {
556
+ where += ` AND entity=?`;
557
+ params.push(scope.entity);
558
+ }
559
+ if (excludeProtected && protectedIds.size > 0) {
560
+ const placeholders = Array.from(protectedIds).map(() => "?").join(", ");
561
+ where += ` AND template_id NOT IN (${placeholders})`;
562
+ params.push(...Array.from(protectedIds));
563
+ }
564
+ const q = `SELECT template_id FROM live_templates WHERE ${where} ORDER BY last_seen_at_ms ASC, template_id ASC LIMIT ?;`;
565
+ params.push(count);
566
+ try {
567
+ const rows = this.db.db.query(q).all(...params) as any[];
568
+ return rows.map((r) => String(r.template_id));
569
+ } catch {
570
+ return [];
571
+ }
572
+ };
573
+
574
+ let ids = pick(true);
575
+ if (ids.length < count) {
576
+ // Evict protected templates only if we have to.
577
+ const extra = pick(false);
578
+ const merged: string[] = [];
579
+ const seen = new Set<string>();
580
+ for (const id of [...ids, ...extra]) {
581
+ if (seen.has(id)) continue;
582
+ seen.add(id);
583
+ merged.push(id);
584
+ if (merged.length >= count) break;
585
+ }
586
+ ids = merged;
587
+ }
588
+ if (ids.length === 0) return [];
589
+
590
+ const update = this.db.db.query(
591
+ `UPDATE live_templates
592
+ SET state='retired', retired_reason='cap_exceeded', retired_at_ms=?
593
+ WHERE stream=? AND template_id=? AND state='active';`
594
+ );
595
+ try {
596
+ for (const id of ids) {
597
+ update.run(nowMs, stream, id);
598
+ out.push({
599
+ type: "live.template_evicted",
600
+ ts: nowIso(nowMs),
601
+ stream,
602
+ templateId: id,
603
+ reason: "cap_exceeded",
604
+ cap: scope.cap,
605
+ });
606
+ this.lastSeenMem.delete(this.key(stream, id));
607
+ this.dirtyLastSeen.delete(this.key(stream, id));
608
+ }
609
+ } finally {
610
+ try {
611
+ update.finalize?.();
612
+ } catch {
613
+ // ignore
614
+ }
615
+ }
616
+
617
+ return out;
618
+ }
619
+ }