Skip to content

Commit a9da6ba

Browse files
committed
feat(tables): gate row TTL expiration
1 parent 5f10f12 commit a9da6ba

24 files changed

Lines changed: 308 additions & 50 deletions

File tree

apps/sim/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic
201201
# DATA_DRAINS_ENABLED= / NEXT_PUBLIC_DATA_DRAINS_ENABLED= # Export streams
202202
# FORKING_ENABLED= # Workspace forks
203203
# CREDENTIAL_GROUPS= # Enterprise managed OAuth collections
204+
# TABLE_ROW_TTL= # Table TTL columns and expired-row cleanup
204205
# ORGANIZATIONS_ENABLED= / NEXT_PUBLIC_ORGANIZATIONS_ENABLED= # Organizations only
205206

206207
# Instance organization (Optional). Most enterprise features read their settings from the

apps/sim/app/api/cron/cleanup-table-row-ttl/route.test.ts

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,20 @@
44
import { createMockRequest } from '@sim/testing'
55
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
66

7-
const { mockEnqueue, mockGetJobQueue, mockVerifyCronAuth } = vi.hoisted(() => ({
8-
mockEnqueue: vi.fn(),
9-
mockGetJobQueue: vi.fn(),
10-
mockVerifyCronAuth: vi.fn(),
11-
}))
7+
const { mockEnqueue, mockGetJobQueue, mockIsTableRowTtlEnabled, mockVerifyCronAuth } = vi.hoisted(
8+
() => ({
9+
mockEnqueue: vi.fn(),
10+
mockGetJobQueue: vi.fn(),
11+
mockIsTableRowTtlEnabled: vi.fn(),
12+
mockVerifyCronAuth: vi.fn(),
13+
})
14+
)
1215

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

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

@@ -21,6 +27,7 @@ describe('table row TTL cleanup route', () => {
2127
vi.useFakeTimers()
2228
vi.setSystemTime(new Date('2026-08-22T17:01:00Z'))
2329
mockVerifyCronAuth.mockReturnValue(null)
30+
mockIsTableRowTtlEnabled.mockResolvedValue(true)
2431
mockEnqueue.mockResolvedValue('job-ttl-1')
2532
mockGetJobQueue.mockResolvedValue({ enqueue: mockEnqueue })
2633
})
@@ -85,4 +92,24 @@ describe('table row TTL cleanup route', () => {
8592
expect(response.status).toBe(401)
8693
expect(mockGetJobQueue).not.toHaveBeenCalled()
8794
})
95+
96+
it('does not enqueue cleanup while the feature is disabled', async () => {
97+
mockIsTableRowTtlEnabled.mockResolvedValue(false)
98+
99+
const response = await GET(
100+
createMockRequest(
101+
'GET',
102+
undefined,
103+
{},
104+
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
105+
)
106+
)
107+
108+
expect(response.status).toBe(200)
109+
await expect(response.json()).resolves.toEqual({
110+
triggered: false,
111+
reason: 'feature-disabled',
112+
})
113+
expect(mockGetJobQueue).not.toHaveBeenCalled()
114+
})
88115
})

apps/sim/app/api/cron/cleanup-table-row-ttl/route.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { verifyCronAuth } from '@/lib/auth/internal'
44
import { getJobQueue } from '@/lib/core/async-jobs'
55
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
6+
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
67

78
export const dynamic = 'force-dynamic'
89

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

