System design · live on Supabase · 14 September 2026

How 恋爱积分簿 is built

A two-person scoring app, end to end: a 6,000-line single-file frontend, 27 Edge Functions, 12 Postgres tables, and one rule that shapes all of it — the client may read, but only a function may write.

Public copy. Personal details — names, the pet, the shared goal and current scores — are redacted here because this repository deploys publicly. Row counts are kept, since they describe the system rather than the couple.
12
tables
27
Edge Functions
605
score entries
64
bag rows
26
letters
10
photos
528K
frontend, 2 files

The stack, in one breath

There is no build step, no framework, and no server the couple owns. index.html carries every style and every screen's markup; app.js carries all 6,000 lines of behaviour. GitHub Pages serves both as static files. Everything dynamic happens in two places: Supabase Edge Functions (Deno) holding the business rules, and Postgres holding the data.

That shape is deliberate for a couple's app that must outlive the enthusiasm of whoever maintains it. There is nothing to keep running, nothing to patch, and no dependency that can rot: open the repo in five years, and node --check app.js is the entire toolchain.

How a request travels

The security model is a shape, not a checklist. This is that shape.

There are two ways out of the browser and into the database, and they are deliberately not equal. Both carry the same public key and the same user session; what differs is what each is permitted to do at the far end.

partner A 📱 partner B 📱 app.js snFetch() path rewrite 401 → refresh → retry Edge Functions ×27 all business rules live here service-role key PostgREST (direct) the session token alone publishable key + user JWT RLS SELECT only Postgres 12 tables RLS on every one reads + ALL writes bypasses RLS Storage · photos private bucket upload · sign browser fetches bytes direct, 4h signed URL Solid green = the only path that can change data. Dashed = reachable with a stolen session, and read-only by policy.
Two routes into the same database. Writes exist only on the green path, so every business rule sits in one place; the dashed path is what an attacker with a valid session token gets, and RLS restricts it to SELECT.

This was tested by attack rather than by reading the policy. A genuine signed-in session was used to PATCH a row directly: the request answers HTTP 204, which looks like success, and changes nothing. Reading the value back afterwards is the only way to tell.

The trade this makes. Because functions hold the service-role key and bypass RLS, the function is the only thing enforcing a rule. There is no second line of defence behind it. A missing check in shop-buy is a missing check, full stop — which is why the score-sum logic lives in one shared helper that both shop-buy and bag-claim call, rather than being written twice.

One write, traced end to end

partner A taps 说早安 +5. Here is everything that happens, in order.

  1. The tap. A category button calls Data.addEntry(). The client computes todayStr() and monthKey() from the device clock — never toISOString(), which is UTC and rolls a day early for a UTC+8 couple.
  2. Path translation. snFetch('/entries', {method:'POST'}) rewrites the ServiceNow-era path onto a function slug: …/functions/v1/entries. Thirty such translations exist so none of the call sites had to change during the migration.
  3. Headers. The publishable key identifies the project; Authorization: Bearer carries the user's access token. Both are needed — the key alone gets you an anonymous session that RLS shows nothing to.
  4. Preflight. Because apikey is a custom header, the browser sends OPTIONS first. Every function answers it through a shared wrapper; when that was missing, every request failed with nothing but "Failed to fetch".
  5. Identity. Inside the function, getCaller() validates the JWT and reads profiles for the caller's match_id and char_id. No match_id ever arrives from the client — it is derived, so a forged one is not a thing that exists.
  6. The insert. The service-role client writes the row with match_id from step 5 and the date from step 1, plus a snapshot of the category's name, icon and point value. The snapshot is why deleting a category later doesn't corrupt the history.
  7. The response. {id, success:true} — the identical shape the ServiceNow resource returned, which is the reason app.js needed no change here at all.
  8. The other phone. Nothing is pushed. partner B's app sees it on its next poll, and if partner B is in the pet room right now that is within four seconds.
Step 1 is the one people get wrong. It looks like a detail and it is a class of bug: the server is in UTC, so an entry logged at 01:00 in Singapore belongs to yesterday as far as it's concerned. ServiceNow had the same hazard from a different timezone, and shipped purchases dated a day early before it was caught. Every date in this system is decided by the phone and merely stored by the server.

The data model

Twelve tables. One couple. Two people who must see the same numbers — except where they must not.

auth.users hashed password every row inside carries match_id — the boundary RLS enforces profiles char_id · username 2 rows: char1, char2 matches the couple pair_code config 1:1 · the pet lives here goal · targets · weather 1:1 WHAT THE COUPLE DEFINES categories 18 · ways to score rewards 4 · claimed per person punishments 0 · thresholds shop 5 · costs points THE LEDGER entries 605 rows · char · points · date monthly_id NULL = unsettled 116 live · 489 archived monthly 2 settled months category_id settling stamps it WHAT THEY ACCUMULATE letters 26 · 情书 opened flag photos 10 · caption · date storage_path bag 64 · 35 buy · 13 reward · 16 decor the one PER-PERSON table shop_item_id purchases only furniture instead carries decor_item_id a text catalog id Storage bucket the actual image bytes one object per row
Everything hangs off matches, and RLS scopes every read to the caller's own couple. bag is the single table also scoped per person — your unredeemed reward is yours, not the couple's — and the only one whose foreign key points two different ways depending on what kind of thing the row is.

Shared or personal — the distinction the whole app turns on

Two people must see byte-identical numbers, or the app is lying to one of them. But a handful of things are genuinely individual, and getting that wrong in either direction is a bug: share a bag and one partner spends the other's reward; split the pet and they are no longer raising it together.

