Skip to content

Commit 8e4066e

Browse files
fix(cards): prevent concurrent default card race condition (#344) (#385)
* fix(cards): prevent concurrent default card race condition (#344) * fix(cards): address review feedback * fix(cards): address lint feedback * chore: remove accidental file from PR * fix: throw not found error instead of returning * fix: throw last card error instead of returning --------- Signed-off-by: SOMAPURAM UDAY <udaysomapuram@gmail.com>
1 parent 01981a0 commit 8e4066e

2 files changed

Lines changed: 185 additions & 51 deletions

File tree

apps/backend/src/__tests__/cards.test.ts

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import Fastify, { type FastifyInstance } from 'fastify';
1+
import Fastify, { type FastifyInstance, type FastifyRequest } from 'fastify';
22
import { describe, it, expect, beforeEach, vi } from 'vitest';
33

44
import { cardRoutes } from '../routes/cards.js';
@@ -48,10 +48,14 @@ const mockPrisma = {
4848
// against the same mock client, preserving existing per-operation mocks.
4949
function wireTransaction(): void {
5050
mockPrisma.$transaction.mockImplementation(
51-
async (callback: (tx: typeof mockPrisma) => Promise<unknown>) => callback(mockPrisma),
51+
async (callback: (tx: typeof mockPrisma) => Promise<unknown>, _options?: unknown) => callback(mockPrisma),
5252
);
5353
}
5454

55+
async function buildApp(): Promise<FastifyInstance> {
56+
const app = Fastify({ logger: false });
57+
app.decorate('prisma', mockPrisma);
58+
app.decorate('authenticate', async (request: FastifyRequest & { user?: { id: string } }) => {
5559
async function buildApp():Promise<FastifyInstance> {
5660
const app = Fastify({ logger: false });
5761
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
@@ -182,6 +186,55 @@ describe('POST /api/cards — link ownership validation', () => {
182186

183187
expect(res.statusCode).toBe(500);
184188
});
189+
190+
it('wraps creation in a Serializable transaction to prevent race conditions', async () => {
191+
mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]);
192+
mockPrisma.card.count.mockResolvedValue(0);
193+
mockPrisma.card.create.mockResolvedValue({ ...mockCard, cardLinks: [] });
194+
195+
const app = await buildApp();
196+
const res = await app.inject({
197+
method: 'POST',
198+
url: '/api/cards',
199+
payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID] },
200+
});
201+
202+
expect(res.statusCode).toBe(201);
203+
expect(mockPrisma.$transaction).toHaveBeenCalledWith(
204+
expect.any(Function),
205+
{ isolationLevel: 'Serializable' }
206+
);
207+
});
208+
209+
it('retries the transaction on P2034 serialization failure', async () => {
210+
mockPrisma.platformLink.findMany.mockResolvedValue([]);
211+
212+
// First attempt fails with P2034 (serialization conflict)
213+
// Second attempt succeeds
214+
const error = new Error('Serialization failure') as Error & { code: string };
215+
error.code = 'P2034';
216+
217+
// We mock $transaction to fail once, then succeed
218+
mockPrisma.$transaction
219+
.mockRejectedValueOnce(error)
220+
.mockImplementationOnce(
221+
async (callback: (tx: typeof mockPrisma) => Promise<unknown>) => callback(mockPrisma)
222+
);
223+
224+
mockPrisma.card.count.mockResolvedValue(1); // second attempt sees count > 0
225+
mockPrisma.card.create.mockResolvedValue({ ...mockCard, isDefault: false, cardLinks: [] });
226+
227+
const app = await buildApp();
228+
const res = await app.inject({
229+
method: 'POST',
230+
url: '/api/cards',
231+
payload: { title: 'Test Card', linkIds: [] },
232+
});
233+
234+
expect(res.statusCode).toBe(201);
235+
expect(res.json().isDefault).toBe(false);
236+
expect(mockPrisma.$transaction).toHaveBeenCalledTimes(2);
237+
});
185238
});
186239

187240
// ─────────────────────────────────────────────────────────────────────────────
Lines changed: 130 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,174 @@
1-
import type { FastifyInstance } from 'fastify'
2-
import type { Prisma } from '@prisma/client'
1+
import type { Prisma } from '@prisma/client';
2+
import type { FastifyInstance } from 'fastify';
3+
4+
type CardLinkResponse = { platformLink: unknown };
5+
type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] };
6+
type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] };
7+
8+
function mapCard(card: RawCard): CardResponse {
9+
return {
10+
id: card.id,
11+
title: card.title,
12+
isDefault: card.isDefault,
13+
links: card.cardLinks.map((cardLink) => cardLink.platformLink),
14+
};
15+
}
316

