feat(api): /api/lead с SQLite и миграциями
db/: better-sqlite3 по DB_PATH (WAL, foreign_keys), миграции через PRAGMA user_version, схема leads и lead_clarifications по плану. createApp(deps) с инъекцией базы, часов и notifier (пока no-op, зовётся после ответа). POST /api/lead: только JSON (415), тело до 16 КБ (413), validateLead → 400, запись → 201. index.ts: env и graceful shutdown.
This commit is contained in:
@@ -0,0 +1,109 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { MAX_BODY_BYTES } from './app.js';
|
||||||
|
import { flush, setup, T0, validBody } from './testing.js';
|
||||||
|
|
||||||
|
describe('GET /api/health', () => {
|
||||||
|
it('отдаёт версию', async () => {
|
||||||
|
const { app } = setup({ version: 'abc123' });
|
||||||
|
const res = await app.request('/api/health');
|
||||||
|
expect(await res.json()).toEqual({ ok: true, version: 'abc123' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('POST /api/lead', () => {
|
||||||
|
it('201: сохраняет заявку с нормализованным телефоном, касаниями и user agent', async () => {
|
||||||
|
const { db, post } = setup();
|
||||||
|
const res = await post(validBody(), { 'user-agent': 'Mozilla/5.0 test' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(await res.json()).toEqual({ ok: true, duplicate: false });
|
||||||
|
const row = db.prepare('SELECT * FROM leads').get() as Record<string, unknown>;
|
||||||
|
expect(row).toMatchObject({
|
||||||
|
id: 1,
|
||||||
|
created_at: T0.toISOString(),
|
||||||
|
phone: '+375291234567',
|
||||||
|
name: 'Аня',
|
||||||
|
interest: 'subscription',
|
||||||
|
comment: 'Под эспрессо',
|
||||||
|
user_agent: 'Mozilla/5.0 test',
|
||||||
|
tg_status: 'pending',
|
||||||
|
tg_attempts: 0,
|
||||||
|
});
|
||||||
|
expect(JSON.parse(row.first_touch as string)).toMatchObject({ utm_source: 'instagram' });
|
||||||
|
expect(JSON.parse(row.last_touch as string)).toMatchObject({ utm_source: 'yandex', submitPage: '/#form' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('номер заявки клиенту не отдаёт', async () => {
|
||||||
|
const { post } = setup();
|
||||||
|
const text = await (await post(validBody())).text();
|
||||||
|
expect(text).toBe('{"ok":true,"duplicate":false}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifier получает id заявки уже после ответа', async () => {
|
||||||
|
const { post, events } = setup();
|
||||||
|
await post(validBody());
|
||||||
|
expect(events).toEqual([]);
|
||||||
|
await flush();
|
||||||
|
expect(events).toEqual([{ kind: 'lead', leadId: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('упавший notifier не ломает ответ', async () => {
|
||||||
|
const { post, db } = setup({
|
||||||
|
notifier: {
|
||||||
|
leadSaved: () => Promise.reject(new Error('telegram down')),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = await post(validBody());
|
||||||
|
await flush();
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 validation: ошибки по полям, ничего не пишет', async () => {
|
||||||
|
const { post, db, events } = setup();
|
||||||
|
const res = await post(validBody({ name: ' ', phone: '+7 999 123-45-67' }));
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
const body = (await res.json()) as { fieldErrors: Record<string, string> };
|
||||||
|
expect(body).toMatchObject({ ok: false, error: 'validation' });
|
||||||
|
expect(Object.keys(body.fieldErrors).sort()).toEqual(['name', 'phone']);
|
||||||
|
await flush();
|
||||||
|
expect(events).toEqual([]);
|
||||||
|
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('400 bad_request: битый JSON', async () => {
|
||||||
|
const { post } = setup();
|
||||||
|
const res = await post('{"name":');
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(await res.json()).toEqual({ ok: false, error: 'bad_request' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('415: не JSON', async () => {
|
||||||
|
const { post } = setup();
|
||||||
|
const res = await post('name=Аня', { 'content-type': 'application/x-www-form-urlencoded' });
|
||||||
|
expect(res.status).toBe(415);
|
||||||
|
expect(await res.json()).toEqual({ ok: false, error: 'bad_request' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('JSON с charset принимается', async () => {
|
||||||
|
const { post } = setup();
|
||||||
|
const res = await post(validBody(), { 'content-type': 'application/json; charset=utf-8' });
|
||||||
|
expect(res.status).toBe(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('413: тело больше 16 КБ', async () => {
|
||||||
|
const { post, db } = setup();
|
||||||
|
const res = await post(validBody({ comment: 'а'.repeat(MAX_BODY_BYTES) }));
|
||||||
|
expect(res.status).toBe(413);
|
||||||
|
expect(await res.json()).toEqual({ ok: false, error: 'bad_request' });
|
||||||
|
expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('500 server: исключение при записи', async () => {
|
||||||
|
const { post, db } = setup();
|
||||||
|
db.close();
|
||||||
|
const res = await post(validBody());
|
||||||
|
expect(res.status).toBe(500);
|
||||||
|
expect(await res.json()).toEqual({ ok: false, error: 'server' });
|
||||||
|
});
|
||||||
|
});
|
||||||
+51
-2
@@ -1,7 +1,56 @@
|
|||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
|
import { bodyLimit } from 'hono/body-limit';
|
||||||
|
import { validateLead, type LeadResponse } from '@kvarta/shared/lead';
|
||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import { saveLead } from './leads.js';
|
||||||
|
import { notifyLater, type Notifier } from './notifier.js';
|
||||||
|
|
||||||
export function createApp() {
|
export type AppDeps = {
|
||||||
|
db: Db;
|
||||||
|
now: () => Date;
|
||||||
|
notifier: Notifier;
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MAX_BODY_BYTES = 16 * 1024;
|
||||||
|
const USER_AGENT_MAX = 500;
|
||||||
|
|
||||||
|
export function createApp(deps: AppDeps) {
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
app.get('/api/health', (c) => c.json({ ok: true, version: process.env.APP_VERSION ?? 'dev' }));
|
const reply = (body: LeadResponse, status: 200 | 201 | 400 | 413 | 415 | 500) => Response.json(body, { status });
|
||||||
|
|
||||||
|
app.onError((err) => {
|
||||||
|
console.error(err);
|
||||||
|
return reply({ ok: false, error: 'server' }, 500);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/health', (c) => c.json({ ok: true, version: deps.version ?? 'dev' }));
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/api/lead',
|
||||||
|
async (c, next) => {
|
||||||
|
const type = c.req.header('content-type')?.split(';')[0]?.trim().toLowerCase();
|
||||||
|
if (type !== 'application/json') return reply({ ok: false, error: 'bad_request' }, 415);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
bodyLimit({ maxSize: MAX_BODY_BYTES, onError: () => reply({ ok: false, error: 'bad_request' }, 413) }),
|
||||||
|
async (c) => {
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await c.req.json();
|
||||||
|
} catch {
|
||||||
|
return reply({ ok: false, error: 'bad_request' }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateLead(body);
|
||||||
|
if (!result.ok) return reply({ ok: false, error: 'validation', fieldErrors: result.fieldErrors }, 400);
|
||||||
|
|
||||||
|
const userAgent = c.req.header('user-agent')?.slice(0, USER_AGENT_MAX) ?? null;
|
||||||
|
const saved = saveLead(deps.db, result.data, { now: deps.now(), userAgent });
|
||||||
|
notifyLater(deps.notifier, saved.event);
|
||||||
|
return reply({ ok: true, duplicate: false }, 201);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import { migrate, openDb } from './index.js';
|
||||||
|
import { MIGRATIONS } from './migrations.js';
|
||||||
|
|
||||||
|
const dirs: string[] = [];
|
||||||
|
afterEach(() => {
|
||||||
|
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('openDb', () => {
|
||||||
|
it('создаёт каталог, включает WAL и внешние ключи, применяет миграции', () => {
|
||||||
|
const dir = mkdtempSync(join(tmpdir(), 'kvarta-db-'));
|
||||||
|
dirs.push(dir);
|
||||||
|
const path = join(dir, 'nested', 'kvarta.sqlite');
|
||||||
|
|
||||||
|
const db = openDb(path);
|
||||||
|
expect(existsSync(path)).toBe(true);
|
||||||
|
expect(db.pragma('journal_mode', { simple: true })).toBe('wal');
|
||||||
|
expect(db.pragma('foreign_keys', { simple: true })).toBe(1);
|
||||||
|
expect(db.pragma('user_version', { simple: true })).toBe(MIGRATIONS.length);
|
||||||
|
db.close();
|
||||||
|
|
||||||
|
// Повторное открытие не пытается создать таблицы заново.
|
||||||
|
const again = openDb(path);
|
||||||
|
expect(again.pragma('user_version', { simple: true })).toBe(MIGRATIONS.length);
|
||||||
|
again.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('схема совпадает с планом', () => {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const columns = (table: string) =>
|
||||||
|
(db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map((c) => c.name);
|
||||||
|
expect(columns('leads')).toEqual([
|
||||||
|
'id', 'created_at', 'phone', 'name', 'interest', 'comment', 'first_touch', 'last_touch',
|
||||||
|
'user_agent', 'tg_status', 'tg_attempts', 'tg_last_error', 'tg_next_at',
|
||||||
|
]);
|
||||||
|
expect(columns('lead_clarifications')).toEqual([
|
||||||
|
'id', 'lead_id', 'created_at', 'name', 'interest', 'comment', 'last_touch',
|
||||||
|
'tg_status', 'tg_attempts', 'tg_last_error', 'tg_next_at',
|
||||||
|
]);
|
||||||
|
expect(() =>
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO lead_clarifications (lead_id, created_at, name, interest, last_touch) VALUES (99, 'x', 'x', 'question', '{}')`,
|
||||||
|
).run(),
|
||||||
|
).toThrow(/FOREIGN KEY/);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('отказывается работать с базой новее кода', () => {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.pragma(`user_version = ${MIGRATIONS.length + 1}`);
|
||||||
|
expect(() => migrate(db)).toThrow(/новее кода/);
|
||||||
|
db.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { mkdirSync } from 'node:fs';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { MIGRATIONS } from './migrations.js';
|
||||||
|
|
||||||
|
export type Db = Database.Database;
|
||||||
|
|
||||||
|
export const DEFAULT_DB_PATH = './data/kvarta.sqlite';
|
||||||
|
|
||||||
|
/** Открывает базу и доводит схему до текущей версии. `:memory:` — для тестов. */
|
||||||
|
export function openDb(path: string = DEFAULT_DB_PATH): Db {
|
||||||
|
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true });
|
||||||
|
const db = new Database(path);
|
||||||
|
try {
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
db.pragma('foreign_keys = ON');
|
||||||
|
migrate(db);
|
||||||
|
} catch (err) {
|
||||||
|
db.close();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function migrate(db: Db): void {
|
||||||
|
const current = db.pragma('user_version', { simple: true }) as number;
|
||||||
|
// База новее кода (свежий бэкап на старой версии) — откатывать схему не умеем, лучше не стартовать.
|
||||||
|
if (current > MIGRATIONS.length) {
|
||||||
|
throw new Error(`Схема базы v${current} новее кода (v${MIGRATIONS.length})`);
|
||||||
|
}
|
||||||
|
for (let version = current; version < MIGRATIONS.length; version++) {
|
||||||
|
db.transaction(() => {
|
||||||
|
db.exec(MIGRATIONS[version]!);
|
||||||
|
db.pragma(`user_version = ${version + 1}`);
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Миграции по порядку: индекс в массиве + 1 = PRAGMA user_version после применения.
|
||||||
|
// Уже выкаченные миграции не меняем — только дописываем новые в конец.
|
||||||
|
export const MIGRATIONS: readonly string[] = [
|
||||||
|
`
|
||||||
|
CREATE TABLE leads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
phone TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
interest TEXT NOT NULL CHECK (interest IN ('subscription','one_time','question')),
|
||||||
|
comment TEXT,
|
||||||
|
first_touch TEXT,
|
||||||
|
last_touch TEXT NOT NULL,
|
||||||
|
user_agent TEXT,
|
||||||
|
tg_status TEXT NOT NULL DEFAULT 'pending' CHECK (tg_status IN ('pending','sent','failed')),
|
||||||
|
tg_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tg_last_error TEXT,
|
||||||
|
tg_next_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX leads_phone_created ON leads(phone, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE lead_clarifications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
lead_id INTEGER NOT NULL REFERENCES leads(id),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
interest TEXT NOT NULL,
|
||||||
|
comment TEXT,
|
||||||
|
last_touch TEXT NOT NULL,
|
||||||
|
tg_status TEXT NOT NULL DEFAULT 'pending' CHECK (tg_status IN ('pending','sent','failed')),
|
||||||
|
tg_attempts INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tg_last_error TEXT,
|
||||||
|
tg_next_at TEXT
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
];
|
||||||
+32
-1
@@ -1,7 +1,38 @@
|
|||||||
import { serve } from '@hono/node-server';
|
import { serve } from '@hono/node-server';
|
||||||
import { createApp } from './app.js';
|
import { createApp } from './app.js';
|
||||||
|
import { DEFAULT_DB_PATH, openDb } from './db/index.js';
|
||||||
|
import { noopNotifier } from './notifier.js';
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 3001);
|
const port = Number(process.env.PORT ?? 3001);
|
||||||
serve({ fetch: createApp().fetch, port }, (info) => {
|
const db = openDb(process.env.DB_PATH ?? DEFAULT_DB_PATH);
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
db,
|
||||||
|
now: () => new Date(),
|
||||||
|
notifier: noopNotifier,
|
||||||
|
version: process.env.APP_VERSION,
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = serve({ fetch: app.fetch, port }, (info) => {
|
||||||
console.log(`api listening on :${info.port}`);
|
console.log(`api listening on :${info.port}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let stopping = false;
|
||||||
|
function shutdown(signal: string) {
|
||||||
|
if (stopping) return;
|
||||||
|
stopping = true;
|
||||||
|
console.log(`${signal}: останавливаемся`);
|
||||||
|
// Сначала дожидаемся текущих запросов, потом закрываем базу — иначе запись оборвётся на полуслове.
|
||||||
|
server.close(() => {
|
||||||
|
db.close();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
console.error('не дождались запросов, выходим');
|
||||||
|
db.close();
|
||||||
|
process.exit(1);
|
||||||
|
}, 10_000).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { ValidLead } from '@kvarta/shared/lead';
|
||||||
|
import type { Db } from './db/index.js';
|
||||||
|
import type { LeadEvent } from './notifier.js';
|
||||||
|
|
||||||
|
export type SaveMeta = { now: Date; userAgent: string | null };
|
||||||
|
|
||||||
|
export type SaveResult = { kind: 'created'; event: LeadEvent };
|
||||||
|
|
||||||
|
export function saveLead(db: Db, lead: ValidLead, meta: SaveMeta): SaveResult {
|
||||||
|
const { lastInsertRowid } = db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO leads (created_at, phone, name, interest, comment, first_touch, last_touch, user_agent)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.run(
|
||||||
|
meta.now.toISOString(),
|
||||||
|
lead.phone,
|
||||||
|
lead.name,
|
||||||
|
lead.interest,
|
||||||
|
lead.comment,
|
||||||
|
lead.firstTouch ? JSON.stringify(lead.firstTouch) : null,
|
||||||
|
JSON.stringify(lead.lastTouch),
|
||||||
|
meta.userAgent,
|
||||||
|
);
|
||||||
|
return { kind: 'created', event: { kind: 'lead', leadId: Number(lastInsertRowid) } };
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// Что сообщить менеджеру после записи в базу. Только id: текст и статус отправки
|
||||||
|
// очередь берёт из строки (tg_*), поэтому событие можно потерять — строка останется pending.
|
||||||
|
export type LeadEvent = { kind: 'lead'; leadId: number };
|
||||||
|
|
||||||
|
export interface Notifier {
|
||||||
|
/** Зовётся после ответа клиенту; ошибки и долгие промисы на ответ не влияют. */
|
||||||
|
leadSaved(event: LeadEvent): void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const noopNotifier: Notifier = { leadSaved() {} };
|
||||||
|
|
||||||
|
/** Отложить вызов до отправки ответа и проглотить ошибку: заявка уже в базе. */
|
||||||
|
export function notifyLater(notifier: Notifier, event: LeadEvent): void {
|
||||||
|
setImmediate(() => {
|
||||||
|
Promise.resolve()
|
||||||
|
.then(() => notifier.leadSaved(event))
|
||||||
|
.catch((err: unknown) => console.error('notifier failed', event, err));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Общие заготовки для тестов api: база в памяти, управляемые часы, notifier-шпион.
|
||||||
|
import type { LeadRequest } from '@kvarta/shared/lead';
|
||||||
|
import { createApp, type AppDeps } from './app.js';
|
||||||
|
import { openDb } from './db/index.js';
|
||||||
|
import type { LeadEvent, Notifier } from './notifier.js';
|
||||||
|
|
||||||
|
export const T0 = new Date('2026-10-17T12:00:00.000Z');
|
||||||
|
|
||||||
|
export function validBody(overrides: Partial<LeadRequest> = {}): LeadRequest {
|
||||||
|
return {
|
||||||
|
name: 'Аня',
|
||||||
|
phone: '+375 29 123-45-67',
|
||||||
|
interest: 'subscription',
|
||||||
|
comment: 'Под эспрессо',
|
||||||
|
firstTouch: { utm_source: 'instagram', landingPage: '/', at: '2026-10-10T10:00:00.000Z' },
|
||||||
|
lastTouch: { utm_source: 'yandex', landingPage: '/', submitPage: '/#form', at: '2026-10-17T11:59:00.000Z' },
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setup(overrides: Partial<AppDeps> = {}) {
|
||||||
|
const db = openDb(':memory:');
|
||||||
|
const clock = { now: T0 };
|
||||||
|
const events: LeadEvent[] = [];
|
||||||
|
const notifier: Notifier = { leadSaved: (e) => void events.push(e) };
|
||||||
|
const app = createApp({ db, now: () => clock.now, notifier, ...overrides });
|
||||||
|
|
||||||
|
const post = (body: unknown, headers: Record<string, string> = {}) =>
|
||||||
|
app.request('/api/lead', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', ...headers },
|
||||||
|
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
const advance = (ms: number) => {
|
||||||
|
clock.now = new Date(clock.now.getTime() + ms);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { app, db, clock, events, post, advance };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** notifier зовётся через setImmediate — ждём, пока очередь событий опустеет. */
|
||||||
|
export const flush = () => new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
Reference in New Issue
Block a user