TableRowsScopeWhy
entries605coupleBoth see every entry; each row's char says whose score it moves
config1coupleTargets, goal, and the pet — one shared row, hence one shared pet
categories / shop
rewards / punishments
18/5
4/0
coupleEither partner edits; both see the change immediately
monthly2coupleSettled history, with each partner's total on the row
letters / photos26/10coupleWritten by one, read by both
bag64per personYour unredeemed rewards are yours — the partner must not be able to spend them
bag where
source_type = decor
16coupleThe exception to the exception: the room is shared, so a sofa one partner bought must be placeable by the other
rewards
claimed_1 / _2
—per personEach claims each reward once per round; settling reopens both
That bag row is where the migration hurt. The old ServiceNow /bag endpoint filtered to the caller's own rows — correct for the app, quietly wrong for a backup, which therefore never captured the other partner's items. At cutover, partner B's seven unredeemed rewards had to be read off a screenshot because no export contained them. The backup now uses a service key and bypasses RLS precisely so one run sees both.

The frontend

6,063 lines of app.js, 4,665 of index.html, no build step.

All state lives in one object, S. All server access goes through one object, Data, which has a ServiceNow-era shape the migration deliberately preserved — and a localStorage twin behind the same methods, which is what Demo mode runs on. Every screen is markup already present in index.html, shown and hidden rather than constructed.

What is cached, and why that matters

GET /photos used to return full base64 images, so pulling it on every home refresh re-downloaded megabytes on mobile data. The rule that came out of that is still in force even though photos are now signed URLs:

Two versions, because HTML caches and JS does not

app.js is always fetched fresh; index.html — which carries every style and all the markup — can sit in a phone's cache for days. Running new JS against old HTML produces unstyled elements and invisible layouts. So HTML_V in app.js must equal the app-html-v meta in index.html; the app compares them on boot and reloads once, cache-busted, when they differ. The retry flag is keyed by version, or one stale flag would block every future release.

Live sync between two phones

There is no websocket. There is a poll that knows when to care.

Measured against the real backend, a config poll is small but takes about a second, so "instant" does not exist here — a couple of seconds is the floor. Rather than poll fast all the time, the gap adapts to whether anything is happening:

WhenGapReasoning
Within 60s of any change4sYou are both in there right now — this is when latency is felt
Next 3 minutes12sSomeone is still around, but it has gone quiet
Idle after that30sA quiet hour costs about 120 requests — fewer than a fixed 20s poll
Tab in the background—Does not poll at all; returning polls immediately rather than waiting out the gap

It is a self-rescheduling setTimeout, not setInterval, so the gap can change between ticks without tearing the timer down. Change detection is an order-independent fingerprint of the room — _roomSig() — rather than a timestamp, so two phones that disagree about the clock still agree about whether anything moved.

The bigger win was not the interval. Detecting a change used to raise a banner the user had to notice and tap, so the perceived delay was however long that took. It now applies silently while you are merely looking, and only asks when you could lose work — a piece selected, or the shop open. Pulling the room out from under someone mid-arrangement would be hostile; doing it while they watch is the part that feels like magic.

Identity and pairing

Two accounts, one couple, and a six-digit code that exists for about a minute.

  1. Partner one registers. A matches row is created with a random six-digit pair_code, an auth.users record with a hashed password, and a profiles row claiming char1.
  2. They share the code. Out of band — spoken, messaged, whatever.
  3. Partner two registers with it. The code resolves to the existing match, so the second profiles row joins it as char2. A unique (match_id, char_id) constraint means the slot cannot be taken twice.
  4. From then on, identity is derived. Every request carries only a session token; the function looks up match_id and char_id from profiles. The client never sends them, so it cannot lie about them.

Supabase Auth requires an email address and these usernames are not emails, so one is synthesised deterministically from the username. That detail caused the migration's last bug: it must be derived from the stored username, because cs and partner A hash to different addresses — and ServiceNow's queries had been case-insensitive for years, so a lowercase login that had always worked started reporting "account does not exist".

Settlement — the one destructive operation

Everything else adds. This is the only thing that sweeps the board.

A 月末结算 archives a month: it writes a monthly row holding each partner's total and outcome, stamps monthly_id onto exactly the entries it counted, and clears every reward's claim flags so both partners can claim again next round. Afterwards those entries no longer appear in the live score — they are history.

The endpoints

27 functions. ServiceNow needed 41 resources for the same surface, because it wanted one per path and method.

AreaFunctionsCarries
Identityauth-register · auth-login
auth-password · auth-charimg
Pairing by six-digit code, case-insensitive usernames, session rotation on password change
SettingsconfigGET and PUT on one function; holds the monotonic pet-EXP floor
Scoringentries · entries-idUnsettled-only by default; ?year= for the annual recap
Catalogscategories · categories-id
rewards · rewards-id
punishments · punishments-id
shop · shop-id
CRUD, with ownership re-checked against match_id before every mutation — a row from another couple answers 404, never an error that confirms it exists
Economyshop-buy · bag · bag-use
bag-history · bag-claim · decor-buy
Score checks, per-person ownership, and the coin ledger furniture spends from
Settlementmonthly-settle · historyArchives entries and reopens reward claims for both partners
Contentletters · letters-id
photos · photos-id
Photos upload to Storage and come back as signed URLs

Rules the design encodes

Each of these is in the schema or the function layout because forgetting it once caused a real bug.