---
title: Offline Sync
description: Learn how devices register, queue sales locally, replay them safely, and pull only the changes they may see.
---

### The pieces you coordinate

Offline sync rests on four tables in `packages/database/schema.sql`: `devices` for registered hardware, `sync_changes` as the global revision log, `sync_state` as each device pull cursor, and `idempotency_keys` for the online sale path. The routes live in `apps/server/src/features/sync/route.ts`. Remember how the two dedupe guards split the work: the `UNIQUE(branch_id, client_mutation_id)` index on `sales` is the permanent guard, and the `X-Idempotency-Key` header on `POST /api/v1/sales` becomes that sale's `client_mutation_id` in `apps/server/src/features/sales/route.ts`, so an online retry and an offline replay of the same payment converge on the same row. The `idempotency_keys` table itself is not a 24 hour expiry guard; it stores the in flight response keyed `key_value` (`packages/database/schema/core.ts`).

### How a device joins

Call `POST /api/v1/sync/devices/register` with a `device_id` and optional label. A re-register of a known id returns the existing row without creating a duplicate. Admins may claim any `branch_id` in the body; everyone else is pinned to their own branch (`registerDevice` in `apps/server/src/features/sync/service.ts`). Registration emits a `devices` UPSERT into `sync_changes` so other clients learn about the new hardware on their next pull.

### The console owns the offline side

The Tauri console in `apps/console` keeps you selling with no network. `apps/console/src/db.rs` creates the SQLite mirror from `apps/console/sql/local-schema.sql` and keeps `_device`, `_sync_state`, `_outbox`, and `_offline_sessions` as internal tables. Selling offline queues the sale in `apps/console/src/outbox/queue.rs`, which freezes `client_mutation_id`, `tax_policy`, and the currency fields at queue time, deducts mirror stock earliest expiry first, and records the draw in `stock_json` so a discard returns the units. The provisional invoice is a `LOCAL-` prefixed number stored with sync status `pending_sync`. `apps/console/src/identity/offline_auth.rs` verifies a stored password hash and honours the `offline_expires_at` window. When the network returns, the loop in `apps/register/hooks/useSyncEngine.tsx` drives the rounds: a 15 second status poll, 3 second debounced rounds on pending changes, an immediate round on the online transition, and a 60 second retry after a failure (`apps/register/app/lib/sync-state.ts`), with the phase reading `offline` whenever the server is unreachable.

### How push replays queued sales

`POST /api/v1/sync/push` accepts `device_id` plus a `changes` array, and v1 supports `sales`, `customers`, `suppliers`, and `medicines` as UPSERT plus DELETE for everything except sales (`pushChanges` in `apps/server/src/features/sync/service.ts`). Sales changes must carry a `client_mutation_id` and an immutable `tax_policy` block parsed by `parseQueuedSaleTaxPolicy` in `apps/server/src/features/settings/tax.ts`. The server checks for an existing sale with the same branch and mutation id first (`findSaleByClientMutation`), so retries return the original invoice instead of double charging, and a fresh replay runs the shared `createSale` core from `apps/server/src/features/sales/creation/service.ts` and logs a `REPLAY` audit action. Pushing mirrored non sale rows carries a `base_rev`: `apps/server/src/features/sync/service.ts` refuses the change with a CONFLICT when the server revision is ahead, records the race with `recordConflict` in `apps/server/src/features/sync/protocol/conflict.ts`, and an admin settles it through `GET /api/v1/sync/conflicts` and `POST /api/v1/sync/conflicts/:id/resolve` in `apps/server/src/features/sync/route.ts`.

### How rejections reach the till

Each change comes back in the `accepted` or `rejected` lists. `deriveRejectionCode` in `apps/server/src/features/sync/protocol/rejection.ts` derives the code, and the register treats `VALIDATION`, `UNSUPPORTED_ENTITY`, `INSUFFICIENT_STOCK`, and `BRANCH_MISMATCH` as terminal in `apps/register/app/lib/tauri-sync.ts`, dropping the sale instead of retrying forever. A CONFLICT is deliberately retryable, because the next round usually wins the race. Console side, `apps/console/src/sync/push.rs` batches 50 outbox rows per push and `apps/console/src/sync/apply.rs` (`reconcile_accepted_sale`) rekeys the local sale id to the server id once the sale is accepted.

### How pull filters by visibility

`GET /api/v1/sync/pull` takes `device_id`, `since_rev` (default 0), and `limit` (default 500, max 1000) and returns changes ordered by `rev` plus the new `max_rev` (`apps/server/src/features/sync/route.ts`). The visibility filter from `listVisibleChanges` in `apps/server/src/features/sync/queries.ts` serves `global` rows to everyone, `branch` rows only to their origin branch, and `directed` rows to the origin or target branch; `classifyVisibility` in `apps/server/src/core/common/visibility.ts` assigns each entity to one of the three. The server records the cursor in `sync_state` keyed `(branch_id, device_id)`, so a device that drops offline simply resumes from its last `max_rev`.

### The round trip as a chart

```mermaid
flowchart TD
    A[apps/console creates the mirror DB] --> B[Device registers]
    B --> C[GET /api/v1/sync/snapshot bootstrap]
    C --> D[Sell offline, draw mirror stock, freeze policy]
    D --> E[Queue in outbox]
    E --> F[POST /api/v1/sync/push, 50 rows per batch]
    F --> G{Mutation seen before?}
    G -->|Yes| H[Return original invoice + rekey]
    G -->|No| I[createSale core runs]
    I --> J[Accepted or rejected per change]
    J --> K[GET /api/v1/sync/pull since_rev]
    K --> L[Visibility filter + cursor save]
```

### How a new device bootstraps

`GET /api/v1/sync/snapshot` returns the current `max_rev` plus branches, medicines, suppliers, coupons, customers, users, batches, and categories in one payload (`getSnapshot` in `apps/server/src/features/sync/service.ts`). Admin callers get every branch and user, branch callers only their own, and no password hashes travel. A fresh device loads that snapshot, stores `max_rev` as its cursor, then calls `GET /api/v1/sync/pull` for anything newer. That two-step keeps first sync to two round trips instead of paging the whole revision log from zero.

### Try it with curl

Log in, register a device, then pull from revision zero.

```bash
curl -s -X POST http://localhost:8000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"pharm1@demo.pharmaflow","password":"Demo@1234"}'
```

```bash
TOKEN="<paste access_token here>"
curl -s "http://localhost:8000/api/v1/sync/pull?device_id=<your-device-id>&since_rev=0&limit=50" \
  -H "Authorization: Bearer $TOKEN"
```

### Next steps

- Read /architecture/request-lifecycle for the auth layer every sync call passes through
- Read /architecture/branches-and-scoping for why pull only serves your branch
- Read /sales/selling-at-the-counter for the online sale the replay path mirrors
