From 86402de1f142a77d4208951f5e300b7f93463688 Mon Sep 17 00:00:00 2001 From: Daniel Sutton Date: Mon, 24 Aug 2026 10:53:31 +0100 Subject: [PATCH] fix(webapp): route the graced flag writes through the traced transaction helper Both graced writes called client.$transaction directly. The repo rule is to use the $transaction helper from db.server, which adds the OTEL span and logs the infrastructure errors the raw client swallows. One of these writes stamps a cutover window and the other deletes flags, so a transaction that silently did not run is the case most worth seeing. The helper resolves undefined instead of throwing when it swallows such an error, so both call sites now treat that as a failure the caller sees. --- apps/webapp/app/v3/featureFlags.server.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/v3/featureFlags.server.ts b/apps/webapp/app/v3/featureFlags.server.ts index e9aad8eb4..b5d65a13c 100644 --- a/apps/webapp/app/v3/featureFlags.server.ts +++ b/apps/webapp/app/v3/featureFlags.server.ts @@ -1,6 +1,6 @@ import { type z } from "zod"; import type { PrismaClient } from "@trigger.dev/database"; -import { prisma, type PrismaClientOrTransaction } from "~/db.server"; +import { $transaction, prisma, type PrismaClientOrTransaction } from "~/db.server"; import { FEATURE_FLAG, type FeatureFlagCatalogSchema, @@ -271,11 +271,18 @@ export async function applyGlobalGracedFlips( requestedFlags: Partial>, graceMs: number ): Promise<{ key: string; value: any }[]> { - return client.$transaction(async (tx) => { + const applied = await $transaction(client, "applyGlobalGracedFlips", async (tx) => { await lockGracedGroups(tx); const stamped = await stampGracedGroups(tx, withoutDerivedKeys(requestedFlags), graceMs); return makeSetMultipleFlags(tx)(stamped as Partial>); }); + + // The helper resolves undefined rather than throwing when Prisma swallows an infrastructure + // error. This write stamps a cutover window, so a transaction that did not run must be loud. + if (!applied) { + throw new Error("applyGlobalGracedFlips: transaction did not complete"); + } + return applied; } // Replace-semantics write for the global admin flags page: submitted flags upsert, omitted ones @@ -298,7 +305,7 @@ export async function replaceGlobalFeatureFlags( ): Promise { const requestedFlags = withoutDerivedKeys(params.requestedFlags); - await client.$transaction(async (tx) => { + const applied = await $transaction(client, "replaceGlobalFeatureFlags", async (tx) => { await lockGracedGroups(tx); const stamped = await stampGracedGroups(tx, requestedFlags, params.graceMs); @@ -331,5 +338,12 @@ export async function replaceGlobalFeatureFlags( if (keysToDelete.length > 0) { await tx.featureFlag.deleteMany({ where: { key: { in: boundedIn(keysToDelete) } } }); } + + return true; }); + + // This write deletes flags, so a transaction that did not run must reach the caller. + if (!applied) { + throw new Error("replaceGlobalFeatureFlags: transaction did not complete"); + } }