# 14 - Databaze Naprogramovano a overeno. Zatim se ukladaji **konektory**, tedy pristupove udaje k sluzbam. Zbytek je v pameti procesu, poradi dalsich kroku je na konci. Ukladat jde tremi zpusoby a rezim se vybira sam podle toho, co je k dispozici. ## Tri rezimy, jedno rozhrani Rozdil se resi **na jednom miste**, v `src/data/connectorStore.ts`. Nikde jinde se nezjistuje, ktery rezim jede - kdyby se to rozlezlo po kodu, jedno misto by se zapomnelo a chovalo by se pak jinak nez zbytek. | Rezim | Kdy | Prezije restart | Prezije redeploy | | ---------- | --------------------------------- | --------------- | ---------------- | | `postgres` | je `DATABASE_URL` i `SECRETS_KEY` | ano | ano | | `file` | neni databaze, ale je `DATA_DIR` | ano | **ne** | | `memory` | ani jedno, nebo nejde zapsat | ne | ne | Rezim `file` je pro mockup. Filesystem containeru je docasny, takze soubor prezije restart procesu i containeru, ale nove nasazeni ho smaze. Je to mezistupen, ne nahrada databaze. Nasazeny mockup tedy jede v rezimu `file` a v portalu je to napsane. Lokalne s Postgresem jede `postgres`. Chybejici databaze **nesmi shodit start**. Container, ktery nenastartuje, je pro AppFactory nefunkcni sluzba (AGENTS.md). Misto toho se do logu napise, ktery rezim jede a proc, a portal to ukaze na strance Konektory. Databaze potrebuje **oboji**. Bez klice by se pristupove udaje ukladaly v plaintextu, a to je horsi nez ztratit je pri restartu - tabulku vidi kazda zaloha a kazdy dump pri ladeni. Stejne tak: kdyz jsou migrace nastavene, ale selzou, jede se dal bez databaze. Psat do rozbiteho schematu je horsi nez psat do souboru. ## Promenne | Promenna | K cemu | | ------------------- | ------------------------------------------------------------------------------- | | `DATABASE_URL` | `postgres://uzivatel:heslo@host:5432/csbot` | | `SECRETS_KEY` | klic pro sifrovani pristupovych udaju, **secret** | | `DATABASE_POOL_MAX` | kolik spojeni si vezme jedna instance, vychozi 10 | | `DATABASE_SSL` | `true` u spravovanych databazi, ktere vyzaduji TLS | | `DATA_DIR` | slozka pro JSON mimo databazi, vychozi `./data`. Prazdna hodnota vypne i soubor | `SECRETS_KEY` ma byt nahodny retezec, ne heslo: ```bash node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))" ``` **Klic se nesmi ztratit ani zmenit bez prevodu dat.** Bez nej se ulozene udaje nerozsifruji. Nic se nerozbije, jen se konektory chovaji jako nevyplnene a v logu je napsane proc - udaje se pak zadaji znovu. ## Lokalni spusteni ```bash docker run -d --name csbot-pg -p 5433:5432 \ -e POSTGRES_PASSWORD=devpass -e POSTGRES_DB=csbot postgres:16-alpine export DATABASE_URL="postgres://postgres:devpass@127.0.0.1:5433/csbot" export SECRETS_KEY="$(node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))")" npm run dev ``` Migrace se pousti samy pri startu. Kontrola, ze to jede z databaze: ```bash curl -s http://localhost:3000/health/ready ``` Bez promennych `npm run dev` funguje dal, jen se uklada do `./data`. ## Soubor misto databaze `src/data/snapshot.ts` a `src/data/connectors/local.ts`. Ukladani je **jeden kod pro pamet i soubor**, lisi se jen tim, kam se zapisuje. Kdyby to byly dve implementace, jedna by se casem opravila a druha ne. | Vlastnost | Jak a proc | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------- | | Atomicky zapis | nejdriv `.tmp`, pak prejmenovani. Pad uprostred zapisu jinak nechá polovicni JSON, ktery se pri startu nenacte | | Slucovani zapisu | deset uprav za sebou znamena jeden zapis na disk | | Zapis pri ukonceni | `SIGTERM` dokonci rozepsany zapis, jinak by se posledni zmena ztratila | | Rozbity soubor | prejmenuje se na `.broken`, zaloguje a jede se s prazdnymi daty. Aplikace, ktera nenastartuje, je pro AppFactory nefunkcni sluzba | | Sifrovani | tajne hodnoty jsou v souboru zasifrovane, plaintext nikdy | ### Klic mimo databazi Bez `SECRETS_KEY` si aplikace v rezimu `file` vygeneruje klic do `DATA_DIR/secrets.key` (prava 0600). Diky tomu funguje sifrovani bez jakehokoliv nastaveni. **Rekneme si nahlas, co to je a co ne.** Klic lezi ve stejne slozce jako data, takze to chrani proti nahodnemu precteni JSONu, **ne** proti nekomu, kdo ma pristup k disku serveru nebo k zaloze slozky. Do provozu patri klic ze secretu, tedy `SECRETS_KEY`. U databaze se klic vedle dat **negeneruje vubec**. Nemelo by to smysl: kdo ma zalohu tabulky, ma i klic ze stejneho stroje. Proto je `allowKeyFile` parametr, ne automatika. Kdyz nejde ani jedno, tajne hodnoty se do souboru neukladaji a zbytek konektoru ano. Radsi je zadat znovu nez je mit v souboru citelne. ## Migrace Soubory `src/db/migrations/*.sql`, v abecednim poradi, kazdy jednou. Co uz proslo, je v tabulce `schema_migrations`. Dve veci, na kterych to stoji: - **Poradovy zamek.** Pri rolling deployi startuje vic instanci naraz a bez `pg_advisory_lock` by migrace pustily vsechny. - **Jeden soubor je jedna transakce.** Pri chybe se z nej neuplatni nic, takze nevznikne rozdelane schema, o kterem nikdo nevi. **Migrace se nikdy neupravuji zpetne.** Uz projely u nekoho jineho, takze zmena souboru znamena dve rozdilna schemata se stejnym cislem. Oprava je vzdy novy soubor. ## Sifrovani pristupovych udaju `src/db/secretBox.ts`, AES-256-GCM. ```json { "v": 1, "iv": "...", "tag": "...", "data": "..." } ``` - **GCM**, ne CBC: sifruje a zaroven overuje, ze s daty nikdo nehybal. - **Nahodne IV** pro kazdou hodnotu, aby dve stejne hodnoty nedaly stejnou sifru. - **`v` je verze klice.** Vymena klice pak znamena precist starym a zapsat novym, ne zahodit vsechna napojeni. - **Nerozsifrovatelna hodnota nepada.** Jeden rozbity konektor nesmi shodit seznam vsech ostatnich, takze se chova jako nevyplneny a zaloguje se to. Sifruji se vsechna pole, i necitliva. Je to jednodussi nez rozhodovat u kazdeho zvlast a nic to nestoji. ## Schema `connectors` (migrace `001_connectors.sql`): | Sloupec | Poznamka | | ------------ | ----------------------------------------------- | | `tenant_id` | povinne, index zacina jim | | `service_id` | odkaz do katalogu v kodu, ne do tabulky | | `secrets` | JSONB se sifrovanymi hodnotami, nikdy plaintext | | `is_default` | jediny vychozi na firmu a sluzbu, hlida index | **Sluzby v databazi nejsou.** Jsou to definice, ktere delame my, a repo je u nich zdroj pravdy kvuli code review a historii v gitu. Rucne upraveny radek v produkci nikdo za tri mesice nedohleda. Duvody jsou v [12-sluzby-a-konektory.md](12-sluzby-a-konektory.md). Vychozi konektor hlida **castecny unikatni index**, ne jen kod: ```sql CREATE UNIQUE INDEX connectors_one_default_idx ON connectors (tenant_id, service_id) WHERE is_default; ``` Bez nej by dva soubezne zapisy udelaly dva vychozi a krok bez vybraneho konektoru by si vybiral podle nahody. ## Dvere k oddelene databazi `dbFor(tenantId)` dnes vraci vzdy tentyz pool. Je to zamerny sev: jednou prijde klient, ktery bude chtit vlastni databazi nebo bude delat tricet procent provozu, a presun ma byt konfigurace, ne prepisovani dotazu. Podminka je **nikdy nespojovat dotazem dva klienty**, coz uz vynucuje povinny argument `tenantIds` v ulozistich. Podrobnosti v [10-runtime-a-kapacita.md](10-runtime-a-kapacita.md). ## Health | Endpoint | Zavisi na DB | K cemu | | --------------- | ------------ | ----------------------------------------- | | `/health` | ne | liveness, AppFactory podle nej restartuje | | `/health/ready` | ano | readiness, vraci 503 pri nedostupne DB | `/health` **nesmi** na databazi zavisel. Kratky vypadek DB by jinak vedl k restartovani containeru, coz nic nespravi. Vysledek pingu se par sekund cachuje, aby monitoring nedelal dotaz pri kazdem pingu. ## Pool a jedno pravidlo `DATABASE_POOL_MAX` je vychozi 10 a je to zamerne malo. **Worker nesmi drzet spojeni po dobu volani ciziho API** - volani do iDokladu trva 300 ms a pri stovce soubeznych kroku by to bylo sto obsazenych spojeni. Se spravnym poradim (odeber ulohu, uvolni spojeni, volej, zapis) staci par. Az bude instanci vic, prijde PgBouncer v transakcnim rezimu. **Pozor: v nem nefunguje `LISTEN/NOTIFY`**, na kterem ma stat sbernice udalosti pro SSE. Ta pak potrebuje prime spojeni mimo PgBouncer. ## Co bylo overeno Proti Postgresu 16 v kontejneru: | Co | Vysledek | | -------------------------------------------------------- | -------- | | Migrace projedou a zapisou se do `schema_migrations` | ano | | Udaje jsou v tabulce sifrovane, plaintext nikde | ano | | Konektor prezije restart procesu | ano | | Se spravnym klicem se udaje rozsifruji | ano | | Se spatnym klicem se chovaji jako nevyplnene a loguje se | ano | | `PATCH` bez tajneho pole tajne pole nesmaze | ano | | Prepnuti vychoziho konektoru | ano | | Smazani vychoziho preda priznak zbylemu | ano | | Bez `DATABASE_URL` jede souborovy rezim a rekne to | ano | | Konektor v souborovem rezimu prezije restart procesu | ano | | Tajne hodnoty jsou v JSONu sifrovane, plaintext nikde | ano | | U databaze se klic vedle dat nevygeneruje | ano | ## Co chybi | Chybi | Poznamka | | ------------------------------------ | ----------------------------------------------------------------------------- | | Automatizace v ulozisti | dalsi na rade, je to to, co si clovek nastavi. Pujde do souboru i do databaze | | Rozlozeni dashboardu | male a samostatne, hned po automatizacich | | Tickety a incidenty | naposled, dnes je generuje simulace | | Uzivatele, firmy, resitele | v prototypu je to spis konfigurace nez data | | Sbernice udalosti pres LISTEN/NOTIFY | dnes `EventEmitter` v pameti jedne instance | | Vymena klice (rotace) | `v` je pripravene, prevod dat napsany neni | | Retence a partitionovani | az u tabulek behu, viz dokument 10 |