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:
| Method | Purpose |
|---|---|
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:
ResolvedMaptracks 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
OnRefreshStageandOnRefreshPercent
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
PendingQueuebefore 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:
- Sets
state.session - Recovers any pending changes from previous sessions (via
storeService) - Loads cached session data from IndexedDB
- Creates a server connection with triggers
- Begins periodic refresh
2. Periodic Refresh Cycle
Every AppConstants.MainRefresh milliseconds:
- Prepare changes: Collects uncommitted changes, resolves UUID→objid for previously inserted items, assigns server time reference
- Send request:
POST /syncwith{ access, timestamp, target }as query params and changes as body - Process response:
- Store newly resolved UUID→objid mappings
- Save updates to IndexedDB cache
- Filter out volatile responses and already-pending items
- Enqueue updates into
PendingQueuefor prioritized processing
- Handle expiration: If
response.expired, reconnect automatically - 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:
- Deduplicates changes (same object replaces previous pending change)
- Validates dependency ordering via
CheckChanges() - Persists changes to local storage as a safety net
- 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
updatedtimestamp takes precedence - Fields from both are merged, with the newer version's fields overriding
5. Dependency Ordering
Before sending changes, CheckChanges() ensures correct ordering:
- Topological sort: If change A depends on change B (references B's UUID), B must come first
- Relation map validation: Every UUID reference must either already have an
objidor be present earlier in the change list - 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:
| Priority | Tables | Rationale |
|---|---|---|
| 1.1–1.5 | SESSION, FCM, USER, STAFF, PLACE | Critical session/identity data |
| 2.1–2.7 | ADDRESS, STRIPE, PLACEOPT, AUDIT, etc. | Infrastructure without UI components |
| 3.1–3.8 | PRODUCT, CATEGORY, OFFER, EXTRA, DISCOUNT, etc. | Catalog data (needed before tickets) |
| 4.1–4.2 | FAMILY, FAMILYPRODUCT, FAMILYPERIOD | Product groupings |
| 5.1–5.6 | TICKET*, SESSION | Active tickets and their components |
| 6.1–6.3 | QRCODE, PRINTER, PAYMENT, PLACE | Place 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:
- Validate session and commit POST body changes in a DB transaction
- Call
connection_notifyto mark affected connections asREFRESH - Long-poll: If
timestamp > 0and status isUPDATED, poll in a loop (~20s) waiting forREFRESH,EXPIRED, orCANCELLED - 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.)
- 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):
| Mode | Where connection state lives |
|---|---|
REDIS | Redis (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). |
APCU | APCu on the node (60 s TTL). Single-node / lab. |
SAFE | CONNECTED 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, …)fromlegacy/base/mutex.php. The mutex backend follows the sameshareMode()(MutexRedisvs local flock) unless a caller forcesMUTEX_LOCAL. Factory details: server contractlegacy/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:
- The server builds a list of
(table, objid)updates connection_notifymatches these against all active connections' triggers- Matching connections are marked
REFRESH - Only those clients receive data on their next poll
Session Expiration
When the server returns errorcode == 2 or errorcode == 3:
syncService.OnSessionExpired()is called- The synchronizer status is set to
EXPIRED - The connection is stopped
- A modal alert notifies the user
OnExpiredobservable 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.RequiresLocalBackupis true only when the active device connection is local (RasppiConnect.IsLocal, i.e. the POS talks to ahost:porton the same network, not to the cloud service).- When
RequiresLocalBackupis true,Syncronizatoropens a connection to the local cache throughwsstoreServiceand dual-writes every pending-changes update (Set/Delonupp-stored-syncdata) to bothstoreServiceand the local cache. Place.WSStorageReadyreflects whether that backup is currently reliable: it is alwaystruewhenRequiresLocalBackupis false (nothing to guarantee), and otherwise mirrorsPlace.BackupReady, whichSyncronizatorflips totrueonce the local connection is open and any previously backed-up changes have been recovered/merged, and back tofalseimmediately 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 byAllowTicketCommit). - If TicketBAI (Basque fiscal signing,
SendBAIEnabled) is enabled and the device is offline, the signing libraries must already be preloaded (Ticketbai.LibrariesReady) —Syncronizator.SetPlacetriggers 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.