Skip to content

Commit 7c030e4

Browse files
committed
fix unique constraint on relational session data. Closes #4183
1 parent 047b055 commit 7c030e4

4 files changed

Lines changed: 151 additions & 15 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
WITH ranked_session_data AS (
2+
SELECT
3+
"session_data_id",
4+
ROW_NUMBER() OVER (
5+
PARTITION BY "session_id", "data_key"
6+
ORDER BY "created_at" DESC NULLS LAST, "session_data_id" DESC
7+
) AS row_num
8+
FROM "session_data"
9+
)
10+
DELETE FROM "session_data"
11+
USING ranked_session_data
12+
WHERE "session_data"."session_data_id" = ranked_session_data."session_data_id"
13+
AND ranked_session_data.row_num > 1;
14+
15+
CREATE UNIQUE INDEX "session_data_session_id_data_key_key"
16+
ON "session_data"("session_id", "data_key");

prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ model SessionData {
208208
@@index([sessionId])
209209
@@index([sessionId, createdAt])
210210
@@index([websiteId, createdAt, dataKey])
211+
@@unique([sessionId, dataKey])
211212
@@map("session_data")
212213
}
213214

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { beforeEach, describe, expect, test, vi } from 'vitest';
2+
import { DATA_TYPE } from '@/lib/constants';
3+
import { relationalQuery } from './saveSessionData';
4+
5+
const { writeRawQueryMock } = vi.hoisted(() => ({
6+
writeRawQueryMock: vi.fn(),
7+
}));
8+
9+
vi.mock('@/lib/prisma', () => ({
10+
default: {
11+
writeRawQuery: writeRawQueryMock,
12+
},
13+
}));
14+
15+
describe('relationalQuery', () => {
16+
beforeEach(() => {
17+
writeRawQueryMock.mockReset();
18+
writeRawQueryMock.mockResolvedValue(undefined);
19+
});
20+
21+
test('writes session data with a Postgres upsert keyed by sessionId and dataKey', async () => {
22+
const createdAt = new Date('2026-07-30T10:00:00.000Z');
23+
24+
await relationalQuery({
25+
websiteId: 'website-1',
26+
sessionId: 'session-1',
27+
sessionData: { plan: 'pro' },
28+
distinctId: 'distinct-1',
29+
createdAt,
30+
});
31+
32+
expect(writeRawQueryMock).toHaveBeenCalledTimes(1);
33+
34+
const [query, params, tag] = writeRawQueryMock.mock.calls[0];
35+
36+
expect(query).toContain('insert into session_data');
37+
expect(query).toContain('on conflict (session_id, data_key)');
38+
expect(query).toContain('do update set');
39+
expect(query).toContain('coalesce({{createdAt}}, now())');
40+
expect(query).toContain('created_at = coalesce({{createdAt}}, session_data.created_at)');
41+
expect(query).toContain('{{id}}');
42+
expect(query).toContain('{{websiteId}}');
43+
expect(query).toContain('{{sessionId}}');
44+
expect(query).toContain('{{dataKey}}');
45+
expect(params).toEqual({
46+
id: expect.any(String),
47+
websiteId: 'website-1',
48+
sessionId: 'session-1',
49+
dataKey: 'plan',
50+
stringValue: 'pro',
51+
numberValue: null,
52+
dateValue: null,
53+
dataType: DATA_TYPE.string,
54+
distinctId: 'distinct-1',
55+
createdAt,
56+
});
57+
expect(tag).toBe('saveSessionData');
58+
});
59+
60+
test('preserves default and existing createdAt behavior when createdAt is omitted', async () => {
61+
await relationalQuery({
62+
websiteId: 'website-1',
63+
sessionId: 'session-1',
64+
sessionData: { plan: 'pro' },
65+
distinctId: 'distinct-1',
66+
});
67+
68+
const [query, params] = writeRawQueryMock.mock.calls[0];
69+
70+
expect(query).toContain('coalesce({{createdAt}}, now())');
71+
expect(query).toContain('created_at = coalesce({{createdAt}}, session_data.created_at)');
72+
expect(params.createdAt).toBeUndefined();
73+
});
74+
});

src/queries/sql/sessions/saveSessionData.ts

Lines changed: 60 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export async function relationalQuery({
3030
distinctId,
3131
createdAt,
3232
}: SaveSessionDataArgs) {
33-
const { client } = prisma;
33+
const { writeRawQuery } = prisma;
3434

3535
const jsonKeys = flattenJSON(sessionData);
3636
const normalizedDistinctId = truncateString(distinctId, FIELD_LENGTH.distinctId);
@@ -49,24 +49,69 @@ export async function relationalQuery({
4949
}));
5050

5151
for (const data of flattenedData) {
52-
const { sessionId, dataKey, ...props } = data;
52+
const {
53+
id,
54+
websiteId,
55+
sessionId,
56+
dataKey,
57+
stringValue,
58+
numberValue,
59+
dateValue,
60+
dataType,
61+
distinctId,
62+
createdAt,
63+
} = data;
5364

54-
const updateResult = await client.sessionData.updateMany({
55-
where: {
65+
await writeRawQuery(
66+
`
67+
insert into session_data (
68+
session_data_id,
69+
website_id,
70+
session_id,
71+
data_key,
72+
string_value,
73+
number_value,
74+
date_value,
75+
data_type,
76+
distinct_id,
77+
created_at
78+
)
79+
values (
80+
{{id}},
81+
{{websiteId}},
82+
{{sessionId}},
83+
{{dataKey}},
84+
{{stringValue}},
85+
{{numberValue}},
86+
{{dateValue}},
87+
{{dataType}},
88+
{{distinctId}},
89+
coalesce({{createdAt}}, now())
90+
)
91+
on conflict (session_id, data_key)
92+
do update set
93+
website_id = excluded.website_id,
94+
string_value = excluded.string_value,
95+
number_value = excluded.number_value,
96+
date_value = excluded.date_value,
97+
data_type = excluded.data_type,
98+
distinct_id = excluded.distinct_id,
99+
created_at = coalesce({{createdAt}}, session_data.created_at)
100+
`,
101+
{
102+
id,
103+
websiteId,
56104
sessionId,
57105
dataKey,
106+
stringValue,
107+
numberValue,
108+
dateValue,
109+
dataType,
110+
distinctId,
111+
createdAt,
58112
},
59-
data: {
60-
...props,
61-
},
62-
});
63-
64-
// If no record was updated, create a new one
65-
if (updateResult.count === 0) {
66-
await client.sessionData.create({
67-
data,
68-
});
69-
}
113+
'saveSessionData',
114+
);
70115
}
71116
}
72117

0 commit comments

Comments
 (0)