@obsunified/collector 2.0.1 → 2.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.
- package/dist/auth.d.ts +1 -1
- package/dist/auth.js +1 -1
- package/dist/{chunk-UGWDOGGH.js → chunk-KMEZKGSO.js} +22 -10
- package/dist/chunk-KMEZKGSO.js.map +1 -0
- package/dist/{chunk-XKQTVIRE.js → chunk-ZSF5XP3B.js} +8 -2
- package/dist/chunk-ZSF5XP3B.js.map +1 -0
- package/dist/index.d.ts +630 -27
- package/dist/index.js +5819 -853
- package/dist/index.js.map +1 -1
- package/dist/{ingest-auth-HFVO1zIp.d.ts → ingest-auth-C5-8Mly5.d.ts} +9 -0
- package/dist/{openai-FJYOQ7WO.js → openai-GSJZGFGL.js} +2 -2
- package/package.json +5 -5
- package/src/__tests__/autosre-closed-loop.test.ts +1275 -0
- package/src/analyses/derive.ts +26 -25
- package/src/analyses/investigations.ts +8 -8
- package/src/analyses/tier0.ts +22 -22
- package/src/auth/dashboard-auth.test.ts +791 -0
- package/src/auth/dashboard-auth.ts +187 -38
- package/src/auth/login-rate-limit.test.ts +129 -0
- package/src/auth/login-rate-limit.ts +143 -0
- package/src/framework/body-limit.test.ts +165 -0
- package/src/framework/collector.ts +355 -115
- package/src/framework/env.ts +9 -0
- package/src/framework/limits.ts +31 -0
- package/src/framework/scheduled-handlers.test.ts +342 -0
- package/src/index.ts +59 -1
- package/src/lib/action-retention.test.ts +472 -0
- package/src/lib/action-retention.ts +420 -0
- package/src/lib/actions-store.ts +6 -12
- package/src/lib/ai-store/calls.ts +2 -2
- package/src/lib/ai-store/sessions.ts +3 -3
- package/src/lib/ai-store/spans.ts +1 -1
- package/src/lib/ai-store/types.ts +2 -1
- package/src/lib/alerts-store.test.ts +153 -0
- package/src/lib/alerts-store.ts +122 -21
- package/src/lib/analyses-runner.test.ts +75 -0
- package/src/lib/analyses-runner.ts +69 -3
- package/src/lib/analyses-store-latest.test.ts +61 -0
- package/src/lib/analyses-store.ts +17 -10
- package/src/lib/autosre-change-correlation.ts +780 -0
- package/src/lib/autosre-findings.ts +725 -0
- package/src/lib/autosre-ontology.ts +914 -0
- package/src/lib/autosre-remediation-gate.ts +1663 -0
- package/src/lib/blob-store.ts +1 -1
- package/src/lib/eval-cases-store.ts +63 -1
- package/src/lib/evidence-references.ts +6 -0
- package/src/lib/housekeeping.test.ts +153 -0
- package/src/lib/housekeeping.ts +193 -0
- package/src/lib/identity-index/action-lookups.ts +6 -6
- package/src/lib/identity-index/key-lookups.ts +2 -2
- package/src/lib/identity-index/user-lookup.ts +8 -5
- package/src/lib/identity-index.test.ts +2 -3
- package/src/lib/metrics-store-series.test.ts +99 -0
- package/src/lib/metrics-store.test.ts +23 -0
- package/src/lib/metrics-store.ts +27 -3
- package/src/lib/projects-store.test.ts +25 -0
- package/src/lib/projects-store.ts +4 -3
- package/src/lib/sql-db-dialect.test.ts +12 -3
- package/src/lib/sql-db-postgres.integration.test.ts +1225 -0
- package/src/lib/sql-db-postgres.test.ts +314 -0
- package/src/lib/sql-db-postgres.ts +392 -54
- package/src/lib/sql-db-sqlite-time-windows.test.ts +194 -0
- package/src/lib/sql-db.ts +40 -6
- package/src/lib/store/ingest.ts +94 -6
- package/src/lib/test-utils/action-graph-fixtures.ts +307 -0
- package/src/lib/test-utils/node-sqlite-db.ts +188 -0
- package/src/lib/webhook-url.test.ts +122 -0
- package/src/lib/webhook-url.ts +155 -0
- package/src/migrations/041_ingest_key_hash_text.sql +5 -0
- package/src/migrations/042_session_replay_project_composite_pk.sql +52 -0
- package/src/migrations/043_metric_exemplars_text_timestamps.sql +5 -0
- package/src/migrations/044_actions_started_at_index.sql +10 -0
- package/src/migrations/045_user_profiles_project_composite_pk.sql +40 -0
- package/src/migrations/046_action_graph_project_composite_pk.sql +255 -0
- package/src/migrations/047_ai_span_payloads_project_composite_pk.sql +61 -0
- package/src/migrations/048_telemetry_spans_project_composite_pk.sql +92 -0
- package/src/migrations/049_alert_state_notified.sql +17 -0
- package/src/migrations/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations/alert-state-notified.test.ts +111 -0
- package/src/migrations/parity.test.ts +68 -0
- package/src/migrations/project-scoped-keys.test.ts +432 -0
- package/src/migrations-postgres/009_projects_and_keys.sql +4 -4
- package/src/migrations-postgres/026_obs_dashboard_project_seed.sql +1 -1
- package/src/migrations-postgres/035_metric_exemplars.sql +5 -2
- package/src/migrations-postgres/041_ingest_key_hash_text.sql +32 -0
- package/src/migrations-postgres/042_session_replay_project_composite_pk.sql +16 -0
- package/src/migrations-postgres/043_metric_exemplars_text_timestamps.sql +47 -0
- package/src/migrations-postgres/044_actions_started_at_index.sql +8 -0
- package/src/migrations-postgres/045_user_profiles_project_composite_pk.sql +33 -0
- package/src/migrations-postgres/046_action_graph_project_composite_pk.sql +39 -0
- package/src/migrations-postgres/047_ai_span_payloads_project_composite_pk.sql +30 -0
- package/src/migrations-postgres/048_telemetry_spans_project_composite_pk.sql +37 -0
- package/src/migrations-postgres/049_alert_state_notified.sql +10 -0
- package/src/migrations-postgres/050_autosre_causal_ontology.sql +216 -0
- package/src/migrations-postgres/README.md +58 -5
- package/src/otlp/decode/body.test.ts +76 -0
- package/src/otlp/decode/body.ts +67 -6
- package/src/plugins/_json.ts +21 -0
- package/src/plugins/action-graph-processor.ts +6 -12
- package/src/plugins/ai-receiver.ts +5 -12
- package/src/plugins/ai-span-payloads-processor.ts +1 -2
- package/src/plugins/alerts-evaluator-retention.test.ts +170 -0
- package/src/plugins/alerts-evaluator.test.ts +464 -3
- package/src/plugins/alerts-evaluator.ts +181 -41
- package/src/plugins/alerts-routes.ts +10 -6
- package/src/plugins/ask-routes.ts +4 -5
- package/src/plugins/autosre-routes.ts +1019 -0
- package/src/plugins/connected-routes/manifest.ts +13 -1
- package/src/plugins/connected-routes.test.ts +106 -0
- package/src/plugins/connected-routes.ts +285 -5
- package/src/plugins/dashboard-routes.test.ts +120 -0
- package/src/plugins/dashboard-routes.ts +11 -2
- package/src/plugins/default-span-enrichment.ts +13 -1
- package/src/plugins/eval-cases-routes.ts +65 -19
- package/src/plugins/evidence-retrieval-routes.ts +300 -4
- package/src/plugins/identity-receiver.ts +9 -11
- package/src/plugins/json-body.test.ts +91 -0
- package/src/plugins/onboarding-routes.test.ts +105 -0
- package/src/plugins/onboarding-routes.ts +26 -11
- package/src/plugins/projects-routes.ts +2 -1
- package/src/plugins/replay-isolation.test.ts +922 -0
- package/src/plugins/replay-receiver.ts +8 -8
- package/src/plugins/usage-receiver.ts +3 -6
- package/dist/chunk-UGWDOGGH.js.map +0 -1
- package/dist/chunk-XKQTVIRE.js.map +0 -1
- /package/dist/{openai-FJYOQ7WO.js.map → openai-GSJZGFGL.js.map} +0 -0
|
@@ -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
|
+
});
|