mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(feature-flags): migrate 3 env-flags to AppConfig-backed runtime flags (#5086)
* feat(feature-flags): migrate 3 env-flags to AppConfig-backed runtime flags * fix(feature-flags): hardcode workflow-columns on, fix feature-flags tests * chore(feature-flags): document mothership-beta userId targeting limitation
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
+8
-15
@@ -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({
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
|
||||
{isWorkflowColumnsEnabledClient && (
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onPickEnrichment}>
|
||||
<Sparkles className='size-[14px] text-[var(--text-icon)]' />
|
||||
Enrichments
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{VISIBLE_COLUMN_TYPE_OPTIONS.map((option) => {
|
||||
<>
|
||||
<DropdownMenuItem onSelect={onPickEnrichment}>
|
||||
<Sparkles className='size-[14px] text-[var(--text-icon)]' />
|
||||
Enrichments
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
{COLUMN_TYPE_OPTIONS.map((option) => {
|
||||
const Icon = option.icon
|
||||
const onSelect =
|
||||
option.type === 'workflow'
|
||||
|
||||
@@ -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<SandboxFile[]> {
|
||||
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 =
|
||||
|
||||
@@ -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<E2BDocFormat | null> {
|
||||
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
|
||||
|
||||
@@ -64,7 +64,7 @@ export const editContentServerTool: BaseServerTool<EditContentArgs, EditContentR
|
||||
try {
|
||||
const { operation, fileRecord } = intent
|
||||
const docInfo = getDocumentFormatInfo(fileRecord.name)
|
||||
const e2bFmt = isE2BDocEnabled ? getE2BDocFormat(fileRecord.name) : null
|
||||
const e2bFmt = isE2BDocEnabled ? await getE2BDocFormat(fileRecord.name) : null
|
||||
|
||||
let finalContent: string
|
||||
switch (operation) {
|
||||
|
||||
@@ -203,13 +203,13 @@ export async function compileDocForWrite(args: {
|
||||
}): Promise<CompileForWriteResult> {
|
||||
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.',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WorkflowAliasTarget | null> {
|
||||
if (!isMothershipBetaFeaturesEnabled) return null
|
||||
if (!(await isFeatureEnabled('mothership-beta'))) return null
|
||||
if (!isPlanAliasPath(args.path)) return null
|
||||
|
||||
let canonicalPath: string
|
||||
|
||||
@@ -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<string, string> {
|
||||
export class WorkspaceVFS {
|
||||
private files: Map<string, string> = 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<WorkspaceFileRecord | null> {
|
||||
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<WorkspaceMdData['workflows']> {
|
||||
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<WorkspaceMdData['files']> {
|
||||
try {
|
||||
const workflowArtifactsEnabled = isMothershipBetaFeaturesEnabled
|
||||
const workflowArtifactsEnabled = this._betaEnabled
|
||||
const folders = await listWorkspaceFileFolders(workspaceId, {
|
||||
includeReservedSystemFolders: true,
|
||||
})
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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<FeatureFlagsConfig>({ 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<FeatureFlagsConfig>({ 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<FeatureFlagsConfig>({ flags: {} })
|
||||
withAppConfig('not-an-object')
|
||||
expect(await getFeatureFlags()).toMatchObject({})
|
||||
withAppConfig(null)
|
||||
expect(await getFeatureFlags()).toEqual<FeatureFlagsConfig>({ 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()
|
||||
})
|
||||
|
||||
@@ -18,12 +18,10 @@ export interface FeatureFlagRule {
|
||||
enabled?: boolean
|
||||
orgIds?: string[]
|
||||
userIds?: string[]
|
||||
admins?: boolean
|
||||
adminEnabled?: boolean
|
||||
}
|
||||
|
||||
export interface FeatureFlagsConfig {
|
||||
flags: Record<string, FeatureFlagRule>
|
||||
}
|
||||
export type FeatureFlagsConfig = Record<string, FeatureFlagRule>
|
||||
|
||||
/**
|
||||
* 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<string, FeatureFlagDefinition>
|
||||
|
||||
/**
|
||||
@@ -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<string, FeatureFlagRule> = {}
|
||||
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<string, unknown>
|
||||
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<string, unknown>
|
||||
const rawFlags = (obj.flags && typeof obj.flags === 'object' ? obj.flags : {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const flags: Record<string, FeatureFlagRule> = {}
|
||||
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<boolean> {
|
||||
const { flags } = await getFeatureFlags()
|
||||
const flags = await getFeatureFlags()
|
||||
return evaluate(flags[flag], ctx)
|
||||
}
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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<string> {
|
||||
const fractionalOrdering = await isFeatureEnabled('tables-fractional-ordering')
|
||||
const orderKeyAtSlot = async (slot: number): Promise<string | null> => {
|
||||
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<string[]> {
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user