Problem
Aplikacja działa. Klient korzysta z niej codziennie. Ma być za tydzień na innej bazie danych – lepszej, szybszej, bez limitów, które już zaczęły boleć. I nagle staje pytanie, którego się nie wypowiada głośno: a jeśli coś pójdzie nie tak w trakcie?
Klasyczny scenariusz „wieczorem eksportujemy, w nocy migrujemy, rano włączamy nową wersję” ma dwa problemy. Pierwszy – firma stoi przez tę noc. Drugi, groźniejszy – jeśli rano coś nie działa, trzeba w panice cofać się do stanu z wieczora, tracąc godziny pracy tego, kto akurat coś wrzucił między jednym a drugim. Da się inaczej.
Wzorzec, który opiszę niżej, wdrożyłam w praktyce w produkcyjnej aplikacji dla drukarni – jeśli wolisz od razu zobaczyć, jak to wyglądało w konkretnym projekcie, zajrzyj do case study migracji z Google Sheets do Postgresa. Ten wpis skupia się na samym wzorcu i kodzie.
Wzorzec: cztery fazy zamiast jednego skoku
Zamiast robić przeskok w jednym ruchu, dzieli się migrację na cztery fazy. Każda z nich jest samodzielnym stanem stabilnym – aplikacja działa poprawnie także wtedy, gdy z jakiegoś powodu utknie w tej fazie na tygodnie.
FAZA 1 - Nowa baza obok starej Postawiona, ale niepodłączona do aplikacji. Skrypty przenoszą historyczne dane. Aplikacja jej nie widzi. FAZA 2 - Odczyt równoległy Aplikacja dalej pisze i czyta ze starej bazy, ale wybrane widoki dostają drugą ścieżkę: "przeczytaj to samo z nowej". Klient nic nie widzi, Ty porównujesz. FAZA 3 - Podwójny zapis (dual-write) Każda operacja pisze do OBU baz jednocześnie. Nowa i stara są w idealnej synchronizacji. Odczyt: dalej ze starej albo już z nowej, zależnie od widoku. FAZA 4 - Odcięcie starej Aplikacja czyta i pisze wyłącznie do nowej bazy. Stara zostaje jako archiwum na wypadek awarii.
Sercem tego wzorca jest faza 3. To ona zamienia migrację ze „skoku wiary” w „spokojny okres obserwacji”.
Jak wygląda dual-write w kodzie
Zasada jest prosta: kod, który wcześniej robił jeden zapis, teraz robi dwa. Ale są dwie subtelności, które decydują o tym, czy to zadziała.
function saveOrder(order) {
// 1. Zapis do starej bazy (source of truth przez cały etap 3)
const oldResult = writeToOldStore(order);
// 2. Zapis do nowej bazy - w try/catch, żeby nie ubić głównej ścieżki
try {
writeToNewStore(order);
} catch (e) {
// NIE rzucamy dalej. Tylko logujemy do porównania na koniec dnia.
log('dual-write-fail', { order_id: order.id, error: e.message });
}
return oldResult; // klient zawsze dostaje odpowiedź ze starej bazy
}
Subtelność pierwsza: stara baza pozostaje źródłem prawdy przez całą fazę 3. Jeśli zapis do nowej padnie, aplikacja nie może tego widzieć. Klient klikający „zapisz zamówienie” nie ma prawa dostać błędu tylko dlatego, że migracja pod spodem ma czkawkę. Dlatego zapis do nowej idzie w try/catch, a błąd tylko się loguje – nie propaguje się na górę.
Subtelność druga: codzienne porównanie. Jeśli nie porównujesz, dual-write jest bezużyteczny – masz dwie bazy, ale nie wiesz, czy się zgadzają. Prosty skrypt „policz ile rekordów przybyło w ciągu ostatnich 24h w starej i w nowej” wystarczy na start. Jeśli liczby się zgadzają – śpisz spokojnie. Jeśli się nie zgadzają – masz jeden dzień do rozgryzienia, dlaczego, zanim narośnie tego więcej.
Jak długo trzymać dual-write
Zasada kciuka: tyle, żeby przeszedł przez to pełny cykl biznesowy plus tydzień. Dla firmy fakturującej miesięcznie to co najmniej pięć tygodni. Chodzi o to, żeby dane odzwierciedlające każdą rutynową operację (miesięczne rozliczenia, korekty, powtarzalne zamówienia) trafiły do nowej bazy przynajmniej raz, żebyś zobaczyła, że wszystko idzie jak trzeba.
Nie skracaj tego okresu, nawet jak wszystko wygląda idealnie po tygodniu. Rzadko używana operacja, o której zapomniałaś, potrafi wyjść dopiero w trzecim tygodniu.
Praktycznie: Apps Script + Supabase w jednej fladze
Powyższy wzorzec jest abstrakcyjny (writeToOldStore, writeToNewStore), bo dual-write działa tak samo dla dowolnej pary „stara baza / nowa baza”. Poniżej wygląd tego samego pomysłu w realiach, w których go najczęściej wdrażałam – aplikacja siedząca na Google Sheets, wyprowadzana do Supabase (Postgres). Cały ruch przechodzi przez jedną flagę MODE, przełączaną w konfiguracji bez wdrożenia.
const CONFIG = {
SUPABASE_URL: PropertiesService.getScriptProperties().getProperty('SUPABASE_URL'),
SUPABASE_KEY: PropertiesService.getScriptProperties().getProperty('SUPABASE_KEY'),
SHEET_NAME: 'Zamowienia',
MODE: 'DUAL_WRITE', // 'SHEETS' | 'DUAL_WRITE' | 'POSTGRES'
};
function createOrder(order) {
// 1. Stara baza - Google Sheets (source of truth w fazach 2 i 3)
if (CONFIG.MODE === 'SHEETS' || CONFIG.MODE === 'DUAL_WRITE') {
const sheet = SpreadsheetApp.getActive().getSheetByName(CONFIG.SHEET_NAME);
sheet.appendRow([order.id, order.client, order.amount, order.status, new Date()]);
}
// 2. Nowa baza - Postgres przez REST Supabase (best-effort w fazie 3)
if (CONFIG.MODE === 'POSTGRES' || CONFIG.MODE === 'DUAL_WRITE') {
try {
UrlFetchApp.fetch(CONFIG.SUPABASE_URL + '/rest/v1/orders', {
method: 'post',
contentType: 'application/json',
headers: {
apikey: CONFIG.SUPABASE_KEY,
Authorization: 'Bearer ' + CONFIG.SUPABASE_KEY,
Prefer: 'return=minimal',
},
payload: JSON.stringify({
id: order.id,
client_name: order.client,
amount: order.amount,
status: order.status,
}),
muteHttpExceptions: true,
});
} catch (e) {
Logger.log('dual-write-fail order=' + order.id + ' err=' + e);
// Nie rzucamy - błąd Postgresa nie może ubić zapisu do Sheets.
}
}
}
Ta jedna flaga oddaje sens całej wcześniejszej gadki o fazach. Widać to najlepiej w tabelce:
MODE = 'SHEETS' - stara baza jak dotychczas, Postgres uśpiony
(faza 1: nowa baza istnieje, aplikacja jej nie widzi)
MODE = 'DUAL_WRITE' - zapisy do OBU baz jednocześnie, odczyt dalej ze starej (faza 3: sedno migracji)
MODE = 'POSTGRES' - Sheets odcięty, wszystko idzie tylko do Postgresa
(faza 4: cutover)
Przejście z fazy 3 do 4 jest wtedy jedną literką w konfiguracji, wdrożoną w cichy piątek po pracy. Bez repushowania kodu, bez fanfar, z opcją powrotu w minutę.
Dwie rzeczy, które trzeba mieć na uwadze, jeśli chcesz to skopiować do siebie:
1. Klucze w Script Properties, nie w kodzie. W przykładzie widać PropertiesService.getScriptProperties().getProperty(...), nie hardkoduj URL-a i klucza w pliku – trafiłyby do gita. Wejdź w edytorze Apps Script w Ustawienia projektu -> Właściwości skryptu i tam dodaj SUPABASE_URL i SUPABASE_KEY.
2. Klucz służbowy vs anon. Jeśli włączasz w Supabase Row Level Security (a powinnaś), używaj klucza service_role na backendzie – omija RLS, bo GAS działa jako „administrator” tej integracji. anon jest do frontendu w przeglądarce, nie do skryptu serwerowego.
Faza 4: kiedy odciąć starą bazę
Odcięcie nie jest „usunięciem”. To zmiana konfiguracji – aplikacja przestaje pisać i czytać ze starej, ale stara nie znika. Zostaje przez kolejne tygodnie w trybie read-only, żeby w razie potrzeby móc do niej zajrzeć. Dopiero gdy przez miesiąc nikt jej nie potrzebuje, można archiwum spakować i schować głębiej.
Sam moment odcięcia to jedna zmiana flagi w konfiguracji, wdrożona w cichy piątek po pracy. Bez fanfar. Klient dowiaduje się w poniedziałek, gdy przychodzi mail z informacją, że działa już na nowej bazie.
Co się może zepsuć
Najczęściej: dane w dwóch bazach subtelnie się rozjeżdżają, mimo że dual-write „działa”. Powody bywają nudne – inna kolejność triggerów po jednej stronie, autoformatowanie liczb w starym silniku, timezone dat, cudzysłowy zamieniane przez arkusz. To normalne. Dlatego istnieje faza 2 i codzienne porównania – żeby to złapać wcześnie, kiedy jeszcze można poprawić bez paniki.
Druga rzecz: kod pisany na fazę 3 zostaje w aplikacji. Ten cały try/catch wokół zapisu do nowej bazy nie jest już potrzebny w fazie 4 i staje się śmieciem. Po odcięciu warto zaplanować „czyszczenie po migracji” jako osobne, spokojne zadanie na tydzień lub dwa później. Nie w tym samym dniu co cutover.
Czego się nauczyłam
Migracja bazy to nie jest problem techniczny, tylko problem zaufania. Klient przez cały ten czas ma po prostu chodzącą firmę – nie interesuje go, że pod maską wymieniasz silnik. Wzorzec dual-write działa dlatego, że w każdym momencie istnieje plan awaryjny w ciągu jednej minuty – flaga do przełączenia, i aplikacja jest z powrotem tam, gdzie była. To ten spokój ducha kupujesz, nie technologię.


