How it works
Acquiring a lock
- Terminal taps a table on the floor plan
- Frontend sends
TABLE_LOCK_ACQUIRE { tableId }via WebSocket - Hub checks if the table is already locked:
- Not locked — Grant. Returns
TABLE_LOCK_GRANTED { tableId } - Locked by same terminal — Grant (re-entrant, extends TTL)
- Locked by another terminal — Deny. Returns
TABLE_LOCK_DENIED { tableId, lockedBy } - Locked but expired — Clean up expired lock, then grant
- Not locked — Grant. Returns
- On grant, hub broadcasts
TABLE_LOCKS_STATEto all other terminals
Maintaining a lock
- Locks expire after 45 seconds (
TABLE_LOCK_TTL_MS) without renewal - Terminals send
TABLE_LOCK_HEARTBEATevery 15 seconds - Each heartbeat resets the expiry to
now + 45s - If a terminal crashes or loses connection, the lock auto-expires after the grace period
Releasing a lock
- Terminal sends
TABLE_LOCK_RELEASE { tableId }when navigating away - Hub broadcasts
TABLE_LOCK_RELEASED { tableId }to all terminals - The
TableLockGuardcomponent releases the lock automatically on unmount
Lock expiry (tick)
The hub runs a periodic tick every 15 seconds (matching the heartbeat interval):- Query all locks where
expires_at <= now - For each expired lock:
- Send
TABLE_LOCK_REVOKED { tableId, reason: "Lock expired" }to the holding terminal - Delete the lock from storage
- Send
Disconnect grace period
When a terminal’s WebSocket disconnects:- Hub shortens lock expiry to
now + 10s(grace period) - If the terminal reconnects within 10 seconds, it can re-acquire
- If grace period expires, the normal tick process revokes the lock
Manager override (force acquire)
Managers can forcibly take over a table locked by another terminal:1
Request
Manager terminal sends
TABLE_LOCK_FORCE_ACQUIRE { tableId }2
Validate
Hub validates that the requesting terminal has
manager or group_pos_manager role (server-side check)3
Revoke current holder
Hub sends
TABLE_LOCK_REVOKED { tableId, reason: "Manager override" } to the current holder4
Grant to manager
Manager receives
TABLE_LOCK_GRANTED { tableId }. Hub broadcasts updated TABLE_LOCKS_STATEProtocol messages
Terminal to hub (4 message types)
Hub to terminal (5 message types)
Frontend lock handling
Hub transport
heldTableLocks: Set<number>— Table IDs currently locked by this terminalpendingTableLocks: Map<number, { resolve, timer }>— In-flight acquire requests- Separate heartbeat timer: sends
TABLE_LOCK_HEARTBEATfor all held locks every 15s
acquireTableLock() and forceAcquireTableLock() return a Promise<TableLockResult> that resolves on TABLE_LOCK_GRANTED or TABLE_LOCK_DENIED, or returns { granted: false } on timeout (fail-closed).
Direct transport
In direct mode, table locking is disabled. AllacquireTableLock() calls return { granted: true } immediately.
TableLockGuard
Renderless component on the order page (realtime/TableLockGuard.tsx):
FloorPlanScreen
- Double-tap guard —
pendingLockTableRefcancels stale in-flight acquires on new table tap - Acquire before navigate — Lock acquired, then navigate to order page on success
- TableLockedDialog — Shown on deny with the holder’s name. Managers see “Take Over”
- Cancel cleanup — Dismissing modals releases any pending lock
- Transfer mode — Destination table lock acquired before transfer navigation
- Lock state subscription — Locked tables show amber label text on the floor plan