Przejdź do głównej zawartości

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 zapisuPrzykład
Kod aplikacjipaymentExpiresAt.toISOString()
Default kolumny w migracjiDEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
Wyrażenie w zapytaniustrftime('%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żenieWynikSeparator
datetime('now')2026-07-20 13:05:20spacja
CURRENT_TIMESTAMP2026-07-20 13:05:20spacja
strftime('%Y-%m-%dT%H:%M:%f','now','subsec') || 'Z'2026-07-20T13:05:20.123ZT

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 Z jest 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

  1. Zapisuj znaczniki czasu wyłącznie jako ISO UTC z Z (toISOString() lub strftime(...) || 'Z').

  2. Porównuj kolumnę z „teraz" przez stałą SQL_NOW_UTC_ISO z lib/date.ts:

    import { SQL_NOW_UTC_ISO } from '@/lib/date';

    const query = `
    SELECT * FROM payment
    WHERE payment_expires_at < ${SQL_NOW_UTC_ISO}
    `;
  3. 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')
  4. Alternatywa dopuszczalna, ale mniej czytelna: obłóż obie strony w datetime(). Nie mieszaj obu podejść w jednym predykacie.

  5. 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:

MiejscePredykatSkutek
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.tsexpires_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.tscreated_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_ISO względem zegara JS,
  • regresja na rozbieżności T kontra 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.