Writing / Baam Crest
Writing a hotel PMS for hotels with no internet
Most hotel software assumes a connection. Baam Crest assumes the opposite: the network is a courier that shows up when it feels like it, and the hotel still has to check guests in.
The hotels I built this for are in northern Pakistan. Power flickers. The uplink is a mobile modem that can vanish for an afternoon, or a week if a road washes out. A front desk in that environment cannot wait for a spinner. If a family is standing at the counter at 11pm, the software has one job: take the booking.
Every cloud PMS I looked at failed that test. They degrade from "working" to "read-only" to "blank screen" as connectivity drops. So Baam Crest is built the other way round — a Rust and Tauri desktop app with a local SQLite database that is the source of truth, and a cloud that is a replica the local machine posts to when it can.
Local first is a data model decision, not a caching decision
The mistake people make with "offline support" is treating it as a cache layer bolted onto an online app. You queue requests, replay them later, and discover that half your assumptions were server-shaped. The server assigned your IDs. The server enforced your uniqueness. The server decided what "current" meant. Take it away and the app has no opinions left.
So the first decision was that every record’s identity is created on the device. IDs are strings generated locally, not auto-increment integers handed out by the database. A booking created during an outage has its final, permanent ID from the moment it exists. Nothing gets renumbered when it reaches the cloud, which means the printed receipt, the local reference and the cloud row all agree — and the same record arriving twice is a no-op instead of a duplicate.
That one choice removes an entire category of reconciliation bug. The uncomfortable trade is that uniqueness the server would normally guarantee now has to be designed for. There is no central authority to arbitrate, so the schema has to make double-booking a room structurally awkward rather than merely validated.
The outbox is a column, not a queue
There is no separate queue table of pending operations. Every syncable table carries a sync_status flag, and every local write sets it to 0 — dirty. A sync pass is then a handful of plain queries: select the dirty bookings, the dirty sales, the dirty expenses, the dirty products, the dirty settings; post them in one request; mark them clean on a successful acknowledgement.
This is less clever than a command log and much better in practice. A command log replays intent, which means the replay can fail in ways the original did not, and you end up writing a small interpreter to figure out what a three-day-old intent means today. A dirty flag replays state. If a booking was edited four times during the outage, it syncs once, in its final form. If the sync fails, nothing was cleared, so the next attempt picks it up unchanged — the retry is free, and it’s idempotent because the ID is stable.
Deletes work the same way. Nothing is hard-deleted; rows get a deleted_at timestamp and sync as ordinary dirty records. A tombstone is a fact that can travel. An absence isn’t.
Pulling with a cursor, and an escape hatch
The other direction is a single stored timestamp — last_pull — kept in the app’s own settings table. Each sync asks the cloud for everything changed since that timestamp, applies it, and advances the cursor. Small, incremental, cheap enough to run over a modem that drops halfway through.
Cursors do drift, though. A restored backup, a half-applied response, a schema change — and the local copy is quietly missing rows nobody notices until a report looks wrong. So there is a deliberate escape hatch: a full resync that rewinds the cursor to 1970-01-01 and pulls the entire dataset again. It’s one button. Being able to say "reset it and pull everything" without a support session is worth more in the field than any amount of clever incremental repair, and I’ve come to think every sync system should ship the boring recovery path on day one.
Derived state is computed locally, every time
A hotel has state that changes because time passed, not because anyone clicked. A guest whose checkout date is yesterday has left. A room with a booking covering today is occupied. If that logic lives in a cloud job, then an offline hotel wakes up to yesterday’s occupancy map — the most dangerous possible failure, because the screen looks confident and is wrong.
So Baam Crest recomputes it locally on every pass: bookings past their checkout roll to checked-out, bookings starting today roll to checked-in, and room availability is derived from whether any live booking covers today rather than stored as an independently editable field. The device never needs the network to know what today looks like.
The subtlety is that these derived updates are themselves writes, so they mark rows dirty and propagate. That’s correct — but it’s also how you accidentally build an infinite loop, where a normalisation rewrites a field to the value it already had, marks it dirty, syncs it, and does it again forever. Each of those updates is guarded to only fire when the value would actually change. That guard is one line of SQL and it is doing an enormous amount of work.
Devices, not users, hold the credential
Authentication is per-device: an activation issues a device key plus a fingerprint, and the desktop app carries both. Staff log in against the local database, so a shift change works with no connectivity at all — the thing that must never require the network is the thing standing between a person and their job.
It also gives a clean revocation story. A machine that leaves the property gets its key rejected at the next sync, and the app treats that specific rejection as a distinct state rather than an ordinary network error. The difference matters: "I couldn’t reach the server" means keep working and try later, while "the server refuses to know you" means stop.
What I’d tell anyone building this
Offline-first isn’t a feature you add; it’s a decision about where truth lives, and it has to be made before the first table. Once you’ve made it, most of the difficulty moves out of the sync code and into the schema — which is a good trade, because schemas are easier to reason about than distributed retry logic.
The rest is discipline about failure. Assume every sync dies mid-flight. Assume the cursor is wrong. Assume someone restores an old backup onto the front desk machine. Then make the recovery path something a hotel manager can perform without calling you, because in a valley with no signal, they can’t.
That’s the whole thesis of Baam Crest, really. Software written for the conditions that actually exist, rather than the conditions the tooling assumes.
Baam Crest is one of three platforms I build and run. If you have a system that has to work where the network doesn’t, I’d like to hear about it.
Start a conversation ↗ Visit Baam Crest ↗
Available — contract & retainer