Files
csbot-prototype/documentation/14-databaze.md
T
JiriUhlir c25e826766 Poptavka z webu je ticket, prilohy, udaje provozovatele, kolacovy graf
Provozovatel portalu: firma s priznakem portalOperator (jen jedna, zapnuti
odebere ostatnim) a novymi poli contactEmail, contactPhone, website vedle
ico, dic, adresy a pravni formy. Verejny GET /api/public/brand vraci jeji
udaje a web je bere pres useBrand() na kontaktu, v paticce, O nas,
prihlaseni i v titulku; brand.ts je jen zaloha.

Poptavka z webu zaklada u provozovatele ticket kanalu form: predmet
"Poptavka: tema", telo JSON s poli formulare, tag Poptavka plus tema,
zakaznik z formulare, poznamka v logu. Bez provozovatele se jen zaloguje.

Prilohy ticketu: formular az 3 soubory po 5 MB, ticket az 10; nahrani,
seznam, stazeni a smazani (pravo ticket.comment, strop viditelnosti,
poznamky v logu, audit). Soubor jde v JSON jako Base64 a lezi v beznem
ulozisti, bez nove zavislosti; strop tela jen na techto cestach.

Vlastni widget s kreslenim Graf umi i pocet ticketu se seskupenim jako
kolac (PieChart.tsx, ciste SVG, osm barev z tokenu, zbytek jako ostatni).

OpenAPI rozdelene na mensi soubory (102 cest, 28 schemat overeno shodnych),
28 novych testu (135 celkem), dokumentace aktualizovana.
2026-09-09 19:35:00 +02:00

15 KiB

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:

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

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:

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):

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.

Prilohy ticketu (kind: 'attachment', src/data/attachments.ts) jdou primo pres defineStore bez obalky: nectou se pri kazdem requestu a nemeni se v pameti, kazde volani jde do uloziste. Zaznam nese vedle metadat (ticketId, name, mime, size, uploadedBy) i obsah souboru v base64. V Postgresu je to radek v records jako u kazde jine entity, v rezimu file soubor DATA_DIR/attachment.json.

Velikost je tu jina nez u ostatnich kolekci: jeden zaznam ma az 5 MB (po base64 skoro 7), ticket jich muze mit deset. V rezimu file kazda zmena prepise cely attachment.json, takze s kazdou prilohou roste cena zapisu vsech ostatnich. Pro jednotky MB v prototypu to staci a zadna nova zavislost nebyla potreba. Dalsi krok je presun obsahu do blob uloziste (S3, nebo tabulka s bytea), az to zacne byt znat; rozhrani modulu zustane, zmeni se jen odkud getAttachment cte content. Seznam priloh obsah nikdy nevraci (toPublic), aby se megabajty netahaly pri kazdem otevreni detailu.

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) 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.

{ "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.

Vychozi konektor hlida castecny unikatni index, ne jen kod:

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.

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
Blob uloziste pro prilohy obsah je base64 v zaznamu, v rezimu file se prepisuje cely attachment.json