Migration record · 11 September 2026 · complete

恋爱积分簿 moved off ServiceNow

All 41 REST resources ported to Supabase Edge Functions over Postgres, every row copied and checked against the original, and the app switched over live. The ServiceNow instance was never written to.

from dev405150.service-now.com to yvllstktmjoedfsgojgs.supabase.co couple the couple · since 2024-12-24
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.
41/41
resources ported
593
entries migrated
170
automated checks passing
0
rows lost
0
ServiceNow writes

Why move at all

The trigger was not a feature. It was where the data was sitting.

Everything — 593 score entries, 25 letters, 10 photos, two settled months, the pet and its furniture — lived on a free ServiceNow Personal Developer Instance. A PDI hibernates after a few idle days and, left inactive long enough, is reclaimed outright with everything on it. There is no vendor backup. tools/backup.js existed precisely because that risk was understood, but a backup script is a mitigation, not a fix.

Postgres also removes a whole class of problems the ServiceNow schema forced. Fixed-width string fields were a live hazard: u_pet_equipped is String(1000) and silently truncates, which is why the room layout had to be compacted into two-letter codes to fit. On Postgres text is unbounded and that entire constraint disappears.

What the architecture became

The contract was held fixed on purpose, so the frontend barely moved.

Was

API
41 Scripted REST resources, pasted into ServiceNow by hand
Data
12 u_love_* tables, fixed-width string fields
Auth
Custom bearer key; passwords stored in plaintext in u_love_auth
Photos
base64 in a String(200000) column
Access
Each resource re-checked u_match by hand in script

Now

API
30 Edge Functions, deployed by CLI in one command
Data
12 Postgres tables, unbounded text, real foreign keys
Auth
Supabase Auth — hashed passwords, JWT sessions, refresh tokens
Photos
Storage bucket, served as signed URLs
Access
Row-level security, enforced by the database itself

The 41 ServiceNow resources collapse into 29 functions (plus backup-full) because ServiceNow needed a separate resource record per HTTP method on the same path, while one function can branch on req.method. GET /config and PUT /config are now a single config function.

The frontend change is deliberately tiny. Every call in app.js already funnelled through two functions, snFetch and snPublicFetch. Those two now rewrite the old ServiceNow-style path onto a function slug — /entries/{id} becomes entries-id?id=…, /shop/buy/{id} becomes shop-buy?id=… — so all ~30 call sites are untouched. All 30 translations are unit-checked.

How it was done

Seven phases, in order. Each one had to pass before the next began.

01
Schema and storage

