import { useCallback } from 'react'; import { Unzip, UnzipInflate } from 'fflate'; import { environment } from '../environments/environment'; import { useHttpService, HttpError } from './HttpService.tsx'; import { isSaveRetired } from '../utils/saveOperation'; // environment.apiUrl already ends in /R4 - do not append another /R4. const API_URL = environment.apiUrl; const UPLOAD_CONCURRENCY = 6; const MAX_UPLOAD_ATTEMPTS = 3; const RETRY_BASE_DELAY_MS = 300; const ZIP_READ_CHUNK_SIZE = 1024 * 1024; // 1 MiB reads of the (still-compressed) archive off disk export const isDicomFile = (bytes: Uint8Array): boolean => { if (bytes.length < 132) return false; return bytes[128] === 0x44 && bytes[129] === 0x49 && bytes[130] === 0x43 && bytes[131] === 0x4d; }; interface UploadPolicy { jobId: string; prefix: string; post: { url: string; fields: Record; }; } interface StartImportResponse { importJobId: string; } /** * Where an upload is filed: under a patient, or against an imaging order. An order upload names no * patient at all; the server finds the patient from the order, so the staging path never carries one. */ export type ImportTarget = string | { orderId: string }; /** The operation a request belongs to: checked before and just before sending, cancelled with it, sent in its practice. */ export interface OwnedRequest { admit?: () => void; signal?: AbortSignal; organizationId?: string; } /** * What Check again found; images already filed on another order carry the sentence that names it. A conflict is a study * of these images that is not provably this patient's: a person reviews it, and this page says so in its own words. */ export type ReconcileAnswer = "filed" | "failed" | "processing" | { alreadyFiled: string } | { kind: 'identity-conflict'; message: string }; export const IMPORT_IDENTITY_CONFLICT_MESSAGE = 'This import could not be matched safely. Ask your practice to review it.'; /** * A patient upload this browser admitted and still owes an outcome for. Opaque ids and declared facts only - never * files, image metadata, names, credentials or presigned fields. `uploadId` is the client-minted upload UUID that * names the staging prefix; `importJobId` is HealthImaging's own job id, known once a start answered with it. */ export interface OwedUpload { uploadId: string; prefix: string; expectedObjectCount: number; patientId: string; /** The account, acted-as person and practice it was made under (the viewer key). */ owner: string; organizationId: string; /** The API it was made against; another environment's record is not this one's. */ api: string; phase: 'starting' | 'started'; importJobId?: string; } // One small versioned record per upload, keyed by its upload id, so settling one never erases another. Kept until // that exact upload is positively settled or proved never started: no age, count, HTTP class or unmount clears it. const JOURNAL_KEY = `${environment.localStoragePrefix ?? ''}imaging-upload-journal`; type Journal = { ok: true; records: Record } | { ok: false }; const text = (value: unknown): value is string => typeof value === 'string' && value.length > 0; const isOwed = (value: unknown, key: string): value is OwedUpload => { const record = value as Partial | null; return !!record && typeof record === 'object' && record.uploadId === key && text(record.prefix) && text(record.patientId) && typeof record.owner === 'string' && typeof record.organizationId === 'string' && text(record.api) && Number.isInteger(record.expectedObjectCount) && (record.expectedObjectCount as number) > 0 && (record.phase === 'starting' || record.phase === 'started') && (record.importJobId === undefined || text(record.importJobId)); }; const readJournal = (): Journal => { try { const raw = localStorage.getItem(JOURNAL_KEY); if (raw === null) return { ok: true, records: {} }; const parsed = JSON.parse(raw) as { version?: unknown; records?: unknown }; if (parsed?.version !== 1 || !parsed.records || typeof parsed.records !== 'object' || Array.isArray(parsed.records)) return { ok: false }; const records = parsed.records as Record; return Object.entries(records).every(([key, record]) => isOwed(record, key)) ? { ok: true, records: records as Record } : { ok: false }; } catch { return { ok: false }; } }; const writeJournal = (records: Record): boolean => { try { localStorage.setItem(JOURNAL_KEY, JSON.stringify({ version: 1, records })); return true; } catch { return false; } }; /** Records one upload and reads it back; false when this browser cannot hold it. */ export const keepOwedUpload = (owed: OwedUpload): boolean => { const journal = readJournal(); if (!journal.ok || !writeJournal({ ...journal.records, [owed.uploadId]: owed })) return false; const back = readJournal(); return back.ok && JSON.stringify(back.records[owed.uploadId]) === JSON.stringify(owed); }; /** Forgets exactly one upload, once it is positively settled or proved never started. */ export const forgetOwedUpload = (uploadId: string): boolean => { const journal = readJournal(); if (!journal.ok) return false; if (!(uploadId in journal.records)) return true; const rest = { ...journal.records }; delete rest[uploadId]; return writeJournal(rest); }; /** This owner's uploads for this patient on this API; undefined when the record cannot be read. */ /** Whether this owner, practice and patient may recover a recorded upload against this API. */ export const mayRecover = (record: OwedUpload, owner: string, organizationId: string, patientId: string): boolean => record.owner === owner && record.organizationId === organizationId && record.patientId === patientId && record.api === API_URL; export const owedUploadsFor = (owner: string, patientId: string): OwedUpload[] | undefined => { const journal = readJournal(); if (!journal.ok) return undefined; return Object.values(journal.records).filter((record) => record.owner === owner && record.patientId === patientId && record.api === API_URL); }; /** The original upload could not be recorded in this browser, so its start was not sent. */ export const IMPORT_RECOVERY_UNAVAILABLE = 'This browser could not keep a record of the upload, so it was not started. Nothing was imported.'; export class ImportRecoveryUnavailable extends Error { constructor() { super(IMPORT_RECOVERY_UNAVAILABLE); this.name = 'ImportRecoveryUnavailable'; } } /** * A start whose fate is not known: its answer was lost, unconfirmed, refused for a reason that does not prove the * original never started, or malformed. It carries the original and what the server said, untouched. */ export class ImportStartUnknown extends Error { constructor(readonly owed: OwedUpload, readonly answer: { status?: number; document?: string; reason?: string; message?: string }) { super('This upload may have started, but its start was not confirmed. Check it before uploading these images again.'); this.name = 'ImportStartUnknown'; } } const bodyOf = (error: HttpError): Record => (error.body && typeof error.body === 'object' ? error.body : {}) as Record; /** * The one patient start answer that proves this original never started: the incomplete-upload refusal, which the * server sends only when no start was recorded, nothing of the upload was removed and no manifest was ever frozen. */ const provesNoStart = (error: unknown): error is HttpError => { if (!(error instanceof HttpError) || error.status !== 409) return false; const body = bodyOf(error); return body.document === undefined && Number.isInteger(body.expected) && Number.isInteger(body.received); }; const startUnknown = (error: unknown, owed: OwedUpload): unknown => { if (isSaveRetired(error)) return error; if (!(error instanceof HttpError)) return new ImportStartUnknown(owed, {}); const body = bodyOf(error); return new ImportStartUnknown(owed, { status: error.status, ...(typeof body.document === 'string' ? { document: body.document } : {}), ...(typeof body.reason === 'string' ? { reason: body.reason } : {}), ...(typeof body.message === 'string' ? { message: body.message } : {}), }); }; // A start answer naming its job settles the start; one that does not is not a start anyone can rely on. const settleStart = (owed: OwedUpload, answer: StartImportResponse | undefined): string => { const importJobId = answer?.importJobId; if (!text(importJobId)) throw new ImportStartUnknown(owed, { message: 'The start answer named no import job.' }); keepOwedUpload({ ...owed, phase: 'started', importJobId }); return importJobId; }; /** What a check of one patient import job found; only an ImagingStudy of this patient settles it as filed. */ export type PatientReconcileAnswer = | { state: 'filed'; studyId: string } | { state: 'processing' } | { state: 'held'; message: string } | { state: 'identity-conflict'; message: string } | { state: 'unresolved'; message: string }; const NOT_YET_CONFIRMED = 'The import could not be confirmed yet. Check again later.'; /** For a patient upload that keeps its own record: who it belongs to, and the request it is sent as. */ export interface UploadOptions { owned?: OwnedRequest; recover?: { owner: string; organizationId: string }; } const importUrl = (target: ImportTarget, suffix = ''): string => typeof target === 'string' ? `${API_URL}/imaging/import${suffix}?patient=Patient/${target}` : `${API_URL}/imaging/order/${target.orderId}/import${suffix}`; const assertTarget = (target: ImportTarget) => { if (typeof target === 'string' ? !target.trim() : !target.orderId?.trim()) { throw new Error(typeof target === 'string' ? 'A patient id is required to upload a study' : 'An order is required to upload its images'); } }; const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); // Reads only the first 132 bytes of a candidate so isDicomFile can check the DICM magic without // pulling the whole file into memory. const readHeader = async (file: File): Promise => { const buffer = await file.slice(0, 132).arrayBuffer(); return new Uint8Array(buffer); }; // CD filenames collide across series (every series starts back at IMG001.dcm) - a collision here // would silently overwrite a slice already sitting in S3 under the same key. const uniqueObjectName = (): string => `${crypto.randomUUID()}.dcm`; // Uploads one object to the presigned S3 POST policy. post.fields MUST be appended before `key` // and `file`: S3's multipart POST reads the form top-to-bottom and ignores anything declared // after the `file` field, so this ordering is load-bearing, not stylistic. This goes DIRECTLY to // S3 with a plain fetch and must NOT carry the app's auth headers. // // `fields` already carries a `key` entry: createPresignedPost sets it to the server's `Key` // argument, which contains a literal `${filename}` placeholder (presignImportUpload.ts) - meant for // callers who never override it. This caller always does (uniqueObjectName's UUID, so CD folders // that restart numbering per series never collide), so `fields`'s own `key` is filtered out here // before appending ours. Sending both would duplicate the `key` field with no documented precedence // between them - if S3 honored the SDK's, `${filename}` would substitute the real filename and every // multi-series folder upload would silently overwrite slices across series. Filtered client-side, // defensively, regardless of what the server returns. const uploadObjectToS3 = async ( postUrl: string, fields: Record, key: string, file: File, signal?: AbortSignal ): Promise => { const formData = new FormData(); Object.entries(fields) .filter(([name]) => name !== 'key') .forEach(([name, value]) => formData.append(name, value)); formData.append('key', key); formData.append('file', file); const response = await fetch(postUrl, { method: 'POST', body: formData, ...(signal ? { signal } : {}) }); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${response.statusText}`); } }; // Retries a single object up to MAX_UPLOAD_ATTEMPTS times with backoff. If every attempt fails, // the last error is rethrown - the pool below treats that as fatal for the whole batch. const uploadObjectWithRetry = async ( postUrl: string, fields: Record, key: string, file: File, signal?: AbortSignal ): Promise => { let lastError: unknown; for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt++) { try { await uploadObjectToS3(postUrl, fields, key, file, signal); return; } catch (error) { lastError = error; if (signal?.aborted) throw error; if (attempt < MAX_UPLOAD_ATTEMPTS) { await sleep(RETRY_BASE_DELAY_MS * attempt); } } } throw lastError instanceof Error ? lastError : new Error('Upload failed after retries'); }; // Bounded-concurrency pool over a known list of files. An unbounded Promise.all over a few // hundred files exhausts the browser's connection pool and the resulting failures look like // server errors rather than a client-side self-inflicted wound. // // Aborts as soon as ONE object exhausts its retries: workers stop picking up new files and the // error propagates out, so the caller never reaches startImport on a batch it already knows is // short. const uploadFilesWithPool = async ( files: File[], limit: number, uploadOne: (file: File) => Promise ): Promise => { let cursor = 0; let firstError: unknown = null; const workers = Array.from({ length: Math.min(limit, files.length) }, async () => { while (cursor < files.length && !firstError) { const file = files[cursor++]; try { await uploadOne(file); } catch (error) { firstError = firstError ?? error; } } }); await Promise.all(workers); if (firstError) throw firstError; }; // Combines multiple Uint8Array chunks for a SINGLE zip entry into one buffer. This only ever // holds one entry's decompressed bytes at a time - never the whole archive - which is what keeps // a 300 MB+ study from blowing out the tab's heap. const concatChunks = (chunks: Uint8Array[]): Uint8Array => { const size = chunks.reduce((total, chunk) => total + chunk.length, 0); const combined = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { combined.set(chunk, offset); offset += chunk.length; } return combined; }; // Streams a ZIP archive with fflate's Unzip so entries are handed to `onEntry` as they decode and // released immediately afterward - never buffering the whole archive. Only DICOM entries (DICM at // offset 128) are handed over; autorun.exe, bundled viewer software and the DICOMDIR index that // ride along on a patient's imaging CD are filtered out here so the import job never sees them. // // Returns the number of DICOM entries handed to `onEntry` - NOT the number that were consumed // successfully. That distinction is what keeps the partial-upload guard meaningful for ZIPs: the // declared count is decided by the archive's contents, never by what happens to each entry after // it's handed over. export const streamZipEntries = async ( zip: File, onEntry: (entry: File) => Promise ): Promise => { let dicomCount = 0; let queue: Promise = Promise.resolve(); let queueError: unknown = null; const unzipper = new Unzip(); unzipper.register(UnzipInflate); unzipper.onfile = (file) => { const chunks: Uint8Array[] = []; file.ondata = (err, chunk, final) => { if (err) { queueError = queueError ?? err; return; } chunks.push(chunk); if (!final) return; const bytes = concatChunks(chunks); chunks.length = 0; // release this entry's compressed-chunk buffers now that they're combined const name = file.name.split('/').pop() || file.name; queue = queue.then(async () => { if (queueError) return; if (!isDicomFile(bytes)) return; dicomCount++; await onEntry(new File([bytes], name)); }).catch(error => { queueError = queueError ?? error; }); }; file.start(); }; let offset = 0; while (offset < zip.size && !queueError) { const end = Math.min(offset + ZIP_READ_CHUNK_SIZE, zip.size); const chunkBytes = new Uint8Array(await zip.slice(offset, end).arrayBuffer()); offset = end; unzipper.push(chunkBytes, offset >= zip.size); } if (zip.size === 0) { unzipper.push(new Uint8Array(0), true); } await queue; if (queueError) throw queueError instanceof Error ? queueError : new Error('Failed to read ZIP archive'); return dicomCount; }; // Same bounded-concurrency contract as uploadFilesWithPool, but for entries arriving one at a // time from streamZipEntries rather than a pre-known array. streamZipEntries hands entries to // `onEntry` serially (that serial handoff is what lets it release each entry's bytes as it goes); // this wraps that handoff in a set of in-flight uploads and only blocks the next entry once // `limit` uploads are already running, so decode and upload overlap up to the bound instead of // either serializing everything or buffering the archive. const uploadEntryStreamWithPool = async ( streamEntries: (onEntry: (entry: File) => Promise) => Promise, limit: number, uploadOne: (file: File) => Promise ): Promise => { const active = new Set>(); let firstError: unknown = null; const total = await streamEntries(async (entry) => { if (firstError) throw firstError; if (active.size >= limit) { await Promise.race(active); if (firstError) throw firstError; } const task: Promise = uploadOne(entry) .catch(error => { firstError = firstError ?? error; }) .finally(() => { active.delete(task); }); active.add(task); }); await Promise.all(active); if (firstError) throw firstError; return total; }; export const useImagingUploadService = () => { const { post } = useHttpService(); // expectedObjectCount is DELIBERATELY NOT sent here. presignImportUpload.ts never reads a // request body at all (confirmed - it only reads the `patient` query parameter), so a client that // posts `{ expectedObjectCount }` to this endpoint is having it silently discarded; the OpenAPI // entry for this route documents no request body, matching what the handler actually does. The // count IS declared - and actually enforced - at startImport below instead. The guard's integrity // comes from the SERVER counting the objects that actually landed in S3 at that point, never from // where or how many times the client's own number was transmitted, so there is nothing to gain // (and a false sense of safety to lose) by also sending it here. const requestUploadPolicy = useCallback(async ( target: ImportTarget, owned: OwnedRequest = {} ): Promise => { return post(importUrl(target), {}, owned); }, [post]); // Surfaces a 409 from startImport (the server's expected/received object-count mismatch) as a // user-facing message built from the server's own text - it already explains that nothing was // imported and the study should be re-uploaded. const startImport = useCallback(async ( jobId: string, target: ImportTarget, prefix: string, expectedObjectCount: number ): Promise => { try { return await post( importUrl(target, `/${jobId}/start`), { prefix, expectedObjectCount } ); } catch (error) { if (error instanceof HttpError && error.status === 409) { const body = error.body as { message?: string } | undefined; throw new Error(body?.message ?? error.message); } throw error; } }, [post]); /** * Starts a selected upload. A patient upload that keeps its own record is recorded first - no record, no start - * and its start fate is carried, not flattened: only the server's no-start proof forgets it. */ const startOriginal = useCallback(async ( policy: UploadPolicy, target: ImportTarget, expectedObjectCount: number, options?: UploadOptions ): Promise<{ importJobId: string; uploadId?: string }> => { if (!options?.recover || typeof target !== 'string') { const { importJobId } = await startImport(policy.jobId, target, policy.prefix, expectedObjectCount); return { importJobId }; } const owed: OwedUpload = { uploadId: policy.jobId, prefix: policy.prefix, expectedObjectCount, patientId: target, owner: options.recover.owner, organizationId: options.recover.organizationId, api: API_URL, phase: 'starting', }; if (!keepOwedUpload(owed)) throw new ImportRecoveryUnavailable(); try { const answer = await post(importUrl(target, `/${policy.jobId}/start`), { prefix: policy.prefix, expectedObjectCount }, options.owned ?? {}); return { importJobId: settleStart(owed, answer), uploadId: policy.jobId }; } catch (error) { if (provesNoStart(error)) { forgetOwedUpload(owed.uploadId); throw new Error(String(bodyOf(error).message ?? error.message)); } throw startUnknown(error, owed); } }, [post, startImport]); /** * Asks again about a recorded upload's start: the identical body to the same original upload path, which the * server answers from its own record of that upload. Never a new upload, and never read as "it did not start". */ const replayStart = useCallback(async (owed: OwedUpload, owned: OwnedRequest): Promise => { try { const answer = await post(importUrl(owed.patientId, `/${owed.uploadId}/start`), { prefix: owed.prefix, expectedObjectCount: owed.expectedObjectCount }, owned); return settleStart(owed, answer); } catch (error) { throw startUnknown(error, owed); } }, [post]); /** * Checks one patient import job. The patient route answers a filed job with the ImagingStudy itself; only one * naming this patient settles it. 202 is still processing; anything else leaves it unconfirmed. */ const reconcilePatientImport = useCallback(async ( importJobId: string, patientId: string, owned: OwnedRequest ): Promise => { let answer: unknown; try { answer = await post(importUrl(patientId, `/${importJobId}/reconcile`), {}, owned); } catch (error) { if (!(error instanceof HttpError)) throw error; const body = bodyOf(error); if (error.status === 409 && body.code === 'study-identity-conflict') return { state: 'identity-conflict', message: IMPORT_IDENTITY_CONFLICT_MESSAGE }; if (error.status === 409 && text(body.document)) return { state: 'held', message: text(body.message) ? body.message : NOT_YET_CONFIRMED }; return { state: 'unresolved', message: NOT_YET_CONFIRMED }; } const study = answer as { resourceType?: unknown; id?: unknown; subject?: { reference?: unknown }; message?: unknown } | null; if (study?.resourceType === 'ImagingStudy' && text(study.id) && study.subject?.reference === `Patient/${patientId}`) { return { state: 'filed', studyId: study.id }; } if (study && typeof study === 'object' && Object.keys(study).length === 1 && text(study.message)) return { state: 'processing' }; return { state: 'unresolved', message: NOT_YET_CONFIRMED }; }, [post]); // Fallback for HealthImaging EventBridge's own "Best effort" completion-event delivery - a dropped // event otherwise means a study that already imported, was billed and is sitting in HealthImaging, // but never gets an ImagingStudy: invisible forever, not just delayed. Called by ImagingStudies.tsx // ONLY after its post-upload poll exhausts without finding the new study - never proactively, and // never in place of the primary EventBridge path. // // `patient` IS A QUERY STRING PARAMETER, NEVER THE BODY - same reasoning as requestUploadPolicy // and startImport above: the Lambda request authorizer never sees the request body, so a body-borne // patient id could never have been authorized before the handler runs. const reconcileImport = useCallback(async ( jobId: string, target: ImportTarget, owned?: OwnedRequest ): Promise => { try { const answer = await post<{ studyId?: string; failed?: boolean; alreadyFiled?: boolean; message?: string }>(importUrl(target, `/${jobId}/reconcile`), {}, owned); if (answer?.alreadyFiled && answer.message) return { alreadyFiled: answer.message }; return answer?.failed ? "failed" : answer?.studyId ? "filed" : "processing"; } catch (error) { // Only this exact refusal is a conflict; whatever else the server wrote with it is not passed on. if (error instanceof HttpError && error.status === 409 && (error.body as { code?: unknown } | undefined)?.code === 'study-identity-conflict') { return { kind: 'identity-conflict', message: IMPORT_IDENTITY_CONFLICT_MESSAGE }; } if (error instanceof HttpError) { const body = error.body as { message?: string } | undefined; throw new Error(body?.message ?? error.message); } throw error; } }, [post]); // Retires an upload whose finished import its malware check refused; the server proves the refusal and that // nothing was filed, and answers what the order now says. `owned` carries the operation that asked: its // admission, cancellation and the practice it was asked in, so a retired opening never sends. const discardImport = useCallback(async (jobId: string, target: ImportTarget, owned: OwnedRequest = {}): Promise => { try { const answer = await post<{ discarded?: boolean; message?: string }>(importUrl(target, `/${jobId}/discard`), {}, owned); return answer?.message ?? "The upload was discarded."; } catch (error) { if (error instanceof HttpError) { const body = error.body as { message?: string } | undefined; throw new Error(body?.message ?? error.message); } throw error; } }, [post]); const uploadStudy = useCallback(async ( files: File[], target: ImportTarget, onProgress?: (done: number, total: number) => void, options?: UploadOptions ): Promise<{ importJobId: string; uploadId?: string }> => { assertTarget(target); const headers = await Promise.all(files.map(file => readHeader(file))); const dicomFiles = files.filter((_, index) => isDicomFile(headers[index])); if (dicomFiles.length === 0) { throw new Error('No DICOM files found in the selected files'); } // expectedObjectCount IS THE SELECTION COUNT, NOT THE SUCCESS COUNT. // // startImport compares this against the objects that actually landed in S3, and refuses the import on // mismatch - that is what stops a truncated study from being read as a whole one. If this were // recomputed from successful uploads, a silent POST failure would lower BOTH sides of the comparison // and the guard would pass on exactly the case it exists to catch. const expectedObjectCount = dicomFiles.length; const policy = await requestUploadPolicy(target, options?.owned); let done = 0; onProgress?.(done, expectedObjectCount); await uploadFilesWithPool(dicomFiles, UPLOAD_CONCURRENCY, async (file) => { options?.owned?.admit?.(); const key = `${policy.prefix}${uniqueObjectName()}`; await uploadObjectWithRetry(policy.post.url, policy.post.fields, key, file, options?.owned?.signal); done++; onProgress?.(done, expectedObjectCount); }); // KEEP importJobId - reconcile is addressed by it, NOT by policy.jobId. Those are two // different id spaces: policy.jobId is the client-minted upload UUID that forms the S3 staging // prefix, while importJobId is HealthImaging's own job id (base36, <=32 chars). Discarding this // and reconciling with policy.jobId is what made every reconcile call fail with a 500 - // HealthImaging rejects a UUID outright. options?.owned?.admit?.(); return startOriginal(policy, target, expectedObjectCount, options); }, [requestUploadPolicy, startOriginal]); const uploadStudyFromZip = useCallback(async ( zip: File, target: ImportTarget, onProgress?: (done: number, total: number) => void, options?: UploadOptions ): Promise<{ importJobId: string; uploadId?: string }> => { assertTarget(target); // Pass 1: stream the archive once purely to count its DICOM entries, discarding each one's // bytes as soon as it's counted. This is what lets expectedObjectCount be known BEFORE any // upload begins for a ZIP - expandZip(file): Promise was rejected for this exact // reason: materialising every entry as a File to count them puts the whole decompressed // study (300 MB+) in the tab's heap, which is the crash this streaming shape exists to avoid. const dicomCount = await streamZipEntries(zip, async () => { /* count-only pass, no upload */ }); if (dicomCount === 0) { throw new Error('No DICOM files found in the ZIP archive'); } // expectedObjectCount IS THE SELECTION COUNT, NOT THE SUCCESS COUNT. // // startImport compares this against the objects that actually landed in S3, and refuses the import on // mismatch - that is what stops a truncated study from being read as a whole one. If this were // recomputed from successful uploads, a silent POST failure would lower BOTH sides of the comparison // and the guard would pass on exactly the case it exists to catch. const expectedObjectCount = dicomCount; const policy = await requestUploadPolicy(target, options?.owned); let done = 0; onProgress?.(done, expectedObjectCount); // Pass 2: stream the archive again, this time uploading each entry as it decodes. await uploadEntryStreamWithPool( (onEntry) => streamZipEntries(zip, onEntry), UPLOAD_CONCURRENCY, async (entry) => { options?.owned?.admit?.(); const key = `${policy.prefix}${uniqueObjectName()}`; await uploadObjectWithRetry(policy.post.url, policy.post.fields, key, entry, options?.owned?.signal); done++; onProgress?.(done, expectedObjectCount); } ); // See uploadStudy above for why importJobId - not policy.jobId - is what reconcile needs. options?.owned?.admit?.(); return startOriginal(policy, target, expectedObjectCount, options); }, [requestUploadPolicy, startOriginal]); /** Picks up an upload the order reserved whose start answer was lost, on its own client token. */ const resumeImport = useCallback(async (uploadId: string, target: ImportTarget, owned?: OwnedRequest): Promise => { try { const answer = await post<{ importJobId?: string }>(importUrl(target, `/${uploadId}/start`), { resume: true }, owned); return answer?.importJobId; } catch (error) { if (error instanceof HttpError) { const body = error.body as { message?: string } | undefined; throw new Error(body?.message ?? error.message); } throw error; } }, [post]); return { uploadStudy, uploadStudyFromZip, streamZipEntries, reconcileImport, resumeImport, discardImport, replayStart, reconcilePatientImport }; };