Skip to content

Commit 739bd6d

Browse files
fix(api): make table import completion retries idempotent
1 parent a36741f commit 739bd6d

4 files changed

Lines changed: 130 additions & 1 deletion

File tree

apps/sim/app/api/table/imports/[importId]/complete/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { parseRequest } from '@/lib/api/server'
44
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
55
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
66
import {
7+
findOwnedTableImport,
78
getOwnedTableImportUpload,
89
startUploadedTableImport,
910
toV2TableImport,
@@ -29,6 +30,12 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Impor
2930
userId: auth.userId,
3031
uploadToken: parsed.data.headers['upload-token'],
3132
})
33+
const existing = await findOwnedTableImport({
34+
importId: upload.id,
35+
workspaceId: upload.workspaceId,
36+
userId: upload.userId,
37+
})
38+
if (existing) return NextResponse.json({ data: toV2TableImport(existing) })
3239
const completed = await completeUploadSession({
3340
session: upload,
3441
parts: parsed.data.body.parts,
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { NextRequest } from 'next/server'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const {
8+
mockCheckRateLimit,
9+
mockResolveWorkspaceScope,
10+
mockGetOwnedTableImportUpload,
11+
mockFindOwnedTableImport,
12+
mockStartUploadedTableImport,
13+
mockToV2TableImport,
14+
mockCompleteUploadSession,
15+
} = vi.hoisted(() => ({
16+
mockCheckRateLimit: vi.fn(),
17+
mockResolveWorkspaceScope: vi.fn(),
18+
mockGetOwnedTableImportUpload: vi.fn(),
19+
mockFindOwnedTableImport: vi.fn(),
20+
mockStartUploadedTableImport: vi.fn(),
21+
mockToV2TableImport: vi.fn(),
22+
mockCompleteUploadSession: vi.fn(),
23+
}))
24+
25+
vi.mock('@/app/api/v1/middleware', () => ({
26+
checkRateLimit: mockCheckRateLimit,
27+
resolveWorkspaceScope: mockResolveWorkspaceScope,
28+
}))
29+
30+
vi.mock('@/app/api/v2/lib/gate', () => ({
31+
v2ApiGateError: vi.fn().mockResolvedValue(null),
32+
}))
33+
34+
vi.mock('@/app/api/v2/tables/utils', () => ({
35+
v2TableLockError: vi.fn().mockReturnValue(null),
36+
}))
37+
38+
vi.mock('@/lib/table/orchestration/import-resource', () => ({
39+
findOwnedTableImport: mockFindOwnedTableImport,
40+
getOwnedTableImportUpload: mockGetOwnedTableImportUpload,
41+
startUploadedTableImport: mockStartUploadedTableImport,
42+
toV2TableImport: mockToV2TableImport,
43+
}))
44+
45+
vi.mock('@/lib/uploads/multipart-session/service', () => ({
46+
completeUploadSession: mockCompleteUploadSession,
47+
}))
48+
49+
import { POST } from '@/app/api/v2/tables/imports/[importId]/complete/route'
50+
51+
const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8'
52+
const RATE_LIMIT = {
53+
allowed: true,
54+
userId: 'user-1',
55+
keyType: 'workspace',
56+
limit: 100,
57+
remaining: 99,
58+
resetAt: new Date('2026-08-03T22:00:00.000Z'),
59+
}
60+
const UPLOAD = {
61+
id: 'import-1',
62+
workspaceId: WORKSPACE_ID,
63+
userId: 'user-1',
64+
}
65+
66+
function request() {
67+
return POST(
68+
new NextRequest(
69+
`http://localhost:3000/api/v2/tables/imports/import-1/complete?workspaceId=${WORKSPACE_ID}`,
70+
{
71+
method: 'POST',
72+
headers: {
73+
'Content-Type': 'application/json',
74+
'upload-token': 'signed-upload-token',
75+
},
76+
body: JSON.stringify({ parts: [{ partNumber: 1, etag: 'etag-1' }] }),
77+
}
78+
),
79+
{ params: Promise.resolve({ importId: 'import-1' }) }
80+
)
81+
}
82+
83+
describe('POST /api/v2/tables/imports/[importId]/complete', () => {
84+
beforeEach(() => {
85+
vi.clearAllMocks()
86+
mockCheckRateLimit.mockResolvedValue(RATE_LIMIT)
87+
mockResolveWorkspaceScope.mockResolvedValue(null)
88+
mockGetOwnedTableImportUpload.mockReturnValue(UPLOAD)
89+
})
90+
91+
it('returns the existing table job when completion is retried', async () => {
92+
const existing = { id: 'import-1', tableId: 'table-1', status: 'ready' }
93+
const responseBody = { id: 'import-1', tableId: 'table-1', status: 'completed' }
94+
mockFindOwnedTableImport.mockResolvedValue(existing)
95+
mockToV2TableImport.mockReturnValue(responseBody)
96+
97+
const response = await request()
98+
99+
expect(response.status).toBe(200)
100+
expect(await response.json()).toEqual({ data: responseBody })
101+
expect(mockGetOwnedTableImportUpload).toHaveBeenCalledWith({
102+
importId: 'import-1',
103+
workspaceId: WORKSPACE_ID,
104+
userId: 'user-1',
105+
uploadToken: 'signed-upload-token',
106+
})
107+
expect(mockFindOwnedTableImport).toHaveBeenCalledWith({
108+
importId: 'import-1',
109+
workspaceId: WORKSPACE_ID,
110+
userId: 'user-1',
111+
})
112+
expect(mockCompleteUploadSession).not.toHaveBeenCalled()
113+
expect(mockStartUploadedTableImport).not.toHaveBeenCalled()
114+
})
115+
})

apps/sim/app/api/v2/tables/imports/[importId]/complete/route.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { v2CompleteTableImportContract } from '@/lib/api/contracts/v2/tables'
55
import { parseRequest } from '@/lib/api/server'
66
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
77
import {
8+
findOwnedTableImport,
89
getOwnedTableImportUpload,
910
startUploadedTableImport,
1011
toV2TableImport,
@@ -49,6 +50,12 @@ export const POST = withRouteHandler(
4950
userId,
5051
uploadToken: parsed.data.headers['upload-token'],
5152
})
53+
const existing = await findOwnedTableImport({
54+
importId: upload.id,
55+
workspaceId: upload.workspaceId,
56+
userId: upload.userId,
57+
})
58+
if (existing) return v2Data(toV2TableImport(existing), { rateLimit })
5259
const completed = await completeUploadSession({
5360
session: upload,
5461
parts: parsed.data.body.parts,

apps/sim/lib/table/orchestration/import-resource.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ export async function getOwnedTableImport(params: {
161161
return record
162162
}
163163

164-
async function findOwnedTableImport(params: {
164+
export async function findOwnedTableImport(params: {
165165
importId: string
166166
workspaceId: string
167167
userId: string

0 commit comments

Comments
 (0)