Skip to main content

Follow one observation through ODE

Pick a single observation — a tree measured in the rain — and follow it from the form on a phone to PostgreSQL and back out again as an export. This is a code map for newcomers, not a protocol specification: every step names a real symbol you can open, and the tests at the end prove the same path behaves as described.

The happy path

Where each step lives

#StepProjectSource
1Render the JSON formFormulus FormplayerApp.tsx — renderer registry and App
2Finalize the sessionFormulus FormplayerFinalizeRenderer.tsx — handleFinalize dispatches a finalizeForm event
3Cross the WebView bridgeFormplayer → FormulusFormulusInterface.ts — submitObservationWithContext; the contract lives in FormulusInterfaceDefinition.ts
4Receive it nativelyFormulusFormulusMessageHandlers.ts — onSubmitObservation delegates to the active session
5Commit draft files, then writeFormulusFormplayerModal.tsx — handleSubmission, then attachmentStorage.ts — persistObservationWithAttachments
6Create the local rowFormulusWatermelonDBRepo.ts — saveObservation; table shape in schema.ts
7Pull first, then pushFormulusSyncService.ts — syncObservations, which orders the two in api/synkronus/index.ts — syncObservationsImpl awaits pullObservations before pushObservations
8Pull the server's changesSynkronus → FormulusGetRecordsSinceVersion in pkg/sync/service.go, then applyServerChanges in WatermelonDBRepo.ts
9Push a batch, server upsertsFormulus → Synkronushandlers/sync.go — Push, then ProcessPushedRecords in the service file above
10ExportSynkronuspkg/dataexport/service.go — ExportParquetZip and ExportRawJSONZip

A sync pulls before it pushes

Step 7 is not an arbitrary order. syncObservationsImpl awaits pullObservations and only then pushObservations, so a device reconciles against fresh server state before it offers its own changes. ODE Desktop drives the two as separate operations — synkPull and synkPush in useCustodianStore.ts — which leaves its UI free to sequence them.

What changes when the device is offline

Nothing in the write path talks to the network. saveObservation resolves a cached location, reads who is submitting, and writes one row to SQLite — so the form is complete and durable with the radio off. "Pending" is not a stored state; it is a derived query over synced_at and updated_at in getPendingChanges, which is why a push that fails part-way leaves the unacknowledged rows eligible for the next attempt. The server is the only place rows merge: ProcessPushedRecords wraps a batch in one transaction and upserts on observation_id, so re-sending a batch is safe.

Attachments travel a separate pipeline

Observation JSON never carries a file. A photo, audio, or video answer persists a GUID-shaped basename — for example 9f1c….jpg — plus metadata, while the binary lives on disk under the profile's attachments/ directory in draft/, pending/, and synced/ subfolders. Those files move over PUT /api/attachments/{attachment_id} and are announced by POST /api/attachments/manifest, recorded in the attachment_operations table and tracked on their own version cursor. The practical consequence: an observation can be fully synced on the server while its photo is still queued on the device.

Prove it to yourself

TestWhat it proves
attachmentStorage.test.ts — calls commitDraftAttachmentsAfterSave and saveObservation for a new observationDraft files are promoted before the row is written, and the committed data is what reaches the repository
WatermelonDBRepo.test.ts — saveObservation should create a new observation and return its IDThe local row is created, readable, and addressable by its id
sync_push_pull_test.go — TestPushThenPullThe server accepts a push and hands the same observations back on a pull (needs PostgreSQL)
attachmentManifestResume.test.ts — advances attachment cursor and defers failed downloads for future syncsAttachment progress uses its own cursor and survives a failed download
attachment_sync_integration_test.go — TestAttachmentUpload_FollowedByManifest_ReturnsDownloadForSecondDeviceAn uploaded attachment becomes discoverable to a second device independently of observation sync (needs PostgreSQL)

What this map leaves out

  • Conflict handling. When a pulled server row is older than the local copy, the local row wins and is tagged last_write_won in WatermelonDBRepo.ts. The full conflict matrix is not covered here.
  • Repository reset. An admin reset bumps repository_generation; clients that sent an older value receive HTTP 409 rather than silently merging.
  • Attachment depth. The manifest, upload queue, and download pool are summarized; their retry and prefetch logic deserves its own map.
  • Sync triggering. How a sync is started from the UI is not part of this journey.
  • form_version. The column is stored and pushed on both sides, but how a form's schema version is chosen is a separate question from where an observation travels.