# 14 - Databaze Naprogramovano a overeno. Uklada se vsechno: konektory, tickety, automatizace, incidenty, rozlozeni dashboardu, entity nastaveni, audit a notifikace. Ukladat jde tremi zpusoby a rezim se vybira sam podle toho, co je k dispozici. ## Tri rezimy, jedno rozhrani, jedno rozhodnuti Rozdil se resi **na jednom miste**, v `initStores` v `src/data/store/index.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. Presne to se stalo: konektory mely vlastni rozhodnuti v `connectorStore.ts` s jinymi podminkami nez zbytek, takze konektory mohly jet z databaze a tickety ze souboru. Ted `connectorStore` jen vola `initStores` a rezim je **jeden pro vsechna uloziste**. | Rezim | Kdy | Prezije restart | Prezije redeploy | | ---------- | --------------------------------------------------------- | --------------- | ---------------- | | `postgres` | je `DATABASE_URL`, migrace prosly a je cim sifrovat (`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 | | Necitelny soubor | jen `ENOENT` je prvni start. Jina chyba cteni (prava, plny disk) **zamkne zapisy** a zaloguje se - jinak by se soubor s daty prepsal prazdnym | | Sifrovani | tajne hodnoty jsou v souboru zasifrovane, plaintext nikdy | Rozdil mezi poslednimi dvema radky je zamer. Rozbity JSON je zalozeny bokem a nic se neztrati. Chyba cteni ale neznamena, ze data neexistuji - a start s prazdnem, ktery by je pri prvnim zapisu prepsal, je jedina cesta, jak o ne v rezimu `file` opravdu prijit. ## Vrstvy nad ulozistem Dve obalky, kazda pro jiny druh dat (podrobne v [17-nastaveni-a-prava.md](17-nastaveni-a-prava.md)): **`withCache`** pro entity, ktere se ctou pri kazdem requestu a meni zridka. Kopie v pameti, `byId` je `Map`. Vsechny cache jsou v registru (`src/data/store/cached.ts`): `refreshCache(kind)` obnovi jednu, `refreshAllCaches()` vsechny. Route nastaveni driv po kazdem zapisu obnovovala vsechny cache, ted jen entitu, do ktere psala (`bootstrapDataRefresh(route)` v `src/data/refresh.ts`). `listByTenant(tenantIds, sortBy)` je jeden filtr a razeni misto sedmi kopii v modulech. **`withMirror`** pro provozni data: meni se v pameti, po zmene se zapise cely zaznam. U ticketu a automatizaci je zapis a nacteni pri startu v modulu `persist.ts` jejich slozky (`src/data/tickets/`, `src/data/automations/`); `ticketStore.ts` a `automationStore.ts` jsou uz jen fasady. Zapisy tehoz ID jsou **serazene za sebou** retezem promise. Bez toho mohl Postgres potvrdit dva `put` tehoz ticketu v opacnem poradi, nez prisly, a v tabulce zustala starsi verze - v pameti to nebylo videt, po restartu ano. Tickety navic slucuji vic zmen v jednom tiku do jednoho zapisu (`persist` / `flushPersist`). **Audit** se jen pripisuje a oreza se davkou (`removeMany`) po 50 zapisech nebo nejvys jednou za minutu. Prvni verze mazala jen radky platformy (`tenantId: null`) a audit firem rostl donekonecna. ### 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. ### Stara kolekce `person` Kolekce `person` je **jen pozustatek**: resitel byval vlastni zaznam (`ppl_...`) spojeny s uctem pres e-mail, dnes je resitel clenstvi uctu (viz [06-tickety.md](06-tickety.md)) a nic se do ni nezapisuje. Prevod nedela SQL migrace, ale `src/data/migratePeople.ts` pri startu z `bootstrapData`, az po nacteni ticketu a automatizaci, a jen kdyz v kolekci neco je - druhy start uz nic nedela. Plati pro vsechny tri rezimy uloziste. | Krok | | ------------------------------------------------------------------------------------------------- | | ke kazdemu zaznamu se najde ucet podle e-mailu, nebo se zalozi s nahodnym heslem a clenstvim `role_agent` | | popisek, kapacita a externi ID se prenesou na clenstvi | | stare ID se prepise na ID uctu v ticketech (`assigneeId`, `resolvedById`), stromech automatizaci, skupinach, telech akci a zdrojich vlastnich widgetu | | prevedene zaznamy se smazou, do logu jde `[migrace] resitele -> ucty: ...` | Chyba jednoho zaznamu jen zaloguje, zaznam zustane a migrace se k nemu vrati pri dalsim startu. Historicke udalosti v logu ticketu si stara ID nechavaji, neprepisuji se. ## 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 | | ------------------------------------ | ----------------------------------------------------------------------------- | | Fronta nad Postgresem | vyber behu je v pameti jedne instance, chce to `SKIP LOCKED` | | 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 |