diff --git a/src/api/media-upload-engine.ts b/src/api/media-upload-engine.ts index aa42f9d0..6e8b0055 100644 --- a/src/api/media-upload-engine.ts +++ b/src/api/media-upload-engine.ts @@ -214,6 +214,48 @@ async function sendChunk( }); } +type ChunkRecoveryContext = { + args: ResumableUploadArgs; + session: MediaUploadSessionDto; + index: number; + chunkSize: number; + recoveryAttempts: Map; +}; + +async function sendChunkWithRecovery({ + args, + session, + index, + chunkSize, + recoveryAttempts, +}: ChunkRecoveryContext): Promise<{ + session: MediaUploadSessionDto; + recovered: boolean; +}> { + try { + const ack = await sendChunk(args, session.uploadId, index, chunkSize); + const receivedChunks = Array.isArray(ack?.receivedChunks) ? ack.receivedChunks : []; + if (!receivedChunks.includes(index)) { + throw new Error("Chunk acknowledgement missing from server response."); + } + return { session: { ...session, receivedChunks }, recovered: false }; + } catch (error) { + if (!isNetworkUploadError(error)) throw error; + const recovered = await recoverSession(args.endpoints, session.uploadId, args.signal); + if (recovered.receivedChunks.includes(index)) { + recoveryAttempts.delete(index); + return { session: recovered, recovered: true }; + } + + const attempts = (recoveryAttempts.get(index) ?? 0) + 1; + recoveryAttempts.set(index, attempts); + if (attempts >= MAX_CHUNK_RECOVERY_ATTEMPTS) throw error; + await new Promise((resolve) => setTimeout(resolve, CHUNK_RECOVERY_BACKOFF_MS * attempts)); + assertNotAborted(args.signal); + return { session: recovered, recovered: true }; + } +} + /** * Upload one file through the resumable session API: create → chunks → commit → poll. * One in-flight chunk request per file; durable progress counts only acknowledged parts. @@ -248,30 +290,15 @@ export async function uploadFileResumable( if (missing.length === 0) break; for (const index of missing) { assertNotAborted(args.signal); - try { - const ack = await sendChunk(args, session.uploadId, index, chunkSize); - const acked = Array.isArray(ack?.receivedChunks) ? ack.receivedChunks : []; - if (!acked.includes(index)) { - // Treat a malformed ack as not-yet-acked so the loop cannot spin forever. - throw new Error("Chunk acknowledgement missing from server response."); - } - session = { ...session, receivedChunks: acked }; - } catch (error) { - if (!isNetworkUploadError(error)) throw error; - session = await recoverSession(args.endpoints, session.uploadId, args.signal); - if (session.receivedChunks.includes(index)) { - recoveryAttempts.delete(index); - } else { - const attempts = (recoveryAttempts.get(index) ?? 0) + 1; - recoveryAttempts.set(index, attempts); - if (attempts >= MAX_CHUNK_RECOVERY_ATTEMPTS) throw error; - await new Promise((resolve) => - setTimeout(resolve, CHUNK_RECOVERY_BACKOFF_MS * attempts), - ); - assertNotAborted(args.signal); - } - break; // Re-derive missing indices from the recovered server state. - } + const result = await sendChunkWithRecovery({ + args, + session, + index, + chunkSize, + recoveryAttempts, + }); + session = result.session; + if (result.recovered) break; reportProgress(args, session.receivedChunks, args.file.size, chunkSize); } } diff --git a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx index 6fe189a3..f992bf5a 100644 --- a/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx +++ b/src/test/app/(protected)/workorders/media-upload-modal-api.test.tsx @@ -129,7 +129,7 @@ function baseRow(type: WorkOrderTableRow["type"] = "Emergency"): WorkOrderTableR }; } -function renderModal(rowType = "Emergency") { +function renderModal(rowType: WorkOrderTableRow["type"] = "Emergency") { const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, });