diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index 3aea989d03..302f81c3a6 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 c8f7d6299e..eef2c1b9af 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 7d5afba2de..3e85dade1b 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' @@ -67,16 +62,14 @@ export function NewColumnDropdown({ )} - {isWorkflowColumnsEnabledClient && ( - <> - - - Enrichments - - - - )} - {VISIBLE_COLUMN_TYPE_OPTIONS.map((option) => { + <> + + + Enrichments + + + + {COLUMN_TYPE_OPTIONS.map((option) => { const Icon = option.icon const onSelect = option.type === 'workflow' diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index d3893be70b..895b218ffb 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { decodeVfsPathSegments, encodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' import { resolveWorkflowAliasForWorkspace } from '@/lib/copilot/vfs/workflow-alias-resolver' import { isPlanAliasPath, workflowAliasSandboxPath } from '@/lib/copilot/vfs/workflow-aliases' -import { isMothershipBetaFeaturesEnabled } from '@/lib/core/config/env-flags' +import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { queryRows } from '@/lib/table/rows/service' import { getTableById, listTables } from '@/lib/table/service' import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' @@ -71,10 +71,11 @@ async function resolveInputFiles( ): Promise { 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 45604e9667..f61c917c9b 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 35b2f599ad..8bedce47b4 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 4e062a51fc..449b201d6f 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 66cf1724de..7042780af6 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 918747577a..8fcdb84155 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.test.ts b/apps/sim/lib/core/config/feature-flags.test.ts index d2250a60f8..ac5fa766a8 100644 --- a/apps/sim/lib/core/config/feature-flags.test.ts +++ b/apps/sim/lib/core/config/feature-flags.test.ts @@ -2,11 +2,7 @@ * @vitest-environment node */ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { - FeatureFlagContext, - FeatureFlagName, - FeatureFlagsConfig, -} from '@/lib/core/config/feature-flags' +import type { FeatureFlagContext, FeatureFlagName } from '@/lib/core/config/feature-flags' const { mockFetch, mockIsPlatformAdmin, envRef, flagRef } = vi.hoisted(() => ({ 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 b4018581bb..d3bc89ad79 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,18 @@ 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. ' + + '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 /** @@ -78,13 +84,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 +104,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 +115,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 +146,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 +178,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 9a7adbb6e8..cca52fadce 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 2d18842949..a8919924aa 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 d0403f9bcd..ccddfd51d3 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 152f1730cc..c676b17813 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) }