apps/backend/src/services/cardService.ts — setDefaultCard() runs:
await app.prisma.$transaction(async (tx) => {
await tx.card.updateMany({ where: { userId }, data: { isDefault: false } });
await tx.card.update({ where: { id }, data: { isDefault: true } });
});
This transaction does not specify an isolation level (unlike createCard, which explicitly uses Serializable). Under the Postgres default Read Committed isolation, two concurrent setDefaultCard calls for the same user (e.g. double-tap on "set as default" in the UI, or two browser tabs) can interleave such that:
- Tx1 clears all
isDefault flags for the user.
- Tx2 clears all
isDefault flags for the user.
- Tx1 sets card A as default.
- Tx2 sets card B as default.
Final state: both A and B are momentarily isDefault: true only if both updates land — but more commonly the lost-update pattern produces a window where the updateMany from Tx2 (clear all) commits after Tx1's update (set A default), wiping out A's default flag without setting B's, depending on commit ordering, leaving zero cards marked default for that user.
This directly desyncs from createCard's invariant (isDefault: cardCount === 0 — exactly one default on creation) and deleteCard's invariant (always promotes a new default when the default card is deleted). Any UI/profile-rendering code that assumes exactly one isDefault: true card exists per user will silently show no default card or crash on .find(c => c.isDefault) returning undefined.
Affected: apps/backend/src/services/cardService.ts (setDefaultCard), contrast with createCard and deleteCard in the same file which maintain the "exactly one default" invariant carefully.
apps/backend/src/services/cardService.ts—setDefaultCard()runs:This transaction does not specify an isolation level (unlike
createCard, which explicitly usesSerializable). Under the Postgres defaultRead Committedisolation, two concurrentsetDefaultCardcalls for the same user (e.g. double-tap on "set as default" in the UI, or two browser tabs) can interleave such that:isDefaultflags for the user.isDefaultflags for the user.Final state: both A and B are momentarily
isDefault: trueonly if both updates land — but more commonly the lost-update pattern produces a window where theupdateManyfrom Tx2 (clear all) commits after Tx1'supdate(set A default), wiping out A's default flag without setting B's, depending on commit ordering, leaving zero cards marked default for that user.This directly desyncs from
createCard's invariant (isDefault: cardCount === 0— exactly one default on creation) anddeleteCard's invariant (always promotes a new default when the default card is deleted). Any UI/profile-rendering code that assumes exactly oneisDefault: truecard exists per user will silently show no default card or crash on.find(c => c.isDefault)returningundefined.Affected:
apps/backend/src/services/cardService.ts(setDefaultCard), contrast withcreateCardanddeleteCardin the same file which maintain the "exactly one default" invariant carefully.