Konwencje czasu w bazie danych
Audyt konwencji przechowywania i porównywania czasu w Cloudflare D1 (AP-720).
👤 Dla kogo
Dokument techniczny (dla developerów). Opisuje, w jakim formacie trzymamy znaczniki czasu, jak je poprawnie porównywać w SQL i jaka pułapka doprowadziła do trzech błędów produkcyjnych. Jeśli piszesz zapytanie dotykające kolumny z datą — przeczytaj sekcję Reguła.
🌍 Punkt wyjścia
Runtime Cloudflare Workers działa w UTC. Cała logika biznesowa aplikacji jest w czasie polskim (Europe/Warsaw, UTC+1 zimą / UTC+2 latem). Te dwie strefy rozjeżdżają się o 1–2 godziny, więc:
- między północą a 01:00/02:00 czasu polskiego data UTC to wciąż poprzedni dzień,
- błędy z tego wynikające ujawniają się tylko w wąskim oknie dobowym i przy zmianie czasu — stąd historia trudnych do odtworzenia usterek.
🗃️ Format zapisu
Znaczniki czasu trzymamy jako TEXT w formacie ISO 8601 UTC z sufiksem Z:
2026-07-20T13:05:20.123Z
To dokładnie to, co zwraca new Date().toISOString() w JavaScript, i to jest format kanoniczny. Piszą w nim:
| Źródło zapisu | Przykład |
|---|---|
| Kod aplikacji | paymentExpiresAt.toISOString() |
| Default kolumny w migracji | DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) |
| Wyrażenie w zapytaniu | strftime('%Y-%m-%dT%H:%M:%f', 'now', 'subsec') || 'Z' |
Kolumny czysto kalendarzowe
Część kolumn to daty bez godziny (yyyy-MM-dd), nie instanty — np. date_of_birth, due_date, exception_date, end_date serii cyklicznych. Nie podlegają konwersji stref; porównuje się je jako stringi albo przez date().
⚠️ Pułapka: datetime('now') kontra format Z
SQLite ma dwa różne sposoby renderowania „teraz", które nie są wymienne:
| Wyrażenie | Wynik | Separator |
|---|---|---|
datetime('now') | 2026-07-20 13:05:20 | spacja |
CURRENT_TIMESTAMP | 2026-07-20 13:05:20 | spacja |
strftime('%Y-%m-%dT%H:%M:%f','now','subsec') || 'Z' | 2026-07-20T13:05:20.123Z | T |
Wszystkie trzy są w UTC — strefa nie jest tu problemem. Problemem jest separator przy porównaniu stringowym.
Gdy porównujesz kolumnę w formacie Z bezpośrednio z datetime('now'), SQLite porównuje leksykalnie. Części dat są wtedy identyczne, więc o wyniku decyduje znak na pozycji 10: 'T' (0x54) kontra spacja (0x20). 'T' jest zawsze większe, więc:
Wartość z formatem
Zjest uznawana za późniejszą niż „teraz" przez całą bieżącą dobę UTC — niezależnie od faktycznej godziny.
Rozbieżność znika dopiero, gdy różnią się same daty (wtedy rozstrzygają wcześniejsze znaki). Dlatego błąd jest niewidoczny w testach na danych sprzed tygodnia, a systematyczny na danych z dziś.
-- payment_expires_at = '2026-07-20T13:00:00.000Z' (wygasła 10 minut temu)
-- ŹLE — zwraca 0, płatność uchodzi za aktywną
SELECT payment_expires_at < datetime('now');
-- DOBRZE — zwraca 1
SELECT payment_expires_at < (strftime('%Y-%m-%dT%H:%M:%f','now','subsec') || 'Z');
Dlaczego datetime(kolumna) też działa
datetime() rozumie sufiks Z i offsety, i normalizuje wszystko do UTC:
datetime('2026-07-20T19:00:00.000Z') -- 2026-07-20 19:00:00
datetime('2026-07-20T21:00:00+02:00') -- 2026-07-20 19:00:00
datetime('2026-07-20 19:00:00') -- 2026-07-20 19:00:00
Dlatego datetime(kolumna) > datetime('now') jest poprawne — obie strony trafiają do tego samego formatu. Niebezpieczne jest wyłącznie porównanie, w którym kolumna zostaje surowym stringiem, a druga strona ma inny separator.
✅ Reguła
-
Zapisuj znaczniki czasu wyłącznie jako ISO UTC z
Z(toISOString()lubstrftime(...) || 'Z'). -
Porównuj kolumnę z „teraz" przez stałą
SQL_NOW_UTC_ISOzlib/date.ts:import { SQL_NOW_UTC_ISO } from '@/lib/date';const query = `SELECT * FROM paymentWHERE payment_expires_at < ${SQL_NOW_UTC_ISO}`; -
Jeśli musisz użyć modyfikatora czasu, wstaw go wewnątrz
strftime, zachowując format:created_at > (strftime('%Y-%m-%dT%H:%M:%f', 'now', '-24 hours', 'subsec') || 'Z') -
Alternatywa dopuszczalna, ale mniej czytelna: obłóż obie strony w
datetime(). Nie mieszaj obu podejść w jednym predykacie. -
Modyfikator
'localtime'jest bezużyteczny i mylący — na Workers strefa systemowa to UTC, więc nic nie zmienia, a sugeruje czas lokalny. Nie używaj go.
🐛 Błędy znalezione w audycie
Wzorzec kanoniczny (strftime ... || 'Z') występował w kodzie ~250 razy i był poprawny. Odstępstw było 13, z czego 9 stanowiło realne błędy:
| Miejsce | Predykat | Skutek |
|---|---|---|
lib/actions/payment.ts (5×), lib/actions/players.ts (1×) | payment_expires_at < datetime('now','localtime') | Okno płatności to ~10 minut, więc expires_at prawie zawsze wypada w bieżącej dobie. Predykat nigdy nie zwracał prawdy — wygasłe płatności oczekujące nie były filtrowane. |
lib/actions/camp-registrations.ts, lib/actions/tennis-course-registrations.ts | expires_at > datetime('now') | Wygasłe linki płatnicze pozostawały ważne do północy UTC. |
lib/actions/camp-discounts.ts (2×), lib/actions/tennis-course-discounts.ts (2×) | start_at <= datetime('now'), end_at >= datetime('now') | Kod rabatowy startujący danego dnia nie działał aż do następnej doby; kod, który wygasł danego dnia, działał do północy UTC. |
lib/actions/hardware.ts | created_at > datetime('now','-24 hours') | Okno alertów za szerokie — zdarzenia starsze niż 24 h, ale z tej samej doby co próg, trafiały do wyniku. |
Wszystkie naprawione przez podmianę na SQL_NOW_UTC_ISO.
Czego audyt nie potwierdził
Pierwotne zgłoszenie zakładało, że game.start_time / end_time są zapisywane jako czas lokalny bez oznaczenia strefy. To nieprawda — powstają przez .toISOString() (ReservationForm.tsx) i zonedTimeToUtc(...).toISOString() (individual-training.ts), czyli są zwykłym ISO UTC z Z. Porównania w rodzaju datetime(start_time) < datetime(?) są spójne i poprawne.
🔭 Znane ryzyko poza zakresem
parseTimeToDate() w app/(dashboard)/dashboard/schedule/utils/time-slot-utils.ts używa setHours(), czyli interpretuje wybraną godzinę w strefie przeglądarki, a dopiero potem konwertuje przez toISOString(). Dla pracownika w Polsce jest to poprawne, ale rezerwacja zakładana z urządzenia w innej strefie zapisze się o odpowiednią liczbę godzin obok. Do rozstrzygnięcia osobnym zgłoszeniem.
🧪 Testy
__tests__/lib/db-time-conventions.test.ts (23 przypadki) uruchamia predykaty na prawdziwym silniku SQLite (better-sqlite3), a nie na atrapie:
- kształt i dryf
SQL_NOW_UTC_ISOwzględem zegara JS, - regresja na rozbieżności
Tkontra spacja (łącznie z dowodem, że rozjazd znika przy różnych datach), - wygasanie płatności w oknie 23:00–02:00 czasu polskiego, latem i zimą,
- obie zmiany czasu 2026: luka przy przejściu wiosennym (29 marca) i powtórzona godzina jesienią (25 października),
- przełom roku po stronie polskiej i po stronie UTC,
- okno ważności kodu rabatowego i 24-godzinne okno alertów sprzętowych.