Turning a single-user app into a multi-user one is mostly not an authentication problem. Pulso, my watch collection tracker, had working accounts, magic-link sign-in and real sessions for days before I noticed that every route behind that login still read and wrote one shared file. The gate was real. Everything behind it was not.
The practical version of that sentence: the second person to sign up would have opened the app and found my watches, my wishlist and my wear history, with full permission to edit all of it. Nobody had been invited yet, which is the only reason this is a story about a cutover rather than an apology.
The cutover itself was the boring half. Every route that touches a collection moved to per-user Postgres, scoped by the signed-in user: watches, wishlist, wear, stories, service, share, suggest, usage, insights and export. The only genuinely multi-table operation is the acquire move, where a shortlisted watch becomes an owned one, and that now runs as a single atomic Postgres function so it cannot half happen and leave a watch in both places or neither. Public share links still render for a signed-out visitor, because the token is the credential, and every request arriving without one is refused.
Migrating my own data was the half worth checking twice. My collection went in as user number one: 4 watches, 24 wishlist items, 49 wear logs, 1 story, 9 price history points and 3 share links, verified by row-count parity and content spot checks against a fresh production export rather than against what I assumed was there. Two rows referenced watches that had already been deleted, so they were skipped and recorded as skipped. Silently dropping them would have produced the same clean-looking result and taught me nothing.
What a cutover breaks is everything you forgot was pointing at the old thing. The monthly price refresh job, the stats endpoint feeding my JARVIS dashboard widget, and the public share pages were all still reading the dead SQLite tables afterwards. None of them errored. They returned empty or stale answers with total confidence, which is the failure mode that costs you a week, because there is nothing to notice.
A backup that passes is not a backup. The nightly job kept snapshotting the SQLite file after every write had moved to Postgres. It ran, it checked integrity, it reported success, every night, on a file that no longer received a single write. There are now two independent copies and a freshness report, and I still will not call this solved, because nobody has yet stood up a full restore from either one and proved it row for row. That is the next job, not a finished one.
Some things stayed behind on purpose. The stats endpoint, the price refresh job, admin import and export, and push notification registration all still use the old shared store, because none of them need per-user scoping to be correct today. Moving them would have been tidy, and would have added risk to a change that already touched every read and write path in the app. Tidiness is not a reason to widen a migration.
The lesson I keep relearning is that a working login is an invitation to stop checking. The auth flow was the visible, satisfying part, it worked, and its success is exactly what made the shared file behind it invisible for days. Pulso is a real multi-user app now, with the first people off the waitlist inside it, and the thing I am least confident about is not the code, it is the restore I have not run yet.