Skip to content

Commit 5baa7a4

Browse files
authored
fix(files): uniquify materialized upload names (#6273)
* fix(files): uniquify materialized upload names * fix(files): sync materialized display names * fix(files): return materialized file names
1 parent 5dbe95e commit 5baa7a4

2 files changed

Lines changed: 255 additions & 41 deletions

File tree

apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts

Lines changed: 168 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,26 @@ import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66

77
const {
8+
mockAllocateUniqueWorkspaceFileName,
89
mockCheckStorageQuotaForBillingContext,
910
mockDecompress,
1011
mockFetchBuffer,
1112
mockFindFolder,
1213
mockFindUpload,
14+
mockGetWorkspaceFile,
1315
mockHasCloudStorage,
1416
mockHeadObject,
1517
mockIncrementStorageUsageForBillingContextInTx,
1618
mockMaybeNotifyStorageLimitForBillingContext,
1719
mockResolveStorageBillingContext,
1820
} = vi.hoisted(() => ({
21+
mockAllocateUniqueWorkspaceFileName: vi.fn(),
1922
mockCheckStorageQuotaForBillingContext: vi.fn(),
2023
mockDecompress: vi.fn(),
2124
mockFetchBuffer: vi.fn(),
2225
mockFindFolder: vi.fn(),
2326
mockFindUpload: vi.fn(),
27+
mockGetWorkspaceFile: vi.fn(),
2428
mockHasCloudStorage: vi.fn(),
2529
mockHeadObject: vi.fn(),
2630
mockIncrementStorageUsageForBillingContextInTx: vi.fn(),
@@ -41,7 +45,9 @@ vi.mock('@/lib/uploads', () => ({
4145
}))
4246

4347
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
48+
allocateUniqueWorkspaceFileName: mockAllocateUniqueWorkspaceFileName,
4449
fetchWorkspaceFileBuffer: mockFetchBuffer,
50+
getWorkspaceFile: mockGetWorkspaceFile,
4551
}))
4652

4753
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({
@@ -76,7 +82,9 @@ vi.mock('@/lib/billing/storage', () => ({
7682
}))
7783

7884
vi.mock('@/lib/copilot/vfs/path-utils', () => ({
79-
canonicalWorkspaceFilePath: vi.fn(() => 'files/report.txt'),
85+
canonicalWorkspaceFilePath: vi.fn(
86+
({ name }: { name: string }) => `files/${encodeURIComponent(name)}`
87+
),
8088
encodeVfsPathSegments: (segments: string[]) =>
8189
segments.map((s) => encodeURIComponent(s)).join('/'),
8290
}))
@@ -237,6 +245,8 @@ describe('executeMaterializeFile - save storage transition', () => {
237245
vi.clearAllMocks()
238246
resetDbChainMock()
239247
mockFindUpload.mockResolvedValue(mothershipRow)
248+
mockAllocateUniqueWorkspaceFileName.mockResolvedValue('report.txt')
249+
mockGetWorkspaceFile.mockResolvedValue({ id: 'file-1', name: 'report.txt' })
240250
mockHeadObject.mockResolvedValue({ size: 250, contentType: 'text/plain' })
241251
mockHasCloudStorage.mockReturnValue(true)
242252
mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT)
@@ -278,6 +288,11 @@ describe('executeMaterializeFile - save storage transition', () => {
278288
expect(result.success).toBe(true)
279289
expect(mockHeadObject).toHaveBeenCalledWith('mothership/file-1', 'mothership')
280290
expect(mockCheckStorageQuotaForBillingContext).toHaveBeenCalledWith(STORAGE_CONTEXT, 250)
291+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledWith(
292+
context.workspaceId,
293+
'report.txt',
294+
null
295+
)
281296
expect(dbChainMockFns.set).toHaveBeenCalledWith(
282297
expect.objectContaining({ context: 'workspace', chatId: null, size: 250 })
283298
)
@@ -287,15 +302,167 @@ describe('executeMaterializeFile - save storage transition', () => {
287302
)
288303
})
289304

305+
it('materializes with an available root-level copy name', async () => {
306+
mockFindUpload.mockResolvedValueOnce({
307+
...mothershipRow,
308+
originalName: 'image.png',
309+
displayName: 'image.png',
310+
})
311+
mockAllocateUniqueWorkspaceFileName.mockResolvedValueOnce('image (1).png')
312+
dbChainMockFns.returning.mockResolvedValueOnce([
313+
{ id: 'file-1', originalName: 'image (1).png' },
314+
])
315+
316+
const result = await executeMaterializeFile(
317+
{ fileNames: ['image.png'], operation: 'save' },
318+
context
319+
)
320+
321+
expect(result.success).toBe(true)
322+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledWith(
323+
context.workspaceId,
324+
'image.png',
325+
null
326+
)
327+
expect(dbChainMockFns.set).toHaveBeenCalledWith(
328+
expect.objectContaining({
329+
context: 'workspace',
330+
originalName: 'image (1).png',
331+
displayName: 'image (1).png',
332+
})
333+
)
334+
expect(result.output).toEqual({ succeeded: ['image (1).png'], failed: [] })
335+
expect(result.resources).toEqual([{ type: 'file', id: 'file-1', title: 'image (1).png' }])
336+
})
337+
338+
it('reallocates and retries when a concurrent root-level write claims the name', async () => {
339+
const nameCollision = Object.assign(new Error('duplicate workspace file name'), {
340+
code: '23505',
341+
constraint_name: 'workspace_files_workspace_folder_name_active_unique',
342+
})
343+
mockFindUpload.mockResolvedValueOnce({
344+
...mothershipRow,
345+
originalName: 'image.png',
346+
displayName: 'image.png',
347+
})
348+
mockAllocateUniqueWorkspaceFileName
349+
.mockResolvedValueOnce('image (1).png')
350+
.mockResolvedValueOnce('image (2).png')
351+
dbChainMockFns.returning
352+
.mockRejectedValueOnce(nameCollision)
353+
.mockResolvedValueOnce([{ id: 'file-1', originalName: 'image (2).png' }])
354+
355+
const result = await executeMaterializeFile(
356+
{ fileNames: ['image.png'], operation: 'save' },
357+
context
358+
)
359+
360+
expect(result.success).toBe(true)
361+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledTimes(2)
362+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenNthCalledWith(
363+
1,
364+
context.workspaceId,
365+
'image.png',
366+
null
367+
)
368+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenNthCalledWith(
369+
2,
370+
context.workspaceId,
371+
'image.png',
372+
null
373+
)
374+
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(2)
375+
expect(dbChainMockFns.set).toHaveBeenNthCalledWith(
376+
1,
377+
expect.objectContaining({ originalName: 'image (1).png' })
378+
)
379+
expect(dbChainMockFns.set).toHaveBeenNthCalledWith(
380+
2,
381+
expect.objectContaining({ originalName: 'image (2).png' })
382+
)
383+
expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledTimes(1)
384+
expect(result.output).toEqual({ succeeded: ['image (2).png'], failed: [] })
385+
expect(result.resources).toEqual([{ type: 'file', id: 'file-1', title: 'image (2).png' }])
386+
})
387+
388+
it('stops after the bounded number of root-level name collisions', async () => {
389+
const nameCollision = Object.assign(new Error('duplicate workspace file name'), {
390+
code: '23505',
391+
constraint_name: 'workspace_files_workspace_folder_name_active_unique',
392+
})
393+
dbChainMockFns.returning.mockRejectedValue(nameCollision)
394+
395+
const result = await executeMaterializeFile(
396+
{ fileNames: ['report.txt'], operation: 'save' },
397+
context
398+
)
399+
400+
expect(result.success).toBe(false)
401+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledTimes(8)
402+
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(8)
403+
expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled()
404+
expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled()
405+
})
406+
407+
it('does not retry unique violations from a different constraint', async () => {
408+
const keyCollision = Object.assign(new Error('duplicate workspace file key'), {
409+
code: '23505',
410+
constraint_name: 'workspace_files_key_active_unique',
411+
})
412+
dbChainMockFns.returning.mockRejectedValueOnce(keyCollision)
413+
414+
const result = await executeMaterializeFile(
415+
{ fileNames: ['report.txt'], operation: 'save' },
416+
context
417+
)
418+
419+
expect(result.success).toBe(false)
420+
expect(mockAllocateUniqueWorkspaceFileName).toHaveBeenCalledTimes(1)
421+
expect(dbChainMockFns.transaction).toHaveBeenCalledTimes(1)
422+
expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled()
423+
})
424+
290425
it('treats a lost conditional transition as a replay no-op', async () => {
291426
dbChainMockFns.returning.mockResolvedValueOnce([])
427+
mockGetWorkspaceFile.mockResolvedValueOnce({ id: 'file-1', name: 'report (1).txt' })
292428

293429
const result = await executeMaterializeFile(
294430
{ fileNames: ['report.txt'], operation: 'save' },
295431
context
296432
)
297433

298434
expect(result.success).toBe(true)
435+
expect(mockGetWorkspaceFile).toHaveBeenCalledWith(context.workspaceId, 'file-1', {
436+
throwOnError: true,
437+
})
438+
expect(result.output).toEqual({ succeeded: ['report (1).txt'], failed: [] })
439+
expect(result.resources).toEqual([{ type: 'file', id: 'file-1', title: 'report (1).txt' }])
440+
expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled()
441+
expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled()
442+
})
443+
444+
it('fails a replay when the materialized workspace file no longer exists', async () => {
445+
dbChainMockFns.returning.mockResolvedValueOnce([])
446+
mockGetWorkspaceFile.mockResolvedValueOnce(null)
447+
448+
const result = await executeMaterializeFile(
449+
{ fileNames: ['report.txt'], operation: 'save' },
450+
context
451+
)
452+
453+
expect(result.success).toBe(false)
454+
expect(result.output).toEqual({
455+
succeeded: [],
456+
failed: [
457+
{
458+
fileName: 'report.txt',
459+
error: 'Upload no longer available: "report.txt".',
460+
},
461+
],
462+
})
463+
expect(mockGetWorkspaceFile).toHaveBeenCalledWith(context.workspaceId, 'file-1', {
464+
throwOnError: true,
465+
})
299466
expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled()
300467
expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled()
301468
})

0 commit comments

Comments
 (0)