From bc768cc16ec70c40d2b1207b9c87173a90199663 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 29 Jan 2026 14:17:42 +0000 Subject: [PATCH 1/3] FeatureFlag rename to singular and add multiple flags function --- .../presenters/v3/RegionsPresenter.server.ts | 4 +- .../runsRepository/runsRepository.server.ts | 4 +- .../app/v3/eventRepository/index.server.ts | 18 ++--- apps/webapp/app/v3/featureFlags.server.ts | 69 ++++++++++++++++--- .../worker/workerGroupService.server.ts | 8 +-- 5 files changed, 77 insertions(+), 26 deletions(-) diff --git a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts index c304597bb1..7a35fb6fb9 100644 --- a/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RegionsPresenter.server.ts @@ -1,6 +1,6 @@ import { type Project } from "~/models/project.server"; import { type User } from "~/models/user.server"; -import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server"; import { BasePresenter } from "./basePresenter.server"; import { getCurrentPlan } from "~/services/platform.v3.server"; @@ -48,7 +48,7 @@ export class RegionsPresenter extends BasePresenter { throw new Error("Project not found"); } - const getFlag = makeFlags(this._replica); + const getFlag = makeFlag(this._replica); const defaultWorkerInstanceGroupId = await getFlag({ key: FEATURE_FLAG.defaultWorkerInstanceGroupId, }); diff --git a/apps/webapp/app/services/runsRepository/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/runsRepository.server.ts index 895c8b5fe5..90b58b8a98 100644 --- a/apps/webapp/app/services/runsRepository/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/runsRepository.server.ts @@ -8,7 +8,7 @@ import parseDuration from "parse-duration"; import { z } from "zod"; import { timeFilters } from "~/components/runs/v3/SharedFilters"; import { type PrismaClient, type PrismaClientOrTransaction } from "~/db.server"; -import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server"; import { startActiveSpan } from "~/v3/tracer.server"; import { logger } from "../logger.server"; import { ClickHouseRunsRepository } from "./clickhouseRunsRepository.server"; @@ -163,7 +163,7 @@ export class RunsRepository implements IRunsRepository { async #getRepository(): Promise { return startActiveSpan("runsRepository.getRepository", async (span) => { - const getFlag = makeFlags(this.options.prisma); + const getFlag = makeFlag(this.options.prisma); const runsListRepository = await getFlag({ key: FEATURE_FLAG.runsListRepository, defaultValue: this.defaultRepository, diff --git a/apps/webapp/app/v3/eventRepository/index.server.ts b/apps/webapp/app/v3/eventRepository/index.server.ts index cb211e2b02..b312bc13b1 100644 --- a/apps/webapp/app/v3/eventRepository/index.server.ts +++ b/apps/webapp/app/v3/eventRepository/index.server.ts @@ -5,9 +5,9 @@ import { clickhouseEventRepositoryV2, } from "./clickhouseEventRepositoryInstance.server"; import { IEventRepository, TraceEventOptions } from "./eventRepository.types"; -import { prisma } from "~/db.server"; +import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; -import { FEATURE_FLAG, flags } from "../featureFlags.server"; +import { FEATURE_FLAG, flag } from "../featureFlags.server"; import { getTaskEventStore } from "../taskEventStore.server"; export function resolveEventRepositoryForStore(store: string | undefined): IEventRepository { @@ -24,13 +24,13 @@ export function resolveEventRepositoryForStore(store: string | undefined): IEven return eventRepository; } - export const EVENT_STORE_TYPES = { - POSTGRES: "postgres", - CLICKHOUSE: "clickhouse", - CLICKHOUSE_V2: "clickhouse_v2", - } as const; +export const EVENT_STORE_TYPES = { + POSTGRES: "postgres", + CLICKHOUSE: "clickhouse", + CLICKHOUSE_V2: "clickhouse_v2", +} as const; -export type EventStoreType = typeof EVENT_STORE_TYPES[keyof typeof EVENT_STORE_TYPES]; +export type EventStoreType = (typeof EVENT_STORE_TYPES)[keyof typeof EVENT_STORE_TYPES]; export async function getConfiguredEventRepository( organizationId: string @@ -122,7 +122,7 @@ export async function getV3EventRepository( async function resolveTaskEventRepositoryFlag( featureFlags: Record | undefined ): Promise<"clickhouse" | "clickhouse_v2" | "postgres"> { - const flag = await flags({ + const flag = await flag({ key: FEATURE_FLAG.taskEventRepository, defaultValue: env.EVENT_REPOSITORY_DEFAULT_STORE, overrides: featureFlags, diff --git a/apps/webapp/app/v3/featureFlags.server.ts b/apps/webapp/app/v3/featureFlags.server.ts index 605c11defc..e889b2123d 100644 --- a/apps/webapp/app/v3/featureFlags.server.ts +++ b/apps/webapp/app/v3/featureFlags.server.ts @@ -25,14 +25,14 @@ export type FlagsOptions = { overrides?: Record; }; -export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) { - function flags( +export function makeFlag(_prisma: PrismaClientOrTransaction = prisma) { + function flag( opts: FlagsOptions & { defaultValue: z.infer<(typeof FeatureFlagCatalog)[T]> } ): Promise>; - function flags( + function flag( opts: FlagsOptions ): Promise | undefined>; - async function flags( + async function flag( opts: FlagsOptions ): Promise | undefined> { const value = await _prisma.featureFlag.findUnique({ @@ -60,11 +60,11 @@ export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) { return parsed.data; } - return flags; + return flag; } -export function makeSetFlags(_prisma: PrismaClientOrTransaction = prisma) { - return async function setFlags( +export function makeSetFlag(_prisma: PrismaClientOrTransaction = prisma) { + return async function setFlag( opts: FlagsOptions & { value: z.infer<(typeof FeatureFlagCatalog)[T]> } ): Promise { await _prisma.featureFlag.upsert({ @@ -82,8 +82,59 @@ export function makeSetFlags(_prisma: PrismaClientOrTransaction = prisma) { }; } +export type AllFlagsOptions = { + defaultValues?: Partial; + overrides?: Record; +}; + +export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) { + return async function flags(options?: AllFlagsOptions): Promise> { + const rows = await _prisma.featureFlag.findMany(); + + // Build a map of key -> value from database + const dbValues = new Map(); + for (const row of rows) { + dbValues.set(row.key, row.value); + } + + const result: Partial = {}; + + // Process each flag in the catalog + for (const key of Object.keys(FeatureFlagCatalog) as FeatureFlagKey[]) { + const schema = FeatureFlagCatalog[key]; + + // Priority: overrides > database > defaultValues + if (options?.overrides?.[key] !== undefined) { + const parsed = schema.safeParse(options.overrides[key]); + if (parsed.success) { + (result as any)[key] = parsed.data; + continue; + } + } + + if (dbValues.has(key)) { + const parsed = schema.safeParse(dbValues.get(key)); + if (parsed.success) { + (result as any)[key] = parsed.data; + continue; + } + } + + if (options?.defaultValues?.[key] !== undefined) { + const parsed = schema.safeParse(options.defaultValues[key]); + if (parsed.success) { + (result as any)[key] = parsed.data; + } + } + } + + return result; + }; +} + +export const flag = makeFlag(); export const flags = makeFlags(); -export const setFlags = makeSetFlags(); +export const setFlag = makeSetFlag(); // Create a Zod schema from the existing catalog export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog); @@ -112,7 +163,7 @@ export function makeSetMultipleFlags(_prisma: PrismaClientOrTransaction = prisma return async function setMultipleFlags( flags: Partial> ): Promise<{ key: string; value: any }[]> { - const setFlag = makeSetFlags(_prisma); + const setFlag = makeSetFlag(_prisma); const updatedFlags: { key: string; value: any }[] = []; for (const [key, value] of Object.entries(flags)) { diff --git a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts index f05c8783ec..936f8bbd48 100644 --- a/apps/webapp/app/v3/services/worker/workerGroupService.server.ts +++ b/apps/webapp/app/v3/services/worker/workerGroupService.server.ts @@ -2,7 +2,7 @@ import { WorkerInstanceGroup, WorkerInstanceGroupType } from "@trigger.dev/datab import { WithRunEngine } from "../baseService.server"; import { WorkerGroupTokenService } from "./workerGroupTokenService.server"; import { logger } from "~/services/logger.server"; -import { FEATURE_FLAG, makeFlags, makeSetFlags } from "~/v3/featureFlags.server"; +import { FEATURE_FLAG, makeFlag, makeSetFlag } from "~/v3/featureFlags.server"; export class WorkerGroupService extends WithRunEngine { private readonly defaultNamePrefix = "worker_group"; @@ -47,14 +47,14 @@ export class WorkerGroupService extends WithRunEngine { }, }); - const getFlag = makeFlags(this._prisma); + const getFlag = makeFlag(this._prisma); const defaultWorkerInstanceGroupId = await getFlag({ key: FEATURE_FLAG.defaultWorkerInstanceGroupId, }); // If there's no global default yet we should set it to the new worker group if (!defaultWorkerInstanceGroupId) { - const setFlag = makeSetFlags(this._prisma); + const setFlag = makeSetFlag(this._prisma); await setFlag({ key: FEATURE_FLAG.defaultWorkerInstanceGroupId, value: workerGroup.id, @@ -166,7 +166,7 @@ export class WorkerGroupService extends WithRunEngine { } async getGlobalDefaultWorkerGroup() { - const flags = makeFlags(this._prisma); + const flags = makeFlag(this._prisma); const defaultWorkerInstanceGroupId = await flags({ key: FEATURE_FLAG.defaultWorkerInstanceGroupId, From 069415e9d84758903516066277f8832ef1c76c22 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 29 Jan 2026 14:18:35 +0000 Subject: [PATCH 2/3] canAccessQuery now uses global flag and env var too --- apps/webapp/app/env.server.ts | 3 + .../OrganizationsPresenter.server.ts | 16 +++-- .../route.tsx | 60 ++++--------------- apps/webapp/app/v3/canAccessQuery.server.ts | 47 +++++++++++++++ 4 files changed, 74 insertions(+), 52 deletions(-) create mode 100644 apps/webapp/app/v3/canAccessQuery.server.ts diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 98c04b6f95..dcbcac079a 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1190,6 +1190,9 @@ const EnvironmentSchema = z CLICKHOUSE_LOGS_DETAIL_MAX_THREADS: z.coerce.number().int().default(2), CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME: z.coerce.number().int().default(60), + // Query feature flag + QUERY_FEATURE_ENABLED: z.string().default("1"), + // Query page ClickHouse limits (for TSQL queries) QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(10), QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_073_741_824), // 1GB in bytes diff --git a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts index b52e69db9c..c229a0d7f4 100644 --- a/apps/webapp/app/presenters/OrganizationsPresenter.server.ts +++ b/apps/webapp/app/presenters/OrganizationsPresenter.server.ts @@ -1,4 +1,4 @@ -import { RuntimeEnvironment, type PrismaClient } from "@trigger.dev/database"; +import type { RuntimeEnvironment, PrismaClient } from "@trigger.dev/database"; import { redirect } from "remix-typedjson"; import { prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; @@ -10,7 +10,7 @@ import { } from "./SelectBestEnvironmentPresenter.server"; import { sortEnvironments } from "~/utils/environmentSort"; import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar"; -import { validatePartialFeatureFlags } from "~/v3/featureFlags.server"; +import { flags, validatePartialFeatureFlags } from "~/v3/featureFlags.server"; export class OrganizationsPresenter { #prismaClient: PrismaClient; @@ -153,18 +153,24 @@ export class OrganizationsPresenter { }, }); + // Get global feature flags (no overrides or defaults) + const globalFlags = await flags(); + return orgs.map((org) => { - const flagsResult = org.featureFlags + const orgFlagsResult = org.featureFlags ? validatePartialFeatureFlags(org.featureFlags as Record) : ({ success: false } as const); - const flags = flagsResult.success ? flagsResult.data : {}; + const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {}; + + // Combine global flags with org flags (org flags win) + const combinedFlags = { ...globalFlags, ...orgFlags }; return { id: org.id, slug: org.slug, title: org.title, avatar: parseAvatar(org.avatar, defaultAvatar), - featureFlags: flags, + featureFlags: combinedFlags, projects: org.projects.map((project) => ({ id: project.id, slug: project.slug, diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx index 996149a469..72020d8adf 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/route.tsx @@ -75,7 +75,7 @@ import { executeQuery, type QueryScope } from "~/services/queryService.server"; import { requireUser } from "~/services/session.server"; import { downloadFile, rowsToCSV, rowsToJSON } from "~/utils/dataExport"; import { EnvironmentParamSchema, organizationBillingPath } from "~/utils/pathBuilder"; -import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server"; +import { canAccessQuery } from "~/v3/canAccessQuery.server"; import { querySchemas } from "~/v3/querySchemas"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; import { QueryHelpSidebar } from "./QueryHelpSidebar"; @@ -91,40 +91,6 @@ function toISOString(value: Date | string): string { return value.toISOString(); } -async function hasQueryAccess( - userId: string, - isAdmin: boolean, - isImpersonating: boolean, - organizationSlug: string -): Promise { - if (isAdmin || isImpersonating) { - return true; - } - - // Check organization feature flags - const organization = await prisma.organization.findFirst({ - where: { - slug: organizationSlug, - members: { some: { userId } }, - }, - select: { - featureFlags: true, - }, - }); - - if (!organization?.featureFlags) { - return false; - } - - const flags = organization.featureFlags as Record; - const hasQueryAccessResult = validateFeatureFlagValue( - FEATURE_FLAG.hasQueryAccess, - flags.hasQueryAccess - ); - - return hasQueryAccessResult.success && hasQueryAccessResult.data === true; -} - const scopeOptions = [ { value: "environment", label: "Environment" }, { value: "project", label: "Project" }, @@ -135,12 +101,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const user = await requireUser(request); const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); - const canAccess = await hasQueryAccess( - user.id, - user.admin, - user.isImpersonating, - organizationSlug - ); + const canAccess = await canAccessQuery({ + userId: user.id, + isAdmin: user.admin, + isImpersonating: user.isImpersonating, + organizationSlug, + }); if (!canAccess) { throw redirect("/"); } @@ -200,12 +166,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const user = await requireUser(request); const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params); - const canAccess = await hasQueryAccess( - user.id, - user.admin, - user.isImpersonating, - organizationSlug - ); + const canAccess = await canAccessQuery({ + userId: user.id, + isAdmin: user.admin, + isImpersonating: user.isImpersonating, + organizationSlug, + }); if (!canAccess) { return typedjson( { diff --git a/apps/webapp/app/v3/canAccessQuery.server.ts b/apps/webapp/app/v3/canAccessQuery.server.ts new file mode 100644 index 0000000000..87a248725b --- /dev/null +++ b/apps/webapp/app/v3/canAccessQuery.server.ts @@ -0,0 +1,47 @@ +import { prisma } from "~/db.server"; +import { env } from "~/env.server"; +import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server"; + +export async function canAccessQuery(options: { + userId: string; + isAdmin: boolean; + isImpersonating: boolean; + organizationSlug: string; +}): Promise { + const { userId, isAdmin, isImpersonating, organizationSlug } = options; + + // 1. If it's on then we have access + const globallyEnabled = env.QUERY_FEATURE_ENABLED === "1"; + if (globallyEnabled) { + return true; + } + + // 2. Admins always have access + if (isAdmin || isImpersonating) { + return true; + } + + // 3. Check if org/global feature flag is on + const org = await prisma.organization.findFirst({ + where: { + slug: organizationSlug, + members: { some: { userId } }, + }, + select: { + featureFlags: true, + }, + }); + + const flag = makeFlag(); + const flagResult = await flag({ + key: FEATURE_FLAG.hasQueryAccess, + defaultValue: false, + overrides: (org?.featureFlags as Record) ?? {}, + }); + if (flagResult) { + return true; + } + + // 4. Not enabled anywhere + return false; +} From 78b9b92b8e755b3a15be2e9aceb27357b362740f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 29 Jan 2026 15:12:44 +0000 Subject: [PATCH 3/3] Stop shadowing the flag function --- apps/webapp/app/v3/eventRepository/index.server.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/v3/eventRepository/index.server.ts b/apps/webapp/app/v3/eventRepository/index.server.ts index b312bc13b1..2f457e2359 100644 --- a/apps/webapp/app/v3/eventRepository/index.server.ts +++ b/apps/webapp/app/v3/eventRepository/index.server.ts @@ -122,21 +122,21 @@ export async function getV3EventRepository( async function resolveTaskEventRepositoryFlag( featureFlags: Record | undefined ): Promise<"clickhouse" | "clickhouse_v2" | "postgres"> { - const flag = await flag({ + const flagResult = await flag({ key: FEATURE_FLAG.taskEventRepository, defaultValue: env.EVENT_REPOSITORY_DEFAULT_STORE, overrides: featureFlags, }); - if (flag === "clickhouse_v2") { + if (flagResult === "clickhouse_v2") { return "clickhouse_v2"; } - if (flag === "clickhouse") { + if (flagResult === "clickhouse") { return "clickhouse"; } - return flag; + return flagResult; } export async function recordRunDebugLog(