Narong's red-flagged, fully-specced item from 2026-07-24: search by name / phone / customer ID; filter by customer type, order activity, joined date and loyalty status; sort by newest, A–Z, most orders, highest spending or highest points; and a count line reading "1 total customer · 1 app member · 0 POS customers". No migration.
Net −108 lines in nix-cafe despite adding the feature. The page carried two near-duplicate 140-line blocks (one per tier) that collapse into a single directory both tiers share.
His count line only parses one way — total = app members + POS customers — so the two populations become one list with type as a facet. That meant absorbing the separate "App Members" card, which he'd asked for on 07-10; Points and Chances survive as columns that fill in only for members.
The two populations are stored apart: POS customers in the cafe database, app members in the loyalty service behind
an HTTP call. The thing that makes a merged list coherent rather than half-empty is that paid orders for
both land in cafe.orders, tagged cafe_customer_id or
loyalty_member_id — so "most orders" and "highest spending" are real for app members too,
not just the POS half.




.limit(50) with no pagination and no indication —
past 50 customers it silently truncated. The directory now loads the full set and says so if a cap is ever hit.
member_qr_id — what the QR actually encodes — is now returned
and searchable. Seven lines in the loyalty backend; it's the only reason that third search field means anything.
The first staging run passed 11 of 13. One failure was my test's; the other was real, and worth recording:
get-coffee rendered "0 total customers · 0 app members" despite having a member. The cause was
hidden by a bare catch {} I'd written when absorbing the loyalty card — the page degraded silently and
then asserted there were zero members, which isn't a graceful degradation, it's a false statement.
Restoring the error surface the old card had immediately produced the real message:
Couldn't load app members: c5.lastOrderAt.toISOString is not a function
MAX(created_at) was annotated sql<Date>, but drizzle returns a string for a
bare aggregate — the type was a lie, and .toISOString() threw. It only ever fired for a customer who
actually had orders, so get-coffee — the one staging tenant with orders tagged to a member — was the only place it
surfaced. Normalised at the DAO boundary, and the failure is now visible rather than silently swallowed.
| Suite | Result |
|---|---|
| Local — the pure search/filter/sort module, exercised directly (no browser) | 20/20 |
| Staging — real SSO across Starter and Pro on the deployed Worker | 13/13 |
| Staging smoke | 5/5 |
tsc --noEmit (nix-cafe + nix-loyalty-backend) | clean |
Expected values are read from Neon and the loyalty API at run time rather than hardcoded, so the suite can't quietly rot as staging data changes — and it fails loudly if the fixture stops proving anything (for instance, if the member-linked orders that make the cross-store assertion meaningful ever disappear).
Two regressions were injected. The first — conflating a POS customer's null points with a member
sitting at zero — was caught precisely. The second, making the A–Z sort case-sensitive, was not:
the fixture happened not to discriminate, so that assertion's name was claiming more than it verified. Changing one
fixture name to lowercase made it real, and the same regression is now caught by three assertions:
✗ filter by order activity: expected ["pos:c1","member:m3","member:m1"], got ["member:m3","member:m1","pos:c1"] ✗ filter by joined date window ✗ sort: A-Z is case-insensitive
nix-cafe staging 4c838e2 (Worker 7fe7fda2) · nix-loyalty-backend
staging 7d8c71f (Fly staging deployed). No migration. nix-loyalty-backend is new to
the promote bundle — it will need a prod Fly deploy alongside the usual steps. Parked for the scheduled
prod promote.