Kim Nejudne ← All work

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.


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:

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:


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.