loklflow
A system for running a whole restaurant: order taking, kitchen display, floor plan, inventory, cash close-out and per-role permissions.
Architecture
The server — NestJS and PostgreSQL — runs inside the premises on a UPS, and the till, the waiters and the kitchen talk to it over the internal WiFi. The cloud only takes the remote dashboard and the backup.
- One monorepo with two applications: the API in NestJS and the interface in Next.js.
- Orders and table state travel live over WebSocket, with no screen reload.
- The uuid the device generates is the primary key of the order and of its lines, so replaying an operation returns the state instead of duplicating the bill.
- With the API down, the three operating screens keep working against a queue on the device itself. Taking payment and opening a till shift still require the network, on purpose.
Backend NestJS 11 · TypeORM · Socket.ioData PostgreSQL 16 · versioned migrationsFrontend Next.js 16 · React 19 · Tailwind 4 · ZustandTesting Jest · Vitest · supertestInfrastructure Docker · GitHub Actions
What I take away
In a system that has to survive without a network, the half you cannot add afterwards is the server half. Having a replayed operation return the state instead of duplicating the charge had to exist before the queue on the device did: you cannot get there in the other order, because by then the double charges are already in the database.
The full case — the decisions, the evidence and what is missing
In a restaurant in Panama City the internet goes down. Not always and not for long, but it goes down at 8:40 on a Friday with fourteen tables occupied. A cloud-hosted point of sale stops taking payment at that moment; one hosted on the premises does not.
So this system’s server runs inside the establishment, on a UPS, and the clients — till, waiters, kitchen — talk to it over the internal WiFi. The cloud is left with the two things that genuinely need it: the owner’s remote dashboard and the backup. That topology decision explains almost every other one, including why the instance I run at loklflow.juank.tech is an exception to it and not the way the system is meant to be installed.
The server goes on the premises, not in the cloud
What it costA real install means a machine in a kitchen; the remote instance is the exception
- Situation
Taking an order and charging for it are the two operations that cannot depend on the internet provider having a good day.
- The decision
The server — NestJS and PostgreSQL — runs on the premises, on a UPS, and the devices connect over the internal network. The cloud only receives the remote dashboard and the backup.
- What I rejected
A cloud API with the venue’s network as a thin client, which is the default architecture and the one I would have picked out of habit. It loses at the only moment that matters: when the link drops, a thin client has nothing to do. I also rejected a native app per device, which would solve the same thing in exchange for three codebases and an app store.
- The consequence
The internet stops being a dependency for operating. The cost is large and worth stating: a real deployment means putting a machine in a kitchen, so what I keep running at loklflow.juank.tech is a remote instance, an exception made to show the system. And that exception is the only deployment of this system that depends on its link for everything and not just for taking payment.
The order number comes from a Postgres sequence
What it costNumbers can jump if a transaction aborts
- Situation
Every order carries a short readable number that the waiter calls out in the kitchen. It has to be unique and must not jump around oddly.
- What was breaking
It was computed as
MAX(order_number) + 1. Eight simultaneous creations returned 500: several read the same maximum, the first won, and the others’ retries collided with each other again. The migration’s own comment says that with two waiters taking orders at once, the order was lost.- The decision
A Postgres sequence queried with
nextval. Numbering is atomic and there is no retry code.- What I rejected
A
SELECT … FOR UPDATEover the last order, which serialises correctly and would turn every order creation into a wait behind the previous one — precisely at peak hour, which is when the problem shows up. I did not measure it: I rejected it for what I expect it to do, not for a number. And a retry with random backoff over theMAX, which lowers the probability without removing it.- The consequence
Eight simultaneous creations produce eight distinct numbers, and an integration test asserts it. The sequence was declared deliberately without
SET DEFAULTand withoutOWNED BY, so TypeORM’s automatic synchronisation in development cannot fight it. The cost is that numbers can jump if a transaction aborts:nextvalis not returned. For an operational counter that is acceptable; for a tax receipt number it would not be.
The device's uuid is the order's primary key
What it costAn extra read before every write; the client decides the id
- Situation
If a device is going to be able to resend an order that may already have arrived, the server has to be able to recognise it as the same order and not as a new one.
- The decision
The device generates the uuid and that uuid is the primary key of the order and of its line items. Before writing, the service looks up whether it already exists and, if it does, returns the existing one.
- What I rejected
A server-generated id plus a
client_request_idcolumn, which works but forces id translation at sync time. And — this is the important one — I rejected catching the unique-key violation and treating it as “already existed”. With TypeORM that does not work:save()on an entity with an existing primary key does not fail, it does anUPDATE. It would have silently overwritten the order and, through the cascade, its line items too. The exception I was waiting for never arrives.- The consequence
Five concurrent replays of the same uuid leave exactly one row, and a test counts it with
count(*). The cost is an extra read before every write, and accepting that the client decides the identifier — which is right here and would be unacceptable if the id had to be secret or unpredictable.
Payment accepts an idempotency key, unique but nullable
What it costAn opt-in guarantee per client, not a universal one
- Situation
A partial payment gets recorded several times against the same tab: someone pays 40 of 100, then 60. A resend cannot turn into an extra charge.
- What was breaking
The comment on the migration that fixed it puts it plainly. It is in Spanish because it comes from a real file, and it is quoted here untranslated for that reason:
Hoy un pago completo repetido se rechaza de rebote —la cuenta ya está cerrada—, pero un pago parcial repetido pasa entero y suma: dos clics dejan 60 cobrados sobre una cuenta de 100. Con una cola de reintentos, sistemático.
apps/api/src/database/migrations/1785449564000-OrderNumberSequenceAndIdempotency.ts:14-16 A repeated full payment bounces off the closed tab, but a repeated partial payment goes straight through and adds up: two clicks leave 60 charged against a tab of 100. That last line is the one that matters for the section further down: with an automatic retry queue, the double charge stops being an accident and becomes the normal behaviour.
- The decision
payments.client_request_id, with a unique index. A client that wants the guarantee sends it; one that does not, does not.- What I rejected
Making the column mandatory, which is the clean answer and breaks the wired point of sale, which sends no key because it does not need one. And a second idempotency-key table, which is the textbook solution and adds a write and a table for a case the index already solves.
- The consequence
The index is unique but allows nulls, because Postgres permits multiple nulls in a unique index. That gives an opt-in guarantee per client without a second table. And there is a test asserting that the un-keyed case does produce two distinct charges, because that is the correct behaviour and not a gap: two genuine partial payments are two payments.
One open till shift per cashier, guaranteed by the index
What it costThe rule lives in two places and the lower one wins
- Situation
A cashier opens a shift, takes payments during their run, and closes with a cash count. That entire count depends on there being exactly one open shift.
- What was breaking
The check lived in the service: read whether there is an open shift, and if not, create one. Two tabs or a double click open two shifts. Payments split across both and neither balances.
- The decision
A partial unique index: unique on the user, but only
WHERE status = 'open'. The index violation is translated into the same 400 the normal check would return.- What I rejected
Leaving it in the service with a transaction and a row lock on the user, which works and puts the guarantee in the code that reads it rather than in the structure that holds it. A full unique index would not do either: it would prevent the next day’s second shift.
- The consequence
The race stopped existing, and because the violation is translated it never surfaces as a 500 — the cashier sees the same clear message as before. The service check stays for the normal case; the index is the net underneath. The cost is that the rule now lives in two places and you have to remember that the lower one is authoritative.
Sessions for a dining room, not for an office
Access control is deny-by-default: JwtAuthGuard and PermissionsGuard are registered as global guards, so every endpoint needs an explicit @RequirePermissions('module:action') or a @Public(). That is 30 permissions across 5 roles, with per-role discount ceilings of 100, 50, 10, 0 and 0%.
There are two ways in, because a dining room is not an office: email and password for administration, and a four-digit PIN for floor staff, who are not going to type a long password into a tablet every time. The lifetimes differ on purpose — 15 minutes of access and 7 days of refresh for email, 4 hours and 12 hours for the PIN. And refresh propagates the original login method: pinning it to 'email' downgraded a PIN session to 15 minutes and falsified the audit log, which was the more serious of the two problems. Every refresh issues a unique identifier, because two logins in the same second produced a byte-identical token against a unique column.
The two halves of offline
A system that survives losing the network has two halves. The first is that the server knows how to receive the same operation twice without charging for it twice. The second is that the client knows how to store the operation while there is no server and resend it when it comes back. They were built in that order on purpose, and the order is the decision.
The first half is the one you cannot add later. An order’s primary key is the uuid the device generates, so a resend does not have to translate identifiers: it is the same row. Payments accept an idempotency key with a unique index. The order number comes from a sequence. Eight simultaneous creations produce eight distinct numbers; five replays of the same uuid produce one single row. Both are asserted by integration tests that run against a real PostgreSQL with the migrations applied from zero.
The second half was built on top of it. There is a queue of operations in IndexedDB, ordered per bill, with backing-off retries and a cross-tab lock so two tabs never resend the same thing. The three operating surfaces — floor, order pad and kitchen — read from the device itself and keep working with the API down. What does not enter the queue is money: taking payment and opening a till shift still require the network, on purpose. A queued charge is resent with nobody watching the result, and in a till shift that is a cash count that does not add up; I would rather the cashier sees “no network” than believes the charge went through.
A resend queue built on top of a server that is not idempotent fixes nothing: it turns an accidental double click into a systematic double charge. That is exactly the bug the idempotency migration documents, and that migration’s comment closes it in six words — «con una cola de reintentos, sistemático». Building the queue first would have produced a demo that works without WiFi and overcharges.
The server contract was fixed first and the client queue leaned on it. You cannot get there in the other order: by the time you want the contract, the double charges are already in the database.


