Skip to main content

Synchronization

The synchronization system keeps client data in sync with the PHP backend through a long-poll mechanism. It is implemented in libs/upp-data/src/modules/sync.ts and coordinates with the server's connection/sync endpoints.

Architecture Overview

Key Classes

syncService

The Angular injectable (providedIn: 'root') that orchestrates everything. Key responsibilities:

  • Session lifecycle: Start(session) / Stop()
  • Entity association: SetUser(user), SetPlace(place), SetQrCode(qrcode)
  • Change flushing: Flush(changes, force) sends committed changes to the server
  • Observables: OnChange, OnExpired, OnReleased, IsReady
@Injectable({ providedIn: 'root' })
export class syncService implements OnDestroy {
constructor(
public lang: languageService,
public store: storeService,
public cache: cacheService,
public adhoc: adhocService,
public state: stateService,
public clock: clockService,
public toast: toastService,
private deviceid: identificationService,
private alertCtrl: alertService
) { ... }
}

Connection

Manages the server connection lifecycle and periodic refresh:

MethodPurpose
CreateConnection()Sends connection/create with triggers, then starts periodic refresh
RefreshConnection()Re-sends current triggers to connection/update
ReleaseConnection()Sends connection/delete, runs final sync, stops refresh
SetUser(user)Updates triggers with user info
SetPlace(place)Updates triggers with place info
SetQrCode(qrcode)Updates triggers with QR code info
CancelRefresh()Sends connection/cancel to interrupt a long-poll

Connection status: RUNNING | STOPPED | EXPIRED.

Syncronizator

The core sync engine. Manages:

  • Change queues: _syncchanges (pending send), _waitchanges (waiting for next cycle), _sentchanges (in-flight)
  • Timestamps: Per-target (SESSION, PLACE, QRCODE) to enable incremental sync
  • Server reference: Adjusts local timing to server clock
  • Dependency resolution: CheckDepends() ensures changes are ordered correctly
  • UUID → objid mapping: ResolvedMap tracks server-assigned IDs for locally-created objects

PendingQueue

Manages incoming server updates with priority-based processing:

  • Updates are assigned numeric priorities based on their table type
  • Lower priority numbers are processed first (synchronously when possible)
  • Processing is batched (up to 5000 items for high-priority, 500 for low) with a 50ms time limit per batch
  • Progress tracking via OnRefreshStage and OnRefreshPercent

CacheManager

Persists sync data to IndexedDB for faster startup:

  • Session cache: Stores session-level updates keyed by (deviceid, sessionid)
  • Place cache: Stores place-level updates keyed by placeid
  • Validates cache freshness against AppConstants.CacheExpiration
  • On startup, loads cached data into PendingQueue before querying the server

Update Triggers

When a connection is created or updated, the client sends triggers that tell the server what data this connection cares about:

interface UpdateTriggers {
SESSION: string; // always required
QRCODE: string | null;
USER: string | null;
PLACE: string | null;
}

The server stores each connection's triggers in connection state (Redis / APCu / CONNECTED, per DATA_SHARE_MODE). When changes are committed that affect matching triggers, the server marks those connections for refresh.

Sync Flow

1. Starting a Session

await syncService.Start(sessionId);

This:

  1. Sets state.session
  2. Recovers any pending changes from previous sessions (via storeService)
  3. Loads cached session data from IndexedDB
  4. Creates a server connection with triggers
  5. Begins periodic refresh

2. Periodic Refresh Cycle

Every AppConstants.MainRefresh milliseconds:

  1. Prepare changes: Collects uncommitted changes, resolves UUID→objid for previously inserted items, assigns server time reference
  2. Send request: POST /sync with { access, timestamp, target } as query params and changes as body
  3. Process response:
    • Store newly resolved UUID→objid mappings
    • Save updates to IndexedDB cache
    • Filter out volatile responses and already-pending items
    • Enqueue updates into PendingQueue for prioritized processing
  4. Handle expiration: If response.expired, reconnect automatically
  5. Handle errors: Retry with backoff (2s for HTTP errors, 10s for server errors)

