

1/1/1970
Paano Lumipat mula Firestore patungong Postgres sa Loob ng 2 Araw
Firebase. Ang salitang iyon lang ay madaling magpapagastos sa iyo ng 10-20x kumpara sa mas murang database, at maaaring mag-limit at magpakumplikado sa disenyo ng iyong database.
Kaya naman maiisip mo kung gaano ako kaginhawahan nang matapos kong lumipat mula Firestore sa loob lang ng 2 araw. O 3 araw kung ibibilang mo ang oras na ginugol ko sa paggawa ng aking unang "PostgresSynchronizer". O 3.5 araw kung isasama mo ang oras na ginugol ko sa paglipat ng workshop system. Sa anumang paraan, mabilis ito. Talagang mabilis.
Ang inisyal kong estimate para sa proyektong ito ay isang buong buwan ng trabaho! Ngunit, salamat sa ilang nakakamanghang LLM prompts, at sa isang kaibigan na nagturo sa akin tungkol sa CDC, naabot ko ito nang mas mabilis.
Kung naghahanap ka ng pub/sub, ang Firebase o Supabase ay maaaring napaka-tempting pakinggan. Kung tutuusin, hindi gaanong maraming database ang sumusuporta sa pub/sub na out-of-the-box. Ngunit ang simpleng arkitektura sa ibaba ay sapat na para sa karamihan ng small-business use cases (at dapat mag-scale nang maayos hanggang mga 10k-100k ops / second).
Bago tayo pumasok sa detalye kung paano gumagana ang pub/sub, balikan muna natin ang mga hakbang na ginawa ko nang maayos.
Una, gumawa ako ng prompt sa Cursor para gumawa ng PostgresSynchronizer. Ang class na ito ay may function na handleWrite, na kumukuha ng Firestore path, ang uri ng operasyon (create, update, delete), at ang data. Pagkatapos ay ina-upsert nito ang data sa Postgres. Pagkatapos ay binalot ko ang Firestore store instance ko para sa bawat pagbabago sa Firestore, tatawagin din ang handleWrite.
Hindi ito 100% perpekto, syempre. Kung magdi-disconnect o mag-crash ang server, posibleng ilang maliliit na pagbabago ang hindi ma-persist. Pero sapat na ito para sa use case ng Foony, at ang Postgres schema ay nagbibigay sa atin ng mas magandang garantiya sa data integrity (hal. foreign key constraints) kumpara sa Firestore.
Sumunod, gumawa at nagpatakbo ako ng backfill na nag-iikot sa lahat ng Firestore collections at tumatawag ng handleWrite para sa bawat document. Sinisiguro nito na nakukuha natin ang lahat ng historical data mula sa Firestore. Para sa bilis, nilimitahan ko ito sa mga registered users lang (pasensya na guests, sana naman ay nag-sign up na kayo). Maayos itong gumagana, at ligtas itong patakbuhin ng maraming beses.
Ngayong may paraan na akong mapunan ng data ang Postgres, at panatilihin itong (halos) sync sa Firestore, nasugod ko na ang malaki at nakakatakot na problema: pub/sub.
Paano ba gawin ang pub/sub sa Postgres? O sa anumang SQL database para sa usaping iyon?
Sasagipin tayo ng Postgres Change Data Capture (CDC)!
Ang CDC ay isang magarbong salita na nangangahulugang "basahin ang mga pagbabago sa database at i-publish ang mga ito sa isang pub/sub system". Sa isang malaking negosyo, baka gamitin mo ang Debezium + Kafka. Ngunit ang Kafka ay nakakahirap i-set up, nandiyan na ang Redis natin, at ang database natin ay nakakatanggap lang ng mga 30 ops / second. Kahit pa 100x nating palakihin ang scale natin patungong ~100,000 concurrent users, malamang ay kasya pa rin ito sa isang Redis instance. Kaya iyon ang ginawa ko.
(Pwede mo ring i-bolt ang pub/sub sa Postgres gamit ang LISTEN/NOTIFY, pero hindi ito nakakatakas sa reconnects at babagsak sa sandaling kailangan mo na talaga ng fanout. Ang CDC ang nakakabagot pero matibay na sagot.)
Ang kaibigan kong si Eric ang nagturo sa akin tungkol sa CDC at nag-open-source ng kanyang CDC code na nagbabasa ng WAL ng Postgres (Write-Ahead Log, kung saan isinusulat ng Postgres ang bawat pagbabago na ginagawa nito sa database) sa pamamagitan ng logical replication slot, at nag-pe-persist nito sa isang sink (hal. Redis).
Sa isang simple at detalyadong prompt sa Cursor, halos one-shot ko nang magawa ang realtime CDC at gateway code (may ilang maliliit na pagbabago at masusing code review). Kabilang dito ang dalawang napakasimpleng serbisyo:
- isang singleton CDC service na nagbabasa ng WAL at nag-pa-publish nito sa Redis Pub/Sub (mas matibay kung Streams ang gamitin, pero may kapalit na pagiging kumplikado)
- isang horizontally autoscaling gateway service na humahawak ng JWT auth at websocket connections mula sa web clients.
Parehong nakasulat sa golang ang dalawang serbisyo, at lalo kong gusto ang autoscaling gateway part. Nag-su-subscribe ang web client sa parehong paraan tulad ng dati sa Firestore, at nakakatanggap ng data sa parehong format. Nakikita ng client ang "usersPublic/" at "usersPrivate/" bilang dalawang hiwalay na collections na may magkahiwalay na permissions. Ang gateway ang responsable sa pagsasalin ng request na iyon sa pinagbabatayang Postgres tables (sa kasong ito ay users), at sa pag-validate na may permission ang client para sa data na iyon.
Nakakagulat kung gaano ito kahusay gumana. Ang buong pub/sub system ay nagagastusan lang natin ng mga $0.50 / buwan sa compute + egress costs, at walang masyadong moving parts na maaaring masira (ang CDC, gateway, at client code ay lahat simple at walang third-party dependencies maliban sa Redis / Postgres).
Mula sa DevEx perspective, mas simple rin ang bagong sistema: hindi na kailangang malaman ng mga developer kung paano mag-model ng data sa Firestore o ang Firestore security rules. Basta't naiintindihan nila ang SQL, mabilis nilang ma-e-edit ang schema.sql natin, makakapagdagdag o makakapagbago ng route sa all.go, at handa na sila. Ang dokumentasyon ay sapat ding malakas para madaling masundan ng mga LLM at makagawa ng mga pagbabago. Mula sa security perspective, panalo rin ito dahil hindi na tayo nakalantad sa mapanganib na denial-of-wallet (Wallet DoS) attack ngayong fixed-cost na ang bagong infra natin at sobra-sobrang mas mura.
Sa kabuuan, kasama ang Postgres instance at araw-araw na S3 backups, nagawa nating bumaba mula $550 / buwan patungong $40 / buwan lang sa database infra costs. Nabawasan din natin ang client bundle size ng ~100KB, na maganda rin.
Ngayong nasa akin na ang bagong sistema, sinubukan ko ito nang lokal para sigurado na gumagana ang lahat ayon sa inaasahan. Pagkatapos, ini-deploy ko ito nang live. Kung may mga problema, madaling i-rollback ang client dahil ang Firestore pa rin ang source of truth sa puntong ito.
Pag-go Live
Medyo nakakatakot ang server cutover (palaging nakakatakot ang database migrations). Hanggang sa puntong ito, ang Firestore pa rin ang source of truth. Ang CDC pub/sub system ay nagbabasa lang mula sa isang Postgres mirror na pinananatiling updated ng synchronizer. Ang pag-deploy ng bagong game servers ang siyang aktwal na magpapalit sa Postgres bilang authoritative para sa writes.
Ganito ko ito inilabas:
- Inupdate muna ang mga partners. Naka-integrate ang Foony sa mga partner tulad ng FRVR, kaya nag-deploy kami ng bagong client build ilang araw nang mas maaga na gagamit sa bagong CDC gateway natin.
- Binigyan ng babala ang mga players. Mga sampung minuto bago ang cutover, nag-post kami ng heads-up tungkol sa isang (sana ay) maikling maintenance window.
- Kumuha ng bagong database backup. Palaging magandang ideya ito sa malalaking database migrations. Tinatawag itong "belt and suspenders" ng mga LLM, na ang ibig sabihin ay sobra-sobrang pag-iingat at pagkakaroon ng backup plan.
- Ni-deploy ang dalawang clusters nang sabay-sabay. Walang blue/green ngayon. Gusto kong sigurado na lahat ng clusters ay nagsusulat sa parehong database para maiwasan ang anumang posibleng inconsistencies.
May rollback plan din ako, na napakasimple: kung may magkamali, ire-deploy ko ulit ang dating client (na nagbabasa pa rin sa Firestore), ire-deploy ang servers, ire-restart ang backfill, at susubukan ulit mamaya. Magdudulot sana ito ng mga 5 minuto ng downtime habang nire-deploy ulit ang servers, at mga kalahating araw para mahabol ng Postgres database ang Firestore.
Ang aktwal na cutover ay tumagal lang ng mga 1 minuto ng downtime. Ang tanging seryosong bug na lumitaw ay sa pag-update ng experience: ang LOWER() ng Postgres sa isang bigint column ay implicit na nagka-cast sa text. Buntong-hininga. Madaling ayusin ito gamit ang simpleng cast pabalik sa bigint, kaya ipinagpatuloy ko ang migration sa pamamagitan ng pag-issue ng isa pang server deploy.
Nagulat ako na mukhang naging maayos ang lahat. Kakaunti ang nag-report ng mga isyu, at lahat ng iyon ay napakaliit (maliban sa bagay na experience). Ang lalong nakakagulat sa akin, gayunpaman, ay kung gaano kakinis ang migration na ito kahit na karamihan nito ay vibe-coded. Medyo iba ito sa mga nakakatakot na kwentong baka mabasa mo sa balita.
Ang Pag-rewrite ng Workshop
Ngayong authoritative na ang Postgres para sa writes, isang malaking bahagi na lang ng codebase ang nakabuhol pa sa Firestore: ang workshop system. Ginagamit ito ng mga players para mag-share ng custom maps sa Dino-Might Bomber Online, word lists sa Draw & Guess, at iba pa. Parang sa Steam Workshop, pero para sa mga laro natin. Ito ang pinaka-Firestore-entangled na feature na natitira, sa parehong client at server, at may awkward na structure ito dahil sa limitasyon ng data model ng Firestore na kailangan kong gawing mas simple.
Bilang simula, binigyan ko ang Cursor ng ganitong prompt (gamit ang Opus 4.7 high sa plan mode):
Now that we've finished most of the migration to Postgres, we have the last big part of the migration: rewriting the workshop system.
We'll need to update schema.sql to support the new workshop tables.
Think of the workshop sort of like Steam's workshop.
In other words, the workshop must:
- Support multiple games.
- Support different data formats (e.g. maps in dinomight, word lists in paintjob)
- Support favoriting
- Support like / dislike
- Support a description
Use best practices to ensure that the SQL is efficient.
After updating schema.sql, you will need to update both the backend and the frontend to use the new workshop system. You will also need to either use the `realtime.use` hook or the API for workshop info (up to you which you choose--realtime requires modifying all.go, API calls require updates to Action.ts, etc.). I'd probably go with the cdc gateway (realtime.use / all.go).
Keep your implementation simple where possible. At the end of this migration, we should no longer have `firestore.use` or any mention of firestore on the client. We should also no longer need firestore on the backend.
As part of this change, you will need to also create a backfill migration (there's already a migration backfill--just modify that to work with these firestore workshop collections) that migrates the firestore data to the postgres schema you decide on.
You shouldn't run the backfill--I will do that myself manually once you're done with your code. You can leave a call to the backfill (commented out) at the bottom of server/src/index.ts for me. The backfill should only handle migration of these remaining tables--it shouldn't do any backfilling of old tables that we've already migrated (e.g. no `userItems`, `usersPublic`, ...).
Sa nakita kong kakayahan ng Opus 4.7 sa Cursor harness, inaasahan kong matatapos ang migration na ito sa magdamag.
Ang malaking bagay na nakaligtaan ko sa orihinal kong prompt, na hiniling ng agent na liwanagin, ay kung paano natin epektibong pangangasiwaan ang "Last Day", "Last Week", "Last Month", at "All Time" sorting. Ipinipilit ng agent ang timestamp-bucketed approach (o tuluyang tanggalin ang timestamp sorting), pero iginiit ng co-founder na panatilihin ang time-based sorting kahit may dagdag na pagiging kumplikado.
Pagkatapos ng kaunting pag-iisip, naisip ko ang isang simpleng solusyon na may decay factor. Ang bawat workshop item ay may played_count_day, played_count_week, played_count_month, at played_count_all columns, at isang oras-oras na cron job ang nag-mu-multiply sa rolling columns ng 23/24, 167/168, at (720-1)/720 ayon sa pagkakasunod. Pinagsama sa partial indexes (WHERE private = false AND played_count_day >= 0.368) sa bawat sort axis, nagbibigay ito sa atin ng napakamurang "most popular by time period" queries nang walang dagdag na infrastructure. Ito ay isang bagay na hindi ko gagawin sa Firestore dahil sa sobrang mahal nitong costs para sa reads at writes, pero halos libre lang ito sa Postgres.
Sa ganoon, natulog na ako. Paggising ko, excited kong tiningnan ang ginawa nito! Idinagdag ng agent ang bagong workshop_items, workshop_item_votes, at user_subscriptions tables, ikinabit ang client para magbasa ng individual items sa pamamagitan ng CDC gateway (realtime.use('workshopItems/{id}')), at na-rewrite ang lahat ng anim na workshop actions sa backend para makipag-usap nang direkta sa Postgres. May ilang loose ends na kinailangan kong ayusin (isang query sa Firestore sa server, isang error sa backfill dahil sa kulang na data sa Firestore, atbp.), pero sa kabuuan halos perpekto ang code.
Pagkatapos ng pagpapatakbo ng backfill, at pagsubok na sigurado na gumagana ang lahat nang lokal, ini-deploy ko ang pagbabago nang live. Sa ganoon, sa wakas, naging Firestore-free na ang codebase. Maganda.
Mga Susunod na Gawain
Sa hinaharap, gusto ko ring suportahan ang patches sa gateway. Sa ngayon, ang gateway ay nagpapabalik ng buong document bilang JSON para sa bawat update. Medyo nakakasayang ito, pero ang egress natin ay halos walang limitasyon dahil nasa Hetzner tayo. Ipapatupad ko ito ngayon, pero hindi ko pa ma-justify ang dagdag na pagiging kumplikado sa ngayon.