18+
if (!(await isTableRowTtlEnabled())) {
19+
logger.info('Table row TTL cleanup skipped because the feature is disabled')
20+
return NextResponse.json({ triggered: false, reason: 'feature-disabled' })
21+
}
22+
1723
const queue = await getJobQueue()
1824
const scheduleWindow = Math.floor(Date.now() / TTL_CLEANUP_INTERVAL_MS)
1925
const jobId = await queue.enqueue(

apps/sim/app/workspace/[workspaceId]/layout.tsx

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { cookies } from 'next/headers'
33
import { redirect } from 'next/navigation'
44
import { getSession } from '@/lib/auth'
55
import { getActiveOrganizationId } from '@/lib/auth/session-response'
6+
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
67
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
78
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
89
import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired'
@@ -15,6 +16,7 @@ import {
1516
import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader'
1617
import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader'
1718
import { DesktopOAuthConnectListener } from '@/app/workspace/[workspaceId]/providers/desktop-oauth-connect-listener'
19+
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
1820
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
1921
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
2022
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -44,7 +46,7 @@ export default async function WorkspaceLayout({
4446
}
4547

4648
const activeOrganizationId = getActiveOrganizationId(session)
47-
const [cookieStore, initialOrgSettings] = await Promise.all([
49+
const [cookieStore, initialOrgSettings, , tableRowTtlEnabled] = await Promise.all([
4850
cookies(),
4951
hostContext.hostOrganizationId
5052
? getOrgWhitelabelSettings(hostContext.hostOrganizationId)
@@ -56,36 +58,39 @@ export default async function WorkspaceLayout({
5658
hostContext,
5759
activeOrganizationId
5860
),
61+
isTableRowTtlEnabled(),
5962
])
6063
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
6164

6265
return (
6366
<HydrationBoundary state={dehydrate(queryClient)}>
64-
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>
65-
<BrandingProvider
66-
hostOrganizationId={hostContext.hostOrganizationId}
67-
viewerIsHostOrganizationMember={hostContext.viewer.isHostOrganizationMember}
68-
initialOrgSettings={initialOrgSettings}
69-
>
70-
<DesktopOAuthConnectListener />
71-
<SettingsLoader />
72-
<ProviderModelsLoader />
73-
<CustomBlocksLoader />
74-
<BlockVisibilityLoader />
75-
<GlobalCommandsProvider>
76-
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
77-
<ImpersonationBanner />
78-
<SessionExpired />
79-
<WorkspacePermissionsProvider>
80-
<WorkspaceScopeSync />
81-
<WorkspaceChrome initialSidebarCollapsed={initialSidebarCollapsed}>
82-
{children}
83-
</WorkspaceChrome>
84-
</WorkspacePermissionsProvider>
85-
</div>
86-
</GlobalCommandsProvider>
87-
</BrandingProvider>
88-
</WorkspaceHostProvider>
67+
<FeatureFlagsProvider flags={{ 'table-row-ttl': tableRowTtlEnabled }}>
68+
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>
69+
<BrandingProvider
70+
hostOrganizationId={hostContext.hostOrganizationId}
71+
viewerIsHostOrganizationMember={hostContext.viewer.isHostOrganizationMember}
72+
initialOrgSettings={initialOrgSettings}
73+
>
74+
<DesktopOAuthConnectListener />
75+
<SettingsLoader />
76+
<ProviderModelsLoader />
77+
<CustomBlocksLoader />
78+
<BlockVisibilityLoader />
79+
<GlobalCommandsProvider>
80+
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
81+
<ImpersonationBanner />
82+
<SessionExpired />
83+
<WorkspacePermissionsProvider>
84+
<WorkspaceScopeSync />
85+
<WorkspaceChrome initialSidebarCollapsed={initialSidebarCollapsed}>
86+
{children}
87+
</WorkspaceChrome>
88+
</WorkspacePermissionsProvider>
89+
</div>
90+
</GlobalCommandsProvider>
91+
</BrandingProvider>
92+
</WorkspaceHostProvider>
93+
</FeatureFlagsProvider>
8994
</HydrationBoundary>
9095
)
9196
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
'use client'
2+
3+
import { createContext, type ReactNode, useContext } from 'react'
4+
5+
export interface WorkspaceFeatureFlags {
6+
'table-row-ttl': boolean
7+
}
8+
9+
const FeatureFlagsContext = createContext<WorkspaceFeatureFlags | null>(null)
10+
11+
interface FeatureFlagsProviderProps {
12+
children: ReactNode
13+
flags: WorkspaceFeatureFlags
14+
}
15+
16+
/** Makes server-resolved runtime flags available to workspace client surfaces. */
17+
export function FeatureFlagsProvider({ children, flags }: FeatureFlagsProviderProps) {
18+
return <FeatureFlagsContext.Provider value={flags}>{children}</FeatureFlagsContext.Provider>
19+
}
20+
21+
/** Reads one server-resolved runtime flag without exposing AppConfig to the browser. */
22+
export function useFeatureFlag(name: keyof WorkspaceFeatureFlags): boolean {
23+
const flags = useContext(FeatureFlagsContext)
24+
if (!flags) throw new Error('useFeatureFlag must be used within FeatureFlagsProvider')
25+
return flags[name]
26+
}

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ interface ColumnConfigSidebarProps {
5353
/** Existing column record for `mode: 'edit'`; ignored otherwise. */
5454
existingColumn: ColumnDefinition | null
5555
allColumns: readonly ColumnDefinition[]
56+
tableRowTtlEnabled: boolean
5657
workspaceId: string
5758
tableId: string
5859
/** Notify parent of a rename so it can rewrite local `columnOrder` /
@@ -104,6 +105,7 @@ function ColumnConfigBody({
104105
onClose,
105106
existingColumn,
106107
allColumns,
108+
tableRowTtlEnabled,
107109
workspaceId,
108110
tableId,
109111
onColumnRename,
@@ -276,7 +278,9 @@ function ColumnConfigBody({
276278
<div className='flex flex-col gap-[9.5px]'>
277279
<RequiredLabel>Type</RequiredLabel>
278280
<ChipCombobox
279-
options={columnTypeOptionsForTable(allColumns, existingColumn)
281+
options={columnTypeOptionsForTable(allColumns, existingColumn, {
282+
tableRowTtlEnabled,
283+
})
280284
.filter((option) => option.type !== 'workflow')
281285
.map((option) => ({
282286
label: option.label,

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-type-limits.test.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ describe('column type picker limits', () => {
3333
option.maxPerTable = 1
3434
Object.assign(definition, { maxPerTable: 1 })
3535

36-
const result = columnTypeOptionsForTable([{ name: 'first', type: 'string' }])
36+
const result = columnTypeOptionsForTable([{ name: 'first', type: 'string' }], undefined, {
37+
tableRowTtlEnabled: true,
38+
})
3739
const stringOption = result.find((candidate) => candidate.type === 'string')
3840

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

47-
const result = columnTypeOptionsForTable([currentColumn], currentColumn)
49+
const result = columnTypeOptionsForTable([currentColumn], currentColumn, {
50+
tableRowTtlEnabled: true,
51+
})
4852
const stringOption = result.find((candidate) => candidate.type === 'string')
4953

5054
expect(stringOption?.disabledReason).toBeUndefined()

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.test.ts

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,35 @@ describe('columnTypeOptionsForTable', () => {
99
const ttlColumn: ColumnDefinition = { name: 'expires_at', type: 'ttl' }
1010

1111
it('disables TTL with an explanation when the table already has one', () => {
12-
const availableTtl = columnTypeOptionsForTable([{ name: 'name', type: 'string' }]).find(
13-
(option) => option.type === 'ttl'
14-
)
15-
const unavailableTtl = columnTypeOptionsForTable([ttlColumn]).find(
16-
(option) => option.type === 'ttl'
17-
)
12+
const availableTtl = columnTypeOptionsForTable([{ name: 'name', type: 'string' }], undefined, {
13+
tableRowTtlEnabled: true,
14+
}).find((option) => option.type === 'ttl')
15+
const unavailableTtl = columnTypeOptionsForTable([ttlColumn], undefined, {
16+
tableRowTtlEnabled: true,
17+
}).find((option) => option.type === 'ttl')
1818

1919
expect(availableTtl?.disabledReason).toBeUndefined()
2020
expect(unavailableTtl?.disabledReason).toBe('Only one Expiration column allowed per table')
2121
})
2222

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

2828
expect(ttlOption?.disabledReason).toBeUndefined()
2929
})
30+
31+
it('hides TTL while disabled unless editing an existing TTL column', () => {
32+
expect(
33+
columnTypeOptionsForTable([], undefined, { tableRowTtlEnabled: false }).some(
34+
(option) => option.type === 'ttl'
35+
)
36+
).toBe(false)
37+
expect(
38+
columnTypeOptionsForTable([ttlColumn], ttlColumn, { tableRowTtlEnabled: false }).some(
39+
(option) => option.type === 'ttl'
40+
)
41+
).toBe(true)
42+
})
3043
})

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export interface ColumnTypeOption {
1818
disabledReason?: string
1919
}
2020

21+
interface ColumnTypeAvailability {
22+
tableRowTtlEnabled: boolean
23+
}
24+
2125
/**
2226
* Real column types come from the registry — adding one there makes it appear
2327
* in every picker automatically. `workflow` is appended because it is a UI
@@ -47,9 +51,13 @@ function columnTypeLimitMessage(label: string, maxPerTable: number): string {
4751
/** Picker entries with unavailable cardinality-limited types marked as disabled. */
4852
export function columnTypeOptionsForTable(
4953
columns: readonly ColumnDefinition[],
50-
currentColumn?: ColumnDefinition | null
54+
currentColumn: ColumnDefinition | null | undefined,
55+
availability: ColumnTypeAvailability
5156
): ColumnTypeOption[] {
52-
return COLUMN_TYPE_OPTIONS.map((option) => {
57+
return COLUMN_TYPE_OPTIONS.filter(
58+
(option) =>
59+
option.type !== 'ttl' || availability.tableRowTtlEnabled || currentColumn?.type === 'ttl'
60+
).map((option) => {
5361
if (option.type === 'workflow') return option
5462
if (currentColumn?.type === option.type) return option
5563
if (option.maxPerTable === undefined) return option

apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-dropdown/column-dropdown.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ const CELL_HEADER =
2121

2222
interface ColumnDropdownProps {
2323
columns: readonly ColumnDefinition[]
24+
tableRowTtlEnabled: boolean
2425
/** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders
2526
* the in-table column-header `<th>` trigger. Same dropdown content either way. */
2627
trigger: 'header' | 'inline-header'
@@ -81,6 +82,7 @@ function ColumnTypeMenuItem({ option, onSelect }: ColumnTypeMenuItemProps) {
8182
*/
8283
export function ColumnDropdown({
8384
columns,
85+
tableRowTtlEnabled,
8486
trigger,
8587
disabled,
8688
onPickType,
@@ -125,7 +127,7 @@ export function ColumnDropdown({
125127
<DropdownMenu>
126128
<DropdownMenuTrigger asChild>{triggerButton}</DropdownMenuTrigger>
127129
<DropdownMenuContent align='start' side='bottom' sideOffset={4}>
128-
{columnTypeOptionsForTable(columns).map((option) => {
130+
{columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled }).map((option) => {
129131
const onSelect =
130132
option.type === 'workflow'
131133
? onPickWorkflow

0 commit comments

Comments
 (0)