SUKI — Case Study
A ledger and stock app for a sari-sari store in Barangay Daro, Dumaguete. One shopkeeper, forty-odd neighbours, run from a counter at the front of a house. Suki means a regular customer — the standing relationship of trust between a shop and the people around it. The ledger is the record of that trust.
Offline-first, because in a Philippine barangay that is not an architectural preference. The power goes out, the signal drops to one bar, and the shop still has to sell a sachet of shampoo and write down who owes what.
- Live: https://suki.kimnejudne.dev
- Stack: Vite 6 · React 19 · TypeScript strict · Dexie/IndexedDB · vite-plugin-pwa · NestJS 11 · Drizzle · Postgres 17 · npm workspaces
- Brief:
sarisari-brief.md— written before this pass, gap list and all
Where this started, plainly
The frontend came from an AI app builder (Emergent Labs), from a prompt I wrote. That is how every spoke in this portfolio starts, and it is stated plainly here rather than left to be discovered.
It was the best scaffold of the five, and the reason is worth naming because it
is the entire thesis of this pass: the prompt specified a seam, and the seam
held. Every fixture import in the app sat inside src/lib/api/. No component
fetched its own data. The operation shape was exactly as specified — { id, type, payload, createdAt, status }, with the id generated by
crypto.randomUUID() at the call site.
That one rule is why replacing a fake queue with a real offline sync engine was a one-directory change. Twelve dependencies, not sixty-one. No component library, no icon library, no dead code.
What it did not have: a backend, a database, durability of any kind, a service worker, a lockfile, or a single test. The queue was an array in memory that marked itself synced on a timer. Reload the page and the day’s takings were gone.
The distance between that and this is the case study.
1. The claim was “offline-first”. It wasn’t.
The scaffold looked offline-first. There was a pending badge, a sync sheet, a connectivity toggle. All of it was theatre over an array in memory.
The thing about durability is that it is invisible until it isn’t. An app that loses your data on reload looks identical to one that doesn’t, right up until the moment it matters — and the moment it matters is a phone dying mid- afternoon with sixty pesos of unrecorded utang on it.
Three things had to become real:
The log had to survive the process. Operations moved to IndexedDB via Dexie. The in-memory array stayed, but demoted to a cache of the durable copy, read synchronously for components that render on every keystroke. Dexie is written first, always.
The writes had to await durability. recordCashSale and its four siblings
became async and now await the IndexedDB commit before returning, so the UI
cannot confirm a sale that isn’t on disk. Six call sites across three screens —
small enough to do properly rather than fire-and-forget.
The shell had to boot with no network. Without a service worker,
“offline-first” was only true after a warm start: the queue survived losing a
connection but not opening the app without one. vite-plugin-pwa precaches the
shell; the API is excluded from the navigation fallback, because a cached push
response would tell a shopkeeper her sales synced when they had not.
Verified by killing the web server outright and reloading. The app rendered from cache and recorded a sale.
2. Idempotency is the whole design, so it is the load-bearing test
Offline sync has one genuinely hard question: did that request actually land? A phone flushes its queue, the request times out, and the phone has no way to distinguish “the server never got it” from “the server got it and the response died on the way back”.
The answer here is to never have to know. Operations carry a client-generated UUID, and that UUID is the server’s primary key. Pushing the same batch again is a no-op. A client that is unsure simply sends again.
.onConflictDoNothing({ target: operations.id })
The whole story is one clause, which is exactly why it needed a test. A design that rests on a single line is a design that breaks silently when someone edits that line.
it('changes nothing when the same batch is pushed three times')
It asserts, after each replay, that the log length, the cursor, the seq
column and the derived balance are all identical to after the first push.
seq is in there deliberately: a replay that appended rows with new sequence
numbers would leave every other device pulling duplicates forever, even with
the ids deduplicated.
Already-stored ids come back as accepted, not as a conflict. From the
client’s side, “we stored it” and “we already had it” mean the same thing —
stop sending. Reporting a 409 would strand the row as pending forever.
Proving the test could fail
A passing test proves nothing until you have watched it fail. I broke the conflict target so replays would insert duplicates:
● push replay › changes nothing when the same batch is pushed three times
● push replay › accepts a batch that overlaps one already sent
● push replay › preserves server order across a replay from a second device
Tests: 3 failed, 21 passed
Exactly the three replay tests, and nothing else. Restored, green again. The same exercise on the client suite: draining every row instead of only pending ones reddened exactly the two re-send tests.
This habit came from PokéTrack, where I found a suite of 37 check() calls
that contained no assertions at all. It reported “8 passed” with a known-true
condition inverted. A suite that cannot fail is worse than no suite, because it
is actively reassuring.
3. What the sums buy you, and what they don’t
A customer’s balance is never stored. It is the sum of their ledger entries, recomputed on every read — purchases add, payments subtract.
That is not a stylistic choice. Sums commute, and that single property is what makes an unreliable connection survivable: an operation that arrives late, or twice, or out of order still produces the same number. Two phones behind the same counter, both offline, will have their operations interleaved by the server in an order neither of them chose, and it does not matter.
Asserted over every permutation rather than a couple of hand-picked shuffles:
it('gives the same balance for every possible ordering', () => {
const permutations = permute(utang); // all 24
expect([...new Set(permutations.map(deriveBalance))]).toEqual([125]);
});
The same property holds for shelf counts, and is tested the same way.
This is also why there are no CRDTs here. There is one writer, and the resolution rule is “append both, the balance is the sum”. There is nothing to merge. A CRDT that invented a balance would be worse than useless — a merge must never invent a number somebody owes.
And the property is not universal, which the tests say out loud: the buy price is last-write-wins, deliberately not order-independent, which is why it is derived separately rather than folded in as a delta.
I also passed on ElectricSQL, PowerSync and Triplit. Each adds a sync service to a 1.9GB droplet already running five apps, and each hides the exact engineering this spoke exists to demonstrate.
4. One source of truth for the arithmetic
packages/domain is a workspace both apps import. The balance derivation, the
tingi conversion, the money rules and the operation projections live there and
nowhere else.
The reason is specific. If the phone projected a credit sale into a ledger entry one way and the server another, the two would disagree about what somebody owes while both believed they were right, and nothing in the system could tell you which. One function, both sides.
Tingi is the business model, and it had been living in a useMemo inside a
React component. Stock is bought in packs and sold by the piece: a ream of 12
sachets at ₱96, sold at ₱10 each. A view owned a pricing rule and the API had
nowhere to get the same answer from.
The awkward part is the arithmetic. A ream of 12 at ₱96 is exactly ₱8 a sachet.
A case of 24 at ₱250 is ₱10.41… and there is no such coin. Flooring understates
cost, which overstates margin — so marginPerPack is the honest figure and
marginPerPiece is the convenient one, and both are shown because a shopkeeper
thinks in both. The tests assert the discrepancy rather than papering over it:
expect(marginPerPack(sardines)).toBeLessThan(marginPerPiece(sardines) * 24);
Money is whole pesos as integers throughout, formatted in exactly one place.
formatPeso throws on a non-integer rather than rounding, because a fractional
peso reaching a screen means an invariant broke several layers upstream and
rounding it would hide the break while producing a number that doesn’t
reconcile.
5. The server does not trust the client
Not because the shopkeeper is dishonest, but because an operation can sit in a queue on a phone for three days across an app update. The thing that finally arrives may have been written by a version of the app that no longer exists.
So every operation is validated server-side, and sale totals are recomputed rather than trusted:
if (computed !== payload.total) {
return `Sale total ${payload.total} does not match its lines (${computed}).`;
}
That total is the number that ends up in somebody’s utang.
Rejections are permanent and are recorded with a reason. The client marks the
row failed rather than deleting it, and shows it — a sale that vanished
without explanation is worse than one that failed loudly, because a shopkeeper
can re-key a failure she can see. A rejected operation is never retried, or the
queue would never drain.
And a malformed row must not cost the shopkeeper the good sales batched alongside it:
it('rejects the bad operation without dropping the good ones beside it')
The sync endpoint had no auth at all
The scaffold had no server, so this is a gap I introduced and then closed. A sync endpoint that accepts anonymous writes is one anyone on the internet can append to, and “append-only log” makes that permanent.
There is now a device-key guard on both routes, compared with timingSafeEqual,
with health left public so a probe doesn’t need the shop key. The e2e suite
sweeps it: 401 with no key, 401 with a wrong key, 401 with a key that is a
prefix of the real one (which asserts the length check exists rather than
assuming it), 200 with the right one.
And I have documented what that key is not. It ships inside the JS bundle, so anyone who opens devtools has it. It is a coarse gate plus rate limiting — it keeps drive-by scanners off the log — and it is not authentication. SUKI has no accounts on purpose: a sign-in screen between a shopkeeper and a sale she is ringing up in front of a customer is the wrong trade. Calling a bundled constant a password would have been worse than saying plainly what it is.
6. Bugs found by actually running it
Every one of these was invisible in the code and obvious the moment something real was pointed at it.
A build reporting success while measuring the wrong thing. I started the
preview server, curled localhost:3000, got a 200, and moved on. It was a
leftover FORME next-server holding the port; SUKI’s preview had failed to
bind and I was verifying a different app entirely. This is the recurring theme
of this whole portfolio: a system reporting success is not evidence it is
doing anything.
The backup script refused the first backup it ever took. Its size floor was 4096 bytes; a complete dump — three tables, a row, valid gzip — came to 1707. At a three-table scale a byte count simply cannot distinguish a schema-only dump from a full one; the gap between them is smaller than gzip’s variance. A floor set high enough to feel reassuring silently throws away good backups, and you find out on the day you need one. It is now a coarse 512-byte floor for genuinely broken output, with structural checks doing the real work — table count, and a COPY block whenever the database has rows. Then I restored a dump into a scratch database and counted the rows, because a backup that has never been restored is a hypothesis.
Two copies of Vite. vite-plugin-pwa’s peer range spans Vite 3 through 8,
so npm satisfied it by installing a second Vite 7 at the workspace root while
the app kept its pinned 6.3.4 nested. Two Vites means two sets of plugin types,
and tsc -b failed on a plugin array that was structurally identical but
nominally from the wrong node_modules. The React and Tailwind plugins refuse
Vite 7, so 6.3.4 was the correct single version — an override, plus a clean
reinstall, because npm would not rewrite a lockfile entry it already had.
-0. The domain suite caught stockDeltaFromOperation returning negative
zero for a sale with no matching line. It sums correctly, so it would never
have caused a wrong balance — but it renders as “-0” and breaks Object.is
comparisons. Fixed at the source rather than by weakening the test.
A test suite that would have tested a different server. The global prefix,
ValidationPipe, helmet and CORS all lived inside bootstrap() in main.ts.
An e2e suite building its own app from AppModule would have got none of them
— exercising unprefixed routes with validation switched off, and reporting
green on a server that does not exist in production. Extracted to a shared
configureApp(). This is the same class of mistake as FORME’s suite seeding one
database and asserting against another, which only surfaced by accident.
7. The Lista, at 360 pixels
Two of the audit gaps were about text losing the wrong half of itself on a cheap phone. Both were found by measuring in a browser, not by reading a stylesheet.
The Till. Item names truncated mid-word, so “Lucky Me Pancit Canton Chilimansi” and “…Canton Original” rendered as the same string — two different products, identical on the screen used a hundred times a day. The monogram moved above the name instead of beside it, giving the name the full cell width and three lines.
The Lista. The subtitle clipped to Purok Mabini · 31d since b…, losing the
one word that says what the number counts. A payment age with no unit is just a
number.
Letting it wrap fixed the clipping but made row heights uneven, in a list whose entire job is being scanned. So the clipping is now aimed rather than left to whichever half ran out of room first: the purok truncates and the age never does. The purok repeats down the column and is recoverable from four values; the age is unique to the row.
Measured at a real 360px shell: uniform 65px rows, 31d since bayad intact on
every one.
The tap targets. /stock filter buttons had minHeight set but not
minWidth, so “All” and “Out” measured 36px across — under the 48px minimum on
the axis a thumb actually misses.
And the copy. The unlock screen read “Hint for the design review: 1234”. The PIN being visible is correct — this is a walkable demo — but it has to read as a deliberate affordance for a shopkeeper, not a note to whoever is marking the work. It now reads “Demo shop — the PIN is 1234”.
The one the audit missed, found by using it
Everything above was found by measuring. This one was found by selling.
The sale in progress used to unroll as a fixed tray over the Till, and the
shelf behind it was cleared by paddingBottom: 220. Those two numbers were
never checked against each other, and they cannot agree: the tray is a list
capped at 32dvh, plus a total block, plus two 56px buttons. On a 640px phone
that is comfortably past 300px, so the bottom row of the shelf sat under the
tray — and the more you had bought, the more of the shop you could not reach.
The failure mode is worth naming, because it is the same one as
prefers-reduced-motion being declared rather than enforced: a constant
standing in for a measurement. 220 was a plausible number someone typed while
looking at a screen with three items in the tray. Nothing would ever tell it it
was wrong.
The fix is a cart page. The lines moved off the Till entirely; what stays is
one row of known height — the running total, and the way in. The Till clears it
with calc(var(--sale-bar-height) + var(--space-4)), the same token that sets
the bar, so the two cannot drift. On the cart page the action block is sticky
rather than fixed, which is the structural reason the bug cannot recur there:
a sticky element occupies flow, so a list of any length ends above it.
Three things fell out of the move that were not the point of it:
- The sale outlived its screen. Once the lines were on their own route,
component state could not hold them, so the sale lifted to a provider above
both screens — and once it was in a provider, persisting the draft to
IndexedDB was fifteen lines. That matters on the target device: the phone has
the shop’s Facebook page open in another tab, and backgrounding a PWA on a
cheap Android is enough to have the tab evicted. Losing a recorded sale was
already impossible; losing eight items you had just walked the shelf for was
not, and nothing was protecting it. The draft write is deliberately
fire-and-forget, unlike every write in
sales.ts— a draft is not a promise. void recordCashSale(...). The tray fired the commit and dropped the promise, resetting the UI while the IndexedDB write was still in flight. The comment at the top ofsales.tsdescribes closing exactly that window; its only caller had reopened it. The cart page awaits.- Clear moved away from Bayad. In the tray they shared a screen edge, a thumb’s width apart. Throwing away a full sale and recording one should not be neighbours.
Verified in pixels rather than by eye, at 360×640 with touch emulation, over
CDP: the last shelf cell clears the bar by 32px and its bottom-left corner
hit-tests to the cell rather than to the bar; the cart’s last line clears the
sticky block by 0px and no more; the draft survives a reload; Bayad lands back
on the Till with the total reported, cash-sale on disk and the draft emptied.
Nineteen assertions, and the two that exist for the original bug were confirmed
to fail — one by restoring the old padding, one by turning the sticky block back
into a fixed one.
8. Designed for a droplet, not a platform
Everything runs on one DigitalOcean droplet shared with five other apps. Memory, not CPU, is the scarce resource.
Two deploy shapes, one origin. The web app is static and rsynced to
/var/www/suki; the API and Postgres are a compose stack with the API bound to
loopback. A Node process whose entire job is returning files it already has
would cost ~60MB of RSS this box cannot spare.
They share an origin deliberately. A service worker can only control pages on its own origin, and a separate API host would put a CORS preflight in front of the one request path that runs on a phone with one bar of signal.
The nginx caching rules are the load-bearing part of the deploy. A long
max-age on sw.js is not a slow site — it is a bricked deploy. Every visitor
keeps running the version they first installed, forever, and there is no way to
reach them: the old worker serves the old precache manifest, which serves the
old app. Chrome caps sw.js at 24h regardless, but “the browser will probably
save us” is not a deployment strategy.
So: no-cache on the worker, the manifest and every HTML route; immutable on
content-hashed /assets/; and one deliberate exception — the icons are not
immutable, because their filenames never change and the promise could not be
kept. Redrawing the mark would leave every installed phone showing the old one.
Verified by serving the real build through a real nginx and checking every
route, including that a missing asset 404s rather than being handed
index.html — otherwise a stale hash returns HTML with a JavaScript content
type, and the failure surfaces as a parse error somewhere unrelated.
The image builds on the workstation and ships over ssh. It prunes dev
dependencies to 206MB, runs as node, and uses dumb-init so compose down
is a clean shutdown that lets Nest drain the pg pool.
I brought the entire stack up locally before writing any of it down, and it
caught something. My first attempt reused the default project name, so the
“deploy” quietly attached to my development database container — and, worse,
recreated it under the deploy config, which deliberately publishes no port.
Everything looked fine: the container was up and healthy, the data was intact,
psql worked. The damage only surfaced twenty minutes later when the e2e suite
went from 24 passing to 24 failing with ECONNREFUSED.
On the droplet there is no second stack to collide with, so this was purely a local hazard. But it is the same lesson as the port that was serving FORME: healthy is not the same as correct, and the only reason I caught it was re-running the full suite at the end rather than trusting that nothing had moved.
9. What the tests actually assert
99 tests across three workspaces.
Domain (58). Money guards, tingi flooring and its documented discrepancy, the projections both sides run. And the two properties the architecture rests on: a balance identical under all 24 orderings of a lista, a shelf count identical under every ordering of its operations.
API e2e (24), against a real Postgres. The replay test. The device-key
sweep. Validation refusals with their reasons. Request-shape rejections that
only pass because configureApp is shared. NODE_ENV=test makes the app
ignore .env entirely, so the suite cannot be fooled into asserting against a
developer’s local database.
Frontend (17), Dexie against a real IndexedDB. Not a mocked Dexie — mocking
it would prove the queue calls put, when the claim is that a sale is still
there after the app is killed. A write is on disk before enqueue resolves; a
restart recovers pending rows; a restart does not re-send what the server
already accepted; a failed drain is a no-op in both directions; a rejection is
kept and shown; two concurrent drains push once.
What I chose not to build
A shareable customer statement — a signed, expiring link the shopkeeper sends over Messenger so a customer can check their own utang with nothing to install. Messenger is how everyone here actually communicates, and this is the only feature that reaches past the counter. It is the first thing I would build next, and it is deferred rather than dropped.
Multi-device. The architecture allows it — the server is authoritative, clients rebase on pull, and the e2e suite already covers two devices replaying into one log. The product does not need it yet.
Accounts. Covered above: the wrong trade for a shop counter.
Per-visitor shops. pull returns every operation past the client’s cursor
regardless of which device wrote it, because the product is one shop with
several devices behind one counter. On a public demo that means visitors share
a shop — one person’s sales appear in the next person’s ledger. The right
answer is a shop_id on the log; the cheap answer, taken here, is a nightly
timer that wipes the demo shop so everybody starts from the seeded fixtures.
The cheap answer had one sharp edge worth recording. The reset must truncate
without RESTART IDENTITY: seq is the cursor clients page from and they
keep it in IndexedDB across the reset, so restarting the sequence hands the
next operation a seq below a returning visitor’s cursor — their own sales
sync to the server and become permanently invisible to them. I demonstrated
that failure before shipping the version that avoids it, and the script asserts
the sequence did not go backwards.
The check that asserts it was itself broken on the first run:
select last_value from pg_get_serial_sequence(...) reads plausibly and errors
at runtime, because that function returns a name. Under the script’s ERR
trap it would have failed the reset every night — a guard that would have
disabled the thing it was guarding. Running it once caught that in seconds.
Still outstanding
Stated rather than quietly omitted:
- A screen reader pass, a real phone in real daylight, an axe sweep. These matter more here than on any other spoke, because “works one-handed on a cheap Android in the sun” is this spoke’s central claim, and a headless measurement at 360px is not the same as a thumb.
- A stock cap on the cart. Nothing stops a sale of eight from an item with three left; the stock overlay clamps at zero afterwards and the count is quietly wrong. Pre-dates the cart page and was left alone by it, because the fix is a decision about what the till should do at the boundary — refuse, warn, or let her sell what she can see on the shelf — and that is the shopkeeper’s answer, not mine.
- The sync indicator overflows the tab bar at 360px:
SYNCEDin Martian Mono is wider than a fifth of the screen and clips at the right edge.
The honest summary
The scaffold gave me a well-organised app with a data seam, good taste, and no durability whatsoever. It is a convincing sketch of an offline-first app.
The pass made the claim true and then tried to break it: a real local database, a real sync protocol whose safety rests on one property, a server that does not trust its clients, and a test suite I sabotaged in three different places to confirm it would notice.
The most useful thing I did on this spoke was not writing the sync engine. It was pushing the same batch three times and counting the rows — and then breaking the code to make sure the count would have told me if it were wrong.