---
title: Offline-First Flow
description: 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.

```bash
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.

```bash
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`.

```bash
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.

```json
{
	"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.

```bash
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`.

```bash
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`.
