Lifecycle
Internal State Machine
The Upload Engine uses an explicit, enforced state machine with 10 internal states. Every state transition is validated by assertTransition() — illegal transitions throw at runtime.

Transition Table (from source)
// UploadStateMachine.ts — STATE_TRANSITIONS
const STATE_TRANSITIONS = {
idle: ['initializing', 'aborted'],
initializing: ['uploading', 'needs-attention', 'aborted'],
uploading: ['paused', 'retrying', 'finalizing', 'needs-attention', 'aborted'],
paused: ['uploading', 'aborted'],
retrying: ['uploading', 'needs-attention', 'aborted'],
finalizing: ['committing', 'needs-attention', 'aborted'],
committing: ['completed', 'needs-attention'],
completed: [], // terminal
'needs-attention': ['uploading', 'aborted'],
aborted: [], // terminal
};
Key functions:
| Function | Purpose |
|---|---|
canTransition(from, to) |
Returns boolean — is transition legal? |
assertTransition(from, to) |
Throws if illegal — used in UploadSession.moveTo() |
transition(from, to) |
Validates + returns to — makes call sites explicit |
isTerminalState(state) |
completed or aborted |
requiresAttention(state) |
needs-attention only |
End-to-End Lifecycle
Phase 1: Start
Feature Module → UploadEngine.start(options) → UploadSession(idle)
UploadEngineImpl.start()creates a newUploadSessionwith stateidlesession.startInitialization()transitionsidle → initializingand emitsUPLOAD_STARTED- Chunk config is applied (caller override → persisted → default 5 MB)
- Session is enqueued into
UploadQueue(max 5 concurrent)
Phase 2: Initialization (Worker)
UploadQueue drains → startWorker() → UploadWorker.run() → initialize()
Fresh upload path:
UploadService.initMultipartUpload()calls/v3.0/.../init?type=<contentType>- Backend returns
{ uploadId, key, bucket, prefixUrl, chunkSize, chunks } session.setUploadServiceData(...)stores multipart metadataUploadPlanneris instantiated with server-authoritativechunkSizesession.startUploading()transitionsinitializing → uploading
Resume path (existing uploadServiceData):
- Completed parts are loaded from snapshot into
Set<number>+Map<number, string> UploadPlanneris instantiated with persistedchunkSizesession.startUploading()transitionspaused → uploadingorneeds-attention → uploading
Phase 3: Chunk Upload Loop

Chunk upload details:
- Each chunk:
file.slice(startByte, endByte)→ PUT to presigned S3 URL ETagextracted from response header- Progress =
completedPartNumbers.size / totalChunks × 100(monotonic, clamped 0–100) - On failure: retry up to 3 times (500ms linear backoff) via
RetryPolicy - During retry:
uploading → retrying → uploading - If retries exhausted:
session.fail('upload', message)→needs-attention
Parallel chunk mode (enableParallelChunkProcessing = true):
- Up to
PARALLEL_CHUNK_LIMIT = 5concurrent chunk PUTs - Uses
Promise.race()sliding window pattern - Window advances only past contiguously completed parts (
getContiguousCompletedPart())
Phase 4: Completion
uploading → finalizing → committing → completed
session.startFinalizing()transitionsuploading → finalizingUploadService.completeMultipartUpload()sends sortedcompletedPartsto/complete- On success:
session.startCommitting()→committing session.complete(fileUrl)setsfileUrl = prefixUrl + key, transitions tocompleted- Emits
UPLOAD_COMPLETEDwithfileUrl
On failure: 2 retries with 1000ms linear backoff. If exhausted → session.fail('completion', msg) + persist for later resume.
Phase 5: Completion Handler
completed → runCompletionHandler(fileUrl)
- If
onCompletewas provided, it is called with{ fileUrl, file, context } - 1 retry attempt on failure
- Handler failure →
session.fail('completion', msg)→needs-attention+ persist - Handler failure does not invalidate the upload —
fileUrlremains valid
Phase 6: Pause
uploading → paused
worker.requestPause()setspauseRequested = trueand aborts theAbortController- Worker's control flow catches
PauseError(internal, never leaks) session.pause()transitionsuploading → paused, emitsUPLOAD_PAUSED- Current state is persisted to
sessionStorage - Queue slot is freed via
queue.finish(uploadId)
Phase 7: Resume
paused → (re-enqueue) → uploading
UploadEngine.resume()restores chunk config and emitsUPLOAD_RESUMED- Session is re-enqueued into
UploadQueue - Worker's
initialize()detects existinguploadServiceData→ takes resume path session.startUploading()transitionspaused → uploading- Upload continues from
getContiguousCompletedPart() + 1
Phase 8: Abort
<any non-terminal> → aborted
worker.abort()aborts theAbortControllersession.abort()is idempotent — returns silently if already terminal- Transitions to
aborted, emitsUPLOAD_ABORTED handleEvent()removes persistence data and deletes session fromsessionsMap- Queue slot is freed — abort is final
Phase 9: Rehydration (App Reload)
sessionStorage → UploadPersistence.loadForTenant() → sessions Map
- On construction,
UploadEngineImplcallsrehydrateFromPersistence() - Loads persisted uploads for current tenant (
CookieManager.get('siteId')+CookieManager.get('site')) - Only
pausedandneeds-attentionstates are rehydrated (safety filter) fromPersistedStateAdapter()createsUploadSessioninstances from persisted data- Sessions appear in
list()— user can resume or abort
:::warning
TheFileobject is not persisted (not serializable). Rehydrated uploads require the user to resume manually — the worker will use persisteduploadServiceDataandcompletedPartsto continue from where it left off.
:::
Control Flow Errors
The worker uses two internal exception types for control flow:
class PauseError extends Error {} // thrown when pauseRequested is detected
class AbortError extends Error {} // thrown when AbortController is aborted
These never leak outside UploadWorker.run(). The outer try/catch/finally handles:
PauseError→ return silently (state already transitioned)- Abort signal detected → call
session.abort() - Any other error →
session.fail('upload', msg)→ persist → notifyonError finally→ always callsonDone()to free the queue slot