From 9399b84f97f4c5f001d2e16740b5fbafa5ea41aa Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 31 Jul 2025 15:43:48 -0700 Subject: [PATCH] Use retryTransaction instead of $transaction --- apps/backend/src/app/api/latest/(api-keys)/handlers.tsx | 4 ++-- .../integrations/credential-scanning/revoke/route.tsx | 6 +++--- apps/backend/src/auto-migrations/auto-migration.tests.ts | 2 ++ apps/backend/src/auto-migrations/index.tsx | 2 ++ apps/backend/src/lib/projects.tsx | 2 +- apps/backend/src/prisma-client.tsx | 1 + configs/eslint/defaults.js | 4 ++++ 7 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx b/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx index 1ef687641..26f9ed20e 100644 --- a/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx +++ b/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx @@ -1,6 +1,6 @@ import { listPermissions } from "@/lib/permissions"; import { Tenancy } from "@/lib/tenancies"; -import { getPrismaClientForTenancy } from "@/prisma-client"; +import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { SmartRequestAuth } from "@/route-handlers/smart-request"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -57,7 +57,7 @@ async function ensureUserCanManageApiKeys( // Check team API key permissions if (options.teamId !== undefined) { const userId = auth.user.id; - const hasManageApiKeysPermission = await prisma.$transaction(async (tx) => { + const hasManageApiKeysPermission = await retryTransaction(prisma, async (tx) => { const permissions = await listPermissions(tx, { scope: 'team', tenancy: auth.tenancy, diff --git a/apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx b/apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx index 9eb976115..16bf5c56a 100644 --- a/apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx @@ -1,7 +1,7 @@ import { getSharedEmailConfig, sendEmail } from "@/lib/emails"; import { listPermissions } from "@/lib/permissions"; import { getTenancy } from "@/lib/tenancies"; -import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; +import { getPrismaClientForTenancy, globalPrismaClient, retryTransaction } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; @@ -27,7 +27,7 @@ export const POST = createSmartRouteHandler({ async handler({ body }) { // Get the API key and revoke it. We use a transaction to ensure we do not send emails multiple times. // We don't support revoking API keys in tenancies with non-global source of truth atm. - const updatedApiKey = await globalPrismaClient.$transaction(async (tx) => { + const updatedApiKey = await retryTransaction(globalPrismaClient, async (tx) => { // Find the API key in the database const apiKey = await tx.projectApiKey.findUnique({ where: { @@ -116,7 +116,7 @@ export const POST = createSmartRouteHandler({ const prisma = await getPrismaClientForTenancy(tenancy); - const userIdsWithManageApiKeysPermission = await prisma.$transaction(async (tx) => { + const userIdsWithManageApiKeysPermission = await retryTransaction(prisma, async (tx) => { if (!updatedApiKey.teamId) { throw new StackAssertionError("Team ID not specified in team API key"); } diff --git a/apps/backend/src/auto-migrations/auto-migration.tests.ts b/apps/backend/src/auto-migrations/auto-migration.tests.ts index ee248b30a..fa7f67921 100644 --- a/apps/backend/src/auto-migrations/auto-migration.tests.ts +++ b/apps/backend/src/auto-migrations/auto-migration.tests.ts @@ -285,6 +285,7 @@ import.meta.vitest?.test("applies migration while running concurrent queries", r })); import.meta.vitest?.test("applies migration while running an interactive transaction", runTest(async ({ expect, prismaClient, dbURL }) => { + // eslint-disable-next-line no-restricted-syntax return await prismaClient.$transaction(async (tx, ...args) => { await runMigrationNeeded({ prismaClient, @@ -304,6 +305,7 @@ import.meta.vitest?.test("applies migration while running an interactive transac import.meta.vitest?.test("applies migration while running concurrent interactive transactions", runTest(async ({ expect, prismaClient, dbURL }) => { const runTransactionWithMigration = async (testValue: string) => { + // eslint-disable-next-line no-restricted-syntax return await prismaClient.$transaction(async (tx) => { await runMigrationNeeded({ prismaClient, diff --git a/apps/backend/src/auto-migrations/index.tsx b/apps/backend/src/auto-migrations/index.tsx index 07dfdb2d3..bd9c3c808 100644 --- a/apps/backend/src/auto-migrations/index.tsx +++ b/apps/backend/src/auto-migrations/index.tsx @@ -39,6 +39,7 @@ async function getAppliedMigrations(options: { prismaClient: PrismaClient, schema: string, }) { + // eslint-disable-next-line no-restricted-syntax const [_1, _2, _3, appliedMigrations] = await options.prismaClient.$transaction([ options.prismaClient.$executeRaw`SELECT pg_advisory_xact_lock(${MIGRATION_LOCK_ID})`, options.prismaClient.$executeRaw(Prisma.sql` @@ -143,6 +144,7 @@ export async function applyMigrations(options: { VALUES (${migration.migrationName}, clock_timestamp()) `); try { + // eslint-disable-next-line no-restricted-syntax await options.prismaClient.$transaction(transaction); } catch (e) { const error = getMigrationError(e); diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 84a9d5f91..ae13aa461 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -234,7 +234,7 @@ export async function createOrUpdateProject( // Update owner metadata const internalEnvironmentConfig = await rawQuery(globalPrismaClient, getRenderedEnvironmentConfigQuery({ projectId: "internal", branchId: DEFAULT_BRANCH_ID })); const prisma = await getPrismaClientForSourceOfTruth(internalEnvironmentConfig.sourceOfTruth, DEFAULT_BRANCH_ID); - await prisma.$transaction(async (tx) => { + await retryTransaction(prisma, async (tx) => { for (const userId of options.ownerIds ?? []) { const projectUserTx = await tx.projectUser.findUnique({ where: { diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index 7637ab582..ec84923b0 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -129,6 +129,7 @@ export async function retryTransaction(client: PrismaClient, fn: (tx: PrismaC return await traceSpan(`transaction attempt #${attemptIndex}`, async (attemptSpan) => { const attemptRes = await (async () => { try { + // eslint-disable-next-line no-restricted-syntax return Result.ok(await client.$transaction(async (tx, ...args) => { let res; try { diff --git a/configs/eslint/defaults.js b/configs/eslint/defaults.js index 7fac0255a..fb257a9ff 100644 --- a/configs/eslint/defaults.js +++ b/configs/eslint/defaults.js @@ -103,6 +103,10 @@ module.exports = { "Identifier[name='localeCompare']", message: "Use stringCompare() from utils/strings.tsx instead of String.prototype.localeCompare.", }, + { + selector: "CallExpression > MemberExpression[property.name='$transaction']", + message: "Calling .$transaction is disallowed. Use retryTransaction() instead.", + }, ], "@typescript-eslint/no-misused-promises": [ "error",