Nastaveni prezije nasazeni: seed/records.json se pri prazdnem ulozisti nacte misto ukazkovych dat (zive uloziste se nikdy neprepisuje). Soubor nese soucasny stav produkce (firmy s provozovatelem, role, typ ticketu, akce, widgety, skupiny, rozlozeni). Novy GET /api/admin/export a skript npm run seed:export pro dalsi exporty, Dockerfile slozku kopiruje. Vykonnostni testy (npm run test:perf) nad 200 firmami a 10 000 tickety a zatezovy skript (npm run load) proti bezici instanci vcetne davky udalosti na webhook. Mereni odhalilo strop workeru: po obsazeni vsech mist spal sekundu, takze fronta odbavila nejvys 4 behy za sekundu. Ted ceka na prvni dokonceny beh: 500 udalosti za 1,3 s (395 behu/s). Strop posluchacu streamu zvednut na 2 000. Dialogy: prekryv modalu a menu v portalu bez backdrop-blur, tecka Zive pulzuje jen pri navazovani spojeni - rozmazani cele obrazovky pod trvalou animaci sekalo video vedle portalu. Bublina udalosti drzi 0,5 s. Dokumentace 14, 19, 20, 22, 04, 01, 03, 15 a 99 aktualizovana. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
20 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 |
SEED_FILE |
nastaveni z repozitare pro prazdne uloziste, vychozi ./seed/records.json. Prazdna hodnota vypne |
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.
Nastaveni v repozitari (seed/records.json)
Nasazeni bezi v rezimu file bez svazku, takze kazdy redeploy smaze data
a spravce platformy zadaval znovu firmu provozovatele, ucty, widgety
a rozlozeni. Spravne reseni je DATABASE_URL nebo DATA_DIR na svazku,
obe jsou vec infrastruktury mimo tento repozitar. Do te doby se konfigurace
drzi v repu a pri startu se nasype sama.
Proc soubor v repu a ne seed v kodu. Seed v kodu je ukazka pro prazdnou instalaci a meni se jen s kodem. Nastaveni provozovatele se meni v portalu a nikdo ho nema prepisovat do TypeScriptu. Export je jeden prikaz a vysledek je data, ne kod.
| Co | Jak |
|---|---|
| soubor | seed/records.json, cesta z SEED_FILE (src/config.ts), v gitu |
| tvar | { "exportedAt": "...", "kinds": { "<druh>": [zaznam, ...] } }, druh je store.kind |
| kdy se pouzije | jen do prazdneho uloziste, presne tam, kde by se pouzil seed z kodu (store.init) |
| co vyhrava | druh v souboru ma prednost pred kodem, i kdyz je prazdny ([] = spravce je smazal) |
| chybejici soubor | ticho, jede se ze seedu v kodu (bezny stav pri vyvoji) |
| rozbity soubor | console.error a seed z kodu; nikdy nepada start |
| druh mimo konfiguraci | ignoruje se s varovanim |
| log | [seed] <druh>: N zaznamu ze souboru nebo [seed] <druh>: kod, jen u prazdneho uloziste |
Rozhodnuti "soubor, nebo kod" je na jednom miste: seedFromFile(kind, seed)
v src/data/store/seedFile.ts, ktere vola defineStore v init. Uloziste
(local.ts, postgres.ts) o souboru nevi, bootstrap.ts se nemeni
a plati to pro vsechny tri rezimy vcetne withMirror (automatizace,
rozlozeni). Automatizace ze souboru prochazi stejnym withDerived jako ty
z kodu, nedodelky a pocet kroku se pri startu prepocitaji.
Co v souboru je: tenant, user, role, personGroup,
tenantFeatures, ticketType, ticketAction, customWidget,
dashboardLayout, automation, tenantScript (seznam SEED_KINDS).
Co v nem neni a proc:
| Druh | Proc ne |
|---|---|
| tickety, incidenty, audit, upozorneni, prilohy, fronta | provozni data, vznikaji provozem; v repu nemaji co delat |
| konektory | udaje jsou sifrovane klicem, ktery se bez SECRETS_KEY meni s nasazenim |
| pozvanky | maji platnost a kod, po nasazeni jsou stejne prosle |
Hash hesla. Export bere ucty tak, jak lezi v ulozisti, tedy vcetne
passwordHash (bcrypt). Bez nej by se po nasazeni nikdo neprihlasil.
Bcrypt neni plaintext, ale hash v gitu je hash v gitu: repozitar musi
zustat soukromy a tokeny firem (intakeToken, webhooky automatizaci)
jsou v nem take. Kdo to nechce, nastavi DATABASE_URL a soubor smaze.
Export: GET /api/admin/export (spravce platformy, pravo audit.view,
audit admin.export) vraci stejny tvar primo z ulozist (listAll).
Skript scripts/export-seed.mjs se prihlasi a soubor zapise:
SEED_EXPORT_URL=https://services.csbot.cz/apps/csbot-prototype SEED_EXPORT_EMAIL=admin@... SEED_EXPORT_PASSWORD=... npm run seed:export
Vypise pocty po druzich, zapisuje UTF-8 bez BOM s LF. Po exportu se soubor commitne a dalsi nasazeni z nej vyjde. Zmena v portalu, ktera se nevyexportuje, se pri dalsim nasazeni ztrati - to je cena za chybejici databazi, ne vlastnost seedu.
Testy: tests/data/seedFile.test.ts (rezim souboru nad docasnou slozkou),
tests/routes/adminExport.test.ts. V testech je SEED_FILE prazdny, aby
nezavisely na obsahu repa.
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_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 |
| Blob uloziste pro prilohy | obsah je base64 v zaznamu, v rezimu file se prepisuje cely attachment.json |