Skip to content

Commit 3131f78

Browse files
committed
fix(files): stop a content save from resurrecting a stale contentType
updateWorkspaceFileContent read the row before taking the FOR UPDATE lock, then wrote that read's contentType back inside the transaction. A save overlapping a type change therefore restored the pre-change type, leaving the file named .txt while still stored as text/markdown. A content write carries no opinion about the file's type unless the caller says so, so the column is now only written when a contentType is supplied. The live-doc markdown gate reads the committed row for the same reason.
1 parent df2308c commit 3131f78

2 files changed

Lines changed: 46 additions & 2 deletions

File tree

apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1389,7 +1389,12 @@ export async function updateWorkspaceFileContent(
13891389
.set({
13901390
key: uploadResult.key,
13911391
size: content.length,
1392-
contentType: nextContentType,
1392+
// Only written when the caller actually declared a type. `nextContentType` falls back to
1393+
// a read taken BEFORE this row was locked, so writing it unconditionally lets a content
1394+
// save that overlaps a retype resurrect the pre-retype type — the file ends up named
1395+
// `.txt` while still stored as `text/markdown`. A content write carries no opinion about
1396+
// the file's type unless it says so, so leave the committed value alone.
1397+
...(contentType ? { contentType } : {}),
13931398
// Replaced bytes: drop the old image's dimensions so the row never describes stale content.
13941399
// The next view reserves nothing (the baseline first-load reflow) rather than a wrong-sized
13951400
// box, then the browser's measurement backfills the correct value. No server-side decode here
@@ -1479,7 +1484,7 @@ export async function updateWorkspaceFileContent(
14791484
// persist and empty-shell creates pass `syncLiveDoc: false` to stay out of it.
14801485
if (
14811486
options?.syncLiveDoc !== false &&
1482-
isMarkdownFile({ type: nextContentType, name: finalized.file.originalName })
1487+
isMarkdownFile({ type: finalized.file.contentType, name: finalized.file.originalName })
14831488
) {
14841489
// Pass the new CONTENT version this write produced, so the relay records that its live doc now
14851490
// incorporates this durable version — the collab persist's optimistic-concurrency guard then won't

apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,45 @@ describe('workspace file metadata and storage accounting', () => {
407407
expect(dbChainMockFns.transaction).not.toHaveBeenCalled()
408408
})
409409

410+
/**
411+
* A content write reads the row BEFORE taking the FOR UPDATE lock. If it wrote that stale
412+
* `contentType` back, a save overlapping a type change would resurrect the pre-change type and
413+
* leave the file named `.txt` while stored as `text/markdown`.
414+
*/
415+
it('leaves contentType alone when the caller declares none', async () => {
416+
const retypedFile = { ...FILE_ROW, originalName: 'note.md', contentType: 'text/markdown' }
417+
dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]).mockResolvedValueOnce([retypedFile])
418+
dbChainMockFns.returning.mockResolvedValueOnce([{ ...retypedFile, size: 10 }])
419+
mockUploadFile.mockResolvedValueOnce({ key: `${FILE_ROW.key}-replacement` })
420+
421+
const updated = await updateWorkspaceFileContent(
422+
FILE_ROW.workspaceId,
423+
FILE_ROW.id,
424+
FILE_ROW.userId,
425+
Buffer.alloc(10)
426+
)
427+
428+
const written = dbChainMockFns.set.mock.calls.at(-1)?.[0] as Record<string, unknown>
429+
expect(written).not.toHaveProperty('contentType')
430+
expect(updated.type).toBe('text/markdown')
431+
})
432+
433+
it('writes contentType when the caller declares one', async () => {
434+
dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]).mockResolvedValueOnce([FILE_ROW])
435+
dbChainMockFns.returning.mockResolvedValueOnce([{ ...FILE_ROW, contentType: 'text/csv' }])
436+
mockUploadFile.mockResolvedValueOnce({ key: `${FILE_ROW.key}-replacement` })
437+
438+
await updateWorkspaceFileContent(
439+
FILE_ROW.workspaceId,
440+
FILE_ROW.id,
441+
FILE_ROW.userId,
442+
Buffer.alloc(10),
443+
'text/csv'
444+
)
445+
446+
expect(dbChainMockFns.set.mock.calls.at(-1)?.[0]).toMatchObject({ contentType: 'text/csv' })
447+
})
448+
410449
it('uploads an overwrite before atomically swapping the locked row and exact delta', async () => {
411450
const concurrentFile = { ...FILE_ROW, size: 7 }
412451
const replacementKey = `${FILE_ROW.key}-replacement`

0 commit comments

Comments
 (0)