How you would check
The concurrency claims are the ones worth running, because they are the only ones you cannot verify by reading. pnpm --filter=api test:int boots the real application against a real PostgreSQL and applies the migrations from zero on every run — which also verifies that the schema builds by itself. Inside there is a test that fires eight simultaneous order creations and asserts the numbers are distinct, another that fires five replays of the same uuid and asserts count(*) = 1, and another that fires three simultaneous shift openings and asserts exactly one stays open.
On top of that, two Playwright suites run in a real browser — eight files under apps/web/e2e/ — and two of them go straight at this: offline-sync.spec.ts and service-worker.spec.ts.
The rest is readable: the global guards are in the root module, the 30 permissions in a seed file, and the partial index in its migration.
Money still requires the network
The offline queue covers service — taking an order, advancing it, seeing it in the kitchen — and leaves out taking payment and opening a till shift. That is deliberate and not an outstanding item: a queued charge is resent with nobody watching the result, and in a till shift that is a cash count that does not add up.
What it would cost to close is not the queue, which already exists. It is deciding what the system does when a queued charge fails on resend and the customer has already left: who absorbs the difference and who finds out. I do not have that answer, and until I do, taking payment keeps asking for the network.
Redis is declared and unused
It is ioredis, in the API’s production dependencies, a service in docker-compose.yml, and a config file the root module loads, and there is not one line that connects to it. The rate limiter uses the in-memory store, which is the right call while there is a single API process inside the venue. It is reserved for the Socket.io adapter and for the limiter’s store the day there is more than one.
I say so because a compose file with Redis in it implies an architecture that does not exist yet, and I would rather say it myself than have someone notice it reading the repository.
It is single-establishment
There is no tenant_id in any table, and the business configuration is a single-row table. The instance running today serves one venue, which fits the topology: if the server is inside the restaurant, there are not two restaurants on that server.
Adding multi-tenancy later touches all 30 tables and touches every query. It is a decision I have not made, not one I have made in favour of.
There are no background jobs
The shift cash count and the reports are computed when someone asks for them. With one venue and one shift per cashier that is correct; with twenty venues it would stop being. Nothing runs outside the cycle of a request: no job queue, no cron, no separate process.