4-
export async function listCards(app: FastifyInstance, userId: string) {
5-
const cards = await app.prisma.card.findMany({
17+
export async function listCards(app: FastifyInstance, userId: string): Promise<CardResponse[]> {
18+
const cards = (await app.prisma.card.findMany({
619
where: { userId },
720
take: 50,
821
include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },
922
orderBy: { createdAt: 'asc' },
10-
})
23+
})) as unknown as RawCard[];
1124

12-
return cards.map((card: any) => ({ id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }))
25+
return cards.map(mapCard);
1326
}
1427

15-
export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }) {
28+
export async function createCard(app: FastifyInstance, userId: string, body: { title: string; linkIds: string[] }): Promise<CardResponse> {
1629
if (body.linkIds.length > 0) {
17-
const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } })
18-
if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })
30+
const ownedLinks = await app.prisma.platformLink.findMany({
31+
where: { id: { in: body.linkIds }, userId },
32+
select: { id: true },
33+
});
34+
35+
if (ownedLinks.length !== body.linkIds.length) {
36+
throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' });
37+
}
1938
}
2039

21-
const cardCount = await app.prisma.card.count({ where: { userId } })
40+
const maxRetries = 3;
41+
for (let attempt = 1; attempt <= maxRetries; attempt++) {
42+
try {
43+
const card = (await app.prisma.$transaction(
44+
async (tx: Prisma.TransactionClient) => {
45+
const cardCount = await tx.card.count({ where: { userId } });
46+
47+
return tx.card.create({
48+
data: {
49+
userId,
50+
title: body.title,
51+
isDefault: cardCount === 0,
52+
cardLinks: {
53+
create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })),
54+
},
55+
},
56+
include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },
57+
});
58+
},
59+
{
60+
isolationLevel: 'Serializable',
61+
},
62+
)) as unknown as RawCard;
63+
64+
return mapCard(card);
65+
} catch (error: unknown) {
66+
if (
67+
typeof error === 'object' &&
68+
error !== null &&
69+
'code' in error &&
70+
(error as { code: string }).code === 'P2034' &&
71+
attempt < maxRetries
72+
) {
73+
continue;
74+
}
2275

23-
const card = await app.prisma.card.create({
24-
data: {
25-
userId,
26-
title: body.title,
27-
isDefault: cardCount === 0,
28-
cardLinks: { create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })) },
29-
},
30-
include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },
31-
})
76+
app.log.error(error);
77+
throw error;
78+
}
79+
}
3280

33-
return { id: card.id, title: card.title, isDefault: card.isDefault, links: card.cardLinks.map((cl: any) => cl.platformLink) }
81+
throw new Error('Failed to create card after retrying serialization conflicts');
3482
}
3583

