@obsunified/collector 2.0.1 → 2.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 (107) hide show
  1. package/dist/auth.d.ts +1 -1
  2. package/dist/auth.js +1 -1
  3. package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
  4. package/dist/chunk-KMEZKGSO.js.map +1 -0
  5. package/dist/index.d.ts +322 -26
  6. package/dist/index.js +1478 -415
  7. package/dist/index.js.map +1 -1
  8. package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
  9. package/package.json +5 -5
  10. package/src/analyses/derive.ts +26 -25
  11. package/src/analyses/investigations.ts +8 -8
  12. package/src/analyses/tier0.ts +22 -22
  13. package/src/auth/dashboard-auth.test.ts +791 -0
  14. package/src/auth/dashboard-auth.ts +187 -38
  15. package/src/auth/login-rate-limit.test.ts +129 -0
  16. package/src/auth/login-rate-limit.ts +143 -0
  17. package/src/framework/body-limit.test.ts +165 -0
  18. package/src/framework/collector.ts +355 -115
  19. package/src/framework/env.ts +9 -0
  20. package/src/framework/limits.ts +31 -0
  21. package/src/framework/scheduled-handlers.test.ts +342 -0
  22. package/src/index.ts +16 -1
  23. package/src/lib/action-retention.test.ts +472 -0
  24. package/src/lib/action-retention.ts +420 -0
  25. package/src/lib/actions-store.ts +6 -12
  26. package/src/lib/ai-store/calls.ts +2 -2
  27. package/src/lib/ai-store/sessions.ts +3 -3
  28. package/src/lib/ai-store/spans.ts +1 -1
  29. package/src/lib/ai-store/types.ts +2 -1
  30. package/src/lib/alerts-store.test.ts +153 -0
  31. package/src/lib/alerts-store.ts +122 -21
  32. package/src/lib/analyses-runner.test.ts +75 -0
  33. package/src/lib/analyses-runner.ts +60 -3
  34. package/src/lib/analyses-store-latest.test.ts +61 -0
  35. package/src/lib/analyses-store.ts +17 -10
  36. package/src/lib/blob-store.ts +1 -1
  37. package/src/lib/housekeeping.test.ts +73 -0
  38. package/src/lib/housekeeping.ts +93 -0
  39. package/src/lib/identity-index/action-lookups.ts +6 -6
  40. package/src/lib/identity-index/key-lookups.ts +2 -2
  41. package/src/lib/identity-index/user-lookup.ts +8 -5
  42. package/src/lib/identity-index.test.ts +2 -3
  43. package/src/lib/metrics-store-series.test.ts +99 -0
  44. package/src/lib/metrics-store.test.ts +23 -0
  45. package/src/lib/metrics-store.ts +27 -3
  46. package/src/lib/projects-store.test.ts +25 -0
  47. package/src/lib/projects-store.ts +4 -3
  48. package/src/lib/sql-db-dialect.test.ts +12 -3
  49. package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
  50. package/src/lib/sql-db-postgres.test.ts +314 -0
  51. package/src/lib/sql-db-postgres.ts +392 -54
  52. package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
  53. package/src/lib/sql-db.ts +40 -6
  54. package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
  55. package/src/lib/test-utils/node-sqlite-db.ts +188 -0
  56. package/src/lib/webhook-url.test.ts +122 -0
  57. package/src/lib/webhook-url.ts +155 -0
  58. package/src/migrations/041_ingest_key_hash_text.sql +5 -0
  59. package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
  60. package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
  61. package/src/migrations/044_actions_started_at_index.sql +10 -0
  62. package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
  63. package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
  64. package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
  65. package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
  66. package/src/migrations/049_alert_state_notified.sql +17 -0
  67. package/src/migrations/alert-state-notified.test.ts +111 -0
  68. package/src/migrations/parity.test.ts +64 -0
  69. package/src/migrations/project-scoped-keys.test.ts +432 -0
  70. package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
  71. package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
  72. package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
  73. package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
  74. package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
  75. package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
  76. package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
  77. package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
  78. package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
  79. package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
  80. package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
  81. package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
  82. package/src/migrations-postgres/README.md +58 -5
  83. package/src/otlp/decode/body.test.ts +76 -0
  84. package/src/otlp/decode/body.ts +67 -6
  85. package/src/plugins/_json.ts +21 -0
  86. package/src/plugins/action-graph-processor.ts +6 -12
  87. package/src/plugins/ai-receiver.ts +5 -12
  88. package/src/plugins/ai-span-payloads-processor.ts +1 -2
  89. package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
  90. package/src/plugins/alerts-evaluator.test.ts +464 -3
  91. package/src/plugins/alerts-evaluator.ts +165 -41
  92. package/src/plugins/alerts-routes.ts +10 -6
  93. package/src/plugins/ask-routes.ts +4 -5
  94. package/src/plugins/connected-routes.test.ts +106 -0
  95. package/src/plugins/connected-routes.ts +14 -5
  96. package/src/plugins/dashboard-routes.test.ts +120 -0
  97. package/src/plugins/dashboard-routes.ts +11 -2
  98. package/src/plugins/eval-cases-routes.ts +8 -17
  99. package/src/plugins/identity-receiver.ts +9 -11
  100. package/src/plugins/json-body.test.ts +91 -0
  101. package/src/plugins/onboarding-routes.test.ts +105 -0
  102. package/src/plugins/onboarding-routes.ts +26 -11
  103. package/src/plugins/projects-routes.ts +2 -1
  104. package/src/plugins/replay-isolation.test.ts +922 -0
  105. package/src/plugins/replay-receiver.ts +8 -8
  106. package/src/plugins/usage-receiver.ts +3 -6
  107. package/dist/chunk-UGWDOGGH.js.map +0 -1