12 Postgres tables mirroring the ServiceNow ones, plus indexes matching the queries that actually run — including a partial index on entries(match_id) where monthly_id is null, which is the exact shape of the "unsettled entries" query the app leans on. A private photos bucket, 5 MB cap, image/* only.

02
Lock the database down, then prove it

The first RLS pass was too permissive and was caught on review before any data existed. Details in the security section below. The corrected policies were then tested by attacking them with a real signed-in session, not by reading the config.

03
Vertical slice before volume

Auth, pairing and the config resource were built and tested end to end first — register, pair by code, read and write config, confirm the partner sees the same row — before the remaining 38 resources were written against the same pattern. Finding a structural mistake in one function is cheap; finding it in 29 is not.

04
Port the remaining resources

Entries, categories, rewards, punishments, settle, history, shop, bag, claims, letters, photos, furniture, avatars. The business rules that CLAUDE.md records as having broken before were carried across verbatim and given their own tests:

  • entries scoped by unsettled, never by calendar month
  • the monotonic pet-EXP floor, with adoption as the one legal reset
  • client-authoritative dates — the server clock is never trusted
  • per-character reward claims; settling re-opens them for both
  • furniture pooled across the couple, while the reward bag stays personal
05
Trial migration and verification

A read-only pull from ServiceNow, imported into Supabase, then compared field by field against the live original — 58 checks, covering totals, per-person scores, claim flags, timestamps, furniture ids and photo bytes. Re-runnable: the importer can wipe and replay the whole couple.

06
Cutover

A final read-only pull at 12:56, re-imported over a clean wipe, re-verified against live ServiceNow, then the branch merged to main and pushed. GitHub Pages rebuilt and both phones moved across. Confirmed by logging into the real site and checking scores, the pet, 情书, 签到, the 中秋 drop and both partners' weather.

partner B's seven unredeemed bag items came off a screenshot rather than an export — /bag only ever returned the caller's own rows, so they were unreadable remotely. That gap is now closed permanently; see phase 07.

07
Repoint the backups

The weekly job was still reading ServiceNow, which had taken no writes since the cutover — faithfully archiving a frozen copy while the live data accumulated unbacked. Since "the instance could be reclaimed" was the entire reason for moving, that undid most of the point.

It now reads a new backup-full endpoint, which is the Supabase counterpart of the ServiceNow r41 resource that was written but never deployed. Its bag query deliberately omits the character filter, so either partner's login captures both bags — the gap that forced partner B's items to be re-typed. Photo bytes are pulled into the snapshot rather than left as signed URLs, which expire.

The parity ledger

Every figure read from both systems at the same moment of cutover and compared. This is the evidence for "nothing was lost".

WhatServiceNowSupabaseResult
Entries, all timesettled and unsettled593593exact multiset
Lifetime points20942094match
Current score · partner A206206match
Current score · partner B228228match
Settled months2026-07, 2026-08 with results22pts + results
Letterstext, sender, opened, timestamp2525no tz shift
Photosbase64 → Storage objects1010all download
Furnitureincl. year-locked keepsakes1616ids preserved
小窝币 spentthe coin ledger800800match
Pet the petspecies, EXP, baselinedog · 1862 · 1382dog · 1862 · 1382match
Room layoutpet_equipped blob12 pieces12 piecesbyte-identical
Categories / shop / rewards18 / 5 / 418 / 5 / 4values + flags
partner B's unredeemed bagunreadable remotely — see below77dates match
Goal the shared goal80008000match
One check failed and was worth the trouble. 167 entries looked "missing" — but ServiceNow returns null for an empty note where Postgres returns "". The data was identical; the comparison was wrong. It was asserting the storage encoding instead of the meaning, which is the exact mistake CLAUDE.md §9.1 warns about. The comparison now normalises both and counts entries as a multiset, because the same person legitimately logs the same category twice in one day.

Five bugs the tests caught

Three were found before any real data moved; two surfaced after the switch. Recorded here in the same spirit as CLAUDE.md — what broke, and why it was possible.

Schema · would have dropped all furniture
Nothing could have been stored as decor

The bag.source_type check constraint allowed only purchase and reward. But furniture is a third kind, written by r40 as decor. Every piece the couple owns — including the year-locked 2026 中秋 keepsakes that cannot be re-bought once the year passes — would have been rejected at insert.

Worse, the two kinds do not even store the same thing: a purchase points at a shop row, while furniture stores a text catalog id like mooncake_box_27. One uuid column could never have held both, so they are now separate columns, coalesced back into the single itemId the API has always returned.

Behaviour · deleting a bought item started failing
Postgres was stricter than ServiceNow, and that was a bug

ServiceNow reference fields never blocked a delete; a shop item could be removed while bag rows still pointed at it, leaving a dangling reference nobody noticed. Real foreign keys refuse that delete instead — so deleting a shop item the couple had already bought began returning an error where it used to work.

Fixed with ON DELETE SET NULL rather than by dropping the constraint. Bag rows already snapshot the item's name and icon at purchase time, so the history survives intact and only the link clears. The same applied to deleting a category that had entries against it.

Transport · every request blocked in the browser
CORS preflight answered 405

Everything passed from the command line and then the app could not log in at all, reporting only "Failed to fetch". A browser preflights any cross-origin request carrying a custom header — and apikey is one — but the functions answered OPTIONS with "Method not allowed", so the real request was never sent.

Every function now runs through one wrapper that answers the preflight. This is the same class of problem as the CORS rule ServiceNow needed; it just had to be solved again on the new side.

Post-cutover · changing a password threw you out
The session dies at the exact moment the change succeeds

Supabase revokes every existing session when a password changes — so the token the request was made with is dead the instant it works. The next call answered 401, the refresh token had been revoked too, and the app reported 当前密码不对: a successful change presenting itself as a typo. Changing a password twice in a row was impossible.

The endpoint now signs back in server-side and returns a fresh session for the client to adopt. A wrong current password answers 403 rather than 401, so "you mistyped it" and "your session is gone" stop being indistinguishable.

Post-cutover · reported from a laptop login
A lowercase username became "account does not exist"

GlideRecord string queries ignore case, so cs had logged in as partner A for as long as the app existed. Postgres = does not. After the switch a lowercase username answered 账号不存在,请先注册 — the account looked deleted rather than mistyped.

Matching case-insensitively is only half of it, and the second half is the one that bites: the synthetic email Supabase Auth needs is derived from the username, so cs and partner A hash to two different addresses and the password check fails even once the profile is found. The address is now always built from the stored spelling — which is also why the app still displays partner A whatever you type.

Security: what actually changed

The move was an opportunity to close two real holes, and the review found a third that the migration itself had introduced.

These were verified by attack, not by inspection: a genuine logged-in session was used to PATCH a row directly. It returned HTTP 204, which looks like success — and changed nothing. The value was re-read afterwards to confirm.

What deliberately did not change

What is left

The migration is done and live. These are the tail-end items.

[ ]
Rewrite CLAUDE.md. It is the file that stops old bugs coming back, and it still documents ServiceNow tables, resource numbers and field limits as current. Anyone reading it — including a future session — would be working from a wrong map.
[ ]
Delete the migration-only functions. db-migrate and migrate-import exist for this job alone. They are gated behind a secret, but they should not outlive their purpose.
[ ]
Decommission ServiceNow once the rollback window closes — roughly two weeks. The credential for it is kept meanwhile at tools/backup.local.servicenow.json.
[ ]
Retire the ServiceNow test suites. test-full-system-v2.sh and its siblings now exercise a backend nothing uses.

If it goes wrong

Rollback is one revert and a push. But ServiceNow stopped receiving writes at 12:56 on 11 September, so it is a snapshot that ages: reverting tomorrow costs a day of entries, reverting in a fortnight costs a fortnight. The realistic window is short, and after that it is an archive rather than a fallback.