Skip to content
PharmaFlow Knowledgebase
Esc
↑↓navigate↵open⌘Jpreview
On this page

Offline-First Flow

Register a device, snapshot it, queue sales offline, push the replay, pull deltas, and handle conflicts.

You run an offline register through the sync endpoints in apps/server/src/features/sync/route.ts (device register, push, pull, and snapshot), mounted under the /api/v1 prefix that apps/server/src/app.ts applies with app.route("/api/v1", apiV1). You log in first, then you treat the device id as the durable identity for everything that follows.

TOKEN=$(curl -s -X POST http://localhost:8000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"pharm1@demo.pharmaflow","password":"Demo@1234"}' \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")

Register the device

You register once and store the id on the device. Registration is idempotent, so re-sending the same device_id returns the existing row with 200 instead of a second 201 row. Non-admin tokens pin to their own branch automatically, and only an admin may name another branch.

curl -X POST http://localhost:8000/api/v1/sync/devices/register \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"device_id":"register-01","label":"Counter tablet"}'

In the real till you do not call this by hand: the register runs device onboarding through Tauri commands in apps/register/app/lib/tauri-sync.ts, with the server address handled by apps/register/app/lib/device-server-url.ts and apps/register/app/lib/org-server-address.ts, and the launch step in apps/register/app/lib/device-onboarding.ts.

Bootstrap with a snapshot

You fetch GET /sync/snapshot on first launch. It returns max_rev plus branches, medicines, suppliers, coupons, customers, users with no password hashes, batches, and categories. You seed your local database from it and store max_rev as your starting since_rev. The console does this with the bootstrap_from_snapshot command in apps/console/src/commands/sync.rs, which mirrors the rows into its SQLite file built from apps/console/sql/local-schema.sql.

curl http://localhost:8000/api/v1/sync/snapshot \
  -H "Authorization: Bearer $TOKEN" > snapshot.json

Queue a sale while offline

You build the queued payload locally while the network is down. In the till that means the queue_sale Tauri command in apps/console/src/commands/sync.rs, which stores the row in the outbox under apps/console/src/outbox/queue.rs, writes a provisional invoice formatted LOCAL-xxxx by provisional_invoice_number, and records the stock the till drew locally in the row’s stock_json. The payload shape comes from buildOfflineSalePayload in apps/register/app/lib/offline-sale.ts.

Every queued sale needs a locally generated client_mutation_id that is unique per branch, plus the immutable tax_policy metadata of QueuedSaleTaxPolicy in apps/register/app/lib/offline-sale.ts, parsed on replay by parseQueuedSaleTaxPolicy in apps/server/src/features/settings/tax.ts. The sale also freezes its money configuration at queue time: currency_code, base_currency_code, and fx_rate_to_base on OfflineSaleBody, so an admin correcting a rate during the outage never re-prices a sale the customer already paid for.

{
	"client_mutation_id": "offline-0001",
	"entity": "sales",
	"op": "UPSERT",
	"payload": {
		"items": [{ "medicine_id": "MEDICINE_ID", "quantity": 1, "unit_price": 20 }],
		"payment_method": "cash",
		"tax_policy": {
			"enabled": true,
			"standard_rate": 0.15,
			"medicine_categories": { "MEDICINE_ID": "standard" }
		}
	}
}

You persist this object to your outbox and keep it until the server confirms it in accepted. The push protocol carries sales, customers, suppliers, and medicines: every one as UPSERT, and DELETE only for customers, suppliers, and medicines, never for a sale.

Push the outbox

You send the queued changes with POST /sync/push once connectivity returns. The console batches up to 50 pending changes per round with PUSH_BATCH_LIMIT in apps/console/src/sync/push.rs and keeps every client_mutation_id stable across retries so replays dedupe.

curl -X POST http://localhost:8000/api/v1/sync/push \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"device_id":"register-01","changes":[{}]}'

Replace the empty object with your queued payloads. The response splits into accepted, which for a sale carries id, icv, and invoice_number, plus rejected with code and message. You delete accepted entries from the outbox and surface rejected ones for the pharmacist to fix.

Pull deltas with since_rev

You poll GET /sync/pull with your stored cursor. The server returns visibility-filtered rows in ascending rev order and records your progress in the sync_state table defined in packages/database/schema/printing.ts.

curl "http://localhost:8000/api/v1/sync/pull?device_id=register-01&since_rev=0&limit=500" \
  -H "Authorization: Bearer $TOKEN"

You apply each change, then you store the returned max_rev as your next since_rev. The limit defaults to 500 and caps at 1000.

Handle conflicts and duplicates

You treat a duplicate push as success: when the server already holds your branch_id plus client_mutation_id, it returns the original sale without running createSale again. The dedupe key is the unique index sales_branch_id_client_mutation_id_unique in packages/database/schema/sales.ts.

A rejected sale is classified by classify_rejection in apps/console/src/sync/retry.rs: VALIDATION, UNSUPPORTED_ENTITY, INSUFFICIENT_STOCK, and BRANCH_MISMATCH are terminal and stop retrying, everything else is transient and retries on an exponential backoff starting at 30 seconds, capped at one hour, in the same file. The register surfaces refused rows by polling sync_status with apps/register/hooks/useSyncStatus.ts and rendering the count in apps/register/components/SyncBadge.tsx through describeSyncBadge in apps/register/app/lib/sync-state.ts, while the failed_outbox, retry_outbox, and discard_outbox commands live in apps/console/src/commands/sync.rs with their retry and stock return logic in apps/console/src/outbox/lifecycle.rs. You never invent a new mutation id for a retry, or the same physical sale posts twice.

Checklist

You verify the device row exists, the snapshot seeds local tables, the offline payload carries tax_policy and the frozen currency fields, the push accepts with an invoice number, a duplicate push returns the same id, and the pull cursor advances past max_rev.

Last updated on October 20, 2026