the extraction kept atelier's own postgres database, because the brief said to when the tables already had rows. the owner reversed it, and the reason is not tidiness: a plugin that behaves differently from every other plugin is a plugin nobody can reason about. the separate database meant a second connection string to set before install, a second thing to back up, a second thing to get wrong on a new machine, and failure modes no other plugin shares — none of it bought anything except avoiding a migration. four tables now: atelier_collections, atelier_collection_items, atelier_providers, atelier_generations. index and check constraint names carry the prefix too — those are global in postgres and are the one thing that would otherwise collide with another plugin. db/schema.ts re-exports the definitions rather than duplicating them: they stay beside the code that queries them in sidecar/db/, and that file exists to be found by gen-plugin-schemas. ATELIER_POSTGRES_URL is gone. db.ts reads OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL, which the platform already sets. the standalone service's rows are NOT here and nothing depends on them being here — the old service is still running against the old database, untouched, and migrating is a separate watched step after this is proven on alpha.
133 lines
6.2 KiB
TypeScript
133 lines
6.2 KiB
TypeScript
import { pgTable, uuid, text, jsonb, integer, numeric, boolean, timestamp, index, check } from 'drizzle-orm/pg-core';
|
|
import { sql } from 'drizzle-orm';
|
|
|
|
/**
|
|
* The set of states a generation can be in. Closed, so it is a `check()` rather than free text —
|
|
* otherwise a status the code stopped writing sits in old rows with nothing structural saying it
|
|
* became illegal.
|
|
*/
|
|
export const GENERATION_STATUSES = ['queued', 'running', 'succeeded', 'failed'] as const;
|
|
export type GenerationStatus = (typeof GENERATION_STATUSES)[number];
|
|
|
|
/**
|
|
* Which provider endpoint made the row. Closed, so a check() rather than free text.
|
|
*
|
|
* One table for both rather than a second one for edits: everything that makes a generation worth
|
|
* recording — prompt, model, cost, duration, what landed on disk — is identical, and history that has
|
|
* to be read from two places is history that gets read from one.
|
|
*/
|
|
export const GENERATION_OPERATIONS = ['generate', 'edit'] as const;
|
|
export type GenerationOperation = (typeof GENERATION_OPERATIONS)[number];
|
|
|
|
/**
|
|
* One row per image we were asked to make, whatever became of it.
|
|
*
|
|
* The row is written when the request arrives, not when it succeeds: a generation that failed, or that
|
|
* a restart interrupted, is a thing that happened and cost time, and the prompt that produced it is
|
|
* worth as much as one that worked.
|
|
*
|
|
* ── Why the image is a path and not bytes ──
|
|
*
|
|
* The files live under ATELIER_DATA_DIR, outside the repo and outside Postgres. They are served
|
|
* unauthenticated as ordinary images, so they need to be a file a static route can hand to
|
|
* `new Response(Bun.file(...))` — not a column something has to decode first.
|
|
*/
|
|
export const generations = pgTable('atelier_generations',
|
|
{
|
|
/**
|
|
* Also the filename on disk. One id means one image, so a row can be turned into a served URL
|
|
* without a second lookup.
|
|
*/
|
|
id: uuid('id').primaryKey().defaultRandom(),
|
|
|
|
/**
|
|
* The request these images came from — `n: 4` is four rows sharing one requestId, and the job the
|
|
* caller polls. Per-image rows rather than an array column because cost, duration and failure are
|
|
* per image: one of four can fail, and a row that averaged them would say nothing true.
|
|
*/
|
|
requestId: uuid('request_id').notNull(),
|
|
|
|
/** Position within the request, so `data[]` comes back in the order it was asked for. */
|
|
index: integer('index').notNull().default(0),
|
|
|
|
operation: text('operation').notNull().default('generate').$type<GenerationOperation>(),
|
|
|
|
status: text('status').notNull().default('queued').$type<GenerationStatus>(),
|
|
|
|
/**
|
|
* The adapter and model, as text, denormalised on purpose.
|
|
*
|
|
* `providerId` below points at the configured provider, but that row can be renamed, repointed at
|
|
* another model, or deleted — and none of that may change what an existing image was made with.
|
|
* The row is a pointer to configuration; these two are the historical fact.
|
|
*/
|
|
provider: text('provider').notNull(),
|
|
model: text('model').notNull(),
|
|
|
|
/** The provider row this came from, when there was one. Null for rows made before providers existed. */
|
|
providerId: uuid('provider_id'),
|
|
|
|
prompt: text('prompt').notNull(),
|
|
|
|
/** Whatever else the caller asked for — size, quality, background. Provider-shaped, so opaque here. */
|
|
options: jsonb('options').$type<Record<string, unknown>>(),
|
|
|
|
/**
|
|
* For edits: the uploaded files this was derived from, as names under the inputs directory.
|
|
*
|
|
* Kept because an edit without its input is unreadable afterwards — the prompt alone does not say
|
|
* what was edited, and "make the hat blue" means nothing without the hat. Names rather than bytes,
|
|
* for the same reason the output is a file.
|
|
*/
|
|
inputImages: jsonb('input_images').$type<string[]>(),
|
|
maskImage: text('mask_image'),
|
|
|
|
/** Filename under the images directory. Null until the bytes exist. */
|
|
imagePath: text('image_path'),
|
|
|
|
// ── What actually landed on disk ──
|
|
//
|
|
// Measured from the written file, never echoed from the request. What was asked for and what
|
|
// arrived are exactly the two things that come apart, and the caller is an agent that cannot look
|
|
// at the image to tell the difference.
|
|
//
|
|
// `hasAlpha` and `opaque` are deliberately both here and they are not the same question.
|
|
// `hasAlpha` is whether the file carries an alpha channel; `opaque` is whether every pixel in it
|
|
// is fully opaque. A PNG with an alpha channel that the model never used is hasAlpha: true,
|
|
// opaque: true — which is a request for a transparent background that silently did not happen,
|
|
// and the single most consequential failure for an asset destined to be an app icon.
|
|
width: integer('width'),
|
|
height: integer('height'),
|
|
hasAlpha: boolean('has_alpha'),
|
|
opaque: boolean('opaque'),
|
|
|
|
/**
|
|
* Cost in USD. `numeric` rather than a float: money that goes through binary floating point comes
|
|
* back slightly wrong, and these get summed.
|
|
*/
|
|
costUsd: numeric('cost_usd', { precision: 12, scale: 6 }),
|
|
|
|
/** Raw usage the provider reported, so cost can be recomputed if the pricing we applied was wrong. */
|
|
usage: jsonb('usage').$type<Record<string, unknown>>(),
|
|
|
|
/** What the provider said back, minus the image bytes — `revised_prompt`, echoed size and quality. */
|
|
result: jsonb('result').$type<Record<string, unknown>>(),
|
|
|
|
durationMs: integer('duration_ms'),
|
|
|
|
error: text('error'),
|
|
|
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
|
startedAt: timestamp('started_at', { withTimezone: true }),
|
|
finishedAt: timestamp('finished_at', { withTimezone: true }),
|
|
},
|
|
(table) => [
|
|
check('atelier_generations_status_check', sql`${table.status} in ('queued', 'running', 'succeeded', 'failed')`),
|
|
check('atelier_generations_operation_check', sql`${table.operation} in ('generate', 'edit')`),
|
|
// The queue reads "what is still to do, oldest first" on every tick and after every restart.
|
|
index('atelier_generations_status_created_idx').on(table.status, table.createdAt),
|
|
// Every poll of a job is this lookup.
|
|
index('atelier_generations_request_idx').on(table.requestId, table.index),
|
|
],
|
|
);
|