Runtime aplikacji: Node + SQLite na VPS
Od 10.09.2026 wszystkie środowiska (dev, qa, produkcja) działają jako zwykły proces Node na serwerze VPS, z bazą SQLite na dysku. Ten dokument opisuje, co to oznacza dla użytkownika, i jak zbudowany jest runtime — od entrypointu, przez warstwę lib/runtime, po crony i kopie zapasowe. Infrastruktura wokół aplikacji (Coolify, tunel, GlitchTip, Healthchecks, backupy R2) jest opisana w docs/infrastruktura-vps.md; historia migracji z Cloudflare Workers — w planie docs/plans/migracja-vps-sqlite-coolify.md.
👤 Dla użytkownika
Adresy
| Środowisko | Adres | Gałąź | Kto korzysta |
|---|---|---|---|
| produkcja | klient.acepark.pl | main | klienci, recepcja, trenerzy, urządzenia Pi |
| qa | qa.acepark.pl | prod_dev | klient testowy, próby przed wydaniem |
| dev | dev.acepark.pl | develop | zespół |
Panel pracownika i portal klienta to jedna aplikacja pod jednym adresem: https://klient.acepark.pl/dashboard. Stary adres panel.acepark.pl przekierowuje na klient.acepark.pl z zachowaniem ścieżki, więc zapisane zakładki dalej działają.
Co się dzieje przy wdrożeniu
Każde wdrożenie to krótka przerwa: stary kontener jest zatrzymywany, nowy startuje, wykonuje migracje bazy i dopiero wtedy przyjmuje ruch. W tym czasie strona pokazuje komunikat o przerwie technicznej (po polsku i angielsku) i sama wraca, gdy aplikacja wstanie — zwykle po kilkunastu sekundach, w najgorszym razie po około dwóch minutach. Żądania, które były w toku (płatność, zapis na zajęcia), są dokończone przed zatrzymaniem.
Wdrożenia produkcyjne robimy poza godzinami pracy recepcji i poza godzinami cronów dziennych.
Gdzie zgłaszać problemy
Przycisk „Zgłoś błąd” na ekranie błędu tworzy zgłoszenie w Jirze z linkiem do zdarzenia w GlitchTipie — patrz GlitchTip. Stan cronów (przypomnienia, raporty) widać w Healthchecks (hc.acepark.pl).
🔧 Dokumentacja techniczna
Procesy
Jeden obraz Dockera (Dockerfile, node:24-bookworm-slim), trzy kontenery z docker-compose.yml:
| Kontener | Polecenie | Rola |
|---|---|---|
web | node dist/server.js | server.ts: Next.js (App Router) + obsługa WebSocketów /api/hardware/ws i /api/hardware/viewer-ws przed Next + wewnętrzne API hardware |
cron | node dist/cron.js | cron.ts: harmonogram z lib/cron/jobs.ts (croner, Europe/Warsaw), catch-up po restarcie, pingi Healthchecks |
litestream | litestream replicate | ciągła replikacja acepark.db do R2 (segmenty co 1 s, snapshot co 24 h, retencja 30 dni) |
Oba pliki dist/*.js powstają z yarn build:server (esbuild, --packages=external); yarn build buduje Next.js. Obraz zawiera .next, dist, migrations, scripts/migrate.mjs i scripts/snapshot-db.mjs.
Start kontenera
docker-entrypoint.sh uruchamia migracje tylko w kontenerze web (kontener cron startuje dopiero, gdy web jest healthy, więc schemat ma jednego pisarza):
scripts/snapshot-db.mjs— kopiaVACUUM INTO /data/snapshots/pre-deploy-<stamp>.dbprzed jakąkolwiek zmianą schematu,scripts/migrate.mjs --database /data/acepark.db --allow-multiple— stosuje brakujące pliki zmigrations/w transakcji per plik, historia w tabelid1_migrations(Migracje bazy danych),exec node dist/server.js.
Healthcheck (GET /api/health, w obrazie i w compose) odpowiada {"status":"ok","runtime":"node","release":"<SHA>","origin":…} dopiero, gdy baza odpowiada na SELECT 1 — Coolify przełącza ruch na nowy kontener po tym sygnale.
Zatrzymanie: drenowanie
Oba procesy obsługują SIGTERM. web zamyka nasłuch, odpowiada 503 z Retry-After na nowe żądania i czeka do 50 s (DRAIN_TIMEOUT_MS) na żądania w locie; cron nie startuje nowych zadań i czeka do 170 s na trwające. Dopiero potem closeNodeRuntime() zamyka SQLite. Limity są celowo niższe niż stop_grace_period w compose (60 s / 180 s), po którym Docker wysyła SIGKILL.
Warstwa runtime (lib/runtime/**)
Tylko ten katalog wie, że pod spodem jest SQLite. Reszta aplikacji używa API w kształcie D1 (prepare().bind().first()/all()/run(), batch()) i KV (get/put/list/delete), zadeklarowanego globalnie w types/runtime-env.d.ts.
| Plik | Rola |
|---|---|
env.ts | getRuntimeEnv() → { DB, APP_CACHE }; runInBackground(promise) oddaje pracę do after() z next/server (poza żądaniem promise leci samodzielnie); getAppRuntime() zwraca 'node' (raportowane w /api/health) |
node-env.ts | jednorazowe otwarcie obu baz (symbol na globalThis, więc przeżywa HMR), closeNodeRuntime() |
sqlite-driver.ts | openNodeSqlite(path) nad node:sqlite (DatabaseSync), cache 256 przygotowanych zapytań, transaction() z BEGIN IMMEDIATE |
sqlite.ts | PRAGMA: journal_mode=WAL, synchronous=NORMAL, busy_timeout=5000, foreign_keys=ON, mmap_size=256 MB, cache_size=64 MB |
d1-sqlite.ts | shim D1: SqliteD1Database, SqliteD1PreparedStatement, batch() w jednej transakcji, D1Result.meta z changes/last_row_id |
kv-sqlite.ts | shim KV na cache.db (tabela kv z expires_at); cron kv-expire co 5 min usuwa przeterminowane wpisy |
kysely-dialect.ts | dialekt Kysely dla Better Auth nad tym samym sterownikiem (jedno połączenie, jedna transakcja) |
drain.ts, maintenance.ts | drenowanie żądań i tryb przerwy technicznej |
synchronous=NORMAL jest na tym hoście obowiązkowe: fsync trwa ~220 ms, a w trybie FULL każdy zapis czekałby na dysk. WAL daje równoległe odczyty przy jednym pisarzu; busy_timeout 5 s pokrywa krótkie blokady, a błędy SQLITE_BUSY są ponawiane przez withD1Retry (Odporność bazy).
yarn bench:runtime (scripts/bench/runtime-shim.ts) mierzy shim: first() po kluczu głównym ok. 7 µs, all() 1 000 wierszy ok. 1,7 ms, batch() 1 000 insertów ok. 1,9 ms, kv get() 12 µs.
Jeden host, dwie role
Aplikacja serwuje portal klienta i panel pracownika z jednego procesu. Podział na role (client / admin) wynika z hosta żądania, nie z buildu: CLIENT_HOSTS i ADMIN_HOSTS w zmiennych środowiska. Host spoza obu list (tak działa dziś produkcja, qa i dev) serwuje całą aplikację bez przekierowań. Mechanizm, plik scripts/route-variants.json i handoff sesji między hostami opisuje Podział CLIENT/ADMIN po hoście.
Zmienne środowiska
Kontener dostaje wyłącznie zmienne wymienione w docker-compose.yml (Coolify nie wstrzykuje niczego automatycznie). Najważniejsze grupy:
| Grupa | Zmienne |
|---|---|
| baza i cache | DATABASE_PATH=/data/acepark.db, CACHE_DATABASE_PATH=/data/cache.db, BACKUP_DIR=/data/backups |
| adresy | APP_BASE_URL, BETTER_AUTH_URL, NEXT_PUBLIC_BASE_URL (build arg), CLIENT_HOSTS, ADMIN_HOSTS, CLIENT_ORIGIN, ADMIN_ORIGIN |
| wersja | APP_RELEASE=${SOURCE_COMMIT} — SHA wdrażanego commita (release w GlitchTipie, pole release w /api/health) |
| monitoring | SENTRY_DSN, SENTRY_ENVIRONMENT, GLITCHTIP_URL, HEALTHCHECKS_PING_URL, HEALTHCHECKS_API_URL, HEALTHCHECKS_SLUG_PREFIX |
| hardware | INTERNAL_API_TOKEN, HARDWARE_CONTROL_URL (cron → web) |
| środowisko | REPORT_ENVIRONMENT (production włącza wysyłkę raportu braków dokumentów), P24_SANDBOX, TZ=UTC |
APP_RUNTIME=node w compose i Dockerfile jest pozostałością po okresie dwóch runtime'ów — aplikacja go nie czyta.
Crony
lib/cron/jobs.ts to jedyne źródło prawdy: 15 zadań aplikacyjnych (przypomnienia, płatności, raporty, hardware, KSeF, Meta) i 4 utrzymaniowe:
| Zadanie | Harmonogram | Co robi |
|---|---|---|
kv-expire | co 5 min | usuwa przeterminowane klucze z cache.db |
logs-retention | 03:00 | kasuje wiersze logs starsze niż 90 dni (partiami) |
db-snapshot | 03:00 | VACUUM INTO do BACKUP_DIR, lokalna retencja 14 dni |
db-optimize | 05:00 | PRAGMA optimize |
Każde uruchomienie pinguje Healthchecks (/start, sukces, /fail); checki tworzy i aktualizuje node dist/cron.js list --sync-healthchecks (uruchamiane po dodaniu zadania). Zadanie dzienne, którego termin minął w ciągu ostatnich 6 h przed startem procesu i nie ma nowszego last_run_at, jest nadrabiane od razu — wdrożenie w minucie triggera nie gubi biegu. Szczegóły i polecenia CLI: Powiadomienia → Zaplanowane zadania.
Kopie zapasowe i odtwarzanie
| Warstwa | Mechanizm | Odtworzenie |
|---|---|---|
| ciągła (PITR, 30 dni) | Litestream → R2 acepark-backups/litestream/<zasób> | litestream restore -o /tmp/restore.db -config litestream.yml /data/acepark.db (ok. 6 s) |
| dzienna, lokalna | cron db-snapshot → /data/backups/*.db (14 dni) | skopiować plik na miejsce acepark.db przy zatrzymanym stosie |
| przed każdym wdrożeniem | pre-deploy-<stamp>.db w /data/snapshots | jak wyżej — to kopia sprzed migracji, gdyby migracja zepsuła dane |
Przywrócenie poprzedniej wersji kodu to w Coolify „Redeploy” wcześniejszego wdrożenia (obraz jest w cache) — patrz RELEASE_GUIDELINES.md. Migracje są tylko „do przodu”, więc cofnięcie kodu po migracji zmieniającej schemat wymaga też odtworzenia bazy z pre-deploy-*.db.
Lokalnie
yarn db:migrate # tworzy ./.data/dev.db z pełnego zestawu migracji
yarn dev # Next.js dev na ./.data/dev.db (bez WebSocketów Pi)
yarn dev:hardware # tsx server.ts — pełny entrypoint z WebSocketami
yarn dev:cron # scheduler lokalnie; `npx tsx cron.ts run <zadanie>` odpala jedno zadanie
yarn build && yarn build:server && yarn start:server # to, co robi obraz
DATABASE_PATH wskazuje inny plik (np. import z produkcji). Wybór pliku opisuje scripts/lib/local-database.js: DATABASE_PATH, a gdy nie ustawione — ./.data/dev.db, jeśli istnieje.
CI
ci-pipeline.yml: Code Formatting Check, Route Variant Classification (yarn variants:check), Build (yarn build + yarn build:server); test-pipeline.yml: yarn test. Lokalnie to samo robi yarn ci:local. Wdrożenie wykonuje Coolify po pushu na gałąź środowiska (webhook GitHub App), budując obraz z Dockerfile na serwerze.