import { randomUUID } from "node:crypto"; import { type DatabaseContext, type Job, mapJob, type NewJob, now, type Row, } from "../model.js"; export class JobRepository { constructor(private readonly context: DatabaseContext) {} createCommand(input: NewJob): { job: Job; created: boolean } { return this.context.transaction(() => { const existing = this.byTriggerKey(input.triggerKey); if (existing) return { job: existing, created: false }; return { job: this.insert(input), created: true }; }); } createLabel(input: NewJob): Job | undefined { if (!input.triggerLabel) throw new Error("Label job requires triggerLabel"); const label = input.triggerLabel; return this.context.transaction(() => { const claim = this.context.db .prepare(` SELECT claimed FROM label_claims WHERE repository_id = $repositoryId AND issue_number = $issueNumber AND label = $label `) .get({ $repositoryId: input.repositoryId, $issueNumber: input.issueNumber, $label: label, }) as Row | undefined; if (Number(claim?.claimed || 0) === 1) return undefined; const job = this.insert({ ...input, triggerKey: `label:${input.repositoryId}:${input.issueNumber}:${label}:${randomUUID()}`, }); this.context.db .prepare(` INSERT INTO label_claims(repository_id, issue_number, label, claimed, job_id, updated_at) VALUES ($repositoryId, $issueNumber, $label, 1, $jobId, $now) ON CONFLICT(repository_id, issue_number, label) DO UPDATE SET claimed = 1, job_id = excluded.job_id, updated_at = excluded.updated_at `) .run({ $repositoryId: input.repositoryId, $issueNumber: input.issueNumber, $label: label, $jobId: job.id, $now: now(), }); return job; }); } releaseLabel( repositoryId: number, issueNumber: number, label: string, ): void { this.context.db .prepare(` INSERT INTO label_claims(repository_id, issue_number, label, claimed, job_id, updated_at) VALUES ($repositoryId, $issueNumber, $label, 0, NULL, $now) ON CONFLICT(repository_id, issue_number, label) DO UPDATE SET claimed = 0, job_id = NULL, updated_at = excluded.updated_at `) .run({ $repositoryId: repositoryId, $issueNumber: issueNumber, $label: label, $now: now(), }); } active(repositoryId: number, issueNumber: number): Job | undefined { const row = this.context.db .prepare(` SELECT * FROM jobs WHERE repository_id = $repositoryId AND issue_number = $issueNumber AND state IN ('admitted', 'queued', 'running', 'publishing') ORDER BY created_at DESC LIMIT 1 `) .get({ $repositoryId: repositoryId, $issueNumber: issueNumber }) as | Row | undefined; return row ? mapJob(row) : undefined; } latest(repositoryId: number, issueNumber: number): Job | undefined { const row = this.context.db .prepare(` SELECT * FROM jobs WHERE repository_id = $repositoryId AND issue_number = $issueNumber ORDER BY created_at DESC LIMIT 1 `) .get({ $repositoryId: repositoryId, $issueNumber: issueNumber }) as | Row | undefined; return row ? mapJob(row) : undefined; } control( triggerKey: string, action: "cancel" | "status", repositoryId: number, issueNumber: number, ): Job | undefined { return this.context.transaction(() => { const existing = this.context.db .prepare( "SELECT target_job_id FROM command_receipts WHERE trigger_key = $triggerKey", ) .get({ $triggerKey: triggerKey }) as Row | undefined; if (existing) return existing.target_job_id === null ? undefined : this.get(String(existing.target_job_id)); const target = action === "cancel" ? this.active(repositoryId, issueNumber) : this.latest(repositoryId, issueNumber); this.context.db .prepare(` INSERT INTO command_receipts(trigger_key, action, repository_id, issue_number, target_job_id, created_at) VALUES ($triggerKey, $action, $repositoryId, $issueNumber, $targetJobId, $now) `) .run({ $triggerKey: triggerKey, $action: action, $repositoryId: repositoryId, $issueNumber: issueNumber, $targetJobId: target?.id || null, $now: now(), }); if (action === "cancel" && target) { this.context.db .prepare(` UPDATE jobs SET cancel_requested = 1, state = CASE WHEN state IN ('admitted', 'queued') THEN 'cancelled' ELSE state END, updated_at = $now WHERE id = $id `) .run({ $id: target.id, $now: now() }); this.context.audit( target.id, "job.cancel-requested", triggerKey, ); return this.get(target.id); } return target; }); } get(id: string): Job | undefined { const row = this.context.db .prepare("SELECT * FROM jobs WHERE id = $id") .get({ $id: id }) as Row | undefined; return row ? mapJob(row) : undefined; } private insert(input: NewJob): Job { const id = randomUUID(); const time = now(); this.context.db .prepare(` INSERT INTO jobs (id, repository_id, issue_number, mode, trigger_kind, trigger_key, trigger_label, actor_id, actor_login, instruction, state, created_at, updated_at) VALUES ($id, $repositoryId, $issueNumber, $mode, $triggerKind, $triggerKey, $triggerLabel, $actorId, $actorLogin, $instruction, 'admitted', $now, $now) `) .run({ $id: id, $repositoryId: input.repositoryId, $issueNumber: input.issueNumber, $mode: input.mode, $triggerKind: input.triggerKind, $triggerKey: input.triggerKey, $triggerLabel: input.triggerLabel || null, $actorId: input.actorId, $actorLogin: input.actorLogin, $instruction: input.instruction || "", $now: time, }); this.context.db .prepare(` INSERT INTO outbox(id, job_id, kind, status, available_at, created_at, updated_at) VALUES ($id, $jobId, 'claim', 'pending', $now, $now, $now) `) .run({ $id: randomUUID(), $jobId: id, $now: time }); this.context.audit( id, "job.admitted", `${input.triggerKind}:${input.actorLogin}`, ); const job = this.get(id); if (!job) throw new Error(`Inserted job ${id} was not found`); return job; } private byTriggerKey(triggerKey: string): Job | undefined { const row = this.context.db .prepare("SELECT * FROM jobs WHERE trigger_key = $key") .get({ $key: triggerKey }) as Row | undefined; return row ? mapJob(row) : undefined; } }