Skip to content

Commit 8825f1b

Browse files
committed
feat(tables): gate row TTL expiration
1 parent 3f94bbb commit 8825f1b

24 files changed

Lines changed: 310 additions & 52 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:12:00Z'))
2329
mockVerifyCronAuth.mockReturnValue(null)
30+
mockIsTableRowTtlEnabled.mockResolvedValue(true)
2431
mockEnqueue.mockResolvedValue('job-ttl-1')
2532
mockGetJobQueue.mockResolvedValue({ enqueue: mockEnqueue })
2633
})
@@ -102,4 +109,24 @@ describe('table row TTL cleanup route', () => {
102109
expect(response.status).toBe(401)
103110
expect(mockGetJobQueue).not.toHaveBeenCalled()
104111
})
112+
113+
it('does not enqueue cleanup while the feature is disabled', async () => {
114+
mockIsTableRowTtlEnabled.mockResolvedValue(false)
115+
116+
const response = await GET(
117+
createMockRequest(
118+
'GET',
119+
undefined,
120+
{},
121+
'http://localhost:3000/api/cron/cleanup-table-row-ttl'
122+
)
123+
)
124+
125+
expect(response.status).toBe(200)
126+
await expect(response.json()).resolves.toEqual({
127+
triggered: false,
128+
reason: 'feature-disabled',
129+
})
130+
expect(mockGetJobQueue).not.toHaveBeenCalled()
131+
})
105132
})

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: 33 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { cookies } from 'next/headers'
44
import { redirect } from 'next/navigation'
55
import { getSession } from '@/lib/auth'
66
import { getActiveOrganizationId } from '@/lib/auth/session-response'
7+
import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability'
78
import { getQueryClient } from '@/app/_shell/providers/get-query-client'
89
import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner'
910
import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired'
@@ -16,6 +17,7 @@ import {
1617
import { BlockVisibilityLoader } from '@/app/workspace/[workspaceId]/providers/block-visibility-loader'
1718
import { CustomBlocksLoader } from '@/app/workspace/[workspaceId]/providers/custom-blocks-loader'
1819
import { DesktopOAuthConnectListener } from '@/app/workspace/[workspaceId]/providers/desktop-oauth-connect-listener'
20+
import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider'
1921
import { GlobalCommandsProvider } from '@/app/workspace/[workspaceId]/providers/global-commands-provider'
2022
import { ProviderModelsLoader } from '@/app/workspace/[workspaceId]/providers/provider-models-loader'
2123
import { SettingsLoader } from '@/app/workspace/[workspaceId]/providers/settings-loader'
@@ -45,7 +47,7 @@ export default async function WorkspaceLayout({
4547
}
4648

4749
const activeOrganizationId = getActiveOrganizationId(session)
48-
const [cookieStore, initialOrgSettings] = await Promise.all([
50+
const [cookieStore, initialOrgSettings, , tableRowTtlEnabled] = await Promise.all([
4951
cookies(),
5052
hostContext.hostOrganizationId
5153
? getOrgWhitelabelSettings(hostContext.hostOrganizationId)
@@ -57,38 +59,41 @@ export default async function WorkspaceLayout({
5759
hostContext,
5860
activeOrganizationId
5961
),
62+
isTableRowTtlEnabled(),
6063
])
6164
const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1'
6265

6366
return (
6467
<HydrationBoundary state={dehydrate(queryClient)}>
65-
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>
66-
<BrandingProvider
67-
hostOrganizationId={hostContext.hostOrganizationId}
68-
viewerIsHostOrganizationMember={hostContext.viewer.isHostOrganizationMember}
69-
initialOrgSettings={initialOrgSettings}
70-
>
71-
<ToastProvider>
72-
<DesktopOAuthConnectListener />
73-
<SettingsLoader />
74-
<ProviderModelsLoader />
75-
<CustomBlocksLoader />
76-
<BlockVisibilityLoader />
77-
<GlobalCommandsProvider>
78-
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
79-
<ImpersonationBanner />
80-
<SessionExpired />
81-
<WorkspacePermissionsProvider>
82-
<WorkspaceScopeSync />
83-
<WorkspaceChrome initialSidebarCollapsed={initialSidebarCollapsed}>
84-
{children}
85-
</WorkspaceChrome>
86-
</WorkspacePermissionsProvider>
87-
</div>
88-
</GlobalCommandsProvider>
89-
</ToastProvider>
90-
</BrandingProvider>
91-
</WorkspaceHostProvider>
68+
<FeatureFlagsProvider flags={{ 'table-row-ttl': tableRowTtlEnabled }}>
69+
<WorkspaceHostProvider workspaceId={workspaceId} initialContext={hostContext}>
70+
<BrandingProvider
71+
hostOrganizationId={hostContext.hostOrganizationId}
72+
viewerIsHostOrganizationMember={hostContext.viewer.isHostOrganizationMember}
73+
initialOrgSettings={initialOrgSettings}
74+
>
75+
<ToastProvider>
76+
<DesktopOAuthConnectListener />
77+
<SettingsLoader />
78+
<ProviderModelsLoader />
79+
<CustomBlocksLoader />
80+
<BlockVisibilityLoader />
81+
<GlobalCommandsProvider>
82+
<div className='flex h-screen w-full flex-col overflow-hidden bg-[var(--surface-1)]'>
83+
<ImpersonationBanner />
84+
<SessionExpired />
85+
<WorkspacePermissionsProvider>
86+
<WorkspaceScopeSync />
87+
<WorkspaceChrome initialSidebarCollapsed={initialSidebarCollapsed}>
88+
{children}
89+
</WorkspaceChrome>
90+
</WorkspacePermissionsProvider>
91+
</div>
92+
</GlobalCommandsProvider>
93+
</ToastProvider>
94+
</BrandingProvider>
95+
</WorkspaceHostProvider>
96+
</FeatureFlagsProvider>
9297
</HydrationBoundary>
9398
)
9499
}
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 TTL 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
@@ -42,9 +46,13 @@ function columnTypeLimitMessage(label: string, maxPerTable: number): string {
4246
/** Picker entries with unavailable cardinality-limited types marked as disabled. */
4347
export function columnTypeOptionsForTable(
4448
columns: readonly ColumnDefinition[],
45-
currentColumn?: ColumnDefinition | null
49+
currentColumn: ColumnDefinition | null | undefined,
50+
availability: ColumnTypeAvailability
4651
): ColumnTypeOption[] {
47-
return COLUMN_TYPE_OPTIONS.map((option) => {
52+
return COLUMN_TYPE_OPTIONS.filter(
53+
(option) =>
54+
option.type !== 'ttl' || availability.tableRowTtlEnabled || currentColumn?.type === 'ttl'
55+
).map((option) => {
4856
if (option.type === 'workflow') return option
4957
if (currentColumn?.type === option.type) return option
5058
if (option.maxPerTable === undefined) return option

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

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

2323
interface NewColumnDropdownProps {
2424
columns: readonly ColumnDefinition[]
25+
tableRowTtlEnabled: boolean
2526
/** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders
2627
* the in-table column-header `<th>` trigger. Same dropdown content either way. */
2728
trigger: 'header' | 'inline-header'
@@ -82,6 +83,7 @@ function ColumnTypeMenuItem({ option, onSelect }: ColumnTypeMenuItemProps) {
8283
*/
8384
export function NewColumnDropdown({
8485
columns,
86+
tableRowTtlEnabled,
8587
trigger,
8688
disabled,
8789
onPickType,
@@ -137,7 +139,7 @@ export function NewColumnDropdown({
137139
</DropdownMenuItem>
138140
<DropdownMenuSeparator />
139141
</>
140-
{columnTypeOptionsForTable(columns).map((option) => {
142+
{columnTypeOptionsForTable(columns, undefined, { tableRowTtlEnabled }).map((option) => {
141143
const onSelect =
142144
option.type === 'workflow'
143145
? onPickWorkflow

0 commit comments

Comments
 (0)