fix unique constraint on relational session data. Closes #4183
This commit is contained in:
1 parent
047b0554b3
commit
7c030e4c5d
4 files changed
+151
-15
No files matched your search
@@ -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");
|
||||
@@ -208,6 +208,7 @@ model SessionData {
|
||||
@@index([sessionId])
|
||||
@@index([sessionId, createdAt])
|
||||
@@index([websiteId, createdAt, dataKey])
|
||||
@@unique([sessionId, dataKey])
|
||||
@@map("session_data")
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user