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

Sync Protocol

Register devices, push idempotent offline sales, pull visibility-filtered deltas, and bootstrap with snapshots.

You keep offline registers in sync through the endpoints in apps/server/src/features/sync/route.ts, all mounted under the /api/v1 prefix in apps/server/src/app.ts. Visibility rules live in apps/server/src/core/common/visibility.ts and writers emit changes with emitChange from apps/server/src/features/sync/protocol/emitter.ts. Every endpoint requires authentication.

Registering a device

You register once per device with POST /sync/devices/register. Registration is idempotent: sending a known device_id returns the existing row instead of failing, and you get 200 for a retry or 201 for a new device. Non-admin callers are pinned to their own branch, while admins may pass a branch_id to register for another branch.

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

You get back device_id, branch_id, and label. The server also emits a devices sync change so other branches learn the device exists.

Pushing offline sales

You replay queued mutations with POST /sync/push. The admin, branch_manager, pharmacist, and cashier roles pass the route guard in apps/server/src/features/sync/route.ts, and the device must belong to your branch unless you are the admin, with a revoked device refused outright by requireDeviceBranchAccess in apps/server/src/features/sync/protocol/branch-scope.ts. Version one supports sales as UPSERT, plus customers, suppliers, and medicines as UPSERT or DELETE; anything else lands in the rejected array with code UNSUPPORTED_ENTITY. Each change needs a non-empty client_mutation_id or it is rejected with VALIDATION, and each sales payload must carry immutable tax_policy metadata parsed by parseQueuedSaleTaxPolicy in apps/server/src/features/settings/tax.ts.

curl -X POST http://localhost:8000/sync/push \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "register-01",
    "changes": [{
      "client_mutation_id": "offline-0001",
      "entity": "sales",
      "op": "UPSERT",
      "payload": {
        "items": [{"medicine_id":"MED-ID","quantity":1,"unit_price":20}],
        "payment_method": "cash",
        "tax_policy": {
          "enabled": true,
          "standard_rate": 0.15,
          "medicine_categories": {"MED-ID": "standard"}
        }
      }
    }]
  }'

Dedupe is by branch_id plus client_mutation_id on the sales table, backed by the UNIQUE (branch_id, client_mutation_id) constraint in packages/database/schema.sql. When you push the same mutation twice, the server skips the createSale core in apps/server/src/features/sales/creation/service.ts (re-exported from apps/server/src/features/sales/service.ts) and returns the original id, icv, and invoice_number in accepted. Stock failures come back as INSUFFICIENT_STOCK.

Rejections are classified by deriveRejectionCode in apps/server/src/features/sync/protocol/rejection.ts. A 403 becomes BRANCH_MISMATCH, a 409 becomes CONFLICT, other client errors become VALIDATION, and a server fault becomes SERVER_ERROR. The terminal codes a till stops retrying are VALIDATION, UNSUPPORTED_ENTITY, INSUFFICIENT_STOCK, and BRANCH_MISMATCH; CONFLICT and SERVER_ERROR stay retryable.

Settling conflicts

A mirrored UPSERT that arrived with a base_rev older than the server revision is refused instead of clobbered, which is where the CONFLICT code comes from. The race is recorded and an admin settles it with GET /sync/conflicts and POST /sync/conflicts/:id/resolve using the last_write_wins, client, server, or merge strategy, as listed in apps/server/src/features/sync/protocol/conflict.ts.

Pulling deltas

You poll GET /sync/pull with device_id, since_rev, and limit. The limit defaults to 500 and caps at 1000, and both parameters must be non-negative integers with the limit at least 1, which apps/server/src/features/sync/route.ts enforces. The device must exist, and a caller whose device belongs to another branch, or whose device is revoked, gets a 403 from requireDeviceBranchAccess in apps/server/src/features/sync/protocol/branch-scope.ts. The server filters rows by the device branch: you see all global rows, your own branch rows, and directed rows where your branch is origin or target.

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

You get changes in ascending rev order plus max_rev. The server upserts sync_state for your branch and device pair, so your next call passes since_rev equal to the last max_rev you stored.

Understanding visibility

You classify every entity in apps/server/src/core/common/visibility.ts, and an entity the file has not classified is refused at emit time. Branches, devices, medicines, suppliers, coupons, insurance providers, patient insurance policies, customers, customer ledgers, and users are global. Sales, sale items, returns with their items, stock movements, batches, cash sessions, session breaks, tender declarations, parked transactions, prescriptions with their items, coupon usage, the controlled dispense log, purchase orders with their items, credit notes, and the audit log are branch. Transfers are directed with an explicit to_branch_id.

Bootstrapping with a snapshot

You bootstrap a fresh device with GET /sync/snapshot. It returns the current max_rev plus full rows for active branches, active medicines, active suppliers, active coupons, all customers, active users, your branch batches, and the categories. An admin snapshot covers every branch and every user, while a branch caller only receives its own branch and its own users, as scoped in apps/server/src/features/sync/queries.ts. User rows never include password hashes. Pair the snapshot with since_rev set to max_rev on your first pull so you only fetch what changed after bootstrap.

curl http://localhost:8000/sync/snapshot \
  -H "Authorization: Bearer $TOKEN"

Next steps

Follow the device lifecycle hands-on in /guides/offline-first-flow, learn the branch model in /platform/branches, and see the sales replay internals in /sales/selling-at-the-counter.

Last updated on October 20, 2026