@@ -0,0 +1,922 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import type { MiddlewareHandler } from "hono";
5
+ import { describe, expect, it } from "vitest";
6
+ import { createTelemetryCollectorApp } from "../framework/collector";
7
+ import type { CollectorEnv } from "../framework/env";
8
+ import { IdentityIndex } from "../lib/identity-index";
9
+ import type { SqlDb, SqlStatement } from "../lib/sql-db";
10
+ import { replayQueryRoutesPlugin } from "./replay-query-routes";
11
+ import { replayReceiverPlugin } from "./replay-receiver";
12
+
13
+ class SyncSqliteDb implements SqlDb {
14
+ constructor(readonly db: DatabaseSync) {}
15
+
16
+ prepare(sql: string): SqlStatement {
17
+ return new SyncSqliteStatement(this.db, sql, []);
18
+ }
19
+
20
+ async batch(
21
+ statements: SqlStatement[],
22
+ ): Promise<Array<{ meta: { changes: number } }>> {
23
+ const results: Array<{ meta: { changes: number } }> = [];
24
+ for (const stmt of statements) {
25
+ results.push(await stmt.run());
26
+ }
27
+ return results;
28
+ }
29
+ }
30
+
31
+ class SyncSqliteStatement implements SqlStatement {
32
+ constructor(
33
+ private readonly db: DatabaseSync,
34
+ private readonly sql: string,
35
+ private readonly binds: unknown[] = [],
36
+ ) {}
37
+
38
+ bind(...args: unknown[]): SqlStatement {
39
+ return new SyncSqliteStatement(this.db, this.sql, args);
40
+ }
41
+
42
+ async first<T = Record<string, unknown>>(): Promise<T | null> {
43
+ const stmt = this.db.prepare(this.sql);
44
+ const row = stmt.get(
45
+ ...(this.binds as (string | number | bigint | null | Uint8Array)[]),
46
+ );
47
+ return (row ?? null) as T | null;
48
+ }
49
+
50
+ async all<T = Record<string, unknown>>(): Promise<{ results: T[] }> {
51
+ const stmt = this.db.prepare(this.sql);
52
+ const rows = stmt.all(
53
+ ...(this.binds as (string | number | bigint | null | Uint8Array)[]),
54
+ );
55
+ return { results: rows as T[] };
56
+ }
57
+
58
+ async run(): Promise<{ meta: { changes: number } }> {
59
+ const stmt = this.db.prepare(this.sql);
60
+ const info = stmt.run(
61
+ ...(this.binds as (string | number | bigint | null | Uint8Array)[]),
62
+ );
63
+ return { meta: { changes: Number(info.changes) } };
64
+ }
65
+ }
66
+
67
+ class MemR2Bucket {
68
+ readonly objects = new Map<string, string>();
69
+
70
+ async put(key: string, value: string): Promise<void> {
71
+ this.objects.set(key, value);
72
+ }
73
+
74
+ async get(key: string) {
75
+ const val = this.objects.get(key);
76
+ if (val === undefined) return null;
77
+ return {
78
+ async json<T>() {
79
+ return JSON.parse(val) as T;
80
+ },
81
+ async text() {
82
+ return val;
83
+ },
84
+ };
85
+ }
86
+
87
+ async list(opts: { prefix?: string; cursor?: string }) {
88
+ const prefix = opts.prefix ?? "";
89
+ const matched = Array.from(this.objects.keys())
90
+ .filter((k) => k.startsWith(prefix))
91
+ .sort()
92
+ .map((k) => ({ key: k }));
93
+ return {
94
+ objects: matched,
95
+ cursor: undefined,
96
+ truncated: false,
97
+ };
98
+ }
99
+
100
+ async delete(key: string): Promise<void> {
101
+ this.objects.delete(key);
102
+ }
103
+ }
104
+
105
+ interface PgTestHarness {
106
+ query<T = Record<string, unknown>>(
107
+ sql: string,
108
+ params?: unknown[],
109
+ ): Promise<{ rows: T[] }>;
110
+ exec(sql: string): Promise<void>;
111
+ close(): Promise<void>;
112
+ }
113
+
114
+ const pgliteEnvPath =
115
+ "/tmp/obs-pglite-env/node_modules/@electric-sql/pglite/dist/index.js";
116
+ const hasPgHarness = Boolean(
117
+ process.env.OBS_PG_TEST_URL || existsSync(pgliteEnvPath),
118
+ );
119
+
120
+ async function createPgTestHarness(): Promise<PgTestHarness | null> {
121
+ if (process.env.OBS_PG_TEST_URL) {
122
+ const pgModule = await import("pg");
123
+ const Client = pgModule.default?.Client ?? pgModule.Client;
124
+ const client = new Client({
125
+ connectionString: process.env.OBS_PG_TEST_URL,
126
+ });
127
+ await client.connect();
128
+
129
+ // Guard: create an isolated scratch schema so we NEVER touch any non-scratch database
130
+ const scratchSchema = `obs_replay_scratch_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
131
+ await client.query(`CREATE SCHEMA "${scratchSchema}"`);
132
+ await client.query(`SET search_path TO "${scratchSchema}"`);
133
+
134
+ // Guard: verify the newly active schema is completely empty
135
+ const tables = await client.query<{ table_name: string }>(
136
+ `SELECT table_name FROM information_schema.tables WHERE table_schema = current_schema() AND table_type = 'BASE TABLE'`,
137
+ );
138
+ if (tables.rows.length > 0) {
139
+ await client.query(`DROP SCHEMA IF EXISTS "${scratchSchema}" CASCADE`);
140
+ await client.end();
141
+ throw new Error("Scratch schema was unexpectedly not empty");
142
+ }
143
+
144
+ return {
145
+ async query<T = Record<string, unknown>>(
146
+ sql: string,
147
+ params: unknown[] = [],
148
+ ) {
149
+ const res = await client.query(sql, params);
150
+ return { rows: res.rows as T[] };
151
+ },
152
+ async exec(sql: string) {
153
+ await client.query(sql);
154
+ },
155
+ async close() {
156
+ try {
157
+ await client.query("RESET search_path").catch(() => {});
158
+ await client.query(
159
+ `DROP SCHEMA IF EXISTS "${scratchSchema}" CASCADE`,
160
+ );
161
+ } finally {
162
+ await client.end();
163
+ }
164
+ },
165
+ };
166
+ }
167
+
168
+ if (existsSync(pgliteEnvPath)) {
169
+ const { PGlite } = (await import(/* @vite-ignore */ pgliteEnvPath)) as {
170
+ PGlite: {
171
+ create: () => Promise<{
172
+ query: <T = Record<string, unknown>>(
173
+ sql: string,
174
+ params?: unknown[],
175
+ ) => Promise<{ rows: T[] }>;
176
+ exec: (sql: string) => Promise<void>;
177
+ close: () => Promise<void>;
178
+ }>;
179
+ };
180
+ };
181
+
182
+ const db = await PGlite.create();
183
+ return {
184
+ async query<T = Record<string, unknown>>(
185
+ sql: string,
186
+ params: unknown[] = [],
187
+ ) {
188
+ const res = await db.query<T>(sql, params);
189
+ return { rows: res.rows };
190
+ },
191
+ async exec(sql: string) {
192
+ await db.exec(sql);
193
+ },
194
+ async close() {
195
+ await db.close();
196
+ },
197
+ };
198
+ }
199
+
200
+ return null;
201
+ }
202
+
203
+ describe("Cross-Project Session Replay Isolation & Migration 042", () => {
204
+ const migrationsDir = resolve(__dirname, "../migrations");
205
+ const pgMigrationsDir = resolve(__dirname, "../migrations-postgres");
206
+
207
+ it("migrates existing deployed SQLite database without data loss and enables cross-project co-existence", () => {
208
+ const db = new DatabaseSync(":memory:");
209
+
210
+ // 1. Simulate existing deployed database schema up to migration 010
211
+ const m007 = readFileSync(
212
+ resolve(migrationsDir, "007_session_replay_metadata.sql"),
213
+ "utf8",
214
+ );
215
+ const m008 = readFileSync(
216
+ resolve(migrationsDir, "008_session_replay_storage_bytes.sql"),
217
+ "utf8",
218
+ );
219
+ // Execute table creation from 007
220
+ db.exec(m007);
221
+ db.exec(m008);
222
+
223
+ // Execute signal_project_id line for session_replay_metadata
224
+ db.exec(
225
+ "ALTER TABLE session_replay_metadata ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';",
226
+ );
227
+ db.exec(
228
+ "CREATE INDEX IF NOT EXISTS idx_replay_project_last ON session_replay_metadata (project_id, last_chunk_at DESC);",
229
+ );
230
+
231
+ // 2. Insert existing data before migration 042
232
+ db.prepare(
233
+ `INSERT INTO session_replay_metadata (project_id, session_id, visitor_id, first_chunk_at, last_chunk_at, chunk_count, events_count, storage_bytes)
234
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
235
+ ).run(
236
+ "proj-existing",
237
+ "sess-123",
238
+ "vis-1",
239
+ "2026-06-01T10:00:00Z",
240
+ "2026-06-01T10:02:00Z",
241
+ 2,
242
+ 20,
243
+ 2048,
244
+ );
245
+
246
+ db.prepare(
247
+ `INSERT INTO session_replay_metadata (project_id, session_id, visitor_id, first_chunk_at, last_chunk_at, chunk_count, events_count, storage_bytes)
248
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
249
+ ).run(
250
+ "default",
251
+ "sess-456",
252
+ "vis-2",
253
+ "2026-06-01T11:00:00Z",
254
+ "2026-06-01T11:05:00Z",
255
+ 5,
256
+ 50,
257
+ 5120,
258
+ );
259
+
260
+ // Prior to 042, inserting a row in another project with duplicate session_id fails due to single-column PK
261
+ expect(() => {
262
+ db.prepare(
263
+ `INSERT INTO session_replay_metadata (project_id, session_id, visitor_id, first_chunk_at, last_chunk_at, chunk_count, events_count, storage_bytes)
264
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
265
+ ).run(
266
+ "proj-other",
267
+ "sess-123",
268
+ "vis-3",
269
+ "2026-06-01T12:00:00Z",
270
+ "2026-06-01T12:00:00Z",
271
+ 1,
272
+ 10,
273
+ 1024,
274
+ );
275
+ }).toThrow();
276
+
277
+ // 3. Execute migration 042
278
+ const m042 = readFileSync(
279
+ resolve(migrationsDir, "042_session_replay_project_composite_pk.sql"),
280
+ "utf8",
281
+ );
282
+ db.exec(m042);
283
+
284
+ // 4. Verify existing rows were completely preserved
285
+ const existingRows = db
286
+ .prepare("SELECT * FROM session_replay_metadata ORDER BY session_id ASC")
287
+ .all() as Record<string, unknown>[];
288
+
289
+ expect(existingRows).toHaveLength(2);
290
+ expect(existingRows[0]).toEqual({
291
+ project_id: "proj-existing",
292
+ session_id: "sess-123",
293
+ visitor_id: "vis-1",
294
+ first_chunk_at: "2026-06-01T10:00:00Z",
295
+ last_chunk_at: "2026-06-01T10:02:00Z",
296
+ chunk_count: 2,
297
+ events_count: 20,
298
+ storage_bytes: 2048,
299
+ });
300
+ expect(existingRows[1]).toEqual({
301
+ project_id: "default",
302
+ session_id: "sess-456",
303
+ visitor_id: "vis-2",
304
+ first_chunk_at: "2026-06-01T11:00:00Z",
305
+ last_chunk_at: "2026-06-01T11:05:00Z",
306
+ chunk_count: 5,
307
+ events_count: 50,
308
+ storage_bytes: 5120,
309
+ });
310
+
311
+ // 5. Verify identical session_id can now be inserted under a different project
312
+ expect(() => {
313
+ db.prepare(
314
+ `INSERT INTO session_replay_metadata (project_id, session_id, visitor_id, first_chunk_at, last_chunk_at, chunk_count, events_count, storage_bytes)
315
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
316
+ ).run(
317
+ "proj-other",
318
+ "sess-123",
319
+ "vis-3",
320
+ "2026-06-01T12:00:00Z",
321
+ "2026-06-01T12:00:00Z",
322
+ 1,
323
+ 10,
324
+ 1024,
325
+ );
326
+ }).not.toThrow();
327
+
328
+ // 6. Verify ON CONFLICT(project_id, session_id) updates ONLY the targeted project's row
329
+ const upsertStmt = db.prepare(`
330
+ INSERT INTO session_replay_metadata (project_id, session_id, visitor_id, first_chunk_at, last_chunk_at, chunk_count, events_count, storage_bytes)
331
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
332
+ ON CONFLICT(project_id, session_id) DO UPDATE SET
333
+ last_chunk_at = excluded.last_chunk_at,
334
+ chunk_count = session_replay_metadata.chunk_count + 1,
335
+ events_count = session_replay_metadata.events_count + excluded.events_count,
336
+ storage_bytes = session_replay_metadata.storage_bytes + excluded.storage_bytes
337
+ `);
338
+
339
+ upsertStmt.run(
340
+ "proj-other",
341
+ "sess-123",
342
+ "vis-3",
343
+ "2026-06-01T12:00:00Z",
344
+ "2026-06-01T12:05:00Z",
345
+ 1,
346
+ 15,
347
+ 1500,
348
+ );
349
+
350
+ const projExistingRow = db
351
+ .prepare(
352
+ "SELECT * FROM session_replay_metadata WHERE project_id = ? AND session_id = ?",
353
+ )
354
+ .get("proj-existing", "sess-123") as Record<string, unknown>;
355
+
356
+ const projOtherRow = db
357
+ .prepare(
358
+ "SELECT * FROM session_replay_metadata WHERE project_id = ? AND session_id = ?",
359
+ )
360
+ .get("proj-other", "sess-123") as Record<string, unknown>;
361
+
362
+ // proj-existing remains unchanged
363
+ expect(projExistingRow.chunk_count).toBe(2);
364
+ expect(projExistingRow.events_count).toBe(20);
365
+ expect(projExistingRow.storage_bytes).toBe(2048);
366
+
367
+ // proj-other has its chunk count and bytes incremented
368
+ expect(projOtherRow.chunk_count).toBe(2);
369
+ expect(projOtherRow.events_count).toBe(25);
370
+ expect(projOtherRow.storage_bytes).toBe(2524);
371
+ expect(projOtherRow.last_chunk_at).toBe("2026-06-01T12:05:00Z");
372
+ });
373
+
374
+ it("applies all migrations sequentially on fresh install without errors", () => {
375
+ const db = new DatabaseSync(":memory:");
376
+ const files = readdirSync(migrationsDir)
377
+ .filter((f) => f.endsWith(".sql"))
378
+ .sort((a, b) => a.localeCompare(b));
379
+
380
+ // Apply all migrations
381
+ for (const file of files) {
382
+ const sql = readFileSync(resolve(migrationsDir, file), "utf8");
383
+ try {
384
+ db.exec(sql);
385
+ } catch (err) {
386
+ throw new Error(
387
+ `Failed to apply migration ${file}: ${(err as Error).message}`,
388
+ );
389
+ }
390
+ }
391
+
392
+ // Inspect table_info for session_replay_metadata to prove composite PK
393
+ const columns = db
394
+ .prepare("PRAGMA table_info(session_replay_metadata)")
395
+ .all() as Array<{
396
+ name: string;
397
+ pk: number;
398
+ }>;
399
+
400
+ const pkCols = columns.filter((c) => c.pk > 0).sort((a, b) => a.pk - b.pk);
401
+ expect(pkCols.map((c) => c.name)).toEqual(["project_id", "session_id"]);
402
+ });
403
+
404
+ it("contains valid PostgreSQL forward migration DDL syntax", () => {
405
+ const pgMigration = readFileSync(
406
+ resolve(pgMigrationsDir, "042_session_replay_project_composite_pk.sql"),
407
+ "utf8",
408
+ );
409
+
410
+ expect(pgMigration).toContain(
411
+ "DROP CONSTRAINT IF EXISTS session_replay_metadata_pkey",
412
+ );
413
+ expect(pgMigration).toContain("PRIMARY KEY (project_id, session_id)");
414
+ expect(pgMigration).toContain(
415
+ "UPDATE session_replay_metadata SET project_id = 'default'",
416
+ );
417
+ });
418
+
419
+ it.runIf(hasPgHarness)(
420
+ "executes PostgreSQL migration 042 against an old-schema table with preserved rows and validates cross-project ON CONFLICT upsert",
421
+ async () => {
422
+ const pg = await createPgTestHarness();
423
+ if (!pg) return;
424
+
425
+ try {
426
+ // 1. Simulate existing deployed database schema up to migration 010
427
+ const m007 = readFileSync(
428
+ resolve(pgMigrationsDir, "007_session_replay_metadata.sql"),
429
+ "utf8",
430
+ );
431
+ const m008 = readFileSync(
432
+ resolve(pgMigrationsDir, "008_session_replay_storage_bytes.sql"),
433
+ "utf8",
434
+ );
435
+
436
+ await pg.exec(m007);
437
+ await pg.exec(m008);
438
+ await pg.exec(
439
+ "ALTER TABLE session_replay_metadata ADD COLUMN project_id TEXT DEFAULT 'default';",
440
+ );
441
+ await pg.exec(
442
+ "CREATE INDEX IF NOT EXISTS idx_replay_project_last ON session_replay_metadata (project_id, last_chunk_at DESC);",
443
+ );
444
+
445
+ // 2. Insert existing row before migration 042
446
+ await pg.query(
447
+ `INSERT INTO session_replay_metadata (project_id, session_id, visitor_id, first_chunk_at, last_chunk_at, chunk_count, events_count, storage_bytes)
448
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
449
+ [
450
+ "proj-existing",
451
+ "sess-123",
452
+ "vis-1",
453
+ "2026-06-01T10:00:00Z",
454
+ "2026-06-01T10:02:00Z",
455
+ 2,
456
+ 20,
457
+ 2048,
458
+ ],
459
+ );
460
+
461
+ // 3. Before 042, inserting a row in another project with duplicate session_id fails due to single-column PK
462
+ let insertFailedBefore = false;
463
+ try {
464
+ await pg.query(
465
+ `INSERT INTO session_replay_metadata (project_id, session_id, visitor_id, first_chunk_at, last_chunk_at, chunk_count, events_count, storage_bytes)
466
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
467
+ [
468
+ "proj-other",
469
+ "sess-123",
470
+ "vis-2",
471
+ "2026-06-01T12:00:00Z",
472
+ "2026-06-01T12:00:00Z",
473
+ 1,
474
+ 10,
475
+ 1024,
476
+ ],
477
+ );
478
+ } catch (err) {
479
+ insertFailedBefore = true;
480
+ expect((err as Error).message).toMatch(/unique|primary/i);
481
+ }
482
+ expect(insertFailedBefore).toBe(true);
483
+
484
+ // 4. Execute migration 042
485
+ const m042 = readFileSync(
486
+ resolve(
487
+ pgMigrationsDir,
488
+ "042_session_replay_project_composite_pk.sql",
489
+ ),
490
+ "utf8",
491
+ );
492
+ await pg.exec(m042);
493
+
494
+ // 5. Verify existing row is preserved intact
495
+ const preserved = await pg.query<{
496
+ project_id: string;
497
+ session_id: string;
498
+ visitor_id: string;
499
+ first_chunk_at: string;
500
+ last_chunk_at: string;
501
+ chunk_count: number;
502
+ events_count: number;
503
+ storage_bytes: number;
504
+ }>(
505
+ "SELECT * FROM session_replay_metadata WHERE project_id = $1 AND session_id = $2",
506
+ ["proj-existing", "sess-123"],
507
+ );
508
+ expect(preserved.rows).toHaveLength(1);
509
+ expect(preserved.rows[0]).toEqual({
510
+ project_id: "proj-existing",
511
+ session_id: "sess-123",
512
+ visitor_id: "vis-1",
513
+ first_chunk_at: "2026-06-01T10:00:00Z",
514
+ last_chunk_at: "2026-06-01T10:02:00Z",
515
+ chunk_count: 2,
516
+ events_count: 20,
517
+ storage_bytes: 2048,
518
+ });
519
+
520
+ // 6. Verify duplicate session_id in proj-other now succeeds
521
+ await pg.query(
522
+ `INSERT INTO session_replay_metadata (project_id, session_id, visitor_id, first_chunk_at, last_chunk_at, chunk_count, events_count, storage_bytes)
523
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
524
+ [
525
+ "proj-other",
526
+ "sess-123",
527
+ "vis-2",
528
+ "2026-06-01T12:00:00Z",
529
+ "2026-06-01T12:00:00Z",
530
+ 1,
531
+ 10,
532
+ 1024,
533
+ ],
534
+ );
535
+
536
+ // 7. Verify ON CONFLICT(project_id, session_id) DO UPDATE SET ... updates only the target project
537
+ const upsertSql = `
538
+ INSERT INTO session_replay_metadata (
539
+ project_id, session_id, visitor_id, first_chunk_at, last_chunk_at,
540
+ chunk_count, events_count, storage_bytes
541
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
542
+ ON CONFLICT(project_id, session_id) DO UPDATE SET
543
+ last_chunk_at = EXCLUDED.last_chunk_at,
544
+ chunk_count = session_replay_metadata.chunk_count + 1,
545
+ events_count = session_replay_metadata.events_count + EXCLUDED.events_count,
546
+ storage_bytes = session_replay_metadata.storage_bytes + EXCLUDED.storage_bytes
547
+ `;
548
+ await pg.query(upsertSql, [
549
+ "proj-other",
550
+ "sess-123",
551
+ "vis-2",
552
+ "2026-06-01T12:00:00Z",
553
+ "2026-06-01T12:05:00Z",
554
+ 1,
555
+ 15,
556
+ 1500,
557
+ ]);
558
+
559
+ const afterExisting = await pg.query<{
560
+ chunk_count: number;
561
+ events_count: number;
562
+ storage_bytes: number;
563
+ last_chunk_at: string;
564
+ }>(
565
+ "SELECT chunk_count, events_count, storage_bytes, last_chunk_at FROM session_replay_metadata WHERE project_id = $1 AND session_id = $2",
566
+ ["proj-existing", "sess-123"],
567
+ );
568
+ const afterOther = await pg.query<{
569
+ chunk_count: number;
570
+ events_count: number;
571
+ storage_bytes: number;
572
+ last_chunk_at: string;
573
+ }>(
574
+ "SELECT chunk_count, events_count, storage_bytes, last_chunk_at FROM session_replay_metadata WHERE project_id = $1 AND session_id = $2",
575
+ ["proj-other", "sess-123"],
576
+ );
577
+
578
+ // proj-existing remains unchanged
579
+ expect(afterExisting.rows[0]?.chunk_count).toBe(2);
580
+ expect(afterExisting.rows[0]?.events_count).toBe(20);
581
+ expect(afterExisting.rows[0]?.storage_bytes).toBe(2048);
582
+ expect(afterExisting.rows[0]?.last_chunk_at).toBe(
583
+ "2026-06-01T10:02:00Z",
584
+ );
585
+
586
+ // proj-other has its chunk count and bytes incremented
587
+ expect(afterOther.rows[0]?.chunk_count).toBe(2);
588
+ expect(afterOther.rows[0]?.events_count).toBe(25);
589
+ expect(afterOther.rows[0]?.storage_bytes).toBe(2524);
590
+ expect(afterOther.rows[0]?.last_chunk_at).toBe("2026-06-01T12:05:00Z");
591
+
592
+ // 8. Re-running migration 042 is idempotent
593
+ await pg.exec(m042);
594
+ const afterRerun = await pg.query<{ count: string | number }>(
595
+ "SELECT COUNT(*) AS count FROM session_replay_metadata",
596
+ );
597
+ expect(Number(afterRerun.rows[0]?.count)).toBe(2);
598
+ } finally {
599
+ await pg.close();
600
+ }
601
+ },
602
+ );
603
+
604
+ it.runIf(hasPgHarness)(
605
+ "applies all PostgreSQL migrations sequentially on fresh install without errors and establishes composite PK",
606
+ async () => {
607
+ const pg = await createPgTestHarness();
608
+ if (!pg) return;
609
+
610
+ try {
611
+ const files = readdirSync(pgMigrationsDir)
612
+ .filter((f) => f.endsWith(".sql"))
613
+ .sort((a, b) => a.localeCompare(b));
614
+
615
+ for (const file of files) {
616
+ const sql = readFileSync(resolve(pgMigrationsDir, file), "utf8");
617
+ await pg.exec(sql);
618
+ }
619
+
620
+ const pkInfo = await pg.query<{ column_name: string }>(
621
+ `SELECT kcu.column_name
622
+ FROM information_schema.table_constraints tc
623
+ JOIN information_schema.key_column_usage kcu
624
+ ON tc.constraint_name = kcu.constraint_name
625
+ AND tc.table_schema = kcu.table_schema
626
+ WHERE tc.table_schema = current_schema()
627
+ AND tc.table_name = 'session_replay_metadata'
628
+ AND tc.constraint_type = 'PRIMARY KEY'
629
+ ORDER BY kcu.column_name ASC`,
630
+ );
631
+ expect(pkInfo.rows.map((r) => r.column_name)).toEqual([
632
+ "project_id",
633
+ "session_id",
634
+ ]);
635
+ } finally {
636
+ await pg.close();
637
+ }
638
+ },
639
+ );
640
+
641
+ it("proves complete write/read/delete isolation across projects with identical session IDs", async () => {
642
+ const rawDb = new DatabaseSync(":memory:");
643
+
644
+ // Run all migrations to establish latest schema
645
+ for (const file of readdirSync(migrationsDir)
646
+ .filter((f) => f.endsWith(".sql"))
647
+ .sort()) {
648
+ rawDb.exec(readFileSync(resolve(migrationsDir, file), "utf8"));
649
+ }
650
+
651
+ const sqlDb = new SyncSqliteDb(rawDb);
652
+ const bucket = new MemR2Bucket();
653
+
654
+ const setProjectMiddleware: MiddlewareHandler<{
655
+ Bindings: CollectorEnv;
656
+ Variables: { projectId: string };
657
+ }> = async (c, next) => {
658
+ const projectHeader = c.req.header("x-project-id");
659
+ if (projectHeader) {
660
+ c.set("projectId", projectHeader);
661
+ }
662
+ await next();
663
+ };
664
+
665
+ const app = createTelemetryCollectorApp({
666
+ plugins: [replayReceiverPlugin, replayQueryRoutesPlugin],
667
+ auth: {
668
+ middleware: setProjectMiddleware,
669
+ },
670
+ dashboardAuth: {
671
+ middleware: setProjectMiddleware,
672
+ registerRoutes: () => {},
673
+ },
674
+ });
675
+
676
+ const env = {
677
+ DB: sqlDb as unknown as D1Database,
678
+ REPLAYS_BUCKET: bucket as unknown as R2Bucket,
679
+ } as CollectorEnv;
680
+
681
+ const sessionId = "colliding-session-id";
682
+
683
+ // 1. Project Alpha ingests chunk 1
684
+ const resAlpha1 = await app.request(
685
+ "/v1/replays",
686
+ {
687
+ method: "POST",
688
+ headers: {
689
+ "content-type": "application/json",
690
+ "x-project-id": "project-alpha",
691
+ },
692
+ body: JSON.stringify({
693
+ sessionId,
694
+ visitorId: "vis-alpha",
695
+ sequenceNumber: 0,
696
+ events: [{ type: 1, data: "alpha-event-1" }],
697
+ }),
698
+ },
699
+ env,
700
+ );
701
+ expect(resAlpha1.status).toBe(200);
702
+
703
+ // 2. Project Beta ingests chunk 1 with the EXACT SAME session ID
704
+ const resBeta1 = await app.request(
705
+ "/v1/replays",
706
+ {
707
+ method: "POST",
708
+ headers: {
709
+ "content-type": "application/json",
710
+ "x-project-id": "project-beta",
711
+ },
712
+ body: JSON.stringify({
713
+ sessionId,
714
+ visitorId: "vis-beta",
715
+ sequenceNumber: 0,
716
+ events: [
717
+ { type: 2, data: "beta-event-1" },
718
+ { type: 2, data: "beta-event-2" },
719
+ ],
720
+ }),
721
+ },
722
+ env,
723
+ );
724
+ expect(resBeta1.status).toBe(200);
725
+
726
+ // 3. Project Alpha ingests chunk 2
727
+ const resAlpha2 = await app.request(
728
+ "/v1/replays",
729
+ {
730
+ method: "POST",
731
+ headers: {
732
+ "content-type": "application/json",
733
+ "x-project-id": "project-alpha",
734
+ },
735
+ body: JSON.stringify({
736
+ sessionId,
737
+ visitorId: "vis-alpha",
738
+ sequenceNumber: 1,
739
+ events: [{ type: 1, data: "alpha-event-2" }],
740
+ }),
741
+ },
742
+ env,
743
+ );
744
+ expect(resAlpha2.status).toBe(200);
745
+
746
+ // 4. Verify storage isolation: bucket prefixes are scoped by project
747
+ const alphaChunkKeys = Array.from(bucket.objects.keys()).filter((k) =>
748
+ k.startsWith(`replays/project-alpha/${sessionId}/`),
749
+ );
750
+ const betaChunkKeys = Array.from(bucket.objects.keys()).filter((k) =>
751
+ k.startsWith(`replays/project-beta/${sessionId}/`),
752
+ );
753
+ expect(alphaChunkKeys).toHaveLength(2);
754
+ expect(betaChunkKeys).toHaveLength(1);
755
+
756
+ // 5. Query /internal/replays for Alpha
757
+ const listAlphaRes = await app.request(
758
+ "/internal/replays",
759
+ {
760
+ method: "GET",
761
+ headers: { "x-project-id": "project-alpha" },
762
+ },
763
+ env,
764
+ );
765
+ expect(listAlphaRes.status).toBe(200);
766
+ const listAlphaBody = (await listAlphaRes.json()) as {
767
+ replays: Array<Record<string, unknown>>;
768
+ };
769
+ expect(listAlphaBody.replays).toHaveLength(1);
770
+ expect(listAlphaBody.replays[0].project_id).toBe("project-alpha");
771
+ expect(listAlphaBody.replays[0].chunk_count).toBe(2);
772
+ expect(listAlphaBody.replays[0].events_count).toBe(2);
773
+
774
+ // Query /internal/replays for Beta
775
+ const listBetaRes = await app.request(
776
+ "/internal/replays",
777
+ {
778
+ method: "GET",
779
+ headers: { "x-project-id": "project-beta" },
780
+ },
781
+ env,
782
+ );
783
+ expect(listBetaRes.status).toBe(200);
784
+ const listBetaBody = (await listBetaRes.json()) as {
785
+ replays: Array<Record<string, unknown>>;
786
+ };
787
+ expect(listBetaBody.replays).toHaveLength(1);
788
+ expect(listBetaBody.replays[0].project_id).toBe("project-beta");
789
+ expect(listBetaBody.replays[0].chunk_count).toBe(1);
790
+ expect(listBetaBody.replays[0].events_count).toBe(2);
791
+
792
+ // 6. Query detail /internal/replays/:sessionId for Alpha
793
+ const detailAlphaRes = await app.request(
794
+ `/internal/replays/${sessionId}`,
795
+ {
796
+ method: "GET",
797
+ headers: { "x-project-id": "project-alpha" },
798
+ },
799
+ env,
800
+ );
801
+ expect(detailAlphaRes.status).toBe(200);
802
+ const detailAlpha = (await detailAlphaRes.json()) as {
803
+ metadata: Record<string, unknown>;
804
+ events: Array<{ data: string }>;
805
+ };
806
+ expect(detailAlpha.metadata.project_id).toBe("project-alpha");
807
+ expect(detailAlpha.events.map((e) => e.data)).toEqual([
808
+ "alpha-event-1",
809
+ "alpha-event-2",
810
+ ]);
811
+
812
+ // Query detail /internal/replays/:sessionId for Beta
813
+ const detailBetaRes = await app.request(
814
+ `/internal/replays/${sessionId}`,
815
+ {
816
+ method: "GET",
817
+ headers: { "x-project-id": "project-beta" },
818
+ },
819
+ env,
820
+ );
821
+ expect(detailBetaRes.status).toBe(200);
822
+ const detailBeta = (await detailBetaRes.json()) as {
823
+ metadata: Record<string, unknown>;
824
+ events: Array<{ data: string }>;
825
+ };
826
+ expect(detailBeta.metadata.project_id).toBe("project-beta");
827
+ expect(detailBeta.events.map((e) => e.data)).toEqual([
828
+ "beta-event-1",
829
+ "beta-event-2",
830
+ ]);
831
+
832
+ // 7. Delete replay from Project Alpha
833
+ const deleteAlphaRes = await app.request(
834
+ `/internal/replays/${sessionId}`,
835
+ {
836
+ method: "DELETE",
837
+ headers: { "x-project-id": "project-alpha" },
838
+ },
839
+ env,
840
+ );
841
+ expect(deleteAlphaRes.status).toBe(200);
842
+
843
+ // Project Alpha now gets 404
844
+ const detailAlphaAfterDelete = await app.request(
845
+ `/internal/replays/${sessionId}`,
846
+ {
847
+ method: "GET",
848
+ headers: { "x-project-id": "project-alpha" },
849
+ },
850
+ env,
851
+ );
852
+ expect(detailAlphaAfterDelete.status).toBe(404);
853
+
854
+ // Project Beta is completely unaffected!
855
+ const detailBetaAfterDelete = await app.request(
856
+ `/internal/replays/${sessionId}`,
857
+ {
858
+ method: "GET",
859
+ headers: { "x-project-id": "project-beta" },
860
+ },
861
+ env,
862
+ );
863
+ expect(detailBetaAfterDelete.status).toBe(200);
864
+ const detailBetaStillValid = (await detailBetaAfterDelete.json()) as {
865
+ metadata: Record<string, unknown>;
866
+ events: Array<{ data: string }>;
867
+ };
868
+ expect(detailBetaStillValid.metadata.project_id).toBe("project-beta");
869
+ expect(detailBetaStillValid.events).toHaveLength(2);
870
+
871
+ // Project Beta's storage chunks still exist
872
+ const betaChunksAfterDelete = Array.from(bucket.objects.keys()).filter(
873
+ (k) => k.startsWith(`replays/project-beta/${sessionId}/`),
874
+ );
875
+ expect(betaChunksAfterDelete).toHaveLength(1);
876
+ });
877
+
878
+ it("proves identity index lookups scope session replay by project_id", async () => {
879
+ const rawDb = new DatabaseSync(":memory:");
880
+ for (const file of readdirSync(migrationsDir)
881
+ .filter((f) => f.endsWith(".sql"))
882
+ .sort()) {
883
+ rawDb.exec(readFileSync(resolve(migrationsDir, file), "utf8"));
884
+ }
885
+
886
+ // Insert 2 replays with same sessionId across two projects
887
+ rawDb
888
+ .prepare(`
889
+ INSERT INTO session_replay_metadata (project_id, session_id, visitor_id, first_chunk_at, last_chunk_at, chunk_count, events_count, storage_bytes)
890
+ VALUES ('proj-1', 'sess-collision', 'vis-1', '2026-06-01T10:00:00Z', '2026-06-01T10:05:00Z', 1, 10, 100),
891
+ ('proj-2', 'sess-collision', 'vis-2', '2026-06-01T11:00:00Z', '2026-06-01T11:05:00Z', 2, 20, 200)
892
+ `)
893
+ .run();
894
+
895
+ const sqlDb = new SyncSqliteDb(rawDb);
896
+ const index = new IdentityIndex(sqlDb);
897
+
898
+ // bySession for proj-1 returns proj-1 replay
899
+ const manifest1 = await index.bySession("proj-1", "sess-collision");
900
+ expect(manifest1.replay).toEqual({
901
+ sessionId: "sess-collision",
902
+ firstChunkAt: "2026-06-01T10:00:00Z",
903
+ lastChunkAt: "2026-06-01T10:05:00Z",
904
+ chunkCount: 1,
905
+ eventsCount: 10,
906
+ });
907
+
908
+ // bySession for proj-2 returns proj-2 replay
909
+ const manifest2 = await index.bySession("proj-2", "sess-collision");
910
+ expect(manifest2.replay).toEqual({
911
+ sessionId: "sess-collision",
912
+ firstChunkAt: "2026-06-01T11:00:00Z",
913
+ lastChunkAt: "2026-06-01T11:05:00Z",
914
+ chunkCount: 2,
915
+ eventsCount: 20,
916
+ });
917
+
918
+ // bySession for unknown project returns null replay
919
+ const manifest3 = await index.bySession("proj-3", "sess-collision");
920
+ expect(manifest3.replay).toBeNull();
921
+ });
922
+ });