Resitel uz neni vlastni zaznam spojeny s uctem pres e-mail: je to clenstvi uctu ve firme a jeho ID je ID uctu. Popisek, kapacita, externi ID a zapnuti visi na clenstvi, takze clovek ve dvou firmach je v kazde jinak a spravce firmy ho vypne jen u sebe. Odebrani z firmy odebere jen clenstvi. Stara data se pri startu jednou prevedou (migratePeople.ts), vcetne odkazu v ticketech, skupinach, automatizacich, akcich a widgetech. Sprava lidi v zalozce Lide zaklada ucty, pozvanka uz nema volbu resitele. Zalozeni firmy z registru ARES: hledani podle IC nebo nazvu, dotazeni IC, DIC, sidla a pravni formy, vyber soucasnych statutarnich zastupcu a prokury, ucty spravce firmy s nahradnim e-mailem IC-poradi@placeholder.cz. Vychozi rozlozeni dashboardu bez resitele neobsahuje list.myTickets. Prepinani jazyku je docasne schovane (MULTILANG_ENABLED), web je cesky. Dokumentace aktualizovana. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
14 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. 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_lockby 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.
vje 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 |