@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,591 @@
1
+ import type { Pool, PoolClient } from "pg";
2
+ import type { StreamRow } from "../store/rows";
3
+ import type {
4
+ LiveTemplateActivationInput,
5
+ LiveTemplateActivationResult,
6
+ LiveTemplateIdentityRow,
7
+ LiveTemplateLastSeenUpdate,
8
+ LiveTemplateStoreRow,
9
+ StreamTouchStateRow,
10
+ TouchProcessorStore,
11
+ } from "../store/touch_store";
12
+ import type { WalReadRow } from "../store/wal_store";
13
+ import type { PgExecutor } from "./types";
14
+
15
+ type PostgresTouchStoreDelegates = {
16
+ nowMs(): bigint;
17
+ getStream(stream: string): Promise<StreamRow | null>;
18
+ ensureStream(stream: string, opts?: { contentType?: string | null; streamFlags?: number }): Promise<StreamRow>;
19
+ isDeleted(row: StreamRow): boolean;
20
+ readWalRange(stream: string, startOffset: bigint, endOffset: bigint, routingKey?: Uint8Array): AsyncIterable<WalReadRow>;
21
+ };
22
+
23
+ type LiveTemplatePgRow = {
24
+ stream: string;
25
+ template_id: string;
26
+ entity: string;
27
+ fields_json: string;
28
+ encodings_json: string;
29
+ state: string;
30
+ created_at_ms: string | number | bigint;
31
+ last_seen_at_ms: string | number | bigint;
32
+ inactivity_ttl_ms: string | number | bigint;
33
+ active_from_source_offset: string | number | bigint;
34
+ retired_at_ms: string | number | bigint | null;
35
+ retired_reason: string | null;
36
+ };
37
+
38
+ function pgInt(value: bigint): string {
39
+ return value.toString();
40
+ }
41
+
42
+ function toBigInt(value: unknown): bigint {
43
+ return typeof value === "bigint" ? value : BigInt(value as any);
44
+ }
45
+
46
+ function clampNumber(value: bigint): number {
47
+ return value > BigInt(Number.MAX_SAFE_INTEGER) ? Number.MAX_SAFE_INTEGER : Number(value);
48
+ }
49
+
50
+ export class PostgresTouchStore implements TouchProcessorStore {
51
+ constructor(
52
+ private readonly pool: Pool,
53
+ private readonly delegates: PostgresTouchStoreDelegates
54
+ ) {}
55
+
56
+ nowMs(): bigint {
57
+ return this.delegates.nowMs();
58
+ }
59
+
60
+ getStream(stream: string): Promise<StreamRow | null> {
61
+ return this.delegates.getStream(stream);
62
+ }
63
+
64
+ async ensureStream(stream: string, opts?: { contentType?: string; streamFlags?: number }): Promise<StreamRow> {
65
+ const row = await this.delegates.ensureStream(stream, { contentType: opts?.contentType ?? null, streamFlags: opts?.streamFlags });
66
+ if (opts?.streamFlags && opts.streamFlags > 0) await this.addStreamFlags(stream, opts.streamFlags);
67
+ return (await this.getStream(stream)) ?? row;
68
+ }
69
+
70
+ async addStreamFlags(stream: string, flags: number): Promise<void> {
71
+ if (!Number.isFinite(flags) || flags <= 0) return;
72
+ await this.pool.query(
73
+ `UPDATE streams
74
+ SET stream_flags = (stream_flags | $1), updated_at_ms = $2
75
+ WHERE stream = $3;`,
76
+ [Math.floor(flags), pgInt(this.nowMs()), stream]
77
+ );
78
+ }
79
+
80
+ isDeleted(row: StreamRow): boolean {
81
+ return this.delegates.isDeleted(row);
82
+ }
83
+
84
+ readWalRange(stream: string, startOffset: bigint, endOffset: bigint, routingKey?: Uint8Array): AsyncIterable<WalReadRow> {
85
+ return this.delegates.readWalRange(stream, startOffset, endOffset, routingKey);
86
+ }
87
+
88
+ async activateLiveTemplates(args: {
89
+ stream: string;
90
+ templates: LiveTemplateActivationInput[];
91
+ maxActiveTemplatesPerStream: number;
92
+ maxActiveTemplatesPerEntity: number;
93
+ maxActivationTokens: number;
94
+ }): Promise<LiveTemplateActivationResult> {
95
+ if (args.templates.length === 0) return { activated: [], invalid: [], rateLimited: [], activationTokensUsed: 0, evicted: [] };
96
+ const client = await this.pool.connect();
97
+ try {
98
+ await client.query("BEGIN");
99
+ await client.query(`SELECT stream FROM streams WHERE stream = $1 FOR UPDATE;`, [args.stream]);
100
+ const activated: string[] = [];
101
+ const invalid: string[] = [];
102
+ const rateLimited: string[] = [];
103
+ let activationTokensUsed = 0;
104
+
105
+ const templateIds = args.templates.map((template) => template.templateId);
106
+ const entities = args.templates.map((template) => template.entity);
107
+ const fieldsJson = args.templates.map((template) => template.fieldsJson);
108
+ const encodingsJson = args.templates.map((template) => template.encodingsJson);
109
+ const nowMs = args.templates.map((template) => template.nowMs);
110
+ const ttlMs = args.templates.map((template) => template.inactivityTtlMs);
111
+ const activeFrom = args.templates.map((template) => pgInt(template.activeFromSourceOffset));
112
+
113
+ await client.query(
114
+ `WITH input AS (
115
+ SELECT *
116
+ FROM unnest($2::text[]) WITH ORDINALITY AS i(template_id, ord)
117
+ )
118
+ SELECT t.template_id
119
+ FROM live_templates AS t
120
+ JOIN input AS i ON i.template_id = t.template_id
121
+ WHERE t.stream = $1
122
+ FOR UPDATE OF t;`,
123
+ [args.stream, templateIds]
124
+ );
125
+
126
+ const classified = await client.query<{
127
+ ord: string | number | bigint;
128
+ template_id: string;
129
+ entity: string;
130
+ fields_json: string;
131
+ encodings_json: string;
132
+ now_ms: string | number | bigint;
133
+ inactivity_ttl_ms: string | number | bigint;
134
+ active_from_source_offset: string | number | bigint;
135
+ existing_entity: string | null;
136
+ existing_fields_json: string | null;
137
+ existing_encodings_json: string | null;
138
+ existing_state: string | null;
139
+ }>(
140
+ `WITH input AS (
141
+ SELECT *
142
+ FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::bigint[], $7::bigint[], $8::bigint[])
143
+ WITH ORDINALITY AS i(template_id, entity, fields_json, encodings_json, now_ms, inactivity_ttl_ms, active_from_source_offset, ord)
144
+ )
145
+ SELECT
146
+ i.ord,
147
+ i.template_id,
148
+ i.entity,
149
+ i.fields_json,
150
+ i.encodings_json,
151
+ i.now_ms,
152
+ i.inactivity_ttl_ms,
153
+ i.active_from_source_offset,
154
+ t.entity AS existing_entity,
155
+ t.fields_json AS existing_fields_json,
156
+ t.encodings_json AS existing_encodings_json,
157
+ t.state AS existing_state
158
+ FROM input AS i
159
+ LEFT JOIN live_templates AS t ON t.stream = $1 AND t.template_id = i.template_id
160
+ ORDER BY i.ord ASC;`,
161
+ [args.stream, templateIds, entities, fieldsJson, encodingsJson, nowMs, ttlMs, activeFrom]
162
+ );
163
+
164
+ const accepted: typeof classified.rows = [];
165
+ for (const row of classified.rows) {
166
+ const hasExisting = row.existing_state != null;
167
+ if (
168
+ hasExisting &&
169
+ (row.existing_entity !== row.entity || row.existing_fields_json !== row.fields_json || row.existing_encodings_json !== row.encodings_json)
170
+ ) {
171
+ invalid.push(row.template_id);
172
+ continue;
173
+ }
174
+ const alreadyActive = row.existing_state === "active";
175
+ if (!alreadyActive && activationTokensUsed >= args.maxActivationTokens) {
176
+ rateLimited.push(row.template_id);
177
+ continue;
178
+ }
179
+ if (!alreadyActive) activationTokensUsed += 1;
180
+ accepted.push(row);
181
+ activated.push(row.template_id);
182
+ }
183
+
184
+ if (accepted.length > 0) {
185
+ await client.query(
186
+ `INSERT INTO live_templates(
187
+ stream, template_id, entity, fields_json, encodings_json,
188
+ state, created_at_ms, last_seen_at_ms, inactivity_ttl_ms, active_from_source_offset,
189
+ retired_at_ms, retired_reason
190
+ )
191
+ SELECT $1, u.template_id, u.entity, u.fields_json, u.encodings_json,
192
+ 'active', u.now_ms, u.now_ms, u.inactivity_ttl_ms, u.active_from_source_offset,
193
+ NULL, NULL
194
+ FROM unnest($2::text[], $3::text[], $4::text[], $5::text[], $6::bigint[], $7::bigint[], $8::bigint[])
195
+ AS u(template_id, entity, fields_json, encodings_json, now_ms, inactivity_ttl_ms, active_from_source_offset)
196
+ ON CONFLICT(stream, template_id) DO UPDATE SET
197
+ state = 'active',
198
+ last_seen_at_ms = excluded.last_seen_at_ms,
199
+ inactivity_ttl_ms = excluded.inactivity_ttl_ms,
200
+ active_from_source_offset = CASE
201
+ WHEN live_templates.state = 'active' THEN live_templates.active_from_source_offset
202
+ ELSE excluded.active_from_source_offset
203
+ END,
204
+ retired_at_ms = NULL,
205
+ retired_reason = NULL;`,
206
+ [
207
+ args.stream,
208
+ accepted.map((row) => row.template_id),
209
+ accepted.map((row) => row.entity),
210
+ accepted.map((row) => row.fields_json),
211
+ accepted.map((row) => row.encodings_json),
212
+ accepted.map((row) => toBigInt(row.now_ms).toString()),
213
+ accepted.map((row) => toBigInt(row.inactivity_ttl_ms).toString()),
214
+ accepted.map((row) => toBigInt(row.active_from_source_offset).toString()),
215
+ ]
216
+ );
217
+ }
218
+ const evicted = await this.evictToCapsInTransaction(client, {
219
+ stream: args.stream,
220
+ protectedIds: new Set(activated),
221
+ maxActiveTemplatesPerStream: args.maxActiveTemplatesPerStream,
222
+ maxActiveTemplatesPerEntity: args.maxActiveTemplatesPerEntity,
223
+ nowMs: args.templates.reduce((max, template) => Math.max(max, template.nowMs), 0),
224
+ });
225
+ await client.query("COMMIT");
226
+ return { activated, invalid, rateLimited, activationTokensUsed, evicted };
227
+ } catch (error) {
228
+ await client.query("ROLLBACK").catch(() => {});
229
+ throw error;
230
+ } finally {
231
+ client.release();
232
+ }
233
+ }
234
+
235
+ async ensureStreamTouchState(stream: string): Promise<void> {
236
+ const srow = await this.getStream(stream);
237
+ const initialThrough = srow ? srow.next_offset - 1n : -1n;
238
+ await ensurePostgresStreamTouchState(this.pool, this.nowMs(), stream, initialThrough);
239
+ }
240
+
241
+ async deleteStreamTouchState(stream: string): Promise<void> {
242
+ await deletePostgresStreamTouchState(this.pool, stream);
243
+ }
244
+
245
+ async getStreamTouchState(stream: string): Promise<StreamTouchStateRow | null> {
246
+ const res = await this.pool.query<{ stream: string; processed_through: string | number | bigint; updated_at_ms: string | number | bigint }>(
247
+ `SELECT stream, processed_through, updated_at_ms
248
+ FROM stream_touch_state
249
+ WHERE stream = $1
250
+ LIMIT 1;`,
251
+ [stream]
252
+ );
253
+ const row = res.rows[0];
254
+ return row
255
+ ? {
256
+ stream: row.stream,
257
+ processed_through: toBigInt(row.processed_through),
258
+ updated_at_ms: toBigInt(row.updated_at_ms),
259
+ }
260
+ : null;
261
+ }
262
+
263
+ async listStreamTouchStates(): Promise<StreamTouchStateRow[]> {
264
+ const res = await this.pool.query<{ stream: string; processed_through: string | number | bigint; updated_at_ms: string | number | bigint }>(
265
+ `SELECT stream, processed_through, updated_at_ms
266
+ FROM stream_touch_state
267
+ ORDER BY stream ASC;`
268
+ );
269
+ return res.rows.map((row) => ({
270
+ stream: row.stream,
271
+ processed_through: toBigInt(row.processed_through),
272
+ updated_at_ms: toBigInt(row.updated_at_ms),
273
+ }));
274
+ }
275
+
276
+ async listStreamsByProfile(kind: string): Promise<string[]> {
277
+ const res = await this.pool.query<{ stream: string }>(
278
+ `SELECT stream FROM streams
279
+ WHERE profile = $1
280
+ AND (stream_flags & 1) = 0
281
+ ORDER BY stream ASC;`,
282
+ [kind]
283
+ );
284
+ return res.rows.map((row) => row.stream);
285
+ }
286
+
287
+ async updateStreamTouchStateThrough(stream: string, processedThrough: bigint): Promise<void> {
288
+ await this.pool.query(
289
+ `INSERT INTO stream_touch_state(stream, processed_through, updated_at_ms)
290
+ VALUES($1, $2, $3)
291
+ ON CONFLICT(stream) DO UPDATE SET
292
+ processed_through = excluded.processed_through,
293
+ updated_at_ms = excluded.updated_at_ms;`,
294
+ [stream, pgInt(processedThrough), pgInt(this.nowMs())]
295
+ );
296
+ }
297
+
298
+ async deleteWalThrough(stream: string, uploadedThrough: bigint): Promise<{ deletedRows: number; deletedBytes: number }> {
299
+ const res = await this.pool.query<{ deleted_rows: string | number | bigint; deleted_bytes: string | number | bigint }>(
300
+ `WITH deleted AS (
301
+ DELETE FROM wal
302
+ WHERE stream = $1 AND "offset" <= $2
303
+ RETURNING payload_len
304
+ ),
305
+ totals AS (
306
+ SELECT COUNT(*)::bigint AS deleted_rows, COALESCE(SUM(payload_len), 0)::bigint AS deleted_bytes FROM deleted
307
+ ),
308
+ updated AS (
309
+ UPDATE streams
310
+ SET wal_rows = GREATEST(0, wal_rows - totals.deleted_rows),
311
+ wal_bytes = GREATEST(0, wal_bytes - totals.deleted_bytes),
312
+ updated_at_ms = $3
313
+ FROM totals
314
+ WHERE stream = $1
315
+ RETURNING totals.deleted_rows, totals.deleted_bytes
316
+ )
317
+ SELECT deleted_rows, deleted_bytes FROM updated
318
+ UNION ALL
319
+ SELECT deleted_rows, deleted_bytes FROM totals
320
+ WHERE NOT EXISTS (SELECT 1 FROM updated)
321
+ LIMIT 1;`,
322
+ [stream, pgInt(uploadedThrough), pgInt(this.nowMs())]
323
+ );
324
+ const row = res.rows[0];
325
+ const deletedRows = row ? toBigInt(row.deleted_rows) : 0n;
326
+ const deletedBytes = row ? toBigInt(row.deleted_bytes) : 0n;
327
+ return { deletedRows: clampNumber(deletedRows), deletedBytes: clampNumber(deletedBytes) };
328
+ }
329
+
330
+ async getWalOldestOffset(stream: string): Promise<bigint | null> {
331
+ const res = await this.pool.query<{ min_off: string | number | bigint | null }>(
332
+ `SELECT MIN("offset") AS min_off FROM wal WHERE stream = $1;`,
333
+ [stream]
334
+ );
335
+ const value = res.rows[0]?.min_off;
336
+ return value == null ? null : toBigInt(value);
337
+ }
338
+
339
+ async trimWalByAge(stream: string, maxAgeMs: number): Promise<{ trimmedRows: number; trimmedBytes: number; keptFromOffset: bigint | null }> {
340
+ const ageMs = Math.max(0, Math.floor(maxAgeMs));
341
+ if (!Number.isFinite(ageMs)) return { trimmedRows: 0, trimmedBytes: 0, keptFromOffset: null };
342
+
343
+ const lastRes = await this.pool.query<{ offset: string | number | bigint }>(
344
+ `SELECT "offset" FROM wal WHERE stream = $1 ORDER BY "offset" DESC LIMIT 1;`,
345
+ [stream]
346
+ );
347
+ const lastOffsetRaw = lastRes.rows[0]?.offset;
348
+ if (lastOffsetRaw == null) return { trimmedRows: 0, trimmedBytes: 0, keptFromOffset: null };
349
+ const lastOffset = toBigInt(lastOffsetRaw);
350
+
351
+ let keepFromOffset: bigint;
352
+ if (ageMs === 0) {
353
+ keepFromOffset = lastOffset;
354
+ } else {
355
+ const cutoff = this.nowMs() - BigInt(ageMs);
356
+ const keepRes = await this.pool.query<{ offset: string | number | bigint }>(
357
+ `SELECT "offset" FROM wal
358
+ WHERE stream = $1 AND ts_ms >= $2
359
+ ORDER BY "offset" ASC
360
+ LIMIT 1;`,
361
+ [stream, pgInt(cutoff)]
362
+ );
363
+ keepFromOffset = keepRes.rows[0]?.offset == null ? lastOffset : toBigInt(keepRes.rows[0]!.offset);
364
+ }
365
+
366
+ if (keepFromOffset <= 0n) return { trimmedRows: 0, trimmedBytes: 0, keptFromOffset: keepFromOffset };
367
+ const deleted = await this.deleteWalThrough(stream, keepFromOffset - 1n);
368
+ if (deleted.deletedRows <= 0) return { trimmedRows: 0, trimmedBytes: 0, keptFromOffset: keepFromOffset };
369
+ await this.pool.query(
370
+ `UPDATE streams
371
+ SET pending_rows = GREATEST(0, pending_rows - $1::bigint),
372
+ pending_bytes = GREATEST(0, pending_bytes - $2::bigint),
373
+ updated_at_ms = $3
374
+ WHERE stream = $4;`,
375
+ [deleted.deletedRows, deleted.deletedBytes, pgInt(this.nowMs()), stream]
376
+ );
377
+ return { trimmedRows: deleted.deletedRows, trimmedBytes: deleted.deletedBytes, keptFromOffset: keepFromOffset };
378
+ }
379
+
380
+ async countActiveLiveTemplates(stream: string): Promise<number> {
381
+ const res = await this.pool.query<{ cnt: string | number | bigint }>(
382
+ `SELECT COUNT(*) AS cnt FROM live_templates WHERE stream = $1 AND state = 'active';`,
383
+ [stream]
384
+ );
385
+ return Number(res.rows[0]?.cnt ?? 0);
386
+ }
387
+
388
+ async listActiveLiveTemplates(stream: string): Promise<LiveTemplateStoreRow[]> {
389
+ const res = await this.pool.query<LiveTemplatePgRow>(
390
+ `SELECT stream, template_id, entity, fields_json, encodings_json, state, created_at_ms, last_seen_at_ms,
391
+ inactivity_ttl_ms, active_from_source_offset, retired_at_ms, retired_reason
392
+ FROM live_templates
393
+ WHERE stream = $1 AND state = 'active'
394
+ ORDER BY entity ASC, template_id ASC;`,
395
+ [stream]
396
+ );
397
+ return res.rows.map(coerceLiveTemplateRow);
398
+ }
399
+
400
+ async updateLiveTemplateLastSeenBatch(updates: LiveTemplateLastSeenUpdate[]): Promise<void> {
401
+ if (updates.length === 0) return;
402
+ await this.pool.query(
403
+ `UPDATE live_templates AS t
404
+ SET last_seen_at_ms = GREATEST(t.last_seen_at_ms, v.last_seen_at_ms)
405
+ FROM (
406
+ SELECT *
407
+ FROM unnest($1::text[], $2::text[], $3::bigint[]) AS u(stream, template_id, last_seen_at_ms)
408
+ ) AS v
409
+ WHERE t.stream = v.stream
410
+ AND t.template_id = v.template_id
411
+ AND t.state = 'active';`,
412
+ [updates.map((update) => update.stream), updates.map((update) => update.templateId), updates.map((update) => update.lastSeenAtMs)]
413
+ );
414
+ }
415
+
416
+ async listExpiredLiveTemplates(stream: string, nowMs: number, limit: number): Promise<LiveTemplateIdentityRow[]> {
417
+ const res = await this.pool.query<{
418
+ template_id: string;
419
+ entity: string;
420
+ fields_json: string;
421
+ encodings_json: string;
422
+ last_seen_at_ms: string | number | bigint;
423
+ inactivity_ttl_ms: string | number | bigint;
424
+ }>(
425
+ `SELECT template_id, entity, fields_json, encodings_json, last_seen_at_ms, inactivity_ttl_ms
426
+ FROM live_templates
427
+ WHERE stream = $1 AND state = 'active' AND (last_seen_at_ms + inactivity_ttl_ms) < $2
428
+ ORDER BY last_seen_at_ms ASC
429
+ LIMIT $3;`,
430
+ [stream, nowMs, Math.max(1, Math.floor(limit))]
431
+ );
432
+ return res.rows.map((row) => ({
433
+ template_id: row.template_id,
434
+ entity: row.entity,
435
+ fields_json: row.fields_json,
436
+ encodings_json: row.encodings_json,
437
+ last_seen_at_ms: toBigInt(row.last_seen_at_ms),
438
+ inactivity_ttl_ms: toBigInt(row.inactivity_ttl_ms),
439
+ }));
440
+ }
441
+
442
+ async retireLiveTemplatesForInactivity(stream: string, templateIds: string[], nowMs: number): Promise<void> {
443
+ if (templateIds.length === 0) return;
444
+ await this.pool.query(
445
+ `UPDATE live_templates
446
+ SET state = 'retired', retired_reason = 'inactivity', retired_at_ms = $1
447
+ WHERE stream = $2 AND state = 'active' AND template_id = ANY($3::text[]);`,
448
+ [nowMs, stream, templateIds]
449
+ );
450
+ }
451
+
452
+ async listActiveLiveTemplateEntitiesByIds(stream: string, templateIds: string[]): Promise<string[]> {
453
+ if (templateIds.length === 0) return [];
454
+ const res = await this.pool.query<{ entity: string }>(
455
+ `SELECT DISTINCT entity
456
+ FROM live_templates
457
+ WHERE stream = $1 AND state = 'active' AND template_id = ANY($2::text[]);`,
458
+ [stream, templateIds]
459
+ );
460
+ return res.rows.map((row) => row.entity.trim()).filter((entity) => entity !== "");
461
+ }
462
+
463
+ private async evictToCapsInTransaction(
464
+ client: PoolClient,
465
+ args: {
466
+ stream: string;
467
+ protectedIds: Set<string>;
468
+ maxActiveTemplatesPerStream: number;
469
+ maxActiveTemplatesPerEntity: number;
470
+ nowMs: number;
471
+ }
472
+ ): Promise<Array<{ templateId: string; reason: "cap_exceeded"; cap: number }>> {
473
+ const evicted: Array<{ templateId: string; reason: "cap_exceeded"; cap: number }> = [];
474
+ const protectedIds = Array.from(args.protectedIds);
475
+ const entityRes = await client.query<{ template_id: string }>(
476
+ `WITH ranked AS (
477
+ SELECT
478
+ template_id,
479
+ COUNT(*) OVER (PARTITION BY entity) AS total_count,
480
+ ROW_NUMBER() OVER (
481
+ PARTITION BY entity
482
+ ORDER BY
483
+ CASE WHEN template_id = ANY($4::text[]) THEN 1 ELSE 0 END ASC,
484
+ last_seen_at_ms ASC,
485
+ template_id ASC
486
+ ) AS rn
487
+ FROM live_templates
488
+ WHERE stream = $1 AND state = 'active'
489
+ ),
490
+ selected AS (
491
+ SELECT template_id
492
+ FROM ranked
493
+ WHERE rn <= GREATEST(total_count - $2, 0)
494
+ ),
495
+ updated AS (
496
+ UPDATE live_templates AS t
497
+ SET state = 'retired', retired_reason = 'cap_exceeded', retired_at_ms = $3
498
+ FROM selected
499
+ WHERE t.stream = $1
500
+ AND t.state = 'active'
501
+ AND t.template_id = selected.template_id
502
+ RETURNING t.template_id
503
+ )
504
+ SELECT template_id FROM updated
505
+ ORDER BY template_id ASC;`,
506
+ [args.stream, args.maxActiveTemplatesPerEntity, args.nowMs, protectedIds]
507
+ );
508
+ for (const row of entityRes.rows) evicted.push({ templateId: row.template_id, reason: "cap_exceeded", cap: args.maxActiveTemplatesPerEntity });
509
+
510
+ const streamRes = await client.query<{ template_id: string }>(
511
+ `WITH ranked AS (
512
+ SELECT
513
+ template_id,
514
+ COUNT(*) OVER () AS total_count,
515
+ ROW_NUMBER() OVER (
516
+ ORDER BY
517
+ CASE WHEN template_id = ANY($4::text[]) THEN 1 ELSE 0 END ASC,
518
+ last_seen_at_ms ASC,
519
+ template_id ASC
520
+ ) AS rn
521
+ FROM live_templates
522
+ WHERE stream = $1 AND state = 'active'
523
+ ),
524
+ selected AS (
525
+ SELECT template_id
526
+ FROM ranked
527
+ WHERE rn <= GREATEST(total_count - $2, 0)
528
+ ),
529
+ updated AS (
530
+ UPDATE live_templates AS t
531
+ SET state = 'retired', retired_reason = 'cap_exceeded', retired_at_ms = $3
532
+ FROM selected
533
+ WHERE t.stream = $1
534
+ AND t.state = 'active'
535
+ AND t.template_id = selected.template_id
536
+ RETURNING t.template_id
537
+ )
538
+ SELECT template_id FROM updated
539
+ ORDER BY template_id ASC;`,
540
+ [args.stream, args.maxActiveTemplatesPerStream, args.nowMs, protectedIds]
541
+ );
542
+ for (const row of streamRes.rows) evicted.push({ templateId: row.template_id, reason: "cap_exceeded", cap: args.maxActiveTemplatesPerStream });
543
+ return evicted;
544
+ }
545
+ }
546
+
547
+ export async function ensurePostgresStreamTouchState(
548
+ executor: PgExecutor,
549
+ nowMs: bigint,
550
+ stream: string,
551
+ initialThrough: bigint
552
+ ): Promise<void> {
553
+ await executor.query(
554
+ `INSERT INTO stream_touch_state(stream, processed_through, updated_at_ms)
555
+ VALUES($1, $2, $3)
556
+ ON CONFLICT(stream) DO UPDATE SET
557
+ processed_through = stream_touch_state.processed_through,
558
+ updated_at_ms = stream_touch_state.updated_at_ms;`,
559
+ [stream, pgInt(initialThrough), pgInt(nowMs)]
560
+ );
561
+ }
562
+
563
+ export async function ensurePostgresStreamTouchStateFromStream(executor: PgExecutor, nowMs: bigint, stream: string): Promise<void> {
564
+ const res = await executor.query<{ next_offset: string | number | bigint }>(
565
+ `SELECT next_offset FROM streams WHERE stream = $1 LIMIT 1;`,
566
+ [stream]
567
+ );
568
+ const nextOffset = toBigInt(res.rows[0]?.next_offset ?? 0);
569
+ await ensurePostgresStreamTouchState(executor, nowMs, stream, nextOffset - 1n);
570
+ }
571
+
572
+ export async function deletePostgresStreamTouchState(executor: PgExecutor, stream: string): Promise<void> {
573
+ await executor.query(`DELETE FROM stream_touch_state WHERE stream = $1;`, [stream]);
574
+ }
575
+
576
+ function coerceLiveTemplateRow(row: LiveTemplatePgRow): LiveTemplateStoreRow {
577
+ return {
578
+ stream: row.stream,
579
+ template_id: row.template_id,
580
+ entity: row.entity,
581
+ fields_json: row.fields_json,
582
+ encodings_json: row.encodings_json,
583
+ state: row.state,
584
+ created_at_ms: toBigInt(row.created_at_ms),
585
+ last_seen_at_ms: toBigInt(row.last_seen_at_ms),
586
+ inactivity_ttl_ms: toBigInt(row.inactivity_ttl_ms),
587
+ active_from_source_offset: toBigInt(row.active_from_source_offset),
588
+ retired_at_ms: row.retired_at_ms == null ? null : toBigInt(row.retired_at_ms),
589
+ retired_reason: row.retired_reason == null ? null : row.retired_reason,
590
+ };
591
+ }
@@ -0,0 +1,32 @@
1
+ import type { PoolClient } from "pg";
2
+
3
+ export type PgExecutor = Pick<PoolClient, "query">;
4
+
5
+ export type PgStreamRow = {
6
+ stream: string;
7
+ created_at_ms: string | number | bigint;
8
+ updated_at_ms: string | number | bigint;
9
+ content_type: string;
10
+ profile: string | null;
11
+ stream_seq: string | null;
12
+ closed: number | boolean;
13
+ closed_producer_id: string | null;
14
+ closed_producer_epoch: number | null;
15
+ closed_producer_seq: number | null;
16
+ ttl_seconds: number | null;
17
+ epoch: number;
18
+ next_offset: string | number | bigint;
19
+ sealed_through?: string | number | bigint;
20
+ uploaded_through?: string | number | bigint;
21
+ uploaded_segment_count?: number | string;
22
+ pending_rows?: string | number | bigint;
23
+ pending_bytes?: string | number | bigint;
24
+ logical_size_bytes: string | number | bigint;
25
+ wal_rows: string | number | bigint;
26
+ wal_bytes: string | number | bigint;
27
+ last_append_ms: string | number | bigint;
28
+ last_segment_cut_ms?: string | number | bigint;
29
+ segment_in_progress?: number | string;
30
+ expires_at_ms: string | number | bigint | null;
31
+ stream_flags: number;
32
+ };