Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/sim/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
# DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams
# FORKING_ENABLED= # Workspace forks
# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections
# TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only

# Instance organization (Optional). Most enterprise features read their settings from the
Expand Down
54 changes: 49 additions & 5 deletions apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,20 @@
import { createMockRequest } from '@sim/testing'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

const { mockEnqueue, mockGetJobQueue, mockVerifyCronAuth } = vi.hoisted(() => ({
mockEnqueue: vi.fn(),
mockGetJobQueue: vi.fn(),
mockVerifyCronAuth: vi.fn(),
}))
const { mockEnqueue, mockGetJobQueue, mockIsTableRowTtlEnabled, mockVerifyCronAuth } = vi.hoisted(
() => ({
mockEnqueue: vi.fn(),
mockGetJobQueue: vi.fn(),
mockIsTableRowTtlEnabled: vi.fn(),
mockVerifyCronAuth: vi.fn(),
})
)

vi.mock('@/lib/auth/internal', () => ({ verifyCronAuth: mockVerifyCronAuth }))
vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: mockGetJobQueue }))
vi.mock('@/lib/table/ttl-availability', () => ({
isTableRowTtlEnabled: mockIsTableRowTtlEnabled,
}))

import { GET } from '@/app/api/cron/cleanup-table-row-ttl/route'

Expand All @@ -21,6 +27,7 @@ describe('table row TTL cleanup route', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-08-22T17:12:00Z'))
mockVerifyCronAuth.mockReturnValue(null)
mockIsTableRowTtlEnabled.mockResolvedValue(true)
mockEnqueue.mockResolvedValue('job-ttl-1')
mockGetJobQueue.mockResolvedValue({ enqueue: mockEnqueue })
})
Expand Down Expand Up @@ -70,6 +77,23 @@ describe('table row TTL cleanup route', () => {
expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId)
})

it('uses a new id immediately after the next fifteen-minute window begins', async () => {
const request = () =>
createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
)

vi.setSystemTime(new Date('2026-08-22T17:14:59.999Z'))
await GET(request())
vi.setSystemTime(new Date('2026-08-22T17:15:00.000Z'))
await GET(request())

expect(mockEnqueue.mock.calls[0]?.[2]?.jobId).not.toBe(mockEnqueue.mock.calls[1]?.[2]?.jobId)
})

it('returns the cron auth refusal without touching the queue', async () => {
mockVerifyCronAuth.mockReturnValue(new Response(null, { status: 401 }))

Expand All @@ -85,4 +109,24 @@ describe('table row TTL cleanup route', () => {
expect(response.status).toBe(401)
expect(mockGetJobQueue).not.toHaveBeenCalled()
})

it('does not enqueue cleanup while the feature is disabled', async () => {
mockIsTableRowTtlEnabled.mockResolvedValue(false)

const response = await GET(
createMockRequest(
'GET',
undefined,
{},
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
)
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
triggered: false,
reason: 'feature-disabled',
})
expect(mockGetJobQueue).not.toHaveBeenCalled()
})
})
6 changes: 6 additions & 0 deletions apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { verifyCronAuth } from '@/lib/auth/internal'
import { getJobQueue } from '@/lib/core/async-jobs'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'

export const dynamic = 'force-dynamic'

Expand All @@ -14,6 +15,11 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const authError = verifyCronAuth(request, 'table row TTL cleanup')
if (authError) return authError

if (!(await isTableRowTtlEnabled())) {
logger.info('Table row TTL cleanup skipped because the feature is disabled')
return NextResponse.json({ triggered: false, reason: 'feature-disabled' })
}

