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:
co-authored by
Claude Fable 5.1
parent
c25e826766
commit
ea9387bea9
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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$...' },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user