5.7 KiB
Upload System Architecture
Overview
The upload system implements an offline-first, queue-based architecture that uploads observations sequentially while processing media in parallel. It separates media uploads from observation metadata, enforces sequential processing per observation, and revalidates authentication at each stage.
Key Files
| File | Purpose |
|---|---|
src/uploaders/observationUploader.ts |
4-step upload pipeline (media → create/update → attach → persist) |
src/uploaders/mediaUploader.ts |
Media filtering, classification, parallel upload |
src/uploaders/dataTransformation/prepareObservationForUpload.ts |
Observation → API payload |
src/uploaders/dataTransformation/prepareMediaForUpload.ts |
Photo/Sound → API payload |
src/uploaders/utils/errorHandling.ts |
Error classification and recovery |
src/uploaders/utils/progressTracker.ts |
Event-based progress tracking |
src/uploaders/utils/realmSync.ts |
Post-upload Realm persistence |
src/stores/createUploadObservationsSlice.ts |
Zustand state (queue, status, progress) |
src/components/MyObservations/hooks/useUploadObservations.ts |
Entry point, upload loop control |
src/components/MyObservations/hooks/useSyncObservations.ts |
Server sync integration |
Upload Pipeline (4 Steps)
Each observation follows this strict sequence:
-
Media Upload (
uploadObservationMedia()) — Upload raw photos/sounds to server viainatjs.photos.create()/inatjs.sounds.create(). Processes all media in parallel viaPromise.all(). -
Observation Create/Update (
createOrUpdateObservation()) — Ifobservation.wasSynced() === false, creates viainatjs.observations.create(). If previously synced, updates withignore_photos=true. Revalidates JWT before API call. -
Media Attachment (
attachMediaToObservation()) — Attaches uploaded media to the observation viainatjs.observation_photos.create()andinatjs.observation_sounds.create(). Handles updates to previously synced media. -
Realm Sync (
markRecordUploaded()) — Updates local Observation with server-assignedid, sets_synced_attimestamp, clearsneeds_syncflag. Handles Realm invalidation errors with retry.
Queue & State Management
The Zustand slice (createUploadObservationsSlice) manages:
uploadQueue— array of observation UUIDs, consumed from the tail (pop()); single UUIDs are added at the front (unshift), so net upload order is oldest-first rather than strictly FIFOuploadStatus—PENDING|IN_PROGRESS|COMPLETE|CANCELLEDcurrentUpload— Observation being processedabortController— Enables cancellation via AbortSignaltotalUploadProgress[]— Per-observation progress trackingtotalToolbarProgress— Aggregate progress 0-1 for toolbar UIerrorsByUuid— Maps UUID → array of error messages
Upload Flow
startUploadObservations()filters unsynced observations from Realm- UUIDs added to queue, status set to
IN_PROGRESS - An effect watches
uploadStatus,uploadQueue,currentUpload - When queue has items and no current upload, pops next UUID
- Fetches Observation from Realm, calls
uploadObservationAndCatchError() - On success: removes from queue. On error: stores error, removes from queue.
- When queue empty:
completeUploads()→ status =COMPLETE - UI resets after 5 seconds (
MS_BEFORE_TOOLBAR_RESET)
Timeout: Each observation upload limited to 5 minutes (300,000ms) via setTimeout + AbortController.abort().
Unsynced Detection
Observation.filterUnsyncedObservations(realm) queries:
_synced_at == null
OR _synced_at <= _updated_at
OR ANY observationPhotos._synced_at == null
OR ANY observationSounds._synced_at == null
OR ANY projectObservations._synced_at == null
OR ANY observationFieldValues._synced_at == null
Results are sorted by _created_at descending (newest first) via .sorted("_created_at", true). Because the upload queue is popped from the tail (see above), observations still end up uploaded oldest-first.
Error Handling
| Scenario | Recovery |
|---|---|
| No API Token | RECOVERY_BY.LOGIN_AGAIN → navigate to LoginStackNavigator |
| Network Failure | User retry (recoveryPossible=true) |
Observation Deleted (detected by matching the error message That observation no longer exists.; 410 in code comments only) |
Remove from queue, delete locally |
| Auth Expired | Token revalidated at each step |
| Realm Access Error | Refresh and retry once |
Progress Tracking
Event-driven via EventRegister:
trackObservationUpload(uuid)→ emitsINCREMENT_SINGLE_UPLOAD_PROGRESSwith 0.5 incrementstrackEvidenceUpload(uuid)→ emits for each photo/sound upload and attachment- Per-observation total = 1 (obs) + count(unsynced photos/sounds) × 1 + count(previously-synced-but-modified photos/sounds) × 0.5. (Each unsynced item emits twice — once on upload, once on attach — at 0.5 each, netting 1; only already-synced-but-changed evidence contributes 0.5.)
- Toolbar progress = sum(currentIncrements) / sum(totalIncrements)
Keep-Awake
Uses @sayem314/react-native-keep-awake to prevent device sleep during uploads. Activated on setStartUploadObservations(), deactivated on completeUploads() or stopAllUploads().
Common Operations
Adding error handling to upload flow
- Define error in
src/uploaders/utils/errorHandling.ts - Catch in
useUploadObservations.ts→uploadObservationAndCatchError() - Store via
addUploadError(message, uuid)in Zustand
Modifying upload payload
- Update mapper in
src/uploaders/dataTransformation/prepareObservationForUpload.ts - If schema changed, also update
Observation.mapObservationForUpload()insrc/realmModels/Observation.js