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])
|
||||||
@@index([sessionId, createdAt])
|
@@index([sessionId, createdAt])
|
||||||
@@index([websiteId, createdAt, dataKey])
|
@@index([websiteId, createdAt, dataKey])
|
||||||
|
@@unique([sessionId, dataKey])
|
||||||
@@map("session_data")
|
@@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,
|
distinctId,
|
||||||
createdAt,
|
createdAt,
|
||||||
}: SaveSessionDataArgs) {
|
}: SaveSessionDataArgs) {
|
||||||
const { client } = prisma;
|
const { writeRawQuery } = prisma;
|
||||||
|
|
||||||
const jsonKeys = flattenJSON(sessionData);
|
const jsonKeys = flattenJSON(sessionData);
|
||||||
const normalizedDistinctId = truncateString(distinctId, FIELD_LENGTH.distinctId);
|
const normalizedDistinctId = truncateString(distinctId, FIELD_LENGTH.distinctId);
|
||||||
@@ -49,24 +49,69 @@ export async function relationalQuery({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
for (const data of flattenedData) {
|
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({
|
await writeRawQuery(
|
||||||
where: {
|
`
|
||||||
|
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,
|
sessionId,
|
||||||
dataKey,
|
dataKey,
|
||||||
|
stringValue,
|
||||||
|
numberValue,
|
||||||
|
dateValue,
|
||||||
|
dataType,
|
||||||
|
distinctId,
|
||||||
|
createdAt,
|
||||||
},
|
},
|
||||||
data: {
|
'saveSessionData',
|
||||||
...props,
|
);
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// If no record was updated, create a new one
|
|
||||||
if (updateResult.count === 0) {
|
|
||||||
await client.sessionData.create({
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user