36-
export async function updateCard(app: FastifyInstance, userId: string, id: string, body: { title?: string; linkIds?: string[] }) {
37-
const existing = await app.prisma.card.findFirst({ where: { id, userId } })
38-
if (!existing) return null
84+
export async function updateCard(
85+
app: FastifyInstance,
86+
userId: string,
87+
id: string,
88+
body: { title?: string; linkIds?: string[] },
89+
): Promise<CardResponse | null> {
90+
const existing = await app.prisma.card.findFirst({ where: { id, userId } });
91+
if (!existing) {
92+
return null;
93+
}
3994

4095
if (body.title) {
41-
await app.prisma.card.update({ where: { id }, data: { title: body.title } })
96+
await app.prisma.card.update({ where: { id }, data: { title: body.title } });
4297
}
4398

4499
if (body.linkIds) {
45100
if (body.linkIds.length > 0) {
46-
const ownedLinks = await app.prisma.platformLink.findMany({ where: { id: { in: body.linkIds }, userId }, select: { id: true } })
47-
if (ownedLinks.length !== body.linkIds.length) throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' })
101+
const ownedLinks = await app.prisma.platformLink.findMany({
102+
where: { id: { in: body.linkIds }, userId },
103+
select: { id: true },
104+
});
105+
106+
if (ownedLinks.length !== body.linkIds.length) {
107+
throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' });
108+
}
48109
}
49110

50-
const linkIds = body.linkIds
111+
const linkIds = body.linkIds;
51112
await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
52-
await tx.cardLink.deleteMany({ where: { cardId: id } })
113+
await tx.cardLink.deleteMany({ where: { cardId: id } });
53114
if (linkIds.length > 0) {
54-
await tx.cardLink.createMany({ data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })) })
115+
await tx.cardLink.createMany({
116+
data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })),
117+
});
55118
}
56-
})
119+
});
120+
}
121+
122+
const updated = (await app.prisma.card.findUnique({
123+
where: { id },
124+
include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } },
125+
})) as unknown as RawCard | null;
126+
127+
if (!updated) {
128+
return null;
57129
}
58130

59-
const updated = await app.prisma.card.findUnique({ where: { id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } })
60-
return { id: updated!.id, title: updated!.title, isDefault: updated!.isDefault, links: updated!.cardLinks.map((cl: any) => cl.platformLink) }
131+
return mapCard(updated);
61132
}
62133

63-
export async function deleteCard(app: FastifyInstance, userId: string, id: string) {
134+
export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise<null> {
64135
return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
65-
const existing = await tx.card.findFirst({ where: { id, userId } })
66-
if (!existing) return Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' })
136+
const existing = await tx.card.findFirst({ where: { id, userId } });
137+
if (!existing) {
138+
throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' });
139+
}
67140

68-
const userCardCount = await tx.card.count({ where: { userId } })
69-
if (userCardCount <= 1) return Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' })
141+
const userCardCount = await tx.card.count({ where: { userId } });
142+
if (userCardCount <= 1) {
143+
throw Object.assign(new Error('Cannot delete last card'), { code: 'LAST_CARD' });
144+
}
70145

71146
if (existing.isDefault) {
72-
const oldestRemainingCard = await tx.card.findFirst({ where: { userId, id: { not: id } }, orderBy: { createdAt: 'asc' } })
147+
const oldestRemainingCard = await tx.card.findFirst({
148+
where: { userId, id: { not: id } },
149+
orderBy: { createdAt: 'asc' },
150+
});
151+
73152
if (oldestRemainingCard) {
74-
await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } })
153+
await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } });
75154
}
76155
}
77156

78-
await tx.card.delete({ where: { id } })
79-
return null
80-
})
157+
await tx.card.delete({ where: { id } });
158+
return null;
159+
});
81160
}
82161

83-
export async function setDefaultCard(app: FastifyInstance, userId: string, id: string) {
84-
const existing = await app.prisma.card.findFirst({ where: { id, userId } })
85-
if (!existing) return null
162+
export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> {
163+
const existing = await app.prisma.card.findFirst({ where: { id, userId } });
164+
if (!existing) {
165+
return null;
166+
}
86167

87168
await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => {
88-
await tx.card.updateMany({ where: { userId }, data: { isDefault: false } })
89-
await tx.card.update({ where: { id }, data: { isDefault: true } })
90-
})
169+
await tx.card.updateMany({ where: { userId }, data: { isDefault: false } });
170+
await tx.card.update({ where: { id }, data: { isDefault: true } });
171+
});
91172

92-
return { message: 'Default card updated' }
173+
return { message: 'Default card updated' };
93174
}

0 commit comments

Comments
 (0)