Migrating Without Migrating
One mitra asuh, one account, three platforms unified MainStory · 2026 (in progress)
A platform unification architecture that eliminates duplicate data entry across three systems — by refusing to do a bulk migration. Currently shipping.
The Setup
A mitra asuh — a caregiver candidate at MainStory — was simultaneously three different people, depending on which database you looked at.
On the hiring platform (Supabase, project A), she was a candidate with status passed. On the training platform (Supabase, project B), the same person was registered separately — sometimes with a slightly different name, sometimes with a different email, always with a fresh password — and her status was active. On the kiwi-api MySQL backend that ran day-to-day operations, she didn't exist at all until a brittle edge function called onboard-trainee ran successfully and copied her data over.
If the edge function failed silently, she'd stay invisible to the BE. Payroll wouldn't see her. The matching system wouldn't see her. Retool dashboards would show her in three different states at once. HR would Slack the engineering team to ask why the same caregiver was still showing as passed in loker but couldn't log into the nanny app.
Three databases. Three auth systems. Three definitions of "who this person is." The bridge between them was a single shared-secret API call that no one was monitoring.
This is the case study where the work isn't a screen. It's the data architecture under all the screens.
The brief I gave myself:
- One canonical account per mitra asuh, from first application to final payroll
- Eliminate manual data re-entry between platforms
- Standardize status definitions across the entire lifecycle
- Don't break anything in production while doing it
That last constraint is the one that makes this hard.
The Insight
Bulk migrations are where companies break.
The default approach to consolidating three databases into one is to write a migration script: read everyone out of Supabase, transform their data, write them into MySQL, then cut over the auth system. This is the approach 90% of teams take. It's also the approach where production goes down for a weekend, three power users find their accounts in unexpected states, and the engineering team spends the next month patching edge cases.
I refused to do it that way.
The alternative I designed: user-driven migration. Existing users migrate themselves, on their next login, at their own pace. New users skip Supabase entirely. The two paths run in parallel until the last legacy user has crossed over — and only then does Supabase get decommissioned.
The unlock: there is no migration weekend. There is no cutover moment. There is just a slow, asymptotic drift from the old system to the new one, controlled by user behavior rather than engineering scripts.
This is the design move that makes the rest of the architecture possible.
The Architecture
TWO BRANCHES, ONE DESTINATION
BRANCH A → Existing Supabase user
↓
Logs in with old email + password
↓
BE returns 404 (not yet in BE)
↓
"Perbarui Account" screen
↓
User sets a 6-digit PIN
↓
BE migrates their data atomically
↓
Supabase session discarded forever
↓
[BE account, JWT, full data]
BRANCH B → New user
↓
Registers with phone + PIN directly on BE
↓
No Supabase involved at all
↓
[BE account, JWT, full data]
Both branches converge on the same BE account schema. After Branch A migration completes, a Branch A user is indistinguishable from a Branch B user — same user row, same hiring_application row, same JWT, same auth flow on subsequent logins. The only artifact of their Supabase past is a supabase_migrated_at timestamp on the user record, kept for audit.
The login flow
The frontend handles both branches transparently:
User submits phone + PIN
↓
Try BE login first
├─ 200 OK + JWT → Branch B path, done
└─ 404 Not Found → Try Supabase
├─ Supabase OK → Show "Perbarui Account"
└─ Supabase Fail → "Invalid credentials"
Three outcomes, every login: BE-authenticated, redirect-to-migrate, or genuine bad credentials. The user never sees the complexity. The frontend never has to ask "are you a legacy user?" The system figures it out from response codes.
The Hard Decisions
Why a fresh PIN instead of reusing the Supabase password.
We can't extract plaintext passwords from Supabase — they're bcrypt-hashed. The options were: (1) ask the user to re-enter their existing password, verify against Supabase, then re-hash with argon2 in the BE, or (2) ask them to set a fresh PIN.
Option 1 is mechanically possible. It's also confusing for the user — they enter what they think is the same password, and now the system has "two passwords" in their head until they migrate.
Option 2 is cleaner. The PIN is short (6 digits), easy to remember, easy to differentiate mentally from the old Supabase password, and gives us a clean argon2 hash in the BE's native format from day one. It also signals to the user that this is a new account, not a continuation of the old one — which mentally maps to the "Perbarui Account" framing.
The cost: the user has to remember a new credential. The benefit: every Branch A account in the BE is mechanically identical to every Branch B account. No special cases, ever.
Why eligibility gates instead of separate accounts.
The earliest version of this architecture had three separate user types: applicant, trainee, nanny. Each platform would only accept its own type.
That's wrong. A mitra asuh isn't three different people across her career. She's one person whose permissions expand as she progresses. So I designed a single account with eligibility gates — your mitra_status controls which platforms you can log into, but it's the same account everywhere.
PRE_CONTRACT or above → can access elearning
CONTRACTED or above → can access nanny app
ACTIVE → can access payroll, tipping, OT
This is the same pattern as role-based access control, but oriented around the lifecycle stage rather than the job function. It collapses what would have been three account types into one account with state.
Why historical statuses, not last-state.
A mitra_status that only stores the current status loses information. If a candidate is currently RESIGNED, was she resigned during screening? During training? After three months as an active caregiver? Those are very different stories.
Every status change is stored as a historical record with a timestamp, a reason (from a standardized note taxonomy), and the actor who triggered it. The current status is derived from the latest record, but the audit trail is the source of truth. Retool dashboards can replay the history. HR can answer "when did we lose her?" without guessing.
This is a small architectural choice that pays back forever. The cost is one extra table. The benefit is permanent.
The Taxonomy Work
The status lifecycle was the single most contested part of the spec. Three platforms had defined statuses independently over the years, and unifying them meant standardizing language no one had agreed on before.
The lifecycle I landed on:
SCREENING Registration submitted
INTERVIEW_HR Passes screening
PSIKOTEST Passes HR interview
INTERVIEW_CM Passes psikotest
OFFERING Passes CM interview, offer extended
IN_TRAINING Accepts offer, eligible for elearning
MCU Passes training, medical check pending
ONBOARDED_REGULAR Contract signed, regular employee
ONBOARDED_ON_DEMAND Contract signed, on-demand employee
Each status is MECE — mutually exclusive, collectively exhaustive. A candidate is in exactly one status at any time. There are no overlapping definitions, no gaps between stages.
Each status carries a sub-state: Active / Rejected / Resigned. A candidate at INTERVIEW_HR can be active (waiting for interview), rejected (failed HR rubric), or resigned (chose to withdraw). This 3x3 sub-state design lets HR query things like "how many candidates resigned during psikotest" without parsing free-text notes.
For Resigned, I designed a standardized note taxonomy that HR multi-selects from rather than typing free-form:
- Sudah dapat pekerjaan baru
- Tidak diizinkan bekerja oleh keluarga
- Tidak diizinkan menginap di daycare oleh keluarga
- Tidak menyelesaikan psikotest dalam waktu 3x24 jam
- Tidak mampu mengikuti aturan yang sudah ditetapkan
- Mengalami musibah
- Sedang sakit atau dalam masa pemulihan
- Skema benefit kurang menarik
- Tidak ada response
- Alasan personal
Free-text notes are an anti-pattern at scale. They're impossible to query, impossible to compare, impossible to use for trend analysis. A multi-select taxonomy looks like more work for HR up front; it pays back forever in operational intelligence.
This is the same show your work pattern that runs through every system I design at MainStory. Free-form data is invisible to the operating system. Structured data is queryable, auditable, and improvable.
The Hard Edge Case
Mitra asuh candidates don't only apply through MainStory's own platform. They apply through Glints, JobStreet, Indeed, and others. Each portal exports its applicants in a different spreadsheet format — Glints uses "Nama Lengkap", JobStreet uses "Full Name" — and HR has to import them into MainStory's pipeline before any platform-native flow can begin.
The architecture handles this with an import + link-by-identifier pattern:
-
Import path. A parser/mapper per portal normalizes each portal's CSV into the unified
hiring_applicationschema. The imported candidate exists as a row, but with nouser_idyet — they don't have a MainStory account. -
Outreach. The system triggers a WhatsApp/SMS notification telling the candidate to complete registration on loker.
-
Linking. When the candidate registers on loker (Branch B flow), the BE checks whether their phone or email matches an existing imported
hiring_applicationrow. If it matches, the new user account is linked to the existing application — no duplicate created. If it doesn't match, a fresh application is created normally. -
Single source after entry. Once linked, the original portal is irrelevant. HR never has to go back to Glints or JobStreet to update statuses. The pipeline runs entirely on loker from that point forward.
This is the kind of edge case that breaks most pipeline systems silently. A candidate enters from a third-party portal, registers a different way later, and the system creates two records of the same person without anyone noticing. The link-by-identifier pattern makes that failure mode mechanically impossible.
Status
This architecture is currently shipping. Backend schema work is in progress. The frontend dual-path login flow is mid-build on both loker and elearning. The migration monitor admin widget is in the backlog.
| Subsystem | Status |
|---|---|
hiring_application schema + migration | In progress |
mitra_status enum on user table | In progress |
| Hiring API endpoints (register, login, progress, admin) | Backlog |
migrate-account endpoint (Branch A) | Backlog |
| Eligibility gate endpoint | In progress |
| loker dual-path login + Perbarui Account | In progress |
| loker registration refactor (Branch B) | In progress |
| loker admin dashboard refactor | In progress |
| elearning dual-path login + Perbarui Account | In progress |
| Migration monitor widget | Backlog |
TODO — current ship state: what's the latest status when this case study goes live in your portfolio? "Currently in user testing," "shipped to internal beta," "first 100 Branch A users migrated successfully" — whatever the truth is at portfolio time. The case study works as in-flight work; it works even better with the current line on the curve.
The estimated total effort is 30–42 engineer-days across BE and FE. Most of that is sequencing, not novel design — the architecture itself is settled. The work that remains is shipping.
The Pattern
The right migration is the one that doesn't have a migration weekend.
User-driven migration is now the default I reach for whenever a system needs to move from one substrate to another. The principles generalize:
- Two paths, one destination. Legacy users migrate at their own pace; new users skip the legacy system entirely. Both converge.
- Detection at the boundary. The system figures out which path you're on from response codes, not from a flag the user has to set.
- Atomic individual migrations. Each user's data moves in a single database transaction. No partial states, no rollback weirdness.
- Decommission asymptotically. The legacy system runs until the last user crosses over. Then, and only then, can it be turned off.
- Audit trail forever. Every migrated user carries a
migrated_attimestamp. The history of how the system evolved is preserved, not erased.
I now apply this framework to any project where production data needs to move between systems. It's slower than a bulk migration. It's also one of the few approaches that reliably ships without breaking production.
This case study is the clearest example I have of "I design operating systems, not interfaces." There is almost no interface in this work — a login screen, a Perbarui Account page, a few admin dashboards. The work is the data architecture, the migration strategy, the status taxonomy, the API surface, the eligibility gates. The interface is the thinnest possible wrapper around the system. The system is the product.
What I'd Do Differently
TODO — first reflection: with the build mid-flight, what's the early lesson? Was there a sequencing decision you'd revisit? A status definition that turned out to be ambiguous in production? A stakeholder you'd have looped in earlier? In-progress case studies often have richer "what I'd do differently" content because the lessons are fresh.
TODO — second reflection: one more honest "I'd do this differently." Two reflections always beats one for Principal-level reflection. What's the strategic miss here, separate from the tactical one above?
MainStory · Indonesia's premium childcare platform · Three platforms unified · 30–42 engineer-days · Shipping