3. Committing Changes

When the client modifies data:

// DataObject collects changes recursively
const changes = ticket.Commit;

// Queue changes for next sync cycle
await dataService.Commit(changes);
await dataService.Flush(force);

The Syncronizator.FlushChanges() method:

  1. Deduplicates changes (same object replaces previous pending change)
  2. Validates dependency ordering via CheckChanges()
  3. Persists changes to local storage as a safety net
  4. If force = true, cancels any running refresh and triggers an immediate one

4. Conflict Resolution

The system uses a last-write-wins strategy with merge:

private _MergeChange(chng1: any, chng2: any): any {
const updated1 = new Date(chng1['updated']);
const updated2 = new Date(chng2['updated']);
const source = updated1 > updated2 ? chng1 : chng2;
const target = updated1 > updated2 ? chng2 : chng1;
return { ...target, ...source };
}

When the same object has both a pending local change and a server update:

  • The entry with the newer updated timestamp takes precedence
  • Fields from both are merged, with the newer version's fields overriding

5. Dependency Ordering

Before sending changes, CheckChanges() ensures correct ordering:

  1. Topological sort: If change A depends on change B (references B's UUID), B must come first
  2. Relation map validation: Every UUID reference must either already have an objid or be present earlier in the change list
  3. Throws a descriptive error if ordering cannot be satisfied

Priority System

The PendingQueue assigns priorities to incoming updates based on table type. Lower numbers are processed first:

PriorityTablesRationale
1.1–1.5SESSION, FCM, USER, STAFF, PLACECritical session/identity data
2.1–2.7ADDRESS, STRIPE, PLACEOPT, AUDIT, etc.Infrastructure without UI components
3.1–3.8PRODUCT, CATEGORY, OFFER, EXTRA, DISCOUNT, etc.Catalog data (needed before tickets)
4.1–4.2FAMILY, FAMILYPRODUCT, FAMILYPERIODProduct groupings
5.1–5.6TICKET*, SESSIONActive tickets and their components
6.1–6.3QRCODE, PRINTER, PAYMENT, PLACEPlace configuration updates
8.1–8.6(same tables, lower status)Deferred items (e.g., deleted QR codes)

Items with _actn == 'do_insert' get a -10 offset, ensuring inserts are processed before updates.

Stages 1–3 are processed synchronously (blocking the UI clock), while stages 4+ are processed asynchronously in 50ms batches with yields to the event loop.

Server-Side Sync

On the PHP backend, the sync flow (model/synchronize.php) works as follows:

  1. Validate session and commit POST body changes in a DB transaction
  2. Call connection_notify to mark affected connections as REFRESH
  3. Long-poll: If timestamp > 0 and status is UPDATED, poll in a loop (~20s) waiting for REFRESH, EXPIRED, or CANCELLED
  4. When refresh is due, load updates from the correct database:
    • Central DB for user-target data (SESSION, USER, PLACE, STAFF)
    • Tenant DB for place-target data (PRODUCT, TICKET, QRCODE, etc.)
  5. Return JSON: { errorcode, timestamp, reference, target, updated, expired, updates[] }

Connection State

Connection state backend is selected by DATA_SHARE_MODE (Deploy::share_mode() / AppConstants::shareMode(), default SAFE):

ModeWhere connection state lives
REDISRedis (Sentinel primary; keys _UPPCONN_ + session, TTL 60 s; PUB/SUB place/global channels). Multi-node. Temporary Redis outage → Critical + NotConnected (no silent fallback to SAFE).
APCUAPCu on the node (60 s TTL). Single-node / lab.
SAFECONNECTED table (central DB).
  • All reads/writes of connection state (create, refresh, update, delete, cancel, notify) run under an exclusive mutex: with_lock('connection_state', MUTEX_NAME, …) from legacy/base/mutex.php. The mutex backend follows the same shareMode() (MutexRedis vs local flock) unless a caller forces MUTEX_LOCAL. Factory details: server contract legacy/base/mutex.mdc.
  • Fields: session, status (UPDATED/REFRESH/CANCELLED), synchronized, config (JSON triggers)

Note: Fiscal registry appends use a separate lock (MUTEX_FILE on the shared registry path), not connection_state. Front-end GlobalMutex / storeService locks are unrelated.

Triggers and Notification

Each connection's config holds triggers mapping tables to object IDs. When changes are committed:

  1. The server builds a list of (table, objid) updates
  2. connection_notify matches these against all active connections' triggers
  3. Matching connections are marked REFRESH
  4. Only those clients receive data on their next poll

Session Expiration

When the server returns errorcode == 2 or errorcode == 3:

  1. syncService.OnSessionExpired() is called
  2. The synchronizer status is set to EXPIRED
  3. The connection is stopped
  4. A modal alert notifies the user
  5. OnExpired observable emits, allowing the app to redirect to login

Stored Changes (Persistence)

Changes awaiting sync are persisted to storeService (Ionic Storage) under the key upp-stored-syncdata. This ensures that if the browser is closed or crashes:

  • On next startup, GetStoredChanges() recovers them
  • They are sent with the next sync cycle
  • After successful sync, DelStoredChanges() clears the stored copy

Retry limits prevent infinite loops: 50 retries for HTTP errors, 5 for server errors. If exhausted, changes are discarded and the user is notified.

Local Backup (Fiscal Devices)

storeService (browser storage) is the primary store for pending changes, but on local fiscal devices (a POS connected to a Raspberry Pi print/cache service over a local WebSocket) it is not durable enough on its own: if the browser storage is lost while the connection to the server is down, unsent fiscal changes could disappear. wsstoreService (libs/upp-data/src/modules/wsconnect.ts) adds a second, independent copy of the same pending changes on that local service, so they can be recovered even if browser storage fails while offline.

When it activates

  • Place.RequiresLocalBackup is true only when the active device connection is local (RasppiConnect.IsLocal, i.e. the POS talks to a host:port on the same network, not to the cloud service).
  • When RequiresLocalBackup is true, Syncronizator opens a connection to the local cache through wsstoreService and dual-writes every pending-changes update (Set/Del on upp-stored-syncdata) to both storeService and the local cache.
  • Place.WSStorageReady reflects whether that backup is currently reliable: it is always true when RequiresLocalBackup is false (nothing to guarantee), and otherwise mirrors Place.BackupReady, which Syncronizator flips to true once the local connection is open and any previously backed-up changes have been recovered/merged, and back to false immediately on disconnect.

Blocking ticket sending: AllowTicketCommit

Place.AllowTicketCommit gates creating/sending new tickets (upp-ticket-edit's "send" action, table/bar ticket creation, ticket thumb "prepare"). It requires the place license to be valid and WSStorageReady. In practice this means: on a local fiscal device, the client refuses to create new tickets until the local backup connection is confirmed reachable, so that any ticket committed afterwards is guaranteed to have a second copy of its pending change. The topbar shows a NOSERVICE warning whenever !WSStorageReady, which is the same condition that blocks sending.

Blocking payment/closing: AllowTicketPayment

Closing/paying a ticket (cash, card) is gated by the stricter Place.AllowTicketPayment, which additionally requires:

  • Either connectivity to the server (viewService.IsOnline) or a ready local backup (covered by AllowTicketCommit).
  • If TicketBAI (Basque fiscal signing, SendBAIEnabled) is enabled and the device is offline, the signing libraries must already be preloaded (Ticketbai.LibrariesReady) — Syncronizator.SetPlace triggers a best-effort background preload (Ticketbai.PreloadLibraries) as soon as a place with local backup is set, so the libraries are typically ready before the till operator needs to close a ticket offline.

This lets fiscal signing happen offline once the libraries are preloaded, while still preventing a payment from being closed on a local device whose backup is not yet confirmed.

Best-effort guarantee

The local backup is a best-effort safety net, not a second source of truth: a transient write failure to the local cache never blocks or rolls back the primary storeService write, and the server remains the single canonical source once connectivity is restored (see Sync Flow above). Local backup only reduces the window in which a pending change could be lost while offline on a fiscal device.