From f6376ebbb070de4678654fbc732e9ad08a81169a Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 19:39:27 -0700 Subject: [PATCH 1/3] feat(feature-flags): migrate 3 env-flags to AppConfig-backed runtime flags --- .../app/api/files/serve/[...path]/route.ts | 2 +- .../files/[fileId]/compiled-check/route.ts | 2 +- .../new-column-dropdown.tsx | 15 ++++--- .../components/table-grid/table-grid.tsx | 3 ++ .../[workspaceId]/tables/[tableId]/page.tsx | 11 ++++- .../[workspaceId]/tables/[tableId]/table.tsx | 5 +++ .../tools/handlers/function-execute.ts | 9 ++-- .../copilot/tools/server/files/doc-compile.ts | 12 +++--- .../tools/server/files/edit-content.ts | 2 +- .../tools/server/files/workspace-file.ts | 4 +- .../copilot/vfs/workflow-alias-resolver.ts | 4 +- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 21 +++++---- apps/sim/lib/core/config/env-flags.ts | 22 ---------- apps/sim/lib/core/config/feature-flags.ts | 43 ++++++++++--------- .../lib/table/__tests__/lock-order.test.ts | 4 +- .../lib/table/__tests__/update-row.test.ts | 4 +- apps/sim/lib/table/rows/ordering.ts | 22 +++++++--- apps/sim/lib/table/rows/service.ts | 22 ++++++---- 18 files changed, 110 insertions(+), 97 deletions(-) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 3aea989d032..302f81c3a6a 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -117,7 +117,7 @@ async function compileDocumentIfNeeded( return { buffer: stored.buffer, contentType: stored.contentType } } - if (isE2BDocEnabled && getE2BDocFormat(filename)) { + if (isE2BDocEnabled && (await getE2BDocFormat(filename))) { // Artifact not built yet (still generating, or the source didn't compile at // write time). Signal "not ready" without compiling — handled as 409. throw new DocCompileUserError('Document is still being generated') diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index c8f7d6299ee..eef2c1b9af0 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -57,7 +57,7 @@ export const GET = withRouteHandler( // In the E2B regime ALL four formats compile in the doc sandbox (Node for // pptx/docx, Python for pdf/xlsx). Gate on the flag (not the stored MIME) so // a stale file can't trigger an E2B compile when the sandbox is disabled. - const e2bFmt = isE2BDocEnabled ? getE2BDocFormat(fileRecord.name) : null + const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(fileRecord.name) : null const taskId = BINARY_DOC_TASKS[ext] const isMermaidFile = ext === 'mmd' || ext === 'mermaid' if (!e2bFmt && !taskId && !isMermaidFile) { diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx index 7d5afba2de9..3c7c20211b6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx @@ -13,14 +13,9 @@ import { DropdownMenuTrigger, Plus, } from '@/components/emcn' -import { isWorkflowColumnsEnabledClient } from '@/lib/core/config/env-flags' import type { ColumnDefinition } from '@/lib/table' import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar' -const VISIBLE_COLUMN_TYPE_OPTIONS = isWorkflowColumnsEnabledClient - ? COLUMN_TYPE_OPTIONS - : COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow') - const CELL_HEADER = 'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle' @@ -32,6 +27,7 @@ interface NewColumnDropdownProps { onPickType: (type: ColumnDefinition['type']) => void onPickWorkflow: () => void onPickEnrichment: () => void + workflowColumnsEnabled: boolean } /** @@ -45,7 +41,12 @@ export function NewColumnDropdown({ onPickType, onPickWorkflow, onPickEnrichment, + workflowColumnsEnabled, }: NewColumnDropdownProps) { + const visibleColumnTypeOptions = workflowColumnsEnabled + ? COLUMN_TYPE_OPTIONS + : COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow') + const menu = ( @@ -67,7 +68,7 @@ export function NewColumnDropdown({ )} - {isWorkflowColumnsEnabledClient && ( + {workflowColumnsEnabled && ( <> @@ -76,7 +77,7 @@ export function NewColumnDropdown({ )} - {VISIBLE_COLUMN_TYPE_OPTIONS.map((option) => { + {visibleColumnTypeOptions.map((option) => { const Icon = option.icon const onSelect = option.type === 'workflow' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 87786d07984..27a459e78ee 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -230,6 +230,7 @@ interface TableGridProps { pushTableRenameUndoSinkRef: React.MutableRefObject< ((previousName: string, newName: string) => void) | null > + workflowColumnsEnabled: boolean } /** Serialize a cell value to its tab-separated clipboard representation. */ @@ -300,6 +301,7 @@ export function TableGrid({ afterDeleteAllSinkRef, confirmDeleteColumnsSinkRef, pushTableRenameUndoSinkRef, + workflowColumnsEnabled, }: TableGridProps) { const params = useParams() const workspaceId = propWorkspaceId || (params.workspaceId as string) @@ -3738,6 +3740,7 @@ export function TableGrid({ onPickType={handleAddColumnOfType} onPickWorkflow={handleAddWorkflowColumn} onPickEnrichment={onOpenEnrichments} + workflowColumnsEnabled={workflowColumnsEnabled} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx index 5ce3b7d9dd3..0390811d0d3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx @@ -1,10 +1,17 @@ import type { Metadata } from 'next' +import { headers } from 'next/headers' +import { getSession } from '@/lib/auth' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { Table } from './table' export const metadata: Metadata = { title: 'Table', } -export default function TablePage() { - return +export default async function TablePage() { + const session = await getSession(await headers()) + const workflowColumnsEnabled = await isFeatureEnabled('workflow-columns', { + userId: session?.user?.id, + }) + return
} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index 49d29ae1df5..d0cc80ac056 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -64,6 +64,8 @@ interface TableProps { /** Identifiers — only set in embedded mode. Page mode reads from `useParams()`. */ workspaceId?: string tableId?: string + /** Resolved server-side from the workflow-columns feature flag. */ + workflowColumnsEnabled?: boolean } /** @@ -116,6 +118,7 @@ export function Table({ embedded, workspaceId: propWorkspaceId, tableId: propTableId, + workflowColumnsEnabled = false, }: TableProps = {}) { const params = useParams() const router = useRouter() @@ -558,6 +561,7 @@ export function Table({ onPickType={handleAddColumnOfType} onPickWorkflow={handleAddWorkflowColumn} onPickEnrichment={onOpenEnrichments} + workflowColumnsEnabled={workflowColumnsEnabled} /> ) : null @@ -691,6 +695,7 @@ export function Table({ afterDeleteAllSinkRef={afterDeleteAllSinkRef} confirmDeleteColumnsSinkRef={confirmDeleteColumnsSinkRef} pushTableRenameUndoSinkRef={pushTableRenameUndoSinkRef} + workflowColumnsEnabled={workflowColumnsEnabled} /> {userPermissions.canEdit && ( { const sandboxFiles: SandboxFile[] = [] let totalSize = 0 + const betaEnabled = await isFeatureEnabled('mothership-beta') if (inputFiles?.length && workspaceId) { const allFiles = await listWorkspaceFiles(workspaceId, { - includeReservedSystemFiles: isMothershipBetaFeaturesEnabled, + includeReservedSystemFiles: betaEnabled, }) for (const fileRef of inputFiles) { const filePath = @@ -136,11 +137,11 @@ async function resolveInputFiles( if (inputDirectories?.length && workspaceId) { const folders = await listWorkspaceFileFolders(workspaceId, { - includeReservedSystemFolders: isMothershipBetaFeaturesEnabled, + includeReservedSystemFolders: betaEnabled, }) const allFiles = await listWorkspaceFiles(workspaceId, { folders, - includeReservedSystemFiles: isMothershipBetaFeaturesEnabled, + includeReservedSystemFiles: betaEnabled, }) for (const dirRef of inputDirectories) { const dirPath = diff --git a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts index 45604e96676..f61c917c9b7 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-compile.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-compile.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { executeInE2B, executeShellInE2B, type SandboxFile } from '@/lib/execution/e2b' import { CodeLanguage } from '@/lib/execution/languages' import { @@ -53,7 +53,7 @@ export interface E2BDocFormat { * pptx/docx → node, pdf/xlsx → python. Only meaningful when the E2B doc sandbox * is enabled; callers gate on isE2BDocEnabled before using this. */ -export function getE2BDocFormat(fileName: string): E2BDocFormat | null { +export async function getE2BDocFormat(fileName: string): Promise { const l = fileName.toLowerCase() if (l.endsWith('.pptx')) return { @@ -79,10 +79,10 @@ export function getE2BDocFormat(fileName: string): E2BDocFormat | null { contentType: PDF_MIME, sourceMime: PYTHON_PDF_SOURCE_MIME, } - // xlsx is gated behind the mothership beta flag (like plans/changelog): the + // xlsx is gated behind the mothership-beta feature flag (like plans/changelog): the // skill + prompt are gated on the Go side, and this is the single Sim chokepoint // that keeps the compile/serve/check/recalc paths off for xlsx when beta is off. - if (l.endsWith('.xlsx') && isMothershipBetaFeaturesEnabled) + if (l.endsWith('.xlsx') && (await isFeatureEnabled('mothership-beta'))) return { ext: 'xlsx', engine: 'python', @@ -385,7 +385,7 @@ export async function compileDoc( args: CompileArgs ): Promise<{ buffer: Buffer; contentType: string }> { const { source, fileName, workspaceId } = args - const fmt = getE2BDocFormat(fileName) + const fmt = await getE2BDocFormat(fileName) if (!fmt) throw new Error(`Unsupported document format: ${fileName}`) const existing = await loadCompiledDoc(workspaceId, source, fmt.ext) @@ -409,7 +409,7 @@ export async function loadCompiledDocByExt( source: string, ext: string ): Promise<{ buffer: Buffer; contentType: string } | null> { - const fmt = getE2BDocFormat(`x.${ext}`) + const fmt = await getE2BDocFormat(`x.${ext}`) if (!fmt) return null const buffer = await loadCompiledDoc(workspaceId, source, fmt.ext) return buffer ? { buffer, contentType: fmt.contentType } : null diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index 35b2f599add..8bedce47b41 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -64,7 +64,7 @@ export const editContentServerTool: BaseServerTool { const { source, fileName, workspaceId, ownerKey, signal, fallbackMime } = args const docInfo = getDocumentFormatInfo(fileName) - const e2bFmt = isE2BDocEnabled ? getE2BDocFormat(fileName) : null + const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(fileName) : null if (!e2bFmt && fileName.toLowerCase().endsWith('.xlsx')) { return { ok: false, message: isE2BDocEnabled - ? 'Excel (.xlsx) generation is currently behind a beta flag (MOTHERSHIP_BETA_FEATURES) and is not available.' + ? 'Excel (.xlsx) generation is currently behind the mothership-beta feature flag and is not available.' : 'Excel (.xlsx) generation requires the E2B document sandbox, which is not enabled in this environment.', } } diff --git a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts index 4e062a51fc2..449b201d6ff 100644 --- a/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts +++ b/apps/sim/lib/copilot/vfs/workflow-alias-resolver.ts @@ -8,14 +8,14 @@ import { resolveWorkspacePlanAliasPath, type WorkflowAliasTarget, } from '@/lib/copilot/vfs/workflow-aliases' -import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { canonicalizeVfsPath } from './path-utils' export async function resolveWorkflowAliasForWorkspace(args: { workspaceId: string path: string }): Promise { - if (!isMothershipBetaFeaturesEnabled) return null + if (!(await isFeatureEnabled('mothership-beta'))) return null if (!isPlanAliasPath(args.path)) return null let canonicalPath: string diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 66cf1724de7..7042780af6c 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -89,7 +89,8 @@ import { workspacePlanBackingPath, workspacePlansBackingFolderPath, } from '@/lib/copilot/vfs/workflow-aliases' -import { isE2BDocEnabled, isMothershipBetaFeaturesEnabled } from '@/lib/core/config/env-flags' +import { isE2BDocEnabled } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { getAccessibleEnvCredentials, getAccessibleOAuthCredentials, @@ -379,6 +380,7 @@ function getStaticComponentFiles(): Map { export class WorkspaceVFS { private files: Map = new Map() private _workspaceId = '' + private _betaEnabled = false get workspaceId(): string { return this._workspaceId @@ -393,6 +395,7 @@ export class WorkspaceVFS { const start = Date.now() this.files = new Map() this._workspaceId = workspaceId + this._betaEnabled = await isFeatureEnabled('mothership-beta', { userId }) // Per-phase wall-clock, stamped on the span so a slow materialize in a // trace names its bottleneck instead of showing up as unattributed dead @@ -591,7 +594,7 @@ export class WorkspaceVFS { path: string, suffix: 'style' | 'compiled-check' | 'compiled' | 'render' | 'extract' ): Promise { - if (!isMothershipBetaFeaturesEnabled && isWorkflowAliasBackingPath(path)) { + if (!this._betaEnabled && isWorkflowAliasBackingPath(path)) { return null } const canonicalMatch = path.match(new RegExp(`^files/(.+)/${suffix}$`)) @@ -642,7 +645,7 @@ export class WorkspaceVFS { totalLines: 1, } } - if (isE2BDocEnabled && getE2BDocFormat(record.name)) { + if (isE2BDocEnabled && (await getE2BDocFormat(record.name))) { bin = ( await compileDoc({ source: code, fileName: record.name, workspaceId: this._workspaceId }) ).buffer @@ -695,7 +698,7 @@ export class WorkspaceVFS { record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled') if (!record) return null const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - const e2bFmt = isE2BDocEnabled ? getE2BDocFormat(record.name) : null + const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(record.name) : null const taskId = BINARY_DOC_TASKS[ext] if (!e2bFmt && !taskId) return null @@ -890,7 +893,7 @@ export class WorkspaceVFS { record = await this.resolveWorkspaceFileForDynamicRead(path, 'compiled-check') if (!record) return null const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - const e2bFmt = isE2BDocEnabled ? getE2BDocFormat(record.name) : null + const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(record.name) : null const taskId = BINARY_DOC_TASKS[ext] const isMermaidFile = ext === 'mmd' || ext === 'mermaid' if (!e2bFmt && !taskId && !isMermaidFile) return null @@ -978,7 +981,7 @@ export class WorkspaceVFS { .replace(/\/content$/, '') .replace(/^\/+/, '') - if (!isMothershipBetaFeaturesEnabled && isWorkflowAliasBackingPath(fileReference)) { + if (!this._betaEnabled && isWorkflowAliasBackingPath(fileReference)) { return null } if (fileReference.endsWith('/meta.json') || path.endsWith('/meta.json')) return null @@ -988,7 +991,7 @@ export class WorkspaceVFS { try { const files = await listWorkspaceFiles(this._workspaceId, { scope, - includeReservedSystemFiles: isMothershipBetaFeaturesEnabled, + includeReservedSystemFiles: this._betaEnabled, }) const record = findWorkspaceFileRecord(files, fileReference) if (!record) return null @@ -1021,7 +1024,7 @@ export class WorkspaceVFS { * Returns a summary for WORKSPACE.md generation. */ private async materializeWorkflows(workspaceId: string): Promise { - const workflowArtifactsEnabled = isMothershipBetaFeaturesEnabled + const workflowArtifactsEnabled = this._betaEnabled const [workflowRows, folderRows] = await Promise.all([ listWorkflows(workspaceId), listFolders(workspaceId), @@ -1404,7 +1407,7 @@ export class WorkspaceVFS { */ private async materializeFiles(workspaceId: string): Promise { try { - const workflowArtifactsEnabled = isMothershipBetaFeaturesEnabled + const workflowArtifactsEnabled = this._betaEnabled const folders = await listWorkspaceFileFolders(workspaceId, { includeReservedSystemFolders: true, }) diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 918747577af..8fcdb841552 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -44,14 +44,6 @@ export const isBillingEnabled = isTruthy(env.BILLING_ENABLED) */ export const isFreeApiDeploymentGateEnabled = isTruthy(env.FREE_API_DEPLOYMENT_GATE_ENABLED) -/** - * Order table rows by fractional `order_key` (O(1) insert/delete) instead of the - * legacy integer `position`. When off, behavior is unchanged. Keys are written - * regardless of this flag; it only controls which column is authoritative for - * reads/ordering and whether inserts/deletes reshift positions. - */ -export const isTablesFractionalOrderingEnabled = isTruthy(env.TABLES_FRACTIONAL_ORDERING) - /** * Is email verification enabled */ @@ -173,20 +165,6 @@ export const isDataRetentionEnabled = isTruthy(env.DATA_RETENTION_ENABLED) */ export const isDataDrainsEnabled = isTruthy(env.DATA_DRAINS_ENABLED) -/** - * Are workflow output columns enabled in user tables. - * Defaults to false; set NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED=true to show - * the "Workflow" column type in the new-column dropdown. - */ -export const isWorkflowColumnsEnabledClient = isTruthy( - getEnv('NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED') -) - -/** - * Enables beta Mothership plan/changelog artifact surfaces. - */ -export const isMothershipBetaFeaturesEnabled = isTruthy(env.MOTHERSHIP_BETA_FEATURES) - /** * Is E2B enabled for remote code execution */ diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index b4018581bba..1f153779222 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -18,12 +18,10 @@ export interface FeatureFlagRule { enabled?: boolean orgIds?: string[] userIds?: string[] - admins?: boolean + adminEnabled?: boolean } -export interface FeatureFlagsConfig { - flags: Record -} +export type FeatureFlagsConfig = Record /** * Per-request evaluation context. Pass only the ids you have — a missing id skips @@ -64,10 +62,19 @@ interface FeatureFlagDefinition { /** The single registry of known flags. To add a flag, add one entry here. */ const FEATURE_FLAGS = { - // 'new-canvas': { - // description: 'New canvas renderer', - // fallback: 'NEW_CANVAS_ENABLED', - // }, + 'tables-fractional-ordering': { + description: 'Order table rows by fractional order_key instead of legacy integer position', + fallback: 'TABLES_FRACTIONAL_ORDERING', + }, + 'mothership-beta': { + description: + 'Mothership beta plan/changelog artifact surfaces in the copilot VFS and doc compiler', + fallback: 'MOTHERSHIP_BETA_FEATURES', + }, + 'workflow-columns': { + description: 'Workflow column type and enrichments in the table new-column dropdown', + fallback: 'NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED', + }, } satisfies Record /** @@ -78,13 +85,13 @@ export type FeatureFlagName = keyof typeof FEATURE_FLAGS /** Build the fallback document from each flag's secret. Truthy secret ⇒ enabled. */ function fallbackFlags(): FeatureFlagsConfig { - const flags: Record = {} + const flags: FeatureFlagsConfig = {} for (const [name, def] of Object.entries(FEATURE_FLAGS) as Array< [string, FeatureFlagDefinition] >) { flags[name] = { enabled: isTruthy(env[def.fallback]) } } - return { flags } + return flags } function normalizeIds(values: unknown): string[] | undefined { @@ -98,7 +105,7 @@ function normalizeRule(value: unknown): FeatureFlagRule | null { const obj = value as Record const rule: FeatureFlagRule = {} if (typeof obj.enabled === 'boolean') rule.enabled = obj.enabled - if (typeof obj.admins === 'boolean') rule.admins = obj.admins + if (typeof obj.adminEnabled === 'boolean') rule.adminEnabled = obj.adminEnabled const orgIds = normalizeIds(obj.orgIds) if (orgIds) rule.orgIds = orgIds const userIds = normalizeIds(obj.userIds) @@ -109,16 +116,12 @@ function normalizeRule(value: unknown): FeatureFlagRule | null { /** Coerce an arbitrary AppConfig/JSON value into a config, dropping malformed entries. */ function parseConfig(json: unknown): FeatureFlagsConfig { const obj = (json && typeof json === 'object' ? json : {}) as Record - const rawFlags = (obj.flags && typeof obj.flags === 'object' ? obj.flags : {}) as Record< - string, - unknown - > - const flags: Record = {} - for (const [name, value] of Object.entries(rawFlags)) { + const flags: FeatureFlagsConfig = {} + for (const [name, value] of Object.entries(obj)) { const rule = normalizeRule(value) if (rule) flags[name] = rule } - return { flags } + return flags } /** @@ -144,7 +147,7 @@ async function evaluate( if (rule.enabled) return true if (ctx.userId && rule.userIds?.includes(ctx.userId)) return true if (ctx.orgId && rule.orgIds?.includes(ctx.orgId)) return true - if (rule.admins) { + if (rule.adminEnabled) { const admin = ctx.isAdmin ?? (ctx.userId ? await resolveAdmin(ctx.userId) : false) if (admin) return true } @@ -176,6 +179,6 @@ export async function isFeatureEnabled( flag: FeatureFlagName, ctx: FeatureFlagContext = {} ): Promise { - const { flags } = await getFeatureFlags() + const flags = await getFeatureFlags() return evaluate(flags[flag], ctx) } diff --git a/apps/sim/lib/table/__tests__/lock-order.test.ts b/apps/sim/lib/table/__tests__/lock-order.test.ts index 9a7adbb6e85..cca52fadcee 100644 --- a/apps/sim/lib/table/__tests__/lock-order.test.ts +++ b/apps/sim/lib/table/__tests__/lock-order.test.ts @@ -15,8 +15,8 @@ import type { TableDefinition } from '@/lib/table/types' vi.mock('@sim/db', () => dbChainMock) -vi.mock('@/lib/core/config/env-flags', () => ({ - isTablesFractionalOrderingEnabled: false, +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: vi.fn().mockResolvedValue(false), })) vi.mock('@/lib/table/validation', () => ({ diff --git a/apps/sim/lib/table/__tests__/update-row.test.ts b/apps/sim/lib/table/__tests__/update-row.test.ts index 2d188429497..a8919924aa9 100644 --- a/apps/sim/lib/table/__tests__/update-row.test.ts +++ b/apps/sim/lib/table/__tests__/update-row.test.ts @@ -18,8 +18,8 @@ vi.mock('@sim/db', () => dbChainMock) // These suites assert flag-off position-shift semantics; pin the flag so they're // deterministic regardless of a local TABLES_FRACTIONAL_ORDERING env value. -vi.mock('@/lib/core/config/env-flags', () => ({ - isTablesFractionalOrderingEnabled: false, +vi.mock('@/lib/core/config/feature-flags', () => ({ + isFeatureEnabled: vi.fn().mockResolvedValue(false), })) vi.mock('@/lib/table/validation', () => ({ diff --git a/apps/sim/lib/table/rows/ordering.ts b/apps/sim/lib/table/rows/ordering.ts index d0403f9bcd0..ccddfd51d3f 100644 --- a/apps/sim/lib/table/rows/ordering.ts +++ b/apps/sim/lib/table/rows/ordering.ts @@ -9,7 +9,7 @@ import { db } from '@sim/db' import { userTableRows } from '@sim/db/schema' import { and, asc, desc, eq, gt, gte, inArray, lt, lte, type SQL, sql } from 'drizzle-orm' -import { isTablesFractionalOrderingEnabled } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import type { DbOrTx } from '@/lib/db/types' import { TABLE_LIMITS } from '@/lib/table/constants' import { keyBetween, nKeysBetween } from '@/lib/table/order-key' @@ -193,9 +193,10 @@ export async function resolveInsertOrderKey( tableId: string, requestedPosition?: number ): Promise { + const fractionalOrdering = await isFeatureEnabled('tables-fractional-ordering') const orderKeyAtSlot = async (slot: number): Promise => { if (slot < 0) return null - if (isTablesFractionalOrderingEnabled) { + if (fractionalOrdering) { const [r] = await trx .select({ orderKey: userTableRows.orderKey }) .from(userTableRows) @@ -248,7 +249,8 @@ export async function resolveInsertByNeighbor( // (key is authoritative) the adjacent-key lookup below can't work — fail // loudly rather than mint a wrong key. Flag off keeps `position` authoritative, // so a best-effort key here is fine (the backfill re-keys before the flip). - if (anchorKey === null && isTablesFractionalOrderingEnabled) { + const fractionalOrdering = await isFeatureEnabled('tables-fractional-ordering') + if (anchorKey === null && fractionalOrdering) { throw new Error(`Row ${anchorId} has no order_key yet (table not backfilled)`) } @@ -312,7 +314,11 @@ export async function resolveBatchInsertOrderKeys( count: number, positions?: number[] ): Promise { - if (!positions || positions.length === 0 || isTablesFractionalOrderingEnabled) { + if ( + !positions || + positions.length === 0 || + (await isFeatureEnabled('tables-fractional-ordering')) + ) { return nKeysBetween(await maxOrderKey(trx, tableId), null, count) } const keys: string[] = [] @@ -354,6 +360,8 @@ export async function insertOrderedRow(params: { await setTableTxTimeouts(trx) await acquireRowOrderLock(trx, tableId) + const fractionalOrdering = await isFeatureEnabled('tables-fractional-ordering') + // Resolve the order key (and a legacy slot position for the flag-off shift // path) from neighbor ids when given, else from the requested position. let orderKey: string @@ -367,7 +375,7 @@ export async function insertOrderedRow(params: { } let targetPosition: number - if (isTablesFractionalOrderingEnabled) { + if (fractionalOrdering) { // order_key is authoritative — keep a best-effort, no-shift position. targetPosition = await nextRowPosition(trx, tableId) } else if (slotPosition !== undefined) { @@ -432,7 +440,7 @@ export async function deleteOrderedRow(params: { if (!deleted) return false // Fractional ordering: deleting a row never changes another row's order_key, // so the O(N) position reshift is skipped entirely. - if (!isTablesFractionalOrderingEnabled) { + if (!(await isFeatureEnabled('tables-fractional-ordering'))) { await shiftRowsDownAfter(trx, tableId, deleted.position) } return true @@ -470,7 +478,7 @@ export async function deleteOrderedRowsByIds(params: { deleted.push(...rows) } // Fractional ordering: deletes leave order_key untouched, so no recompaction. - if (!isTablesFractionalOrderingEnabled && deleted.length > 0) { + if (!(await isFeatureEnabled('tables-fractional-ordering')) && deleted.length > 0) { const minDeletedPos = deleted.reduce( (min, r) => (r.position < min ? r.position : min), deleted[0].position diff --git a/apps/sim/lib/table/rows/service.ts b/apps/sim/lib/table/rows/service.ts index 152f1730cc7..c676b178130 100644 --- a/apps/sim/lib/table/rows/service.ts +++ b/apps/sim/lib/table/rows/service.ts @@ -16,7 +16,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, count, eq, inArray, lte, notInArray, type SQL, sql } from 'drizzle-orm' -import { isTablesFractionalOrderingEnabled } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { getColumnId } from '@/lib/table/column-keys' import { TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from '@/lib/table/constants' import { nKeysBetween } from '@/lib/table/order-key' @@ -250,13 +250,14 @@ export async function batchInsertRowsWithTx( }) await acquireRowOrderLock(trx, data.tableId) + const fractionalOrdering = await isFeatureEnabled('tables-fractional-ordering') // Undo restore passes exact saved keys; otherwise derive from positions/append. const orderKeys = data.orderKeys && data.orderKeys.length > 0 ? data.orderKeys : await resolveBatchInsertOrderKeys(trx, data.tableId, data.rows.length, data.positions) let positions: number[] - if (isTablesFractionalOrderingEnabled) { + if (fractionalOrdering) { // order_key authoritative — best-effort append positions, no shift. const start = await nextRowPosition(trx, data.tableId) positions = Array.from({ length: data.rows.length }, (_, i) => start + i) @@ -690,11 +691,10 @@ export async function upsertRow( function buildRowOrderBySql( sort: Sort | undefined, tableName: string, - columns: ColumnDefinition[] + columns: ColumnDefinition[], + fractionalOrderingEnabled: boolean ): SQL { - const primary = isTablesFractionalOrderingEnabled - ? `${tableName}.order_key` - : `${tableName}.position` + const primary = fractionalOrderingEnabled ? `${tableName}.order_key` : `${tableName}.position` const id = `${tableName}.id` if (sort && Object.keys(sort).length > 0) { const sortClause = buildSortClause(sort, tableName, columns) @@ -759,7 +759,8 @@ export async function findRowMatches( if (filterClause) whereClause = and(baseConditions, filterClause) } - const orderBySql = buildRowOrderBySql(options.sort, tableName, columns) + const fractionalOrdering = await isFeatureEnabled('tables-fractional-ordering') + const orderBySql = buildRowOrderBySql(options.sort, tableName, columns, fractionalOrdering) const pattern = `%${escapeLikePattern(options.q)}%` const result = await db.transaction(async (trx) => { @@ -908,7 +909,10 @@ export async function queryRows( // Hide rows a running delete job is about to remove — both the page and the count below share // this clause, so totals stay consistent with the visible rows. - const deleteMask = await pendingDeleteMask(table) + const [deleteMask, fractionalOrdering] = await Promise.all([ + pendingDeleteMask(table), + isFeatureEnabled('tables-fractional-ordering'), + ]) const baseConditions = and( eq(userTableRows.tableId, table.id), @@ -941,7 +945,7 @@ export async function queryRows( .select() .from(userTableRows) .where(pageWhere ?? baseConditions) - .orderBy(buildRowOrderBySql(sort, tableName, columns)) + .orderBy(buildRowOrderBySql(sort, tableName, columns, fractionalOrdering)) return after ? query.limit(limit) : query.limit(limit).offset(offset) } From 4745d51f1bddbcfaefbc05b1a5a47777b13837bf Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 19:50:05 -0700 Subject: [PATCH 2/3] fix(feature-flags): hardcode workflow-columns on, fix feature-flags tests --- .../new-column-dropdown.tsx | 24 +++----- .../components/table-grid/table-grid.tsx | 3 - .../[workspaceId]/tables/[tableId]/page.tsx | 11 +--- .../[workspaceId]/tables/[tableId]/table.tsx | 5 -- .../sim/lib/core/config/feature-flags.test.ts | 58 +++++++++---------- apps/sim/lib/core/config/feature-flags.ts | 4 -- 6 files changed, 39 insertions(+), 66 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx index 3c7c20211b6..3e85dade1bc 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx @@ -27,7 +27,6 @@ interface NewColumnDropdownProps { onPickType: (type: ColumnDefinition['type']) => void onPickWorkflow: () => void onPickEnrichment: () => void - workflowColumnsEnabled: boolean } /** @@ -41,12 +40,7 @@ export function NewColumnDropdown({ onPickType, onPickWorkflow, onPickEnrichment, - workflowColumnsEnabled, }: NewColumnDropdownProps) { - const visibleColumnTypeOptions = workflowColumnsEnabled - ? COLUMN_TYPE_OPTIONS - : COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow') - const menu = ( @@ -68,16 +62,14 @@ export function NewColumnDropdown({ )} - {workflowColumnsEnabled && ( - <> - - - Enrichments - - - - )} - {visibleColumnTypeOptions.map((option) => { + <> + + + Enrichments + + + + {COLUMN_TYPE_OPTIONS.map((option) => { const Icon = option.icon const onSelect = option.type === 'workflow' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index 27a459e78ee..87786d07984 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -230,7 +230,6 @@ interface TableGridProps { pushTableRenameUndoSinkRef: React.MutableRefObject< ((previousName: string, newName: string) => void) | null > - workflowColumnsEnabled: boolean } /** Serialize a cell value to its tab-separated clipboard representation. */ @@ -301,7 +300,6 @@ export function TableGrid({ afterDeleteAllSinkRef, confirmDeleteColumnsSinkRef, pushTableRenameUndoSinkRef, - workflowColumnsEnabled, }: TableGridProps) { const params = useParams() const workspaceId = propWorkspaceId || (params.workspaceId as string) @@ -3740,7 +3738,6 @@ export function TableGrid({ onPickType={handleAddColumnOfType} onPickWorkflow={handleAddWorkflowColumn} onPickEnrichment={onOpenEnrichments} - workflowColumnsEnabled={workflowColumnsEnabled} /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx index 0390811d0d3..5ce3b7d9dd3 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx @@ -1,17 +1,10 @@ import type { Metadata } from 'next' -import { headers } from 'next/headers' -import { getSession } from '@/lib/auth' -import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { Table } from './table' export const metadata: Metadata = { title: 'Table', } -export default async function TablePage() { - const session = await getSession(await headers()) - const workflowColumnsEnabled = await isFeatureEnabled('workflow-columns', { - userId: session?.user?.id, - }) - return
+export default function TablePage() { + return
} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx index d0cc80ac056..49d29ae1df5 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -64,8 +64,6 @@ interface TableProps { /** Identifiers — only set in embedded mode. Page mode reads from `useParams()`. */ workspaceId?: string tableId?: string - /** Resolved server-side from the workflow-columns feature flag. */ - workflowColumnsEnabled?: boolean } /** @@ -118,7 +116,6 @@ export function Table({ embedded, workspaceId: propWorkspaceId, tableId: propTableId, - workflowColumnsEnabled = false, }: TableProps = {}) { const params = useParams() const router = useRouter() @@ -561,7 +558,6 @@ export function Table({ onPickType={handleAddColumnOfType} onPickWorkflow={handleAddWorkflowColumn} onPickEnrichment={onOpenEnrichments} - workflowColumnsEnabled={workflowColumnsEnabled} /> ) : null @@ -695,7 +691,6 @@ export function Table({ afterDeleteAllSinkRef={afterDeleteAllSinkRef} confirmDeleteColumnsSinkRef={confirmDeleteColumnsSinkRef} pushTableRenameUndoSinkRef={pushTableRenameUndoSinkRef} - workflowColumnsEnabled={workflowColumnsEnabled} /> {userPermissions.canEdit && ( ({ mockFetch: vi.fn(), @@ -23,6 +19,7 @@ vi.mock('@/lib/core/config/appconfig', () => ({ })) vi.mock('@/lib/core/config/env', () => ({ + isTruthy: (v: unknown) => Boolean(v), get env() { return envRef }, @@ -60,21 +57,22 @@ describe('getFeatureFlags', () => { flagRef.isAppConfigEnabled = false }) - it('derives flags from fallback secrets (empty registry → empty) when AppConfig is disabled, without fetching', async () => { - expect(await getFeatureFlags()).toEqual({ flags: {} }) + it('derives flags from fallback secrets when AppConfig is disabled, without fetching', async () => { + const flags = await getFeatureFlags() + // All registered flags should be present, disabled (env vars unset in test env) + expect(flags['tables-fractional-ordering']).toEqual({ enabled: false }) + expect(flags['mothership-beta']).toEqual({ enabled: false }) expect(mockFetch).not.toHaveBeenCalled() }) it('reads the feature-flags profile and normalizes the payload when enabled', async () => { withAppConfig({ - flags: { - a: { enabled: true }, - b: { orgIds: ['Org_1', ' org_1 ', '', 'org_2'], userIds: 'nope' }, - c: 'not-an-object', - }, + a: { enabled: true }, + b: { orgIds: ['Org_1', ' org_1 ', '', 'org_2'], userIds: 'nope' }, + c: 'not-an-object', }) - const { flags } = await getFeatureFlags() + const flags = await getFeatureFlags() expect(flags.a).toEqual({ enabled: true }) expect(flags.b).toEqual({ orgIds: ['Org_1', 'org_1', 'org_2'] }) expect(flags.c).toBeUndefined() @@ -87,14 +85,16 @@ describe('getFeatureFlags', () => { it('falls back to the secret-derived document when the fetch yields null', async () => { flagRef.isAppConfigEnabled = true mockFetch.mockResolvedValue(null) - expect(await getFeatureFlags()).toEqual({ flags: {} }) + const flags = await getFeatureFlags() + expect(flags['tables-fractional-ordering']).toEqual({ enabled: false }) + expect(flags['mothership-beta']).toEqual({ enabled: false }) }) it('degrades gracefully on a malformed document', async () => { - withAppConfig({ flags: 'not-an-object' }) - expect(await getFeatureFlags()).toEqual({ flags: {} }) + withAppConfig('not-an-object') + expect(await getFeatureFlags()).toMatchObject({}) withAppConfig(null) - expect(await getFeatureFlags()).toEqual({ flags: {} }) + expect(await getFeatureFlags()).toMatchObject({}) }) }) @@ -105,31 +105,31 @@ describe('isFeatureEnabled', () => { }) it('returns false for an unknown flag', async () => { - withAppConfig({ flags: {} }) + withAppConfig({}) expect(await enabled('missing', { userId: 'u1' })).toBe(false) }) it('matches the global enabled clause', async () => { - withAppConfig({ flags: { f: { enabled: true } } }) + withAppConfig({ f: { enabled: true } }) expect(await enabled('f')).toBe(true) }) it('matches the userId allowlist', async () => { - withAppConfig({ flags: { f: { userIds: ['u1'] } } }) + withAppConfig({ f: { userIds: ['u1'] } }) expect(await enabled('f', { userId: 'u1' })).toBe(true) expect(await enabled('f', { userId: 'u2' })).toBe(false) expect(await enabled('f', {})).toBe(false) }) it('matches the orgId allowlist', async () => { - withAppConfig({ flags: { f: { orgIds: ['o1'] } } }) + withAppConfig({ f: { orgIds: ['o1'] } }) expect(await enabled('f', { orgId: 'o1' })).toBe(true) expect(await enabled('f', { orgId: 'o2' })).toBe(false) }) describe('admin clause (lazy resolution)', () => { - it('resolves admin from userId when admins is the deciding clause', async () => { - withAppConfig({ flags: { f: { admins: true } } }) + it('resolves admin from userId when adminEnabled is the deciding clause', async () => { + withAppConfig({ f: { adminEnabled: true } }) mockIsPlatformAdmin.mockResolvedValue(true) expect(await enabled('f', { userId: 'u1' })).toBe(true) expect(mockIsPlatformAdmin).toHaveBeenCalledWith('u1') @@ -139,28 +139,28 @@ describe('isFeatureEnabled', () => { }) it('uses the isAdmin override without querying', async () => { - withAppConfig({ flags: { f: { admins: true } } }) + withAppConfig({ f: { adminEnabled: true } }) expect(await enabled('f', { userId: 'u1', isAdmin: true })).toBe(true) expect(mockIsPlatformAdmin).not.toHaveBeenCalled() }) it('resolves to false without querying when userId is absent', async () => { - withAppConfig({ flags: { f: { admins: true } } }) + withAppConfig({ f: { adminEnabled: true } }) expect(await enabled('f', { orgId: 'o1' })).toBe(false) expect(mockIsPlatformAdmin).not.toHaveBeenCalled() }) it('does not query when an earlier clause already matched', async () => { - withAppConfig({ flags: { f: { enabled: true, admins: true } } }) + withAppConfig({ f: { enabled: true, adminEnabled: true } }) expect(await enabled('f', { userId: 'u1' })).toBe(true) - withAppConfig({ flags: { g: { userIds: ['u1'], admins: true } } }) + withAppConfig({ g: { userIds: ['u1'], adminEnabled: true } }) expect(await enabled('g', { userId: 'u1' })).toBe(true) expect(mockIsPlatformAdmin).not.toHaveBeenCalled() }) - it('does not query when the rule has no admins clause', async () => { - withAppConfig({ flags: { f: { userIds: ['u2'] } } }) + it('does not query when the rule has no adminEnabled clause', async () => { + withAppConfig({ f: { userIds: ['u2'] } }) expect(await enabled('f', { userId: 'u1' })).toBe(false) expect(mockIsPlatformAdmin).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index 1f153779222..da21343f8ac 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -71,10 +71,6 @@ const FEATURE_FLAGS = { 'Mothership beta plan/changelog artifact surfaces in the copilot VFS and doc compiler', fallback: 'MOTHERSHIP_BETA_FEATURES', }, - 'workflow-columns': { - description: 'Workflow column type and enrichments in the table new-column dropdown', - fallback: 'NEXT_PUBLIC_WORKFLOW_COLUMNS_ENABLED', - }, } satisfies Record /** From 850bd7e142aba7e9f81c729e557f0fe32de03e42 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 15 Jun 2026 19:57:57 -0700 Subject: [PATCH 3/3] chore(feature-flags): document mothership-beta userId targeting limitation --- apps/sim/lib/core/config/feature-flags.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/core/config/feature-flags.ts b/apps/sim/lib/core/config/feature-flags.ts index da21343f8ac..d3bc89ad794 100644 --- a/apps/sim/lib/core/config/feature-flags.ts +++ b/apps/sim/lib/core/config/feature-flags.ts @@ -68,7 +68,10 @@ const FEATURE_FLAGS = { }, 'mothership-beta': { description: - 'Mothership beta plan/changelog artifact surfaces in the copilot VFS and doc compiler', + 'Mothership beta plan/changelog artifact surfaces in the copilot VFS and doc compiler. ' + + 'Note: userId/orgId targeting only works for WorkspaceVfs (resolved in materialize). ' + + 'getE2BDocFormat, resolveInputFiles, and resolveWorkflowAliasForWorkspace evaluate without ' + + 'user context — use enabled:true for global rollout rather than per-user targeting.', fallback: 'MOTHERSHIP_BETA_FEATURES', }, } satisfies Record