> ## 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.

# Table locking

> Exclusive table-level access control, heartbeats, manager takeover, and floor plan UI.

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.

## How it works

### Acquiring a lock

1. Terminal taps a table on the floor plan
2. Frontend sends `TABLE_LOCK_ACQUIRE { tableId }` via WebSocket
3. 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
4. On grant, hub broadcasts `TABLE_LOCKS_STATE` to all other terminals

### Maintaining a lock

* Locks expire after **45 seconds** (`TABLE_LOCK_TTL_MS`) without renewal
* Terminals send `TABLE_LOCK_HEARTBEAT` every **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 `TableLockGuard` component releases the lock automatically on unmount

### Lock expiry (tick)

The hub runs a periodic tick every 15 seconds (matching the heartbeat interval):

1. Query all locks where `expires_at <= now`
2. For each expired lock:
   * Send `TABLE_LOCK_REVOKED { tableId, reason: "Lock expired" }` to the holding terminal
   * Delete the lock from storage

### Disconnect grace period

When a terminal's WebSocket disconnects:

1. Hub shortens lock expiry to `now + 10s` (grace period)
2. If the terminal reconnects within 10 seconds, it can re-acquire
3. If grace period expires, the normal tick process revokes the lock

```
  Terminal                              Hub
     │                                   │
     ├──── WS Disconnect ───────────────►│  (unexpected close)
     │                                   │
     │                                   │  lock.expires_at = now + 10s
     │                                   │
     │      (reconnects within 10s)      │
     ├──── WS Connect ──────────────────►│
     │◄─── AUTH_OK ──────────────────────┤
     │◄─── TABLE_LOCKS_STATE ────────────┤  (current lock state)
     │                                   │
     │      (TableLockGuard re-acquires) │
     ├──── TABLE_LOCK_ACQUIRE { id } ───►│
     │◄─── TABLE_LOCK_GRANTED { id } ────┤  resume editing
```

## Manager override (force acquire)

Managers can forcibly take over a table locked by another terminal:

<Steps>
  <Step title="Request">
    Manager terminal sends `TABLE_LOCK_FORCE_ACQUIRE { tableId }`
  </Step>

  <Step title="Validate">
    Hub validates that the requesting terminal has `manager` or `group_pos_manager` role (server-side check)
  </Step>

  <Step title="Revoke current holder">
    Hub sends `TABLE_LOCK_REVOKED { tableId, reason: "Manager override" }` to the current holder
  </Step>

  <Step title="Grant to manager">
    Manager receives `TABLE_LOCK_GRANTED { tableId }`. Hub broadcasts updated `TABLE_LOCKS_STATE`
  </Step>
</Steps>

<Warning>
  The handler is the single authority for revoke notifications during force-acquire. The `onRevoke` callback is only for async events (TTL tick expiry) — never both, to prevent duplicate notifications.
</Warning>

Non-managers see a "Table Locked" dialog with only a "Dismiss" button. Managers see an additional "Take Over" button.

## Protocol 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 terminal
* `pendingTableLocks: Map<number, { resolve, timer }>` — In-flight acquire requests
* Separate heartbeat timer: sends `TABLE_LOCK_HEARTBEAT` for 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**. All `acquireTableLock()` 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** — `pendingLockTableRef` cancels 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`           |
