Skip to content

Commit 0c8137e

Browse files
committed
fix(tables): close TTL extension gaps
1 parent e650905 commit 0c8137e

8 files changed

Lines changed: 128 additions & 14 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44
import { describe, expect, it } from 'vitest'
55
import type { ColumnDefinition } from '@/lib/table'
6-
import { columnTypeOptionsForTable } from './column-types'
6+
import { columnTypeOptionsForTable } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types'
77

88
describe('columnTypeOptionsForTable', () => {
99
const ttlColumn: ColumnDefinition = { name: 'expires_at', type: 'ttl' }

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export interface ToolCatalogEntry {
7171
| 'load_deployment'
7272
| 'load_integration_tool'
7373
| 'load_skill'
74+
| 'load_slide_layout'
7475
| 'manage_credential'
7576
| 'manage_custom_tool'
7677
| 'manage_knowledge_base'
@@ -199,6 +200,7 @@ export interface ToolCatalogEntry {
199200
| 'load_deployment'
200201
| 'load_integration_tool'
201202
| 'load_skill'
203+
| 'load_slide_layout'
202204
| 'manage_credential'
203205
| 'manage_custom_tool'
204206
| 'manage_knowledge_base'
@@ -3143,6 +3145,24 @@ export const LoadSkill: ToolCatalogEntry = {
31433145
},
31443146
}
31453147

3148+
export const LoadSlideLayout: ToolCatalogEntry = {
3149+
id: 'load_slide_layout',
3150+
name: 'load_slide_layout',
3151+
route: 'go',
3152+
mode: 'sync',
3153+
parameters: {
3154+
type: 'object',
3155+
properties: {
3156+
name: {
3157+
type: 'string',
3158+
description:
3159+
"Layout name exactly as it appears in the Layout Library index (e.g. 'metric-cards').",
3160+
},
3161+
},
3162+
required: ['name'],
3163+
},
3164+
}
3165+
31463166
export const ManageCredential: ToolCatalogEntry = {
31473167
id: 'manage_credential',
31483168
name: 'manage_credential',
@@ -7044,6 +7064,7 @@ export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
70447064
[LoadDeployment.id]: LoadDeployment,
70457065
[LoadIntegrationTool.id]: LoadIntegrationTool,
70467066
[LoadSkill.id]: LoadSkill,
7067+
[LoadSlideLayout.id]: LoadSlideLayout,
70477068
[ManageCredential.id]: ManageCredential,
70487069
[ManageCustomTool.id]: ManageCustomTool,
70497070
[ManageKnowledgeBase.id]: ManageKnowledgeBase,

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3028,6 +3028,20 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
30283028
},
30293029
resultSchema: undefined,
30303030
},
3031+
load_slide_layout: {
3032+
parameters: {
3033+
type: 'object',
3034+
properties: {
3035+
name: {
3036+
type: 'string',
3037+
description:
3038+
"Layout name exactly as it appears in the Layout Library index (e.g. 'metric-cards').",
3039+
},
3040+
},
3041+
required: ['name'],
3042+
},
3043+
resultSchema: undefined,
3044+
},
30313045
manage_credential: {
30323046
parameters: {
30333047
type: 'object',

apps/sim/lib/table/column-types/ttl.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ export const ttlColumnType: ColumnTypeDefinition = {
8383
return seconds === null ? { ok: false } : { ok: true, value: seconds }
8484
},
8585

86+
coerceForImport(value, context) {
87+
return parseTtlEpochSeconds(value, context) ?? String(value)
88+
},
89+
8690
valueForConversion(value, target: ColumnDefinition) {
8791
if (target.type !== 'date') return value
8892
return epochSecondsToIso(value) ?? value

apps/sim/lib/table/column-types/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ export interface ColumnTypeDefinition {
167167
context?: NormalizeDateCellOptions
168168
): CoerceResult
169169

170+
/** Import-only normalization when invalid raw text must survive for row-level validation. */
171+
coerceForImport?(value: unknown, context?: NormalizeDateCellOptions): Exclude<JsonValue, Date>
172+
170173
/** Source-owned normalization applied before checking or rewriting a type conversion. */
171174
valueForConversion?(value: JsonValue, target: ColumnDefinition): JsonValue
172175

apps/sim/lib/table/import.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@
1414
import type { Options as CsvParseOptions } from 'csv-parse'
1515
import { OrchestrationError } from '@/lib/core/orchestration/types'
1616
import { getColumnId } from '@/lib/table/column-keys'
17-
import type { ColumnType } from '@/lib/table/column-types'
18-
import { parseTtlEpochSeconds } from '@/lib/table/column-types/ttl'
17+
import { type ColumnType, columnTypeById } from '@/lib/table/column-types'
1918
import { parseCurrencyInput } from '@/lib/table/currency'
2019
import { type NormalizeDateCellOptions, normalizeDateCellValue } from '@/lib/table/dates'
2120
import type { ColumnDefinition, RowData, TableSchema } from '@/lib/table/types'
@@ -469,12 +468,11 @@ export function inferSchemaFromCsv(
469468
* back to the original string when unparseable so that schema validation can
470469
* reject it with context rather than silently inserting `null`.
471470
*
472-
* Deliberately NOT routed through the column-type registry's `coerce`, despite
473-
* covering the same types. The registry's contract is "coerced or rejected",
474-
* which the write path turns into `null`; an import instead wants an
475-
* unparseable date or JSON blob to survive as its raw string so the row-level
476-
* validation error names the offending value. Unifying the two would silently
477-
* swap a descriptive import error for a blanked cell.
471+
* Deliberately not routed through the registry's ordinary `coerce`: that
472+
* contract is "coerced or rejected", while an import needs invalid raw text to
473+
* survive so row-level validation can name it. Types with special import
474+
* behavior own it through `coerceForImport`; the remaining legacy import
475+
* semantics stay here until they can move without changing error behavior.
478476
*/
479477
export function coerceValue(
480478
value: unknown,
@@ -483,6 +481,9 @@ export function coerceValue(
483481
): string | number | boolean | null | Record<string, unknown> | unknown[] {
484482
if (value === null || value === undefined || value === '') return null
485483

484+
const importValue = columnTypeById(colType).coerceForImport?.(value, options)
485+
if (importValue !== undefined) return importValue
486+
486487
switch (colType) {
487488
case 'number': {
488489
const n = Number(value)
@@ -503,9 +504,6 @@ export function coerceValue(
503504
case 'date': {
504505
return normalizeDateCellValue(String(value), options) ?? String(value)
505506
}
506-
case 'ttl': {
507-
return parseTtlEpochSeconds(value, options) ?? String(value)
508-
}
509507
case 'json': {
510508
if (typeof value === 'object') return value as Record<string, unknown> | unknown[]
511509
try {

apps/sim/lib/table/workflow-groups/service.test.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44
import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import type { TableDefinition, WorkflowGroup } from '@/lib/table/types'
66

7-
const { mockWithLockedTable, mockGetTableById } = vi.hoisted(() => ({
7+
const { mockWithLockedTable, mockGetTableById, mockAssertTableRowTtlEnabled } = vi.hoisted(() => ({
88
mockWithLockedTable: vi.fn(),
99
mockGetTableById: vi.fn(),
10+
mockAssertTableRowTtlEnabled: vi.fn(),
1011
}))
1112

1213
vi.mock('@/lib/table/service', () => ({
@@ -20,6 +21,9 @@ vi.mock('@/lib/table/mutation-locks', () => ({
2021
vi.mock('@/lib/table/rows/secret-provenance', () => ({
2122
updateTableRowsWithDerivedSecretProvenance: vi.fn(),
2223
}))
24+
vi.mock('@/lib/table/ttl-availability', () => ({
25+
assertTableRowTtlEnabled: mockAssertTableRowTtlEnabled,
26+
}))
2327
vi.mock('@/lib/table/workflow-columns', () => ({
2428
runWorkflowColumn: vi.fn().mockResolvedValue(undefined),
2529
stripGroupDeps: (schema: unknown) => schema,
@@ -34,7 +38,11 @@ vi.mock('@/lib/table/schema-invariants', () => ({
3438
}))
3539

3640
import { TABLE_LIMITS } from '@/lib/table/constants'
37-
import { addWorkflowGroup } from '@/lib/table/workflow-groups/service'
41+
import {
42+
addWorkflowGroup,
43+
addWorkflowGroupOutput,
44+
updateWorkflowGroup,
45+
} from '@/lib/table/workflow-groups/service'
3846

3947
function groupAt(index: number): WorkflowGroup {
4048
return {
@@ -73,6 +81,7 @@ function tableWithGroups(count: number): TableDefinition {
7381
describe('addWorkflowGroup group ceiling', () => {
7482
beforeEach(() => {
7583
vi.clearAllMocks()
84+
mockAssertTableRowTtlEnabled.mockResolvedValue(undefined)
7685
})
7786

7887
function add(existingGroups: number) {
@@ -108,3 +117,57 @@ describe('addWorkflowGroup group ceiling', () => {
108117
await expect(add(TABLE_LIMITS.MAX_WORKFLOW_GROUPS_PER_TABLE - 1)).resolves.toBeDefined()
109118
})
110119
})
120+
121+
describe('workflow group TTL availability', () => {
122+
beforeEach(() => {
123+
vi.clearAllMocks()
124+
mockAssertTableRowTtlEnabled.mockRejectedValue(new Error('Expiration columns are not enabled'))
125+
})
126+
127+
it.each([
128+
[
129+
'group creation',
130+
() =>
131+
addWorkflowGroup(
132+
{
133+
tableId: 'table-1',
134+
workspaceId: 'workspace-1',
135+
group: groupAt(1),
136+
outputColumns: [{ name: 'expires_at', type: 'ttl' }],
137+
} as Parameters<typeof addWorkflowGroup>[0],
138+
'request-1'
139+
),
140+
],
141+
[
142+
'group update',
143+
() =>
144+
updateWorkflowGroup(
145+
{
146+
tableId: 'table-1',
147+
workspaceId: 'workspace-1',
148+
groupId: 'group-1',
149+
newOutputColumns: [{ name: 'expires_at', type: 'ttl' }],
150+
} as Parameters<typeof updateWorkflowGroup>[0],
151+
'request-1'
152+
),
153+
],
154+
[
155+
'single output addition',
156+
() =>
157+
addWorkflowGroupOutput(
158+
{
159+
tableId: 'table-1',
160+
workspaceId: 'workspace-1',
161+
groupId: 'group-1',
162+
blockId: 'block-1',
163+
path: 'expiresAt',
164+
resolvedOutput: { workflowId: 'workflow-1', columnType: 'ttl', order: [] },
165+
},
166+
'request-1'
167+
),
168+
],
169+
])('rejects TTL introduction through %s while disabled', async (_label, introduceTtl) => {
170+
await expect(introduceTtl()).rejects.toThrow('Expiration columns are not enabled')
171+
expect(mockWithLockedTable).not.toHaveBeenCalled()
172+
})
173+
})

apps/sim/lib/table/workflow-groups/service.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { stripGroupExecutions } from '@/lib/table/rows/executions'
2626
import { updateTableRowsWithDerivedSecretProvenance } from '@/lib/table/rows/secret-provenance'
2727
import { assertValidSchema } from '@/lib/table/schema-invariants'
2828
import { getTableById, withLockedTable } from '@/lib/table/service'
29+
import { assertTableRowTtlEnabled } from '@/lib/table/ttl-availability'
2930
import { setTableTxTimeouts } from '@/lib/table/tx'
3031
import type {
3132
AddWorkflowGroupData,
@@ -132,6 +133,10 @@ export async function addWorkflowGroup(
132133
data: AddWorkflowGroupData,
133134
requestId: string
134135
): Promise<TableDefinition> {
136+
if (data.outputColumns.some((column) => column.type === 'ttl')) {
137+
await assertTableRowTtlEnabled()
138+
}
139+
135140
const updatedTable = await withLockedTable(
136141
data.tableId,
137142
async (table, trx) => {
@@ -258,6 +263,10 @@ export async function updateWorkflowGroup(
258263
requestId: string
259264
): Promise<TableDefinition> {
260265
const mappingUpdates = data.mappingUpdates ?? []
266+
const introducesTtl =
267+
data.newOutputColumns?.some((column) => column.type === 'ttl') === true ||
268+
data.resolvedMappingTypes?.columns.some((column) => column.type === 'ttl') === true
269+
if (introducesTtl) await assertTableRowTtlEnabled()
261270

262271
// Phase 1 (no lock): consume the output types resolved and authorized by the
263272
// application command. Resolution stays outside the advisory-lock critical
@@ -640,6 +649,8 @@ export async function addWorkflowGroupOutput(
640649
},
641650
requestId: string
642651
): Promise<TableDefinition> {
652+
if (data.resolvedOutput.columnType === 'ttl') await assertTableRowTtlEnabled()
653+
643654
// Phase 1 (no lock): validate the authorized workflow metadata against the
644655
// group's current workflow. Phase 2 re-validates the same binding under the
645656
// table lock before applying the mutation.

0 commit comments

Comments
 (0)