Table locks enforce exclusive access at the table level. Before a terminal can open or edit a table’s order, it must hold the lock for that table. Table locks operate independently from order leases. A lease governs write access to a specific order (snapshot level); a table lock governs who may navigate to a table at all (floor plan level). In practice, a terminal acquires the table lock first (on the floor plan), then acquires the order lease when editing.Documentation Index
Fetch the complete documentation index at: https://docs.laportenard.com/llms.txt
Use this file to discover all available pages before exploring further.
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:Validate
Hub validates that the requesting terminal has
manager or group_pos_manager role (server-side check)Revoke current holder
Hub sends
TABLE_LOCK_REVOKED { tableId, reason: "Manager override" } to the current holderProtocol messages
Terminal to hub (4 message types)
| Type | Purpose | Key fields |
|---|---|---|
TABLE_LOCK_ACQUIRE | Request table lock | tableId |
TABLE_LOCK_RELEASE | Release table lock | tableId |
TABLE_LOCK_HEARTBEAT | Keep lock alive | tableId |
TABLE_LOCK_FORCE_ACQUIRE | Manager override | tableId |
Hub to terminal (5 message types)
| Type | Purpose | Key fields |
|---|---|---|
TABLE_LOCK_GRANTED | Lock acquired | tableId |
TABLE_LOCK_DENIED | Lock rejected | tableId, lockedBy: { userId, userName } |
TABLE_LOCK_RELEASED | Lock released by holder | tableId |
TABLE_LOCK_REVOKED | Lock taken away | tableId, reason |
TABLE_LOCKS_STATE | Full lock snapshot | locks: [{ tableId, terminalId, userId, userName }] |
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):
| Responsibility | How |
|---|---|
| Release on unmount | useEffect cleanup calls releaseTableLock(tableId) |
| Re-acquire on reconnect | onStateChange("connected") triggers acquireTableLock(tableId) |
| Redirect on revocation | onTableLockRevoked(tableId) calls router.replace("/") |
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
Table locks vs order leases
| Aspect | Table lock | Order lease |
|---|---|---|
| Scope | Table (floor plan level) | Order (snapshot level) |
| When acquired | Tapping a table on the floor plan | Opening an order for editing |
| TTL | 45s | 60s |
| Heartbeat | 15s | 20s |
| Force acquire | Requires manager role (server-enforced) | force: true flag in LEASE_ACQUIRE |
| Fail-closed timeout | Yes — deny on timeout | No — deny on timeout |
| Offline behavior | Disabled (returns granted: true) | Denied (returns granted: false) |
Timing summary
| Parameter | Value | Defined in |
|---|---|---|
| Lock TTL | 45s | TABLE_LOCK_TTL_MS in constants.ts |
| Heartbeat interval | 15s | TABLE_LOCK_HEARTBEAT_MS in constants.ts |
| Disconnect grace | 10s | GRACE_MS in constants.ts (shared with leases) |
| Acquire timeout (frontend) | 10s | LEASE_TIMEOUT_MS in hubTransport.ts |
| Tick interval (hub) | 15s | TABLE_LOCK_HEARTBEAT_MS in index.ts |