import type { Db } from "@wrnexus/db"; import type { AddOptions, Job } from "./index.ts"; import type { DefinedJob } from "./defined.ts"; import type { DurableQueue } from "./durable.ts"; const SAFE = /^[A-Za-z_][A-Za-z0-9_]*$/; const ident = (value: string) => { if (!SAFE.test(value)) throw new TypeError(`Unsafe queue identifier: ${value}`); return value; }; export interface OutboxEntry { id: number; queue: string; payload: T; options?: AddOptions; } export interface OutboxWriter { enqueue(job: Pick, "name">, data: T, options?: AddOptions): Promise; } export async function installOutboxSchema(db: Db, table = "wrnexus_outbox"): Promise { const target = ident(table); const id = db.driver.dialect === "postgres" ? "BIGSERIAL PRIMARY KEY" : "INTEGER PRIMARY KEY AUTOINCREMENT"; await db.exec(`CREATE TABLE IF NOT EXISTS ${target} ( id ${id}, queue_name TEXT NOT NULL, payload TEXT NOT NULL, options TEXT, created_at BIGINT NOT NULL, dispatched_at BIGINT )`); await db.exec(`CREATE INDEX IF NOT EXISTS ${target}_pending ON ${target} (dispatched_at, id)`); } /** Commit business writes and durable enqueue intents in the same database transaction. */ export async function withOutbox( db: Db, operation: (context: { db: Db; enqueue: OutboxWriter["enqueue"] }) => Promise, table = "wrnexus_outbox", ): Promise { await installOutboxSchema(db, table); const target = ident(table); return db.tx((transaction) => operation({ db: transaction, enqueue: async (job, data, options) => { await transaction.exec( `INSERT INTO ${target} (queue_name,payload,options,created_at,dispatched_at) VALUES (?,?,?,?,NULL)`, [job.name, JSON.stringify(data), options ? JSON.stringify(options) : null, Date.now()], ); }, }), ); } /** Dispatch committed outbox rows. A crash before marking simply retries idempotently. */ export async function drainOutbox( db: Db, resolve: ( queueName: string, ) => { add(data: unknown, options?: AddOptions): Promise } | undefined, options: { table?: string; limit?: number } = {}, ): Promise { const target = ident(options.table ?? "wrnexus_outbox"); await installOutboxSchema(db, target); const rows = await db.all<{ id: number; queue_name: string; payload: string; options: string | null; }>( `SELECT id,queue_name,payload,options FROM ${target} WHERE dispatched_at IS NULL ORDER BY id LIMIT ?`, [options.limit ?? 100], ); let dispatched = 0; for (const row of rows) { const producer = resolve(row.queue_name); if (!producer) continue; const addOptions = row.options ? (JSON.parse(row.options) as AddOptions) : {}; await producer.add(JSON.parse(row.payload), { idempotencyKey: `outbox:${row.id}`, ...addOptions, }); await db.exec(`UPDATE ${target} SET dispatched_at = ? WHERE id = ? AND dispatched_at IS NULL`, [ Date.now(), row.id, ]); dispatched++; } return dispatched; } export interface JobStateMachineOptions { db: () => Db; table: string; idColumn?: string; stateColumn?: string; states: { queued: string; running: string; success: string; failed: string }; } export function defineJobStateMachine(options: JobStateMachineOptions) { const table = ident(options.table); const id = ident(options.idColumn ?? "id"); const state = ident(options.stateColumn ?? "status"); const transition = async (recordId: string | number, from: string, to: string) => ( await options .db() .exec(`UPDATE ${table} SET ${state} = ? WHERE ${id} = ? AND ${state} = ?`, [ to, recordId, from, ]) ).changes > 0; return { queue: (recordId: string | number) => transition(recordId, options.states.failed, options.states.queued), claim: (recordId: string | number) => transition(recordId, options.states.queued, options.states.running), complete: (recordId: string | number) => transition(recordId, options.states.running, options.states.success), fail: (recordId: string | number) => transition(recordId, options.states.running, options.states.failed), transition, }; } export function testQueue(queue: DurableQueue) { return { add: queue.add.bind(queue), runNext: () => queue.drain(), async runAll(limit = 1_000) { let total = 0; while (total < limit) { const count = await queue.drain(); if (!count) break; total += count; } return total; }, get: queue.get.bind(queue), status: queue.status.bind(queue), history: queue.history.bind(queue), shutdown: queue.shutdown.bind(queue), }; } export interface SagaStep { name: string; run(value: T): void | Promise; compensate?(value: T, cause: unknown): void | Promise; } export function defineSaga(definition: { name: string; steps: readonly SagaStep[] }) { return { name: definition.name, async run(value: T): Promise { const completed: SagaStep[] = []; try { for (const step of definition.steps) { await step.run(value); completed.push(step); } } catch (cause) { for (const step of completed.reverse()) await step.compensate?.(value, cause); throw cause; } }, }; }