const queue = await getJobQueue()
const scheduleWindow = Math.floor(Date.now() / TTL_CLEANUP_INTERVAL_MS)
const jobId = await queue.enqueue(
Expand Down
61 changes: 33 additions & 28 deletions apps/sim/app/workspace/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
import { getActiveOrganizationId } from '@/lib/auth/session-response'
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired'
Expand All @@ -16,6 +17,7 @@ import {
import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader'
import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader'
import { DesktopOAuthConnectListener } from '@/app/workspace/[workspaceId]/providers/desktop-oauth-connect-listener'
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
Expand Down Expand Up @@ -45,7 +47,7 @@ export default async function WorkspaceLayout({
}

const activeOrganizationId = getActiveOrganizationId(session)
const [cookieStore, initialOrgSettings] = await Promise.all([
const [cookieStore, initialOrgSettings, , tableRowTtlEnabled] = await Promise.all([
cookies(),
hostContext.hostOrganizationId
? getOrgWhitelabelSettings(hostContext.hostOrganizationId)
Expand All @@ -57,38 +59,41 @@ export default async function WorkspaceLayout({
hostContext,
activeOrganizationId
),
isTableRowTtlEnabled(),
])
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>
<BrandingProvider
hostOrganizationId={hostContext.hostOrganizationId}
viewerIsHostOrganizationMember={hostContext.viewer.isHostOrganizationMember}
initialOrgSettings={initialOrgSettings}
>
<ToastProvider>
<DesktopOAuthConnectListener />
<SettingsLoader />
<ProviderModelsLoader />
<CustomBlocksLoader />
<BlockVisibilityLoader />
<GlobalCommandsProvider>
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
<SessionExpired />
<WorkspacePermissionsProvider>
<WorkspaceScopeSync />
<WorkspaceChrome initialSidebarCollapsed={initialSidebarCollapsed}>
{children}
</WorkspaceChrome>
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
</ToastProvider>
</BrandingProvider>
</WorkspaceHostProvider>
<FeatureFlagsProvider flags={{ 'table-row-ttl': tableRowTtlEnabled }}>
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>
<BrandingProvider
hostOrganizationId={hostContext.hostOrganizationId}
viewerIsHostOrganizationMember={hostContext.viewer.isHostOrganizationMember}
initialOrgSettings={initialOrgSettings}
>
<ToastProvider>
<DesktopOAuthConnectListener />
<SettingsLoader />
<ProviderModelsLoader />
<CustomBlocksLoader />
<BlockVisibilityLoader />
<GlobalCommandsProvider>
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
<ImpersonationBanner />
<SessionExpired />
<WorkspacePermissionsProvider>
<WorkspaceScopeSync />
<WorkspaceChrome initialSidebarCollapsed={initialSidebarCollapsed}>
{children}
</WorkspaceChrome>
</WorkspacePermissionsProvider>
</div>
</GlobalCommandsProvider>
</ToastProvider>
</BrandingProvider>
</WorkspaceHostProvider>
</FeatureFlagsProvider>
</HydrationBoundary>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client'

import { createContext, type ReactNode, useContext } from 'react'

export interface WorkspaceFeatureFlags {
'table-row-ttl': boolean
}

const FeatureFlagsContext = createContext<WorkspaceFeatureFlags | null>(null)

interface FeatureFlagsProviderProps {
children: ReactNode
flags: WorkspaceFeatureFlags
}

/** Makes server-resolved runtime flags available to workspace client surfaces. */
export function FeatureFlagsProvider({ children, flags }: FeatureFlagsProviderProps) {
return <FeatureFlagsContext.Provider value={flags}>{children}</FeatureFlagsContext.Provider>
}

/** Reads one server-resolved runtime flag without exposing AppConfig to the browser. */
export function useFeatureFlag(name: keyof WorkspaceFeatureFlags): boolean {
const flags = useContext(FeatureFlagsContext)
if (!flags) throw new Error('useFeatureFlag must be used within FeatureFlagsProvider')
return flags[name]
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ interface ColumnConfigSidebarProps {
/** Existing column record for `mode: 'edit'`; ignored otherwise. */
existingColumn: ColumnDefinition | null
allColumns: readonly ColumnDefinition[]
tableRowTtlEnabled: boolean
workspaceId: string
tableId: string
/** Notify parent of a rename so it can rewrite local `columnOrder` /
Expand Down Expand Up @@ -104,6 +105,7 @@ function ColumnConfigBody({
onClose,
existingColumn,
allColumns,
tableRowTtlEnabled,
workspaceId,
tableId,
onColumnRename,
Expand Down Expand Up @@ -276,7 +278,9 @@ function ColumnConfigBody({
<div className='flex flex-col gap-[9.5px]'>
<RequiredLabel>Type</RequiredLabel>
<ChipCombobox
options={columnTypeOptionsForTable(allColumns, existingColumn)
options={columnTypeOptionsForTable(allColumns, existingColumn, {
tableRowTtlEnabled,
})
.filter((option) => option.type !== 'workflow')
.map((option) => ({
label: option.label,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ describe('column type picker limits', () => {
option.maxPerTable = 1
Object.assign(definition, { maxPerTable: 1 })

const result = columnTypeOptionsForTable([{ name: 'first', type: 'string' }])
const result = columnTypeOptionsForTable([{ name: 'first', type: 'string' }], undefined, {
tableRowTtlEnabled: true,
})
const stringOption = result.find((candidate) => candidate.type === 'string')

expect(stringOption?.disabledReason).toBe('Only one Text column allowed per table')
Expand All @@ -44,7 +46,9 @@ describe('column type picker limits', () => {
Object.assign(definition, { maxPerTable: 1 })
const currentColumn = { name: 'first', type: 'string' } as const

const result = columnTypeOptionsForTable([currentColumn], currentColumn)
const result = columnTypeOptionsForTable([currentColumn], currentColumn, {
tableRowTtlEnabled: true,
})
const stringOption = result.find((candidate) => candidate.type === 'string')

expect(stringOption?.disabledReason).toBeUndefined()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,35 @@ describe('columnTypeOptionsForTable', () => {
const ttlColumn: ColumnDefinition = { name: 'expires_at', type: 'ttl' }

it('disables TTL with an explanation when the table already has one', () => {
const availableTtl = columnTypeOptionsForTable([{ name: 'name', type: 'string' }]).find(
(option) => option.type === 'ttl'
)
const unavailableTtl = columnTypeOptionsForTable([ttlColumn]).find(
(option) => option.type === 'ttl'
)
const availableTtl = columnTypeOptionsForTable([{ name: 'name', type: 'string' }], undefined, {
tableRowTtlEnabled: true,
}).find((option) => option.type === 'ttl')
const unavailableTtl = columnTypeOptionsForTable([ttlColumn], undefined, {
tableRowTtlEnabled: true,
}).find((option) => option.type === 'ttl')

expect(availableTtl?.disabledReason).toBeUndefined()
expect(unavailableTtl?.disabledReason).toBe('Only one TTL column allowed per table')
})

it('keeps TTL enabled while editing the existing TTL column', () => {
const ttlOption = columnTypeOptionsForTable([ttlColumn], ttlColumn).find(
(option) => option.type === 'ttl'
)
const ttlOption = columnTypeOptionsForTable([ttlColumn], ttlColumn, {
tableRowTtlEnabled: true,
}).find((option) => option.type === 'ttl')

expect(ttlOption?.disabledReason).toBeUndefined()
})

it('hides TTL while disabled unless editing an existing TTL column', () => {
expect(
columnTypeOptionsForTable([], undefined, { tableRowTtlEnabled: false }).some(
(option) => option.type === 'ttl'
)
).toBe(false)
expect(
columnTypeOptionsForTable([ttlColumn], ttlColumn, { tableRowTtlEnabled: false }).some(
(option) => option.type === 'ttl'
)
).toBe(true)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export interface ColumnTypeOption {
disabledReason?: string
}

interface ColumnTypeAvailability {
tableRowTtlEnabled: boolean
}

/**
* Real column types come from the registry — adding one there makes it appear
* in every picker automatically. `workflow` is appended because it is a UI
Expand All @@ -42,9 +46,13 @@ function columnTypeLimitMessage(label: string, maxPerTable: number): string {
/** Picker entries with unavailable cardinality-limited types marked as disabled. */
export function columnTypeOptionsForTable(
columns: readonly ColumnDefinition[],
currentColumn?: ColumnDefinition | null
currentColumn: ColumnDefinition | null | undefined,
availability: ColumnTypeAvailability
): ColumnTypeOption[] {
return COLUMN_TYPE_OPTIONS.map((option) => {
return COLUMN_TYPE_OPTIONS.filter(
(option) =>
option.type !== 'ttl' || availability.tableRowTtlEnabled || currentColumn?.type === 'ttl'
).map((option) => {
if (option.type === 'workflow') return option
if (currentColumn?.type === option.type) return option
if (option.maxPerTable === undefined) return option
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const CELL_HEADER =

interface NewColumnDropdownProps {
columns: readonly ColumnDefinition[]
tableRowTtlEnabled: boolean
/** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders
* the in-table column-header `<th>` trigger. Same dropdown content either way. */
trigger: 'header' | 'inline-header'
Expand Down Expand Up @@ -82,6 +83,7 @@ function ColumnTypeMenuItem({ option, onSelect }: ColumnTypeMenuItemProps) {
*/
export function NewColumnDropdown({
columns,
tableRowTtlEnabled,
trigger,
disabled,
onPickType,
Expand Down Expand Up @@ -137,7 +139,7 @@ export function NewColumnDropdown({
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
{columnTypeOptionsForTable(columns).map((option) => {
{columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled }).map((option) => {
const onSelect =
option.type === 'workflow'
? onPickWorkflow
Expand Down
Loading
Loading