Files
sprite-ai/server/db.ts
T

486 lines
25 KiB
TypeScript

// SQLite (встроенный node:sqlite): задачи ComfyUI, батчи картинок и их источники, значения полей форм.
import fs from 'node:fs';
import path from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import type {
BatchImage, BatchState, FileRef, ImageBatch, Job, JobState, ModelArtifacts, Project, SettingsPage, Text2ImgInput,
TextureArtifacts,
} from '../shared/api.ts';
import { DB_PATH } from './config.ts';
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
const db = new DatabaseSync(DB_PATH);
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
-- задачи ComfyUI (все типы); отслеживаются трекером
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY, -- prompt_id ComfyUI
type TEXT NOT NULL, -- text2img | cleanup | model
created INTEGER NOT NULL, -- мс
state TEXT NOT NULL, -- pending | done | error
error TEXT,
source TEXT, -- JSON FileRef исходника
params TEXT NOT NULL, -- JSON параметров
images TEXT NOT NULL DEFAULT '[]', -- JSON FileRef[]
models TEXT NOT NULL DEFAULT '[]' -- JSON FileRef[]
);
CREATE INDEX IF NOT EXISTS jobs_type_created ON jobs (type, created DESC);
CREATE INDEX IF NOT EXISTS jobs_state ON jobs (state);
-- значения полей форм страниц
CREATE TABLE IF NOT EXISTS settings (
page TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- батч картинок: результат одного запуска text2img или одной загрузки файлов
CREATE TABLE IF NOT EXISTS ImageBatches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created INTEGER NOT NULL, -- мс
updated INTEGER NOT NULL,
sourceType TEXT NOT NULL, -- text | upload
sourceId INTEGER NOT NULL, -- ImageBatchTextSources.id | ImageBatchUploadSources.id (по sourceType)
state TEXT NOT NULL, -- draft | pending | done | error
error TEXT,
jobId TEXT, -- text: prompt_id последнего запуска в ComfyUI
seedUsed INTEGER, -- text: фактический seed последнего запуска
prompt TEXT, -- text: итоговый позитивный промпт последнего запуска
clonedFrom INTEGER REFERENCES ImageBatches(id) ON DELETE SET NULL
);
CREATE INDEX IF NOT EXISTS ImageBatches_created ON ImageBatches (created DESC);
-- пользовательский ввод text2img — всё, что нужно, чтобы повторить запуск
CREATE TABLE IF NOT EXISTS ImageBatchTextSources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
shape TEXT NOT NULL,
colors TEXT NOT NULL,
template TEXT NOT NULL, -- как ввёл пользователь; пусто — шаблон по умолчанию
negative TEXT NOT NULL,
batchSize INTEGER NOT NULL,
seed TEXT NOT NULL -- как введено; пусто — случайный
);
-- загрузка файлов пользователем (один или несколько файлов за раз)
CREATE TABLE IF NOT EXISTS ImageBatchUploadSources (
id INTEGER PRIMARY KEY AUTOINCREMENT,
files TEXT NOT NULL -- JSON: исходные имена файлов
);
-- картинки батчей (сгенерированные и загруженные)
CREATE TABLE IF NOT EXISTS Images (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batchId INTEGER NOT NULL REFERENCES ImageBatches(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
subfolder TEXT NOT NULL,
type TEXT NOT NULL, -- input | output
UNIQUE (type, subfolder, filename)
);
CREATE INDEX IF NOT EXISTS Images_batchId ON Images (batchId);
-- проект: работа над одной картинкой из батча (из одной картинки — только один проект)
CREATE TABLE IF NOT EXISTS Projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created INTEGER NOT NULL, -- мс
imageId INTEGER NOT NULL UNIQUE REFERENCES Images(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
// Столбцы, добавленные позже: в существующих базах — ALTER TABLE.
function addColumns(table: string, columns: Record<string, string>) {
const have = new Set((db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]).map(c => c.name));
for (const [name, def] of Object.entries(columns)) if (!have.has(name)) db.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${def}`);
}
// последняя очищенная картинка проекта (история очисток не хранится)
addColumns('Projects', {
clean: 'TEXT', // JSON FileRef последней очищенной картинки
cleanUpdated: 'INTEGER', // мс
cleanJobId: 'TEXT', // prompt_id идущей очистки
cleanError: 'TEXT', // ошибка последней очистки
});
// 3D-артефакты проекта (история не хранится): модель без текстур и текстурная группа (TRELLIS.2 или Paint).
// v2: прежние раздельные столбцы model*/texture*/trellis*/paint* убраны, 3D-результаты начаты с нуля.
if (!(db.prepare("SELECT 1 FROM meta WHERE key = 'projects.artifacts.v2'").get())) {
const have = new Set((db.prepare('PRAGMA table_info(Projects)').all() as { name: string }[]).map(c => c.name));
for (const c of ['model', 'modelPreview', 'modelUpdated', 'modelJobId', 'modelError', 'modelFaces',
'texture', 'textureUpdated', 'textureFaces', 'textureJobId', 'textureError',
'trellis', 'trellisUpdated', 'trellisFaces', 'trellisJobId', 'trellisError',
'paint', 'paintUpdated', 'paintFaces', 'paintJobId', 'paintError']) {
if (have.has(c)) db.exec(`ALTER TABLE Projects DROP COLUMN ${c}`);
}
db.exec(`DELETE FROM jobs WHERE type IN ('model', 'texture', 'trellis', 'paint')`);
db.exec(`DELETE FROM settings WHERE page IN ('trellis', 'paint')`);
db.exec(`INSERT INTO meta (key, value) VALUES ('projects.artifacts.v2', '1')`);
}
addColumns('Projects', {
model: 'TEXT', // JSON ModelArtifacts: модель без текстур
modelJobId: 'TEXT', // prompt_id идущей генерации
modelError: 'TEXT', // ошибка последней генерации
texture: 'TEXT', // JSON TextureArtifacts: текстурированная модель, текстуры, развёртка, виды
textureJobId: 'TEXT', // prompt_id идущего текстурирования
textureError: 'TEXT', // ошибка последнего текстурирования
});
// истории очисток, 3D-моделей и текстур не хранятся (результат — в проекте) — завершённые задачи удаляются
db.exec(`DELETE FROM jobs WHERE type IN ('cleanup', 'model', 'texture') AND state <> 'pending'`);
/** Несколько записей одной транзакцией. */
export function transaction<T>(fn: () => T): T {
db.exec('BEGIN');
try {
const res = fn();
db.exec('COMMIT');
return res;
} catch (e) {
db.exec('ROLLBACK');
throw e;
}
}
// ---------- jobs ----------
interface JobRow {
id: string; type: string; created: number; state: string; error: string | null;
source: string | null; params: string; images: string; models: string;
}
function toJob(row: JobRow): Job {
return {
id: row.id, type: row.type, created: row.created, state: row.state, ...(row.error ? { error: row.error } : {}),
source: row.source ? JSON.parse(row.source) : null, params: JSON.parse(row.params),
images: JSON.parse(row.images), models: JSON.parse(row.models),
} as Job;
}
const jq = {
insert: db.prepare(`INSERT OR IGNORE INTO jobs (id, type, created, state, error, source, params, images, models)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`),
pending: db.prepare(`SELECT * FROM jobs WHERE state = 'pending'`),
finish: db.prepare('UPDATE jobs SET state = ?, error = ?, images = ?, models = ? WHERE id = ?'),
};
export function insertJob(j: Job) {
jq.insert.run(j.id, j.type, j.created, j.state, j.error ?? null, j.source ? JSON.stringify(j.source) : null,
JSON.stringify(j.params), JSON.stringify(j.images), JSON.stringify(j.models));
}
export function pendingJobs(): Job[] {
return (jq.pending.all() as unknown as JobRow[]).map(toJob);
}
export function finishJob(id: string, state: Exclude<JobState, 'pending'>, result: { error?: string; images?: FileRef[]; models?: FileRef[] }) {
jq.finish.run(state, result.error ?? null, JSON.stringify(result.images ?? []), JSON.stringify(result.models ?? []), id);
}
// ---------- settings, meta ----------
const sq = {
get: db.prepare('SELECT value FROM settings WHERE page = ?'),
put: db.prepare('INSERT INTO settings (page, value) VALUES (?, ?) ON CONFLICT(page) DO UPDATE SET value = excluded.value'),
getMeta: db.prepare('SELECT value FROM meta WHERE key = ?'),
setMeta: db.prepare('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'),
};
export function getSettings(page: SettingsPage): object {
const row = sq.get.get(page) as { value: string } | undefined;
return row ? JSON.parse(row.value) : {};
}
export function putSettings(page: SettingsPage, value: object) {
sq.put.run(page, JSON.stringify(value));
}
export function getMeta(key: string): string | null {
return (sq.getMeta.get(key) as { value: string } | undefined)?.value ?? null;
}
export function setMeta(key: string, value: string) {
sq.setMeta.run(key, value);
}
// ---------- ImageBatches + источники + Images ----------
interface BatchRow {
id: number; created: number; updated: number; sourceType: string; sourceId: number; state: string; error: string | null;
jobId: string | null; seedUsed: number | null; prompt: string | null; clonedFrom: number | null;
// из ImageBatchTextSources
shape: string | null; colors: string | null; template: string | null; negative: string | null; batchSize: number | null; seed: string | null;
// из ImageBatchUploadSources
files: string | null;
}
interface ImageRow { id: number; batchId: number; filename: string; subfolder: string; type: string; projectId: number | null }
/** Картинки с id и проектом (если из картинки создан проект). */
const SELECT_IMAGES = `
SELECT i.id, i.batchId, i.filename, i.subfolder, i.type, p.id AS projectId
FROM Images i LEFT JOIN Projects p ON p.imageId = i.id`;
const SELECT_BATCH = `
SELECT b.*, t.shape, t.colors, t.template, t.negative, t.batchSize, t.seed, u.files
FROM ImageBatches b
LEFT JOIN ImageBatchTextSources t ON b.sourceType = 'text' AND t.id = b.sourceId
LEFT JOIN ImageBatchUploadSources u ON b.sourceType = 'upload' AND u.id = b.sourceId`;
const bq = {
get: db.prepare(SELECT_BATCH + ' WHERE b.id = ?'),
list: db.prepare(SELECT_BATCH + ' ORDER BY b.created DESC, b.id DESC LIMIT ?'),
byJob: db.prepare('SELECT id FROM ImageBatches WHERE jobId = ?'),
insertBatch: db.prepare(`INSERT INTO ImageBatches (created, updated, sourceType, sourceId, state, seedUsed, prompt, clonedFrom)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`),
insertText: db.prepare(`INSERT INTO ImageBatchTextSources (shape, colors, template, negative, batchSize, seed)
VALUES (?, ?, ?, ?, ?, ?)`),
updateText: db.prepare(`UPDATE ImageBatchTextSources SET shape = ?, colors = ?, template = ?, negative = ?, batchSize = ?, seed = ?
WHERE id = ?`),
insertUpload: db.prepare('INSERT INTO ImageBatchUploadSources (files) VALUES (?)'),
touch: db.prepare('UPDATE ImageBatches SET updated = ? WHERE id = ?'),
start: db.prepare(`UPDATE ImageBatches SET state = 'pending', error = NULL, jobId = ?, seedUsed = ?, prompt = ?, updated = ? WHERE id = ?`),
finish: db.prepare('UPDATE ImageBatches SET state = ?, error = ?, updated = ? WHERE id = ?'),
removeBatch: db.prepare('DELETE FROM ImageBatches WHERE id = ?'),
removeText: db.prepare('DELETE FROM ImageBatchTextSources WHERE id = ?'),
removeUpload: db.prepare('DELETE FROM ImageBatchUploadSources WHERE id = ?'),
images: db.prepare(SELECT_IMAGES + ' WHERE i.batchId = ? ORDER BY i.id'),
listImages: db.prepare(SELECT_IMAGES + `
WHERE i.batchId IN (SELECT id FROM ImageBatches ORDER BY created DESC, id DESC LIMIT ?) ORDER BY i.id`),
image: db.prepare(SELECT_IMAGES + ' WHERE i.id = ?'),
attach: db.prepare('INSERT OR IGNORE INTO Images (batchId, filename, subfolder, type) VALUES (?, ?, ?, ?)'),
imageBatch: db.prepare('SELECT batchId FROM Images WHERE type = ? AND subfolder = ? AND filename = ?'),
};
const ref = (i: ImageRow): BatchImage => ({
id: i.id, filename: i.filename, subfolder: i.subfolder, type: i.type, projectId: i.projectId,
});
function toBatch(row: BatchRow, images: BatchImage[]): ImageBatch {
const base = {
id: row.id, created: row.created, updated: row.updated, sourceId: row.sourceId, state: row.state as BatchState,
...(row.error ? { error: row.error } : {}), clonedFrom: row.clonedFrom, images,
};
if (row.sourceType === 'upload') {
return { ...base, sourceType: 'upload', source: { id: row.sourceId, files: row.files ? JSON.parse(row.files) : [] } };
}
return {
...base, sourceType: 'text', jobId: row.jobId, seedUsed: row.seedUsed, prompt: row.prompt ?? '',
source: {
id: row.sourceId,
input: { shape: row.shape ?? '', colors: row.colors ?? '', template: row.template ?? '', negative: row.negative ?? '',
batch: row.batchSize ?? 1, seed: row.seed ?? '' },
},
};
}
export function getBatch(id: number): ImageBatch | null {
const row = bq.get.get(id) as BatchRow | undefined;
return row ? toBatch(row, (bq.images.all(id) as unknown as ImageRow[]).map(ref)) : null;
}
export function listBatches(limit = 1000): ImageBatch[] {
const byBatch = new Map<number, BatchImage[]>();
for (const i of bq.listImages.all(limit) as unknown as ImageRow[]) byBatch.set(i.batchId, [...(byBatch.get(i.batchId) ?? []), ref(i)]);
return (bq.list.all(limit) as unknown as BatchRow[]).map(row => toBatch(row, byBatch.get(row.id) ?? []));
}
export function batchByJob(jobId: string): number | null {
return (bq.byJob.get(jobId) as { id: number } | undefined)?.id ?? null;
}
/** В каком батче картинка (или null). */
export function batchOfImage(i: FileRef): number | null {
return (bq.imageBatch.get(i.type, i.subfolder, i.filename) as { batchId: number } | undefined)?.batchId ?? null;
}
/** Новый text-батч с источником. Без transaction — вызывать внутри неё, если нужно вместе с другими записями. */
export function insertTextBatch(input: Text2ImgInput, o: { created?: number; state?: BatchState; clonedFrom?: number | null;
seedUsed?: number | null; prompt?: string | null } = {}): number {
const src = bq.insertText.run(input.shape, input.colors, input.template, input.negative, input.batch, input.seed);
const now = Date.now();
const res = bq.insertBatch.run(o.created ?? now, now, 'text', Number(src.lastInsertRowid), o.state ?? 'draft',
o.seedUsed ?? null, o.prompt ?? null, o.clonedFrom ?? null);
return Number(res.lastInsertRowid);
}
export function createTextBatch(input: Text2ImgInput, clonedFrom: number | null = null): number {
return transaction(() => insertTextBatch(input, { clonedFrom }));
}
/** Новый upload-батч с загруженными картинками (одна загрузка — один батч). */
export function createUploadBatch(originalNames: string[], images: FileRef[]): number {
return transaction(() => {
const src = bq.insertUpload.run(JSON.stringify(originalNames));
const now = Date.now();
const id = Number(bq.insertBatch.run(now, now, 'upload', Number(src.lastInsertRowid), 'done', null, null, null).lastInsertRowid);
attachImages(id, images);
return id;
});
}
export function updateTextInput(batch: ImageBatch, input: Text2ImgInput) {
transaction(() => {
bq.updateText.run(input.shape, input.colors, input.template, input.negative, input.batch, input.seed, batch.sourceId);
bq.touch.run(Date.now(), batch.id);
});
}
export function startBatch(id: number, jobId: string, seedUsed: number, prompt: string) {
bq.start.run(jobId, seedUsed, prompt, Date.now(), id);
}
/** Прикрепить картинки к батчу (без своей транзакции). */
export function attachImages(id: number, images: FileRef[]) {
for (const i of images) bq.attach.run(id, i.filename, i.subfolder, i.type);
}
/** Завершение запуска: картинки к батчу + состояние (без своей транзакции — трекер вызывает внутри своей). */
export function finishBatch(id: number, state: 'done' | 'error', images: FileRef[], error?: string) {
attachImages(id, images);
bq.finish.run(state, error ?? null, Date.now(), id);
}
export function deleteBatch(b: ImageBatch) {
transaction(() => {
bq.removeBatch.run(b.id);
(b.sourceType === 'text' ? bq.removeText : bq.removeUpload).run(b.sourceId);
});
}
// ---------- Projects ----------
const SELECT_PROJECT = `
SELECT p.id, p.created, p.clean, p.cleanUpdated, p.cleanJobId, p.cleanError,
p.model, p.modelJobId, p.modelError, p.texture, p.textureJobId, p.textureError,
i.id AS imageId, i.batchId, i.filename, i.subfolder, i.type, b.sourceType
FROM Projects p JOIN Images i ON i.id = p.imageId JOIN ImageBatches b ON b.id = i.batchId`;
const pq = {
insert: db.prepare('INSERT OR IGNORE INTO Projects (created, imageId) VALUES (?, ?)'),
byImage: db.prepare('SELECT id FROM Projects WHERE imageId = ?'),
get: db.prepare(SELECT_PROJECT + ' WHERE p.id = ?'),
list: db.prepare(SELECT_PROJECT + ' ORDER BY p.created DESC, p.id DESC LIMIT ?'),
byCleanJob: db.prepare('SELECT id FROM Projects WHERE cleanJobId = ?'),
startClean: db.prepare('UPDATE Projects SET cleanJobId = ?, cleanError = NULL WHERE id = ?'),
finishClean: db.prepare('UPDATE Projects SET clean = COALESCE(?, clean), cleanUpdated = ?, cleanError = ?, cleanJobId = NULL WHERE id = ?'),
clearClean: db.prepare('UPDATE Projects SET clean = NULL, cleanUpdated = NULL, cleanError = NULL WHERE id = ?'),
byModelJob: db.prepare('SELECT id FROM Projects WHERE modelJobId = ?'),
startModel: db.prepare('UPDATE Projects SET modelJobId = ?, modelError = NULL WHERE id = ?'),
// новая модель заменяет прежнюю вместе с текстурами (они от прежней модели); при ошибке всё остаётся
finishModel: db.prepare(`UPDATE Projects SET model = COALESCE(?1, model),
texture = CASE WHEN ?1 IS NULL THEN texture ELSE ?2 END,
modelError = ?3, modelJobId = NULL WHERE id = ?4`),
byTextureJob: db.prepare('SELECT id FROM Projects WHERE textureJobId = ?'),
startTexture: db.prepare('UPDATE Projects SET textureJobId = ?, textureError = NULL WHERE id = ?'),
finishTexture: db.prepare('UPDATE Projects SET texture = COALESCE(?, texture), textureError = ?, textureJobId = NULL WHERE id = ?'),
clearModel: db.prepare('UPDATE Projects SET model = NULL, modelError = NULL, texture = NULL, textureError = NULL WHERE id = ?'),
clearTexture: db.prepare('UPDATE Projects SET texture = NULL, textureError = NULL WHERE id = ?'),
};
/** Генерация 3D-модели проекта поставлена в очередь. */
export function startProjectModel(id: number, jobId: string) {
pq.startModel.run(jobId, id);
}
export function projectByModelJob(jobId: string): number | null {
return (pq.byModelJob.get(jobId) as { id: number } | undefined)?.id ?? null;
}
/**
* Генерация завершена: новая модель заменяет прежнюю, прежние текстуры убираются (TRELLIS.2 сразу даёт свои).
* При ошибке (model = null) всё остаётся как было.
*/
export function finishProjectModel(id: number, model: ModelArtifacts | null, texture: TextureArtifacts | null, error?: string) {
pq.finishModel.run(model ? JSON.stringify(model) : null, texture ? JSON.stringify(texture) : null, error ?? null, id);
}
/** Текстурирование проекта поставлено в очередь. */
export function startProjectTexture(id: number, jobId: string) {
pq.startTexture.run(jobId, id);
}
export function projectByTextureJob(jobId: string): number | null {
return (pq.byTextureJob.get(jobId) as { id: number } | undefined)?.id ?? null;
}
/** Текстурирование завершено: новый результат заменяет прежний (при ошибке прежний остаётся). */
export function finishProjectTexture(id: number, texture: TextureArtifacts | null, error?: string) {
pq.finishTexture.run(texture ? JSON.stringify(texture) : null, error ?? null, id);
}
/** Убрать модель из проекта вместе с текстурами — они сделаны для неё (файлы в ComfyUI/output остаются). */
export function clearProjectModel(id: number) {
pq.clearModel.run(id);
}
/** Убрать текстурную группу (файлы в ComfyUI/output остаются). */
export function clearProjectTexture(id: number) {
pq.clearTexture.run(id);
}
/** Убрать очищенную картинку из проекта (файл в ComfyUI/output остаётся). */
export function clearProjectClean(id: number) {
pq.clearClean.run(id);
}
interface ProjectRow {
id: number; created: number; clean: string | null; cleanUpdated: number | null; cleanJobId: string | null; cleanError: string | null;
model: string | null; modelJobId: string | null; modelError: string | null;
texture: string | null; textureJobId: string | null; textureError: string | null;
imageId: number; batchId: number; filename: string; subfolder: string; type: string; sourceType: string;
}
const json = (s: string | null) => (s ? JSON.parse(s) : null);
function toProject(r: ProjectRow): Project {
return {
id: r.id, created: r.created, batchId: r.batchId, imageSource: r.sourceType === 'upload' ? 'upload' : 'text',
image: { id: r.imageId, filename: r.filename, subfolder: r.subfolder, type: r.type, projectId: r.id },
clean: json(r.clean), cleanUpdated: r.cleanUpdated,
cleanJobId: r.cleanJobId, ...(r.cleanError ? { cleanError: r.cleanError } : {}),
model: json(r.model), modelJobId: r.modelJobId, ...(r.modelError ? { modelError: r.modelError } : {}),
texture: json(r.texture), textureJobId: r.textureJobId, ...(r.textureError ? { textureError: r.textureError } : {}),
};
}
export function listProjects(limit = 1000): Project[] {
return (pq.list.all(limit) as unknown as ProjectRow[]).map(toProject);
}
/** Очистка исходника проекта поставлена в очередь. */
export function startProjectClean(id: number, jobId: string) {
pq.startClean.run(jobId, id);
}
export function projectByCleanJob(jobId: string): number | null {
return (pq.byCleanJob.get(jobId) as { id: number } | undefined)?.id ?? null;
}
/** Очистка завершена: результат заменяет прежнюю очищенную картинку (при ошибке прежняя остаётся). */
export function finishProjectClean(id: number, clean: FileRef | null, error?: string) {
pq.finishClean.run(clean ? JSON.stringify(clean) : null, Date.now(), error ?? null, id);
}
export function getImage(id: number): (BatchImage & { batchId: number }) | null {
const row = bq.image.get(id) as ImageRow | undefined;
return row ? { ...ref(row), batchId: row.batchId } : null;
}
/** Проект из картинки: из одной картинки — только один проект; если уже есть, возвращается он. */
export function projectForImage(imageId: number): number {
pq.insert.run(Date.now(), imageId);
return (pq.byImage.get(imageId) as { id: number }).id;
}
export function getProject(id: number): Project | null {
const row = pq.get.get(id) as ProjectRow | undefined;
return row ? toProject(row) : null;
}
/** Удаление задачи (история очисток не хранится). */
export function deleteJob(id: string) {
db.prepare('DELETE FROM jobs WHERE id = ?').run(id);
}