Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1 +1,22 @@
ALTER TABLE "SessionRecordingChunk" RENAME COLUMN "tabId" TO "sessionReplaySegmentId";

ALTER TABLE "SessionRecording" RENAME TO "SessionReplay";
ALTER TABLE "SessionRecordingChunk" RENAME TO "SessionReplayChunk";
ALTER TABLE "SessionReplayChunk" RENAME COLUMN "sessionRecordingId" TO "sessionReplayId";
Comment on lines +2 to +5
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Modifying an already-committed migration

This migration file previously contained only one statement (renaming the tabId column), which was already committed on the base branch rename-replay-tab-id. This PR appends three additional DDL statements to the same migration file.

Prisma tracks which migrations have been applied by storing the SQL content hash in _prisma_migrations. Any environment that already ran the original single-line migration will have a hash mismatch against this updated file. When prisma migrate deploy is next run on such an environment, Prisma will report a "drift" error and refuse to apply, because the applied migration no longer matches the file on disk.

Since the base branch is a pre-merge feature branch (not yet applied to dev or production), the risk here is scoped to developer local environments and CI runs on the base branch. However, if any developer or CI runner has already applied the one-line migration against a real database, they will need to manually resolve the drift (e.g. prisma migrate resolve --applied).

The safer practice is to add a new, separate migration file for the table renames (e.g. 20260217000000_rename_session_recording_tables_to_session_replay.sql). This keeps each migration atomic, avoids hash conflicts on any already-migrated database, and also fixes the misleading migration name (which currently only references the tabId rename, not the table renames).


-- Rename primary key constraints
ALTER TABLE "SessionReplay" RENAME CONSTRAINT "SessionRecording_pkey" TO "SessionReplay_pkey";
ALTER TABLE "SessionReplayChunk" RENAME CONSTRAINT "SessionRecordingChunk_pkey" TO "SessionReplayChunk_pkey";

-- Rename foreign key constraints
ALTER TABLE "SessionReplay" RENAME CONSTRAINT "SessionRecording_tenancyId_fkey" TO "SessionReplay_tenancyId_fkey";
ALTER TABLE "SessionReplay" RENAME CONSTRAINT "SessionRecording_tenancyId_projectUserId_fkey" TO "SessionReplay_tenancyId_projectUserId_fkey";
ALTER TABLE "SessionReplayChunk" RENAME CONSTRAINT "SessionRecordingChunk_tenancyId_fkey" TO "SessionReplayChunk_tenancyId_fkey";
ALTER TABLE "SessionReplayChunk" RENAME CONSTRAINT "SessionRecordingChunk_tenancyId_sessionRecordingId_fkey" TO "SessionReplayChunk_tenancyId_sessionReplayId_fkey";

