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

Offline Sync

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

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.

curl -s -X POST http://localhost:8000/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"pharm1@demo.pharmaflow","password":"Demo@1234"}'
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

Last updated on October 20, 2026