Skip to content

Commit 7c97570

Browse files
committed
refactor: use the $transaction helper in removeTeamMember + document the rule
1 parent 841ca71 commit 7c97570

2 files changed

Lines changed: 17 additions & 3 deletions

File tree

apps/webapp/CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ The `triggerTask.server.ts` service is the **highest-throughput code path** in t
125125

126126
- **Always use `findFirst` instead of `findUnique`.** Prisma's `findUnique` has an implicit DataLoader that batches concurrent calls into a single `IN` query. This batching cannot be disabled and has active bugs even in Prisma 6.x: uppercase UUIDs returning null (#25484, confirmed 6.4.1), composite key SQL correctness issues (#22202), and 5-10x worse performance than manual DataLoader (#6573, open since 2021). `findFirst` is never batched and avoids this entire class of issues.
127127

128+
## Transactions
129+
130+
- **Always use the `$transaction` helper from `~/db.server`, never `prisma.$transaction` (or `$replica.$transaction`) directly.** The helper wraps the raw call with tracing (an OTEL span + an `isolation_level` attribute) and boundary logging for infrastructure errors (e.g. `PrismaClientInitializationError`) that the raw client swallows. Signature: `$transaction(prisma, name?, async (tx) => { ... }, options?)`.
131+
- Pass the isolation level via options as a string: `{ isolationLevel: "Serializable" }`. Reach for `Serializable` when a read-then-write must be atomic against concurrent transactions (e.g. a count-then-delete invariant); the loser of a race fails and can retry, which is the right trade for rare, correctness-critical paths.
132+
- The helper returns `R | undefined` — guard the result (`if (!result) throw ...`) when callers need a definite value.
133+
128134
## React Patterns
129135

130136
- Only use `useCallback`/`useMemo` for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations.

apps/webapp/app/models/member.server.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { Organization, OrgMember, Project } from "@trigger.dev/database";
2-
import { Prisma as PrismaNamespace, type Prisma, prisma } from "~/db.server";
2+
import { Prisma as PrismaNamespace, type Prisma, prisma, $transaction } from "~/db.server";
33
import { createEnvironment } from "./organization.server";
44
import { customAlphabet } from "nanoid";
55
import { logger } from "~/services/logger.server";
@@ -96,7 +96,9 @@ export async function removeTeamMember({
9696
// interleave with a concurrent removal, or two requests could each pass the
9797
// count and leave the org with zero members. Guard lives here (not per-caller)
9898
// so the dashboard and the management API both get it.
99-
return prisma.$transaction(
99+
const removed = await $transaction(
100+
prisma,
101+
"removeTeamMember",
100102
async (tx) => {
101103
// Scope the target to this org. A member id is a globally unique key, so
102104
// deleting by id alone would remove members of other orgs; bind it to the
@@ -122,8 +124,14 @@ export async function removeTeamMember({
122124

123125
return member;
124126
},
125-
{ isolationLevel: PrismaNamespace.TransactionIsolationLevel.Serializable }
127+
{ isolationLevel: "Serializable" }
126128
);
129+
130+
if (!removed) {
131+
throw new Error("removeTeamMember transaction did not return a member");
132+
}
133+
134+
return removed;
127135
}
128136

129137
export async function inviteMembers({

0 commit comments

Comments
 (0)