Skip to content

Sync & Cloud

Fexl Lite runs as a desktop app and as a cloud service from the same Go binary. When you turn on Cloud Sync, your desktop devices talk to a single cloud database as their primary, fall back to a local copy if the network drops, and reconcile any divergence the next time they reconnect. This page is the section landing — it sketches the architecture and points to the deep dives for each concern.

Updated 4 May 2026·For v1.6.100·6 min read
settings · system · cloud sync panel · device pairing QR

The architecture in one paragraph

There’s one Go binary. On a desktop it runs against an embedded SQLite file with DB_TYPE=sqlite; on the cloud it runs against PostgreSQL with DB_TYPE=postgres. The frontend (React) doesn’t know or care which it’s talking to — every read and write goes through the same REST API. When cloud sync is enabled, the desktop’s API client points at the cloud URL as primary and keeps the local URL as backup for when the cloud is unreachable. When cloud sync is disabled (single-device deployments), the desktop just talks to its embedded server forever and never reaches out.

That’s it. Three moving parts: the cloud server, the desktop with its embedded server, and a 30-second sync worker that pushes-then-pulls when both are awake.

How devices reach data

Two flows you should understand. The first is the read/write that actually serves a click.

1

The user does something

Hits Complete Sale, opens an invoice, scrolls the inventory list. The frontend calls api.get(...) or api.post(...).

2

The API client picks a URL

If cloud sync is enabled and configured, the cloud URL is primary. If not, localhost is. The client tries primary first.

3

If primary fails, it fails over

A timeout or 5xx response triggers automatic failover to the secondary URL (local on a synced desktop, or no failover on a sync-disabled desktop). The user gets their answer either way; a toast warns when failover happens so the cashier knows they’re on the backup.

4

Write goes to whichever URL responded

Writes don’t fan out. They land on one server. The sync worker is responsible for getting that write to the other server.

The second flow is the sync cycle itself.

The desktop’s SyncWorker (in apps/retail/server-go/sync/worker.go) runs every 30 seconds while the app is open:

  1. Health checkGET <cloud>/api/sync/health with a 5-second timeout. If it fails, the cycle ends and the worker waits for the next tick.
  2. Push — every locally-_isDirty row (with _lastSyncedAt < updated_at) is bundled into a payload and POSTed to the cloud. The cloud writes them, runs FK resolution to map any local IDs to cloud IDs, and returns a snapshot of confirmed UUIDs.
  3. Pull — the desktop asks the cloud “what changed since my last sync?” and merges the returned rows into local SQLite. Conflicts are resolved last-write-wins on updated_at.
  4. Stamp_lastSyncedAt = now on every row that round-tripped successfully. _isDirty = 0.

A separate realtime WebSocket runs alongside the cycle: when the cloud broadcasts an event (e.g. invoice:created from another device), every connected desktop hears it and refreshes the affected query. So in practice a sale rung on Device A shows up on Device B’s POS within a second or two — the 30-second cycle is the safety net, not the live channel.

Conflict resolution: last-write-wins

When the cloud and a desktop have both written to the same row since the last sync, Fexl Lite resolves with last-write-wins on updated_at. The newer timestamp keeps its values; the older one is overwritten on both sides.

This is simple and predictable, and it works because Fexl Lite is mostly write-once-read-many: an invoice doesn’t get edited after it’s posted; an inventory layer doesn’t get edited after it’s drained. The cases where two devices race to update the same record are rare — usually concurrent edits to a customer profile or a product price.

The two exceptions are invoices and journal entries: those are written once, in a transaction, and the cloud rejects a duplicate UUID with a 409. The push retries on 409 by remapping the local ID to the cloud-assigned one and stamping _cloudId so subsequent edits route to the right row.

See Conflicts for the full set of rules and worked examples.

Pairing a device

Pairing a desktop to a cloud tenant is a one-time setup:

1

Spin up the cloud tenant

Sign in to the cloud admin (https://cloud.fexl.io or your self-hosted equivalent), create the tenant, get the license key and tenant UUID. Both are needed by the desktop.

2

On the desktop, open Settings → System → Cloud Sync

Toggle Enable cloud sync. The panel asks for the cloud URL, license key, and tenant UUID — paste them in.

3

The desktop registers itself

On save, the desktop POSTs to /api/sync/credentials with its own device UUID, name, and license key. The cloud creates a device row and returns the configured-but-pending status.

4

Approve the device from the cloud

Cloud admin sees the new device in the Devices list and approves it. Once approved, the desktop’s next sync cycle pulls the full tenant snapshot — products, customers, suppliers, invoices, the works — and the device is live.

The same flow runs in reverse for unpairing (desktop side: turn cloud sync off; cloud side: revoke the device). After unpairing, the local SQLite stays exactly where it is — you can keep using the desktop offline indefinitely or re-pair it later.

See Devices for the cloud-side device list, the approve/revoke flow, and the per-device sync stats.

Offline failover

If the network drops mid-day and the cloud becomes unreachable, the API client fails over to the local URL on the next request. The local server keeps serving — it has its own SQLite, with everything that synced down at the last successful pull. Sales rung during the offline window land in the local DB with _isDirty = 1.

When the network comes back and the next sync cycle succeeds, those dirty rows push to the cloud and reconcile. If anything wrote on another device while you were offline, the cloud’s pull side surfaces it on your next cycle.

Deep dives