Files
JiriUhlirandClaude Fable 5.1 ea9387bea9 Seed z repozitare, zatezove testy, worker bez spanku, dialogy bez rozmazani
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>
2026-09-09 20:41:42 +02:00

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_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