@@ -3,7 +3,10 @@ import type { Readable } from 'stream'
33import JSZip from 'jszip'
44import { readZipCentralDirectoryStats } from '@/lib/file-parsers/zip-guard'
55import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
6- import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
6+ import {
7+ deleteWorkspaceFile ,
8+ uploadWorkspaceFile ,
9+ } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
710import { getFileExtension , getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils'
811import type { UserFile } from '@/executor/types'
912
@@ -328,40 +331,55 @@ export async function decompressArchiveBufferToWorkspaceFiles(
328331 }
329332
330333 // Pass 2 — extract: the archive is proven within caps; inflate again and upload.
334+ // Uploads themselves can still fail mid-loop (storage/DB errors, quota crossed
335+ // by another writer), so a failure rolls back every file written so far —
336+ // callers and their retries must never observe a partial tree.
331337 const folderIdCache = new Map < string , string | null > ( )
332338 const extracted : UserFile [ ] = [ ]
333339 let totalBytes = 0
334- for ( const { entry, segments } of safeEntries ) {
335- const result = await inflateEntryWithinCaps ( entry , MAX_ARCHIVE_TOTAL_BYTES - totalBytes , true )
336- if ( ! result . ok ) throwInflateCapError ( result . reason , entry . name )
337- totalBytes += result . size
338- const entryBuffer = result . buffer as Buffer
339-
340- const leafName = segments [ segments . length - 1 ]
341- const folderSegments = [ ...rootFolderSegments , ...segments . slice ( 0 , - 1 ) ]
342- const folderKey = folderSegments . join ( '/' )
343- let folderId = folderIdCache . get ( folderKey )
344- if ( folderId === undefined ) {
345- folderId = await ensureWorkspaceFileFolderPath ( {
340+ try {
341+ for ( const { entry, segments } of safeEntries ) {
342+ const result = await inflateEntryWithinCaps ( entry , MAX_ARCHIVE_TOTAL_BYTES - totalBytes , true )
343+ if ( ! result . ok ) throwInflateCapError ( result . reason , entry . name )
344+ totalBytes += result . size
345+ const entryBuffer = result . buffer as Buffer
346+
347+ const leafName = segments [ segments . length - 1 ]
348+ const folderSegments = [ ...rootFolderSegments , ...segments . slice ( 0 , - 1 ) ]
349+ const folderKey = folderSegments . join ( '/' )
350+ let folderId = folderIdCache . get ( folderKey )
351+ if ( folderId === undefined ) {
352+ folderId = await ensureWorkspaceFileFolderPath ( {
353+ workspaceId,
354+ userId,
355+ pathSegments : folderSegments ,
356+ } )
357+ folderIdCache . set ( folderKey , folderId )
358+ }
359+
360+ const mimeType = getMimeTypeFromExtension ( getFileExtension ( leafName ) )
361+ const uploaded = await uploadWorkspaceFile (
346362 workspaceId ,
347363 userId ,
348- pathSegments : folderSegments ,
349- } )
350- folderIdCache . set ( folderKey , folderId )
364+ entryBuffer ,
365+ leafName ,
366+ mimeType ,
367+ {
368+ folderId,
369+ }
370+ )
371+ extracted . push ( uploaded )
351372 }
352-
353- const mimeType = getMimeTypeFromExtension ( getFileExtension ( leafName ) )
354- const uploaded = await uploadWorkspaceFile (
355- workspaceId ,
356- userId ,
357- entryBuffer ,
358- leafName ,
359- mimeType ,
360- {
361- folderId,
373+ } catch ( error ) {
374+ for ( const file of extracted ) {
375+ try {
376+ await deleteWorkspaceFile ( workspaceId , file . id )
377+ } catch {
378+ // Best-effort: a file whose cleanup fails is still soft-deletable by hand;
379+ // the original error is what the caller needs to see.
362380 }
363- )
364- extracted . push ( uploaded )
381+ }
382+ throw error
365383 }
366384
367385 return { extracted, skipped, skippedUnsafePaths }
0 commit comments