From 7c030e4c5da4b5fdf3e75e80787a0344b040ac8a Mon Sep 17 00:00:00 2001 From: Francis Cao Date: Thu, 30 Jul 2026 10:39:38 -0700 Subject: [PATCH] fix unique constraint on relational session data. Closes #4183 --- .../23_update_session_data/migration.sql | 16 ++++ prisma/schema.prisma | 1 + .../sql/sessions/saveSessionData.test.ts | 74 ++++++++++++++++++ src/queries/sql/sessions/saveSessionData.ts | 75 +++++++++++++++---- 4 files changed, 151 insertions(+), 15 deletions(-) create mode 100644 prisma/migrations/23_update_session_data/migration.sql create mode 100644 src/queries/sql/sessions/saveSessionData.test.ts diff --git a/prisma/migrations/23_update_session_data/migration.sql b/prisma/migrations/23_update_session_data/migration.sql new file mode 100644 index 000000000..6ca32b48a --- /dev/null +++ b/prisma/migrations/23_update_session_data/migration.sql @@ -0,0 +1,16 @@ +WITH ranked_session_data AS ( + SELECT + "session_data_id", + ROW_NUMBER() OVER ( + PARTITION BY "session_id", "data_key" + ORDER BY "created_at" DESC NULLS LAST, "session_data_id" DESC + ) AS row_num + FROM "session_data" +) +DELETE FROM "session_data" +USING ranked_session_data +WHERE "session_data"."session_data_id" = ranked_session_data."session_data_id" + AND ranked_session_data.row_num > 1; + +CREATE UNIQUE INDEX "session_data_session_id_data_key_key" +ON "session_data"("session_id", "data_key"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dd6b20e32..97436ca16 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -208,6 +208,7 @@ model SessionData { @@index([sessionId]) @@index([sessionId, createdAt]) @@index([websiteId, createdAt, dataKey]) + @@unique([sessionId, dataKey]) @@map("session_data") } diff --git a/src/queries/sql/sessions/saveSessionData.test.ts b/src/queries/sql/sessions/saveSessionData.test.ts new file mode 100644 index 000000000..eacc271e9 --- /dev/null +++ b/src/queries/sql/sessions/saveSessionData.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { DATA_TYPE } from '@/lib/constants'; +import { relationalQuery } from './saveSessionData'; + +const { writeRawQueryMock } = vi.hoisted(() => ({ + writeRawQueryMock: vi.fn(), +})); + +vi.mock('@/lib/prisma', () => ({ + default: { + writeRawQuery: writeRawQueryMock, + }, +})); + +describe('relationalQuery', () => { + beforeEach(() => { + writeRawQueryMock.mockReset(); + writeRawQueryMock.mockResolvedValue(undefined); + }); + + test('writes session data with a Postgres upsert keyed by sessionId and dataKey', async () => { + const createdAt = new Date('2026-07-30T10:00:00.000Z'); + + await relationalQuery({ + websiteId: 'website-1', + sessionId: 'session-1', + sessionData: { plan: 'pro' }, + distinctId: 'distinct-1', + createdAt, + }); + + expect(writeRawQueryMock).toHaveBeenCalledTimes(1); + + const [query, params, tag] = writeRawQueryMock.mock.calls[0]; + + expect(query).toContain('insert into session_data'); + expect(query).toContain('on conflict (session_id, data_key)'); + expect(query).toContain('do update set'); + expect(query).toContain('coalesce({{createdAt}}, now())'); + expect(query).toContain('created_at = coalesce({{createdAt}}, session_data.created_at)'); + expect(query).toContain('{{id}}'); + expect(query).toContain('{{websiteId}}'); + expect(query).toContain('{{sessionId}}'); + expect(query).toContain('{{dataKey}}'); + expect(params).toEqual({ + id: expect.any(String), + websiteId: 'website-1', + sessionId: 'session-1', + dataKey: 'plan', + stringValue: 'pro', + numberValue: null, + dateValue: null, + dataType: DATA_TYPE.string, + distinctId: 'distinct-1', + createdAt, + }); + expect(tag).toBe('saveSessionData'); + }); + + test('preserves default and existing createdAt behavior when createdAt is omitted', async () => { + await relationalQuery({ + websiteId: 'website-1', + sessionId: 'session-1', + sessionData: { plan: 'pro' }, + distinctId: 'distinct-1', + }); + + const [query, params] = writeRawQueryMock.mock.calls[0]; + + expect(query).toContain('coalesce({{createdAt}}, now())'); + expect(query).toContain('created_at = coalesce({{createdAt}}, session_data.created_at)'); + expect(params.createdAt).toBeUndefined(); + }); +}); diff --git a/src/queries/sql/sessions/saveSessionData.ts b/src/queries/sql/sessions/saveSessionData.ts index ec9020256..61ad0e3a5 100644 --- a/src/queries/sql/sessions/saveSessionData.ts +++ b/src/queries/sql/sessions/saveSessionData.ts @@ -30,7 +30,7 @@ export async function relationalQuery({ distinctId, createdAt, }: SaveSessionDataArgs) { - const { client } = prisma; + const { writeRawQuery } = prisma; const jsonKeys = flattenJSON(sessionData); const normalizedDistinctId = truncateString(distinctId, FIELD_LENGTH.distinctId); @@ -49,24 +49,69 @@ export async function relationalQuery({ })); for (const data of flattenedData) { - const { sessionId, dataKey, ...props } = data; + const { + id, + websiteId, + sessionId, + dataKey, + stringValue, + numberValue, + dateValue, + dataType, + distinctId, + createdAt, + } = data; - const updateResult = await client.sessionData.updateMany({ - where: { + await writeRawQuery( + ` + insert into session_data ( + session_data_id, + website_id, + session_id, + data_key, + string_value, + number_value, + date_value, + data_type, + distinct_id, + created_at + ) + values ( + {{id}}, + {{websiteId}}, + {{sessionId}}, + {{dataKey}}, + {{stringValue}}, + {{numberValue}}, + {{dateValue}}, + {{dataType}}, + {{distinctId}}, + coalesce({{createdAt}}, now()) + ) + on conflict (session_id, data_key) + do update set + website_id = excluded.website_id, + string_value = excluded.string_value, + number_value = excluded.number_value, + date_value = excluded.date_value, + data_type = excluded.data_type, + distinct_id = excluded.distinct_id, + created_at = coalesce({{createdAt}}, session_data.created_at) + `, + { + id, + websiteId, sessionId, dataKey, + stringValue, + numberValue, + dateValue, + dataType, + distinctId, + createdAt, }, - data: { - ...props, - }, - }); - - // If no record was updated, create a new one - if (updateResult.count === 0) { - await client.sessionData.create({ - data, - }); - } + 'saveSessionData', + ); } }