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>
This commit is contained in:
JiriUhlir
2026-09-09 20:41:42 +02:00
co-authored by Claude Fable 5.1
parent c25e826766
commit ea9387bea9
44 changed files with 3512 additions and 28 deletions
+20
View File
@@ -53,6 +53,13 @@ function normalizeRootPath(value: string | undefined): string {
return withSlash.replace(/\/+$/, '');
}
/** Nenastavena promenna = vychozi cesta, prazdna = vypnuto (`''`). */
function resolveSeedFile(value: string | undefined): string {
if (value === undefined) return path.join(process.cwd(), 'seed', 'records.json');
const trimmed = value.trim();
return trimmed === '' ? '' : path.resolve(trimmed);
}
export const config = {
isProduction,
/** Port urcuje AppFactory sablona, vychozi 3000. Nemenit bez upravy metadat. */
@@ -113,6 +120,19 @@ export const config = {
* Prazdna hodnota vypne i tohle a jede se v ciste pameti.
*/
dataDir: (process.env.DATA_DIR ?? path.join(process.cwd(), 'data')).trim(),
/**
* Nastaveni z repozitare pro prazdne uloziste.
*
* Nasazeni bez databaze a bez svazku prijde pri kazdem redeployi o data
* a spravce platformy zadava firmy, ucty a widgety znovu. Soubor v repu
* (vystup `npm run seed:export`) drzi konfiguraci a pri startu se pouzije
* misto vychozi sady z kodu - **jen do prazdneho uloziste**, nikdy pres
* ziva data. Chybejici soubor je bezny stav (lokalni vyvoj). Spravne reseni
* je `DATABASE_URL`, tohle je nahrada do te doby. Prazdna hodnota soubor
* vypne uplne (stejne jako u `DATA_DIR`), testy tak nezavisi na obsahu repa.
* Viz documentation/14-databaze.md.
*/
seedFile: resolveSeedFile(process.env.SEED_FILE),
// --------------------------------------------------------------- znacka
+26
View File
@@ -0,0 +1,26 @@
/**
* Export nastaveni do tvaru `seed/records.json`.
*
* Opacny smer k `store/seedFile.ts`: precte vsechny zaznamy konfiguracnich
* druhu primo z ulozist a slozi z nich soubor, ktery se ulozi do repozitare
* (`npm run seed:export`). Bere se `listAll`, tedy vcetne `passwordHash`
* uctu - bez nej by se po nasazeni nikdo neprihlasil. Proto musi repozitar
* zustat soukromy, viz documentation/14-databaze.md.
*/
import { nowIso, storeByKind } from './store/index.js';
import { SEED_KINDS, type SeedExport } from './store/seedFile.js';
export async function exportSeed(): Promise<SeedExport> {
const kinds: SeedExport['kinds'] = {};
for (const kind of SEED_KINDS) {
const store = storeByKind(kind);
if (!store) {
// Druh bez uloziste je chyba v seznamu SEED_KINDS, ne stav dat.
console.warn(`[seed] export: druh ${kind} nema uloziste, preskakuje se`);
continue;
}
kinds[kind] = await store.listAll();
}
return { exportedAt: nowIso(), kinds };
}
+9 -1
View File
@@ -21,6 +21,7 @@ import { isDatabaseEnabled } from '../../db/pool.js';
import { canSealSecrets, initSecrets } from '../../db/secretBox.js';
import { createLocalStore } from './local.js';
import { createPostgresStore } from './postgres.js';
import { seedFromFile } from './seedFile.js';
import type { EntityStore, TenantEntity } from './types.js';
export type { Entity, EntityStore, ListOptions, TenantEntity } from './types.js';
@@ -88,7 +89,9 @@ export function defineStore<T extends TenantEntity>(kind: string): EntityStore<T
const store: EntityStore<T> = {
kind,
init: (seed) => resolve().init(seed),
// Vychozi sada ze souboru v repu ma prednost pred kodem. Uplatni se
// stejne jako seed z kodu, tedy jen do prazdneho uloziste.
init: (seed) => resolve().init(seedFromFile<T>(kind, seed)),
list: (options) => resolve().list(options),
listAll: () => resolve().listAll(),
get: (id, options) => resolve().get(id, options),
@@ -111,6 +114,11 @@ export async function flushStores(): Promise<void> {
await Promise.all([...registry.values()].map((store) => store.flush()));
}
/** Uloziste podle druhu. Pro export nastaveni, jinde se bere primo z modulu entity. */
export function storeByKind(kind: string): EntityStore<TenantEntity> | undefined {
return registry.get(kind);
}
/** Kolik druhu entit je zaregistrovanych. Pro diagnostiku. */
export function storeKinds(): string[] {
return [...registry.keys()].sort();
+153
View File
@@ -0,0 +1,153 @@
/**
* Nastaveni z repozitare (`seed/records.json`) pro prazdne uloziste.
*
* Nasazeni bez databaze a bez svazku prijde pri kazdem redeployi o vsechno,
* co spravce platformy nastavil: firmy, ucty, role, typy, akce, widgety.
* Dokud neni `DATABASE_URL` nebo `DATA_DIR` na svazku, drzi se konfigurace
* v repu a pri startu se nasype misto vychozi sady z kodu.
*
* Dve pravidla, ktera to drzi bezpecne:
* - **Jen do prazdneho uloziste.** Seed se pouzije presne tam, kde by se
* pouzil seed z kodu (`store.init(seed)`), tedy nikdy pres ziva data.
* - **Druh v souboru vyhrava cely.** Kdyz soubor druh obsahuje, plati jeho
* zaznamy, i kdyby jich bylo nula - vyexportovany prazdny seznam znamena
* "spravce je smazal", ne "doplnte vychozi".
*
* Soubor je vystup `GET /api/admin/export` (`npm run seed:export`), takze tvar
* zaznamu je tvar uloziste. Kontroluje se jen obalka a `id`; hlubsi kontrola
* je vec modulu entity, stejne jako u zaznamu nactenych ze souboru
* v `DATA_DIR`. Chybejici soubor je bezny stav, rozbity soubor se zaloguje
* a jede se ze seedu v kodu.
*/
import fs from 'node:fs';
import { z } from 'zod';
import { config } from '../../config.js';
import type { TenantEntity } from './types.js';
/**
* Druhy, ktere jsou konfigurace a patri do souboru. Provozni data (tickety,
* incidenty, audit, upozorneni, prilohy, fronta) ne: ta vznikaji provozem
* a v repu nemaji co delat. Konektory take ne: jejich udaje jsou sifrovane
* klicem, ktery se bez `SECRETS_KEY` meni s kazdym nasazenim. Pozvanky ne:
* maji platnost a kod, po nasazeni jsou stejne prosle.
*/
export const SEED_KINDS = [
'tenant',
'user',
'role',
'personGroup',
'tenantFeatures',
'ticketType',
'ticketAction',
'customWidget',
'dashboardLayout',
'automation',
'tenantScript',
] as const;
export type SeedKind = (typeof SEED_KINDS)[number];
export function isSeedKind(kind: string): kind is SeedKind {
return (SEED_KINDS as readonly string[]).includes(kind);
}
/** Tvar souboru i odpovedi `GET /api/admin/export`. */
export interface SeedExport {
exportedAt: string;
kinds: Partial<Record<SeedKind, TenantEntity[]>>;
}
/** Kontroluje se jen obalka a `id`; zbytek zaznamu prochazi tak, jak je. */
const recordSchema = z.object({ id: z.string().min(1) }).passthrough();
const seedFileSchema = z.object({
exportedAt: z.string().optional(),
kinds: z.record(z.string(), z.array(recordSchema)),
});
type LoadedSeed = Map<SeedKind, TenantEntity[]>;
/** Jednou nacteny soubor za cestu. `null` = neni, nebo je rozbity. */
const cache = new Map<string, LoadedSeed | null>();
/**
* Nacte a zkontroluje soubor. Vola se pri startu za kazdy druh, proto se
* vysledek drzi v pameti. Chybejici soubor je ticho, ostatni chyby se loguji.
*/
export function loadSeedFile(file: string): LoadedSeed | null {
const cached = cache.get(file);
if (cached !== undefined) return cached;
const loaded = readSeedFile(file);
cache.set(file, loaded);
return loaded;
}
function readSeedFile(file: string): LoadedSeed | null {
let raw: string;
try {
raw = fs.readFileSync(file, 'utf8');
} catch (err) {
// Bez souboru se jede ze seedu v kodu. Bezny stav pri vyvoji.
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
console.error(`[seed] ${file} nelze precist:`, err instanceof Error ? err.message : err);
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
console.error(`[seed] ${file} neni platny JSON:`, err instanceof Error ? err.message : err);
return null;
}
const checked = seedFileSchema.safeParse(parsed);
if (!checked.success) {
const problems = checked.error.issues
.map((issue) => `${issue.path.join('.') || '(koren)'}: ${issue.message}`)
.join('; ');
console.error(`[seed] ${file} ma spatny tvar, jede se ze seedu v kodu: ${problems}`);
return null;
}
const kinds: LoadedSeed = new Map();
for (const [kind, records] of Object.entries(checked.data.kinds)) {
if (!isSeedKind(kind)) {
console.warn(`[seed] ${file}: druh ${kind} se nenacita, neni to konfigurace`);
continue;
}
kinds.set(kind, records as unknown as TenantEntity[]);
}
return kinds;
}
/**
* Seed pro jeden druh: ze souboru, kdyz ho ma, jinak z kodu.
*
* Vraci funkci ve stejnem tvaru, jaky bere `store.init(seed)`, takze
* rozhodnuti "soubor, nebo kod" je na jednom miste a uloziste o souboru
* nevi. Funkce se zavola jen pro prazdne uloziste, proto se loguje az v ni.
* Cesta je parametr kvuli testum, jinak se bere z konfigurace.
*/
export function seedFromFile<T extends TenantEntity>(
kind: string,
fallback?: () => T[],
file: string = config.seedFile,
): (() => T[]) | undefined {
// Prazdna cesta = soubor vypnuty konfiguraci, cte se jen kod.
const records = file !== '' && isSeedKind(kind) ? loadSeedFile(file)?.get(kind) : undefined;
if (records) {
return () => {
console.info(`[seed] ${kind}: ${records.length} zaznamu ze souboru`);
// Kopie, aby uloziste nemenilo to, co je v kesi.
return structuredClone(records) as T[];
};
}
if (!fallback) return undefined;
return () => {
console.info(`[seed] ${kind}: kod`);
return fallback();
};
}
+9 -2
View File
@@ -22,9 +22,16 @@ export type { DashboardEvent, DashboardEventType, EntityEventKind, EntityEventTy
const CHANNEL = 'dashboard';
const MAX_RECENT = 50;
/**
* Kazdy pripojeny klient streamu je jeden listener. Vychozich 10 je malo
* a 200 hlasilo varovani uz pri zatezovem testu s 500 klienty; varovani
* neni chyba, ale zbytecne plni log. Stovky uzivatelu s otevrenym portalem
* jsou bezny provoz, proto strop nad nimi.
*/
const MAX_STREAM_LISTENERS = 2_000;
const emitter = new EventEmitter();
// Kazdy pripojeny klient je jeden listener, vychozich 10 je malo.
emitter.setMaxListeners(200);
emitter.setMaxListeners(MAX_STREAM_LISTENERS);
const recent: DashboardEvent[] = [];
+38
View File
@@ -0,0 +1,38 @@
/** Sprava platformy: tvar exportu nastaveni. */
export const adminSchemas = {
SeedExport: {
type: 'object',
description:
'Tvar `seed/records.json`. Klic v `kinds` je druh uloziste (`store.kind`), ' +
'hodnota jsou zaznamy tak, jak lezi v ulozisti. Druhy mimo konfiguraci se ' +
'pri nacteni ignoruji.',
required: ['exportedAt', 'kinds'],
properties: {
exportedAt: { type: 'string', format: 'date-time' },
kinds: {
type: 'object',
additionalProperties: {
type: 'array',
items: {
type: 'object',
required: ['id'],
properties: {
id: { type: 'string' },
tenantId: { type: 'string', nullable: true },
createdAt: { type: 'string', format: 'date-time' },
updatedAt: { type: 'string', format: 'date-time' },
},
additionalProperties: true,
},
},
example: {
tenant: [{ id: 'tnt_automia', tenantId: null, name: 'Automia s.r.o.' }],
user: [
{ id: 'usr_1', tenantId: null, email: 'admin@automia.cz', passwordHash: '$2a$10$...' },
],
},
},
},
},
};
+2
View File
@@ -11,6 +11,7 @@ import { connectorSchemas } from './connectors.js';
import { scriptSchemas } from './scripts.js';
import { ticketSchemas } from './tickets.js';
import { automationSchemas } from './automations.js';
import { adminSchemas } from './admin.js';
export const components = {
securitySchemes: {
@@ -31,5 +32,6 @@ export const components = {
...loginSchemas,
...ticketSchemas,
...automationSchemas,
...adminSchemas,
},
};
+22 -1
View File
@@ -1,6 +1,27 @@
/** Sprava platformy: audit a prepnuti na jiny ucet. */
/** Sprava platformy: audit, prepnuti na jiny ucet, export nastaveni. */
export const adminPaths: Record<string, unknown> = {
'/api/admin/export': {
get: {
tags: ['Sprava platformy'],
summary: 'Export nastaveni pro seed/records.json',
description:
'Vsechny zaznamy konfiguracnich druhu (firmy, ucty, role, skupiny, moduly, ' +
'typy ticketu, akce, widgety, rozlozeni, automatizace, skripty firmy) tak, jak ' +
'lezi v ulozisti, **vcetne hashu hesel**. Konektory, tickety, incidenty, audit ' +
'a upozorneni v exportu nejsou. Vystup uklada `npm run seed:export` do ' +
'`seed/records.json`, ktery se pri startu nasype do prazdneho uloziste. ' +
'Kazdy export je v auditu jako `admin.export`.',
security: [{ bearerAuth: [] }],
responses: {
'200': {
description: 'Nastaveni po druzich',
content: { 'application/json': { schema: { $ref: '#/components/schemas/SeedExport' } } },
},
'403': { description: 'Neni spravce platformy' },
},
},
},
'/api/admin/impersonate': {
post: {
tags: ['Sprava platformy'],
+33
View File
@@ -16,6 +16,7 @@ import { z } from 'zod';
import { config } from '../config.js';
import { listAudit, recordAudit } from '../data/audit.js';
import { hasPermission } from '../data/permissions.js';
import { exportSeed } from '../data/seedExport.js';
import { listAllUsers, findUserById } from '../data/users.js';
import { safeRouter } from '../middleware/asyncHandler.js';
import { requireAuth, requirePlatformAdmin } from '../middleware/auth.js';
@@ -174,3 +175,35 @@ adminRouter.get(
});
},
);
// ------------------------------------------------------------------ export
/**
* Nastaveni pro `seed/records.json`, viz `data/seedExport.ts`.
*
* Vraci i `passwordHash` uctu, proto jen spravce platformy s pravem na audit:
* je to stejna kategorie citlivosti jako zaznam o lidech. Kazdy export je
* v auditu, aby bylo videt, kdo si data odnesl.
*/
adminRouter.get(
'/export',
requirePlatformAdmin,
requirePlatformPermission('audit.view'),
async (req, res) => {
const data = await exportSeed();
const counts = Object.fromEntries(
Object.entries(data.kinds).map(([kind, records]) => [kind, records.length]),
);
recordAudit({
userId: req.user!.id,
userEmail: req.user!.email,
actedBy: req.impersonation?.actorId ?? null,
tenantId: null,
action: 'admin.export',
detail: counts,
});
return res.json(data);
},
);
+29 -10
View File
@@ -33,7 +33,14 @@ import {
/** Kolik behu naraz. Kroky cekaji na cizi sluzby, procesor se skoro nepouzije. */
const CONCURRENCY = 4;
/** Jak casto se fronta kontroluje, kdyz je prazdna. */
/**
* Jak casto se fronta kontroluje, kdyz je prazdna.
*
* Jen pri prazdne fronte. Kdyz jsou obsazena vsechna mista, ceka se na prvni
* uvolnene, ne na casovac: se spanim i pri plnem bazenu byl strop
* CONCURRENCY behu za sekundu (zatezovy test: 2 000 udalosti webhooku se
* odbavovalo presne 4 za sekundu, i kdyz fronta sama zvlada tisice).
*/
const IDLE_MS = 1_000;
/** Jak casto se uklizi hotove behy. */
@@ -55,6 +62,9 @@ let rounds = 0;
/** Behy, ktere prave bezi v tomto procesu. Fronta je nesmi povazovat za zaseknute. */
const active = new Set<string>();
/** Tytez behy jako promisy, aby slo pockat na prvni dokonceny. */
const inFlight = new Map<string, Promise<void>>();
/**
* Spusti workera.
*
@@ -81,14 +91,19 @@ async function loop(): Promise<void> {
* ulohou MCP by drzel vsechna ctyri mista a fronta by stala.
*/
const free = CONCURRENCY - active.size;
if (free === 0) {
// Plny bazen: dalsi kolo hned, jak prvni beh skonci. Casovac je jen
// pojistka, kdyby se nejaky promise nikdy nevyridil.
await Promise.race([...inFlight.values(), sleep(IDLE_MS)]);
continue;
}
let claimed: QueueItem[] = [];
if (free > 0) {
try {
claimed = await claimBatch(free, active);
} catch (err) {
// Nedostupne uloziste nesmi workera zabit. Za chvili to zkusi znovu.
console.error('[worker] frontu se nepodarilo precist:', err);
}
try {
claimed = await claimBatch(free, active);
} catch (err) {
// Nedostupne uloziste nesmi workera zabit. Za chvili to zkusi znovu.
console.error('[worker] frontu se nepodarilo precist:', err);
}
if (claimed.length === 0) {
@@ -98,9 +113,13 @@ async function loop(): Promise<void> {
for (const item of claimed) {
active.add(item.id);
void runOne(item)
const task = runOne(item)
.catch((err: unknown) => console.error(`[worker] beh ${item.id} spadl mimo pojistku:`, err))
.finally(() => active.delete(item.id));
.finally(() => {
active.delete(item.id);
inFlight.delete(item.id);
});
inFlight.set(item.id, task);
}
rounds += 1;