import type { Db } from "@wrnexus/db"; import type { Job } from "./index.ts"; import type { QueueJobRecord, QueueStore } from "./durable.ts"; const SAFE = /^[A-Za-z_][A-Za-z0-9_]*$/; const check = (value: string) => { if (!SAFE.test(value)) throw new TypeError(`Invalid queue table: ${value}`); return value; }; export async function installDatabaseQueueSchema(db: Db, table = "wrnexus_jobs") { const target = check(table); await db.exec(`CREATE TABLE IF NOT EXISTS ${target} ( id VARCHAR(191) PRIMARY KEY, name VARCHAR(191) NOT NULL, payload TEXT NOT NULL, run_at BIGINT NOT NULL, priority INTEGER NOT NULL DEFAULT 0, idempotency_key VARCHAR(191), lease_owner VARCHAR(191), lease_until BIGINT )`); await db.exec(`CREATE TABLE IF NOT EXISTS ${target}_history ( id VARCHAR(191) PRIMARY KEY, name VARCHAR(191) NOT NULL, state VARCHAR(32) NOT NULL, payload TEXT NOT NULL, error TEXT, finished_at BIGINT NOT NULL )`); for (const statement of [ `CREATE INDEX ${target}_due ON ${target} (run_at, priority)`, `CREATE UNIQUE INDEX ${target}_idempotency ON ${target} (name, idempotency_key)`, ]) { try { await db.exec(statement); } catch (error) { if (!/exist|duplicate|already/i.test(String(error))) throw error; } } } export function databaseQueueStore(db: Db, table = "wrnexus_jobs"): QueueStore { const target = check(table); type Row = { payload: string }; const decode = (row: Row) => JSON.parse(row.payload) as Job; return { async put(job) { if (db.driver.dialect === "mysql") { await db.exec( `INSERT INTO ${target} (id,name,payload,run_at,priority,idempotency_key,lease_owner,lease_until) VALUES (?,?,?,?,?,?,NULL,NULL) ON DUPLICATE KEY UPDATE name=VALUES(name),payload=VALUES(payload),run_at=VALUES(run_at),priority=VALUES(priority),lease_owner=NULL,lease_until=NULL`, [ job.id, job.name, JSON.stringify(job), job.runAt, job.priority, job.idempotencyKey ?? null, ], ); } else { await db.exec( `INSERT INTO ${target} (id,name,payload,run_at,priority,idempotency_key,lease_owner,lease_until) VALUES (?,?,?,?,?,?,NULL,NULL) ON CONFLICT(id) DO UPDATE SET name=excluded.name,payload=excluded.payload,run_at=excluded.run_at,priority=excluded.priority,lease_owner=NULL,lease_until=NULL`, [ job.id, job.name, JSON.stringify(job), job.runAt, job.priority, job.idempotencyKey ?? null, ], ); } }, async get(id) { const row = await db.one(`SELECT payload FROM ${target} WHERE id = ?`, [id]); return row ? decode(row) : null; }, async remove(id) { await db.exec(`DELETE FROM ${target} WHERE id = ?`, [id]); }, async due(now, limit) { return ( await db.all( `SELECT payload FROM ${target} WHERE run_at <= ? AND (lease_until IS NULL OR lease_until < ?) ORDER BY priority DESC,run_at LIMIT ?`, [now, now, limit], ) ).map(decode); }, async list(name) { return ( await db.all( `SELECT payload FROM ${target}${name ? " WHERE name = ?" : ""} ORDER BY run_at`, name ? [name] : [], ) ).map(decode); }, async findByIdempotencyKey(name, key) { const row = await db.one( `SELECT payload FROM ${target} WHERE name = ? AND idempotency_key = ?`, [name, key], ); return row ? decode(row) : null; }, async size() { return Number( (await db.one<{ total: number | string }>(`SELECT COUNT(*) AS total FROM ${target}`)) ?.total ?? 0, ); }, async claim(id, worker, leaseUntil, now = Date.now()) { return ( ( await db.exec( `UPDATE ${target} SET lease_owner=?,lease_until=? WHERE id=? AND (lease_until IS NULL OR lease_until < ?)`, [worker, leaseUntil, id, now], ) ).changes > 0 ); }, async release(id, worker) { await db.exec( `UPDATE ${target} SET lease_owner=NULL,lease_until=NULL WHERE id=? AND lease_owner=?`, [id, worker], ); }, async archive(record) { const values = [ record.job.id, record.job.name, record.state, JSON.stringify(record.job), record.error ?? null, record.finishedAt, ]; if (db.driver.dialect === "mysql") await db.exec( `INSERT INTO ${target}_history (id,name,state,payload,error,finished_at) VALUES (?,?,?,?,?,?) ON DUPLICATE KEY UPDATE state=VALUES(state),payload=VALUES(payload),error=VALUES(error),finished_at=VALUES(finished_at)`, values, ); else await db.exec( `INSERT INTO ${target}_history (id,name,state,payload,error,finished_at) VALUES (?,?,?,?,?,?) ON CONFLICT(id) DO UPDATE SET state=excluded.state,payload=excluded.payload,error=excluded.error,finished_at=excluded.finished_at`, values, ); }, async history(id) { const rows = await db.all<{ state: QueueJobRecord["state"]; payload: string; error: string | null; finished_at: number; }>( `SELECT state,payload,error,finished_at FROM ${target}_history${id ? " WHERE id = ?" : ""} ORDER BY finished_at DESC`, id ? [id] : [], ); return rows.map((row) => ({ job: JSON.parse(row.payload), state: row.state, error: row.error ?? undefined, finishedAt: Number(row.finished_at), })); }, }; }