ported from the standalone officer-atelier service: providers (adapter-shaped, openai the only adapter today), the generation queue, collections, and the database layer — kept on its own Postgres database rather than folded into officer_db, since both collections and provider_settings had rows on the machine this was extracted from. what changed rather than just moved: - images are served from an authenticated GET /files/:filename instead of an unauthenticated PUBLIC_URL-prefixed static route - the OpenAI-key bootstrap fallback reads the secret store (getSecret) before .env, per-adapter - provider_settings (the one-row-per-adapter shape providers replaced) did not come along — it has no callers left anywhere in the server code, only a migration script that also did not move ping/echo (smoke-test routes with no caller) did not move either; the sidecar's own /_health covers liveness. not yet wired into the platform: sidecar/index.ts exists but nothing mounts it, and web/ has not been written, so this is not yet a discoverable plugin.
100 lines
3.6 KiB
TypeScript
100 lines
3.6 KiB
TypeScript
import { and, asc, desc, eq, inArray } from 'drizzle-orm';
|
|
import { db } from '../db';
|
|
import { generations } from './schema';
|
|
import type { GenerationStatus } from './schema';
|
|
|
|
export type Generation = typeof generations.$inferSelect;
|
|
export type NewGeneration = typeof generations.$inferInsert;
|
|
|
|
export async function createGeneration(values: NewGeneration): Promise<Generation> {
|
|
const [row] = await db.insert(generations).values(values).returning();
|
|
return row!;
|
|
}
|
|
|
|
export async function getGeneration(id: string): Promise<Generation | undefined> {
|
|
const [row] = await db.select().from(generations).where(eq(generations.id, id)).limit(1);
|
|
return row;
|
|
}
|
|
|
|
export async function listGenerations(limit = 50): Promise<Generation[]> {
|
|
return db.select().from(generations).orderBy(desc(generations.createdAt)).limit(limit);
|
|
}
|
|
|
|
/** Every image of one request, in the order it was asked for. This is what polling a job reads. */
|
|
export async function getRequest(requestId: string): Promise<Generation[]> {
|
|
return db.select().from(generations).where(eq(generations.requestId, requestId)).orderBy(asc(generations.index));
|
|
}
|
|
|
|
export async function createRequest(rows: NewGeneration[]): Promise<Generation[]> {
|
|
return db.insert(generations).values(rows).returning();
|
|
}
|
|
|
|
/** The backlog, oldest first — what the queue picks up on a tick and after a restart. */
|
|
export async function claimableGenerations(limit: number): Promise<Generation[]> {
|
|
return db
|
|
.select()
|
|
.from(generations)
|
|
.where(eq(generations.status, 'queued'))
|
|
.orderBy(asc(generations.createdAt))
|
|
.limit(limit);
|
|
}
|
|
|
|
/**
|
|
* Move a row to `running`, but only from `queued`.
|
|
*
|
|
* The `and(... eq(status, 'queued'))` is the whole point: two workers reaching for the same row both
|
|
* update, and only the one that got there first matches. The loser sees no returned row and moves on,
|
|
* so a generation is never paid for twice.
|
|
*/
|
|
export async function markRunning(id: string): Promise<Generation | undefined> {
|
|
const [row] = await db
|
|
.update(generations)
|
|
.set({ status: 'running', startedAt: new Date() })
|
|
.where(and(eq(generations.id, id), eq(generations.status, 'queued')))
|
|
.returning();
|
|
return row;
|
|
}
|
|
|
|
export async function markSucceeded(
|
|
id: string,
|
|
values: {
|
|
imagePath: string;
|
|
width?: number | null;
|
|
height?: number | null;
|
|
hasAlpha?: boolean | null;
|
|
opaque?: boolean | null;
|
|
costUsd?: string | null;
|
|
usage?: Record<string, unknown> | null;
|
|
result?: Record<string, unknown> | null;
|
|
durationMs: number;
|
|
},
|
|
): Promise<void> {
|
|
await db
|
|
.update(generations)
|
|
.set({ ...values, status: 'succeeded', finishedAt: new Date() })
|
|
.where(eq(generations.id, id));
|
|
}
|
|
|
|
export async function markFailed(id: string, error: string, durationMs?: number): Promise<void> {
|
|
await db
|
|
.update(generations)
|
|
.set({ status: 'failed', error, durationMs: durationMs ?? null, finishedAt: new Date() })
|
|
.where(eq(generations.id, id));
|
|
}
|
|
|
|
/**
|
|
* Fail whatever was mid-flight when the process died.
|
|
*
|
|
* A row left `running` has no worker behind it — the only thing that would have finished it went away
|
|
* with the last process. Without this it stays running forever, and a caller polling it waits on
|
|
* something that will never answer.
|
|
*/
|
|
export async function failInterrupted(): Promise<number> {
|
|
const rows = await db
|
|
.update(generations)
|
|
.set({ status: 'failed', error: 'Interrupted by a server restart', finishedAt: new Date() })
|
|
.where(inArray(generations.status, ['running' satisfies GenerationStatus]))
|
|
.returning({ id: generations.id });
|
|
return rows.length;
|
|
}
|