-- Rename indexes
ALTER INDEX "SessionRecording_tenancyId_lastEventAt_idx" RENAME TO "SessionReplay_tenancyId_lastEventAt_idx";
ALTER INDEX "SessionRecording_tenancyId_projectUserId_startedAt_idx" RENAME TO "SessionReplay_tenancyId_projectUserId_startedAt_idx";
ALTER INDEX "SessionRecording_tenancyId_refreshTokenId_updatedAt_idx" RENAME TO "SessionReplay_tenancyId_refreshTokenId_updatedAt_idx";
ALTER INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_batchId_key" RENAME TO "SessionReplayChunk_tenancyId_sessionReplayId_batchId_key";
ALTER INDEX "SessionRecordingChunk_tenancyId_sessionRecordingId_createdA_idx" RENAME TO "SessionReplayChunk_tenancyId_sessionReplayId_createdAt_idx";
26 changes: 14 additions & 12 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ model Tenancy {
organizationId String? @db.Uuid
hasNoOrganization BooleanTrue?
emailOutboxes EmailOutbox[]
sessionRecordings SessionRecording[]
sessionRecordingChunks SessionRecordingChunk[]
sessionReplays SessionReplay[]
sessionReplayChunks SessionReplayChunk[]

@@unique([projectId, branchId, organizationId])
@@unique([projectId, branchId, hasNoOrganization])
Expand Down Expand Up @@ -236,7 +236,7 @@ model ProjectUser {
Project Project? @relation(fields: [projectId], references: [id])
projectId String?
userNotificationPreference UserNotificationPreference[]
sessionRecordings SessionRecording[]
sessionReplays SessionReplay[]

@@id([tenancyId, projectUserId])
@@unique([mirroredProjectId, mirroredBranchId, projectUserId])
Expand Down Expand Up @@ -280,7 +280,7 @@ model ProjectUserOAuthAccount {
@@index([tenancyId, projectUserId])
}

model SessionRecording {
model SessionReplay {
id String @db.Uuid

tenancyId String @db.Uuid
Expand All @@ -296,20 +296,21 @@ model SessionRecording {
projectUser ProjectUser @relation(fields: [tenancyId, projectUserId], references: [tenancyId, projectUserId], onDelete: Cascade)
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)

chunks SessionRecordingChunk[]
chunks SessionReplayChunk[]

@@id([tenancyId, id])
@@map("SessionReplay")
@@index([tenancyId, projectUserId, startedAt])
@@index([tenancyId, lastEventAt])
// index by updatedAt instead of lastEventAt because event timing can be spoofed
@@index([tenancyId, refreshTokenId, updatedAt])
}

model SessionRecordingChunk {
model SessionReplayChunk {
id String @id @default(uuid()) @db.Uuid

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant @map annotation

The @map("sessionReplayId") decorator is redundant here because the Prisma field name (sessionReplayId) is identical to the mapped database column name. Prisma only uses @map when the field name differs from the underlying column name. This can be removed to avoid confusion.

Suggested change
sessionReplayId String @db.Uuid

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

tenancyId String @db.Uuid
sessionRecordingId String @db.Uuid
tenancyId String @db.Uuid
sessionReplayId String @db.Uuid @map("sessionReplayId")

// Unique per uploaded batch for a given session id.
batchId String @db.Uuid
Expand All @@ -329,11 +330,12 @@ model SessionRecordingChunk {

createdAt DateTime @default(now())

sessionRecording SessionRecording @relation(fields: [tenancyId, sessionRecordingId], references: [tenancyId, id], onDelete: Cascade)
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)
sessionReplay SessionReplay @relation(fields: [tenancyId, sessionReplayId], references: [tenancyId, id], onDelete: Cascade)
tenancy Tenancy @relation(fields: [tenancyId], references: [id], onDelete: Cascade)

@@unique([tenancyId, sessionRecordingId, batchId])
@@index([tenancyId, sessionRecordingId, createdAt])
@@unique([tenancyId, sessionReplayId, batchId])
@@map("SessionReplayChunk")
@@index([tenancyId, sessionReplayId, createdAt])
}

enum ContactChannelType {
Expand Down
28 changes: 14 additions & 14 deletions apps/backend/prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1118,11 +1118,11 @@ async function seedDummyProject(options: DummyProjectSeedOptions) {
userEmailToId,
});

await seedDummySessionRecordings({
await seedDummySessionReplays({
prisma: dummyPrisma,
tenancyId: dummyTenancy.id,
userEmailToId,
targetSessionRecordingCount: 75
targetSessionReplayCount: 75
});

console.log('Seeded dummy project data');
Expand Down Expand Up @@ -1773,43 +1773,43 @@ async function seedDummySessionActivityEvents(options: SessionActivityEventSeedO
console.log('Finished seeding session activity events');
}

type SessionRecordingSeedOptions = {
type SessionReplaySeedOptions = {
prisma: PrismaClientTransaction,
tenancyId: string,
userEmailToId: Map<string, string>,
targetSessionRecordingCount?: number,
targetSessionReplayCount?: number,
};

async function seedDummySessionRecordings(options: SessionRecordingSeedOptions) {
async function seedDummySessionReplays(options: SessionReplaySeedOptions) {
const {
prisma,
tenancyId,
userEmailToId,
targetSessionRecordingCount = 250,
targetSessionReplayCount = 250,
} = options;

const existingCount = await prisma.sessionRecording.count({
const existingCount = await prisma.sessionReplay.count({
where: {
tenancyId,
},
});

if (existingCount >= targetSessionRecordingCount) {
console.log(`Dummy project already has ${existingCount} session recordings, skipping seeding`);
if (existingCount >= targetSessionReplayCount) {
console.log(`Dummy project already has ${existingCount} session replays, skipping seeding`);
return;
}

const toCreate = targetSessionRecordingCount - existingCount;
const toCreate = targetSessionReplayCount - existingCount;
const userIds = Array.from(userEmailToId.values());
if (userIds.length === 0) {
throw new Error('Cannot seed session recordings: no dummy project users exist');
throw new Error('Cannot seed session replays: no dummy project users exist');
}

const now = new Date();
const twoWeeksAgo = new Date(now);
twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14);

const seeds: Prisma.SessionRecordingCreateManyInput[] = [];
const seeds: Prisma.SessionReplayCreateManyInput[] = [];
for (let i = 0; i < toCreate; i++) {
const startedAt = new Date(
twoWeeksAgo.getTime() + Math.random() * (now.getTime() - twoWeeksAgo.getTime()),
Expand All @@ -1828,9 +1828,9 @@ async function seedDummySessionRecordings(options: SessionRecordingSeedOptions)
});
}

await prisma.sessionRecording.createMany({
await prisma.sessionReplay.createMany({
data: seeds,
});

console.log(`Seeded ${toCreate} session recordings`);
console.log(`Seeded ${toCreate} session replays`);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const GET = createSmartRouteHandler({
tenancy: adaptSchema.defined(),
}).defined(),
params: yupObject({
session_recording_id: yupString().defined(),
session_replay_id: yupString().defined(),
chunk_id: yupString().defined(),
}).defined(),
}),
Expand All @@ -31,13 +31,13 @@ export const GET = createSmartRouteHandler({
async handler({ auth, params }) {
const prisma = await getPrismaClientForTenancy(auth.tenancy);

const sessionRecordingId = params.session_recording_id;
const sessionReplayId = params.session_replay_id;
const chunkId = params.chunk_id;

const chunk = await prisma.sessionRecordingChunk.findFirst({
const chunk = await prisma.sessionReplayChunk.findFirst({
where: {
tenancyId: auth.tenancy.id,
sessionRecordingId,
sessionReplayId,
id: chunkId,
},
select: {
Expand All @@ -64,20 +64,20 @@ export const GET = createSmartRouteHandler({
try {
parsed = JSON.parse(new TextDecoder().decode(unzipped));
} catch (e) {
throw new StackAssertionError("Failed to decode session recording chunk JSON", { cause: e });
throw new StackAssertionError("Failed to decode session replay chunk JSON", { cause: e });
}

if (typeof parsed !== "object" || parsed === null) {
throw new StackAssertionError("Decoded session recording chunk is not an object");
throw new StackAssertionError("Decoded session replay chunk is not an object");
}
if (parsed.session_recording_id !== sessionRecordingId) {
throw new StackAssertionError("Decoded session recording chunk session_recording_id mismatch", {
expected: sessionRecordingId,
actual: parsed.session_recording_id,
if (parsed.session_replay_id !== sessionReplayId) {
throw new StackAssertionError("Decoded session replay chunk session_replay_id mismatch", {
expected: sessionReplayId,
actual: parsed.session_replay_id,
});
}
if (!Array.isArray(parsed.events)) {
throw new StackAssertionError("Decoded session recording chunk events is not an array");
throw new StackAssertionError("Decoded session replay chunk events is not an array");
}

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const GET = createSmartRouteHandler({
tenancy: adaptSchema.defined(),
}).defined(),
params: yupObject({
session_recording_id: yupString().defined(),
session_replay_id: yupString().defined(),
}).defined(),
query: yupObject({
cursor: yupString().optional(),
Expand Down Expand Up @@ -45,13 +45,13 @@ export const GET = createSmartRouteHandler({
async handler({ auth, params, query }) {
const prisma = await getPrismaClientForTenancy(auth.tenancy);

const sessionRecordingId = params.session_recording_id;
const exists = await prisma.sessionRecording.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: sessionRecordingId } },
const sessionReplayId = params.session_replay_id;
const exists = await prisma.sessionReplay.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: sessionReplayId } },
select: { id: true },
});
if (!exists) {
throw new KnownErrors.ItemNotFound(sessionRecordingId);
throw new KnownErrors.ItemNotFound(sessionReplayId);
}

const rawLimit = query.limit ?? String(DEFAULT_LIMIT);
Expand All @@ -61,10 +61,10 @@ export const GET = createSmartRouteHandler({
const cursorId = query.cursor;
let cursorPivot: { firstEventAt: Date } | null = null;
if (cursorId) {
cursorPivot = await prisma.sessionRecordingChunk.findFirst({
cursorPivot = await prisma.sessionReplayChunk.findFirst({
where: {
tenancyId: auth.tenancy.id,
sessionRecordingId,
sessionReplayId,
id: cursorId,
},
select: { firstEventAt: true },
Expand All @@ -74,17 +74,17 @@ export const GET = createSmartRouteHandler({
}
}

const cursorWhere: Prisma.SessionRecordingChunkWhereInput = cursorId && cursorPivot ? {
const cursorWhere: Prisma.SessionReplayChunkWhereInput = cursorId && cursorPivot ? {
OR: [
{ firstEventAt: { gt: cursorPivot.firstEventAt } },
{ AND: [{ firstEventAt: { equals: cursorPivot.firstEventAt } }, { id: { gt: cursorId } }] },
],
} : {};

const chunks = await prisma.sessionRecordingChunk.findMany({
const chunks = await prisma.sessionReplayChunk.findMany({
where: {
tenancyId: auth.tenancy.id,
sessionRecordingId,
sessionReplayId,
...cursorWhere,
},
orderBy: [{ firstEventAt: "asc" }, { id: "asc" }],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const GET = createSmartRouteHandler({
tenancy: adaptSchema.defined(),
}).defined(),
params: yupObject({
session_recording_id: yupString().defined(),
session_replay_id: yupString().defined(),
}).defined(),
query: yupObject({
offset: yupString().optional(),
Expand Down Expand Up @@ -49,19 +49,19 @@ export const GET = createSmartRouteHandler({
async handler({ auth, params, query }) {
const prisma = await getPrismaClientForTenancy(auth.tenancy);

const sessionRecordingId = params.session_recording_id;
const exists = await prisma.sessionRecording.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: sessionRecordingId } },
const sessionReplayId = params.session_replay_id;
const exists = await prisma.sessionReplay.findUnique({
where: { tenancyId_id: { tenancyId: auth.tenancy.id, id: sessionReplayId } },
select: { id: true },
});
if (!exists) {
throw new KnownErrors.ItemNotFound(sessionRecordingId);
throw new KnownErrors.ItemNotFound(sessionReplayId);
}

const chunks = await prisma.sessionRecordingChunk.findMany({
const chunks = await prisma.sessionReplayChunk.findMany({
where: {
tenancyId: auth.tenancy.id,
sessionRecordingId,
sessionReplayId,
},
orderBy: [{ firstEventAt: "asc" }, { id: "asc" }],
select: {
Expand Down Expand Up @@ -110,20 +110,20 @@ export const GET = createSmartRouteHandler({
try {
parsed = JSON.parse(new TextDecoder().decode(unzipped));
} catch (e) {
throw new StackAssertionError("Failed to decode session recording chunk JSON", { cause: e });
throw new StackAssertionError("Failed to decode session replay chunk JSON", { cause: e });
}

if (typeof parsed !== "object" || parsed === null) {
throw new StackAssertionError("Decoded session recording chunk is not an object");
throw new StackAssertionError("Decoded session replay chunk is not an object");
}
if (parsed.session_recording_id !== sessionRecordingId) {
throw new StackAssertionError("Decoded session recording chunk session_recording_id mismatch", {
expected: sessionRecordingId,
actual: parsed.session_recording_id,
if (parsed.session_replay_id !== sessionReplayId) {
throw new StackAssertionError("Decoded session replay chunk session_replay_id mismatch", {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Session replay event retrieval throws error for old S3 data that uses session_recording_id instead of session_replay_id

Fix on Vercel

expected: sessionReplayId,
actual: parsed.session_replay_id,
});
}
if (!Array.isArray(parsed.events)) {
throw new StackAssertionError("Decoded session recording chunk events is not an array");
throw new StackAssertionError("Decoded session replay chunk events is not an array");
}

chunkEvents[idx] = { chunk_id: chunk.id, events: parsed.events as any[] };
Expand Down
Loading
Loading