Skip to content

Commit 6a927c9

Browse files
waleedlatif1claude
andauthored
fix(tables): optimistic updates for column delete/update (#4512)
* fix(tables): optimistic updates for column delete/update Add onMutate/onError to useDeleteColumn and useUpdateColumn so column deletes feel instant on large tables (no flash-back during the JSONB rewrite) and concurrent type changes don't race the in-flight delete's invalidation. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(tables): toast on delete-column failure, case-insensitive row cleanup, rename row-data keys Address greptile review: - useDeleteColumn now toasts on non-validation errors so users see when the column "snaps back" after a server/network failure - Row data cleanup matches keys case-insensitively in both useDeleteColumn and useUpdateColumn so a column stored as "Age" is cleaned even when the request uses "age" - useUpdateColumn now migrates row-data keys when updates.name is set, preventing blank cells during the server round-trip on a rename Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 235a62f commit 6a927c9

2 files changed

Lines changed: 334 additions & 1 deletion

File tree

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { queryClient, cacheStore } = vi.hoisted(() => {
7+
const cache = new Map<string, unknown>()
8+
return {
9+
cacheStore: cache,
10+
queryClient: {
11+
cancelQueries: vi.fn().mockResolvedValue(undefined),
12+
invalidateQueries: vi.fn().mockResolvedValue(undefined),
13+
getQueryData: vi.fn((key: readonly unknown[]) => cache.get(JSON.stringify(key))),
14+
setQueryData: vi.fn((key: readonly unknown[], updater: unknown) => {
15+
const k = JSON.stringify(key)
16+
const prev = cache.get(k)
17+
const next =
18+
typeof updater === 'function' ? (updater as (p: unknown) => unknown)(prev) : updater
19+
cache.set(k, next)
20+
return next
21+
}),
22+
getQueriesData: vi.fn((opts: { queryKey: readonly unknown[] }) => {
23+
const prefix = JSON.stringify(opts.queryKey).slice(0, -1)
24+
return [...cache.entries()]
25+
.filter(([k]) => k.startsWith(prefix))
26+
.map(([k, v]) => [JSON.parse(k), v])
27+
}),
28+
},
29+
}
30+
})
31+
32+
vi.mock('@tanstack/react-query', () => ({
33+
keepPreviousData: {},
34+
useQuery: vi.fn(),
35+
useInfiniteQuery: vi.fn(),
36+
useQueryClient: vi.fn(() => queryClient),
37+
useMutation: vi.fn((options) => options),
38+
}))
39+
40+
vi.mock('@/lib/api/client/request', () => ({
41+
requestJson: vi.fn(),
42+
}))
43+
44+
vi.mock('@/lib/api/client/errors', () => ({
45+
isValidationError: vi.fn(() => false),
46+
}))
47+
48+
vi.mock('@/lib/api/contracts/tables', () => ({
49+
addTableColumnContract: {},
50+
addWorkflowGroupContract: {},
51+
batchCreateTableRowsContract: {},
52+
batchUpdateTableRowsContract: {},
53+
cancelTableRunsContract: {},
54+
createTableContract: {},
55+
createTableRowContract: {},
56+
deleteTableColumnContract: {},
57+
deleteTableContract: {},
58+
deleteTableRowContract: {},
59+
deleteTableRowsContract: {},
60+
deleteWorkflowGroupContract: {},
61+
getTableContract: {},
62+
importCsvContract: {},
63+
listTableRowsContract: {},
64+
listTablesContract: {},
65+
renameTableContract: {},
66+
restoreTableContract: {},
67+
runWorkflowGroupContract: {},
68+
updateTableColumnContract: {},
69+
updateTableMetadataContract: {},
70+
updateTableRowContract: {},
71+
updateWorkflowGroupContract: {},
72+
uploadCsvContract: {},
73+
}))
74+
75+
vi.mock('@/app/workspace/providers/socket-provider', () => ({
76+
useSocket: vi.fn(() => ({ socket: null })),
77+
}))
78+
79+
vi.mock('@/components/emcn', () => ({
80+
toast: { error: vi.fn(), success: vi.fn() },
81+
}))
82+
83+
import { tableKeys, useDeleteColumn, useUpdateColumn } from '@/hooks/queries/tables'
84+
85+
const TABLE_ID = 'tbl-1'
86+
const WORKSPACE_ID = 'ws-1'
87+
88+
function setCache(key: readonly unknown[], value: unknown) {
89+
cacheStore.set(JSON.stringify(key), value)
90+
}
91+
92+
function getCache<T>(key: readonly unknown[]): T | undefined {
93+
return cacheStore.get(JSON.stringify(key)) as T | undefined
94+
}
95+
96+
beforeEach(() => {
97+
cacheStore.clear()
98+
vi.clearAllMocks()
99+
})
100+
101+
describe('useDeleteColumn optimistic update', () => {
102+
it('removes column from schema cache, strips its width, and clears it from row data', async () => {
103+
setCache(tableKeys.detail(TABLE_ID), {
104+
id: TABLE_ID,
105+
schema: {
106+
columns: [
107+
{ name: 'name', type: 'string' },
108+
{ name: 'age', type: 'number' },
109+
],
110+
},
111+
metadata: {
112+
columnWidths: { name: 200, age: 100 },
113+
},
114+
})
115+
setCache(tableKeys.rowsRoot(TABLE_ID), {
116+
rows: [
117+
{ id: 'r1', data: { name: 'a', age: 1 } },
118+
{ id: 'r2', data: { name: 'b', age: 2 } },
119+
],
120+
totalCount: 2,
121+
})
122+
123+
const hook = useDeleteColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
124+
const ctx = await hook.onMutate?.('age')
125+
126+
const detail = getCache<{
127+
schema: { columns: Array<{ name: string }> }
128+
metadata: { columnWidths: Record<string, number> }
129+
}>(tableKeys.detail(TABLE_ID))
130+
expect(detail?.schema.columns.map((c) => c.name)).toEqual(['name'])
131+
expect(detail?.metadata.columnWidths).toEqual({ name: 200 })
132+
133+
const rows = getCache<{ rows: Array<{ data: Record<string, unknown> }> }>(
134+
tableKeys.rowsRoot(TABLE_ID)
135+
)
136+
expect(rows?.rows.every((r) => !('age' in r.data))).toBe(true)
137+
expect(rows?.rows[0]?.data).toEqual({ name: 'a' })
138+
139+
expect(ctx?.previousDetail).toBeDefined()
140+
expect(ctx?.rowSnapshots?.length).toBeGreaterThan(0)
141+
})
142+
143+
it('rolls back schema and rows on error using snapshots', async () => {
144+
const originalDetail = {
145+
id: TABLE_ID,
146+
schema: { columns: [{ name: 'name' }, { name: 'age' }] },
147+
metadata: { columnWidths: { name: 200, age: 100 } },
148+
}
149+
const originalRows = {
150+
rows: [{ id: 'r1', data: { name: 'a', age: 1 } }],
151+
totalCount: 1,
152+
}
153+
setCache(tableKeys.detail(TABLE_ID), originalDetail)
154+
setCache(tableKeys.rowsRoot(TABLE_ID), originalRows)
155+
156+
const hook = useDeleteColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
157+
const ctx = await hook.onMutate?.('age')
158+
159+
expect(getCache(tableKeys.detail(TABLE_ID))).not.toEqual(originalDetail)
160+
161+
hook.onError?.(new Error('boom'), 'age', ctx)
162+
163+
expect(getCache(tableKeys.detail(TABLE_ID))).toEqual(originalDetail)
164+
expect(getCache(tableKeys.rowsRoot(TABLE_ID))).toEqual(originalRows)
165+
})
166+
167+
it('invalidates schema, rows, and lists in onSettled', () => {
168+
const hook = useDeleteColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
169+
hook.onSettled?.(undefined, null, 'age', undefined)
170+
171+
const calls = queryClient.invalidateQueries.mock.calls.map((c) => c[0]?.queryKey)
172+
expect(calls).toEqual(
173+
expect.arrayContaining([
174+
tableKeys.detail(TABLE_ID),
175+
tableKeys.rowsRoot(TABLE_ID),
176+
tableKeys.lists(),
177+
])
178+
)
179+
})
180+
})
181+
182+
describe('useUpdateColumn optimistic update', () => {
183+
it('writes the column update to the schema cache and rolls back on error', async () => {
184+
const original = {
185+
id: TABLE_ID,
186+
schema: {
187+
columns: [
188+
{ name: 'name', type: 'string' },
189+
{ name: 'age', type: 'string' },
190+
],
191+
},
192+
}
193+
setCache(tableKeys.detail(TABLE_ID), original)
194+
195+
const hook = useUpdateColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
196+
const ctx = await hook.onMutate?.({ columnName: 'age', updates: { type: 'number' } })
197+
198+
const after = getCache<{ schema: { columns: Array<{ name: string; type: string }> } }>(
199+
tableKeys.detail(TABLE_ID)
200+
)
201+
expect(after?.schema.columns.find((c) => c.name === 'age')?.type).toBe('number')
202+
203+
hook.onError?.(new Error('boom'), { columnName: 'age', updates: { type: 'number' } }, ctx)
204+
205+
expect(getCache(tableKeys.detail(TABLE_ID))).toEqual(original)
206+
})
207+
208+
it('renames the corresponding row-data key when updates.name is set', async () => {
209+
setCache(tableKeys.detail(TABLE_ID), {
210+
id: TABLE_ID,
211+
schema: { columns: [{ name: 'age', type: 'number' }] },
212+
})
213+
setCache(tableKeys.rowsRoot(TABLE_ID), {
214+
rows: [
215+
{ id: 'r1', data: { age: 30 } },
216+
{ id: 'r2', data: { age: 40 } },
217+
],
218+
totalCount: 2,
219+
})
220+
221+
const hook = useUpdateColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
222+
await hook.onMutate?.({ columnName: 'age', updates: { name: 'years' } })
223+
224+
const rows = getCache<{ rows: Array<{ data: Record<string, unknown> }> }>(
225+
tableKeys.rowsRoot(TABLE_ID)
226+
)
227+
expect(rows?.rows[0]?.data).toEqual({ years: 30 })
228+
expect(rows?.rows[1]?.data).toEqual({ years: 40 })
229+
})
230+
})
231+
232+
describe('useDeleteColumn case-insensitive row cleanup', () => {
233+
it('strips the row data key even when stored casing differs from the requested name', async () => {
234+
setCache(tableKeys.detail(TABLE_ID), {
235+
id: TABLE_ID,
236+
schema: { columns: [{ name: 'Age', type: 'number' }] },
237+
})
238+
setCache(tableKeys.rowsRoot(TABLE_ID), {
239+
rows: [{ id: 'r1', data: { Age: 30, name: 'a' } }],
240+
totalCount: 1,
241+
})
242+
243+
const hook = useDeleteColumn({ workspaceId: WORKSPACE_ID, tableId: TABLE_ID })
244+
await hook.onMutate?.('age')
245+
246+
const rows = getCache<{ rows: Array<{ data: Record<string, unknown> }> }>(
247+
tableKeys.rowsRoot(TABLE_ID)
248+
)
249+
expect(rows?.rows[0]?.data).toEqual({ name: 'a' })
250+
})
251+
})

apps/sim/hooks/queries/tables.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -872,7 +872,43 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) {
872872
body: { workspaceId, columnName, updates },
873873
})
874874
},
875-
onError: (error) => {
875+
onMutate: async ({ columnName, updates }) => {
876+
await queryClient.cancelQueries({ queryKey: tableKeys.detail(tableId) })
877+
const previousDetail = queryClient.getQueryData<TableDefinition>(tableKeys.detail(tableId))
878+
if (previousDetail) {
879+
const lower = columnName.toLowerCase()
880+
const nextColumns = previousDetail.schema.columns.map((c) =>
881+
c.name.toLowerCase() === lower ? { ...c, ...updates } : c
882+
)
883+
queryClient.setQueryData<TableDefinition>(tableKeys.detail(tableId), {
884+
...previousDetail,
885+
schema: { ...previousDetail.schema, columns: nextColumns },
886+
})
887+
}
888+
889+
const newName = (updates as { name?: string }).name
890+
const rowSnapshots =
891+
typeof newName === 'string' && newName.length > 0 && newName !== columnName
892+
? await snapshotAndMutateRows(queryClient, tableId, (row) => {
893+
const lower = columnName.toLowerCase()
894+
const matchKey = Object.keys(row.data).find((k) => k.toLowerCase() === lower)
895+
if (!matchKey) return null
896+
const { [matchKey]: value, ...rest } = row.data
897+
return { ...row, data: { ...rest, [newName]: value } }
898+
})
899+
: []
900+
901+
return { previousDetail, rowSnapshots }
902+
},
903+
onError: (error, _vars, context) => {
904+
if (context?.previousDetail) {
905+
queryClient.setQueryData(tableKeys.detail(tableId), context.previousDetail)
906+
}
907+
if (context?.rowSnapshots) {
908+
for (const [key, data] of context.rowSnapshots) {
909+
queryClient.setQueryData(key, data)
910+
}
911+
}
876912
// Validation errors are surfaced as inline FieldErrors by the caller.
877913
if (isValidationError(error)) return
878914
toast.error(error.message, { duration: 5000 })
@@ -1152,6 +1188,52 @@ export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) {
11521188
body: { workspaceId, columnName },
11531189
})
11541190
},
1191+
onMutate: async (columnName) => {
1192+
await queryClient.cancelQueries({ queryKey: tableKeys.detail(tableId) })
1193+
1194+
const lower = columnName.toLowerCase()
1195+
const previousDetail = queryClient.getQueryData<TableDefinition>(tableKeys.detail(tableId))
1196+
if (previousDetail) {
1197+
const nextColumns = previousDetail.schema.columns.filter(
1198+
(c) => c.name.toLowerCase() !== lower
1199+
)
1200+
const prevWidths = previousDetail.metadata?.columnWidths
1201+
const nextMetadata = prevWidths
1202+
? {
1203+
...previousDetail.metadata,
1204+
columnWidths: Object.fromEntries(
1205+
Object.entries(prevWidths).filter(([k]) => k.toLowerCase() !== lower)
1206+
),
1207+
}
1208+
: previousDetail.metadata
1209+
queryClient.setQueryData<TableDefinition>(tableKeys.detail(tableId), {
1210+
...previousDetail,
1211+
schema: { ...previousDetail.schema, columns: nextColumns },
1212+
metadata: nextMetadata,
1213+
})
1214+
}
1215+
1216+
const rowSnapshots = await snapshotAndMutateRows(queryClient, tableId, (row) => {
1217+
const matchKey = Object.keys(row.data).find((k) => k.toLowerCase() === lower)
1218+
if (!matchKey) return null
1219+
const { [matchKey]: _removed, ...rest } = row.data
1220+
return { ...row, data: rest }
1221+
})
1222+
1223+
return { previousDetail, rowSnapshots }
1224+
},
1225+
onError: (error, _columnName, context) => {
1226+
if (context?.previousDetail) {
1227+
queryClient.setQueryData(tableKeys.detail(tableId), context.previousDetail)
1228+
}
1229+
if (context?.rowSnapshots) {
1230+
for (const [key, data] of context.rowSnapshots) {
1231+
queryClient.setQueryData(key, data)
1232+
}
1233+
}
1234+
if (isValidationError(error)) return
1235+
toast.error(error.message, { duration: 5000 })
1236+
},
11551237
onSettled: () => {
11561238
invalidateTableSchema(queryClient, tableId)
11571239
},

0 commit comments

Comments
 (0)