feat: add application productivity foundations
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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<Row>(`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<Row>(
|
||||
`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<Row>(
|
||||
`SELECT payload FROM ${target}${name ? " WHERE name = ?" : ""} ORDER BY run_at`,
|
||||
name ? [name] : [],
|
||||
)
|
||||
).map(decode);
|
||||
},
|
||||
async findByIdempotencyKey(name, key) {
|
||||
const row = await db.one<Row>(
|
||||
`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),
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user