manifest, thin proxy router, and the sidecar backend
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.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
export * from './schema';
|
||||
export * from './queries';
|
||||
@@ -0,0 +1,133 @@
|
||||
import { and, desc, eq, inArray, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { generations } from '../generations/schema';
|
||||
import { collectionItems, collections } from './schema';
|
||||
|
||||
export type Collection = typeof collections.$inferSelect;
|
||||
export type CollectionWithCount = Collection & { itemCount: number; coverImagePath: string | null };
|
||||
|
||||
/**
|
||||
* Every collection, with how many images it holds and one to show for it.
|
||||
*
|
||||
* Counted in the query rather than by loading the memberships and calling `.length`: the list screen
|
||||
* asks for this on every render, and the alternative reads every row of every collection to produce
|
||||
* a number.
|
||||
*/
|
||||
export async function listCollections(): Promise<CollectionWithCount[]> {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: collections.id,
|
||||
name: collections.name,
|
||||
description: collections.description,
|
||||
createdAt: collections.createdAt,
|
||||
updatedAt: collections.updatedAt,
|
||||
itemCount: sql<number>`count(${collectionItems.generationId})::int`,
|
||||
// The newest image that actually has a file — a failed generation makes a poor cover.
|
||||
coverImagePath: sql<string | null>`
|
||||
(select g.image_path from ${collectionItems} ci
|
||||
join ${generations} g on g.id = ci.generation_id
|
||||
where ci.collection_id = ${collections.id} and g.image_path is not null
|
||||
order by g.created_at desc limit 1)
|
||||
`,
|
||||
})
|
||||
.from(collections)
|
||||
.leftJoin(collectionItems, eq(collectionItems.collectionId, collections.id))
|
||||
.groupBy(collections.id)
|
||||
.orderBy(desc(collections.updatedAt));
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getCollection(id: string): Promise<Collection | undefined> {
|
||||
const [row] = await db.select().from(collections).where(eq(collections.id, id)).limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function getCollectionByName(name: string): Promise<Collection | undefined> {
|
||||
const [row] = await db.select().from(collections).where(eq(collections.name, name)).limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function createCollection(name: string, description?: string | null): Promise<Collection> {
|
||||
const [row] = await db.insert(collections).values({ name, description }).returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find by name, or make it.
|
||||
*
|
||||
* `onConflictDoUpdate` rather than select-then-insert: two requests naming the same new collection
|
||||
* arrive together often — that is exactly what "put this afternoon's work in one place" produces —
|
||||
* and the check-then-act version loses that race and fails one of them on the unique constraint.
|
||||
*/
|
||||
export async function findOrCreateCollection(name: string): Promise<Collection> {
|
||||
const [row] = await db
|
||||
.insert(collections)
|
||||
.values({ name })
|
||||
.onConflictDoUpdate({ target: collections.name, set: { updatedAt: new Date() } })
|
||||
.returning();
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function renameCollection(id: string, values: { name?: string; description?: string | null }) {
|
||||
const [row] = await db
|
||||
.update(collections)
|
||||
.set({ ...values, updatedAt: new Date() })
|
||||
.where(eq(collections.id, id))
|
||||
.returning();
|
||||
return row;
|
||||
}
|
||||
|
||||
/** The memberships go with it; the images do not. */
|
||||
export async function deleteCollection(id: string): Promise<void> {
|
||||
await db.delete(collections).where(eq(collections.id, id));
|
||||
}
|
||||
|
||||
/** Adding something already in the collection is a no-op, not an error. */
|
||||
export async function addToCollection(collectionId: string, generationIds: string[]): Promise<number> {
|
||||
if (generationIds.length === 0) return 0;
|
||||
|
||||
const rows = await db
|
||||
.insert(collectionItems)
|
||||
.values(generationIds.map((generationId) => ({ collectionId, generationId })))
|
||||
.onConflictDoNothing()
|
||||
.returning({ generationId: collectionItems.generationId });
|
||||
|
||||
await db.update(collections).set({ updatedAt: new Date() }).where(eq(collections.id, collectionId));
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
export async function removeFromCollection(collectionId: string, generationIds: string[]): Promise<void> {
|
||||
if (generationIds.length === 0) return;
|
||||
|
||||
await db
|
||||
.delete(collectionItems)
|
||||
.where(and(eq(collectionItems.collectionId, collectionId), inArray(collectionItems.generationId, generationIds)));
|
||||
|
||||
await db.update(collections).set({ updatedAt: new Date() }).where(eq(collections.id, collectionId));
|
||||
}
|
||||
|
||||
/** The images in a collection, newest first. */
|
||||
export async function collectionImages(collectionId: string) {
|
||||
return db
|
||||
.select({ generation: generations, addedAt: collectionItems.addedAt })
|
||||
.from(collectionItems)
|
||||
.innerJoin(generations, eq(generations.id, collectionItems.generationId))
|
||||
.where(eq(collectionItems.collectionId, collectionId))
|
||||
.orderBy(desc(generations.createdAt))
|
||||
.then((rows) => rows.map((row) => row.generation));
|
||||
}
|
||||
|
||||
/** Which collections a generation is in — what the "add to collection" control needs to render state. */
|
||||
export async function collectionsForGenerations(generationIds: string[]): Promise<Record<string, string[]>> {
|
||||
if (generationIds.length === 0) return {};
|
||||
|
||||
const rows = await db
|
||||
.select({ generationId: collectionItems.generationId, collectionId: collectionItems.collectionId })
|
||||
.from(collectionItems)
|
||||
.where(inArray(collectionItems.generationId, generationIds));
|
||||
|
||||
const map: Record<string, string[]> = {};
|
||||
for (const row of rows) (map[row.generationId] ??= []).push(row.collectionId);
|
||||
return map;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { pgTable, uuid, text, timestamp, index, primaryKey } from 'drizzle-orm/pg-core';
|
||||
import { generations } from '../generations/schema';
|
||||
|
||||
/**
|
||||
* A named set of images — an afternoon's work, an icon suite, a client.
|
||||
*
|
||||
* `name` is unique, and that is load-bearing rather than tidy: the generation API accepts a
|
||||
* collection by name and creates it when it does not exist, so without the constraint a second
|
||||
* request with the same name would quietly start a second collection and split the batch in two.
|
||||
*/
|
||||
export const collections = pgTable('collections', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
name: text('name').notNull().unique(),
|
||||
description: text('description'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Membership, as a join table rather than a column on `generations`.
|
||||
*
|
||||
* An image belongs to as many collections as it earns: the icon that came out of Tuesday's session is
|
||||
* in "tuesday" and also in "shipped", and removing it from one must not touch the other. A
|
||||
* collection_id column would force a choice at generation time and make re-filing a destructive edit.
|
||||
*
|
||||
* Both sides cascade. Deleting a collection removes its memberships and NOT its images — a collection
|
||||
* is a way of looking at generations, not a container that owns them. Deleting a generation removes
|
||||
* its memberships, since a row pointing at nothing is only a way to crash a join later.
|
||||
*/
|
||||
export const collectionItems = pgTable(
|
||||
'collection_items',
|
||||
{
|
||||
collectionId: uuid('collection_id')
|
||||
.notNull()
|
||||
.references(() => collections.id, { onDelete: 'cascade' }),
|
||||
generationId: uuid('generation_id')
|
||||
.notNull()
|
||||
.references(() => generations.id, { onDelete: 'cascade' }),
|
||||
addedAt: timestamp('added_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
// The composite key is what makes adding an image twice a no-op instead of a duplicate.
|
||||
primaryKey({ columns: [table.collectionId, table.generationId] }),
|
||||
index('collection_items_generation_idx').on(table.generationId),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||
import postgres from 'postgres';
|
||||
import * as schema from './schema';
|
||||
|
||||
// The `atelier` Postgres database — separate from officer_db, on purpose. See PLUGIN.md.
|
||||
//
|
||||
// `ATELIER_POSTGRES_URL` rather than officer's own `OFFICERDEV_POSTGRES_URL`/`POSTGRES_URL`: this
|
||||
// plugin talks to a different database on (most likely) the same Postgres server, and the platform's
|
||||
// own connection string must never be reused here by accident — the standalone service's
|
||||
// `atelier_db/src/env.ts` had a whole enforcement mechanism for exactly this mistake ("a development
|
||||
// process cannot reach the production database"), which no longer applies once this runs as one
|
||||
// process under one PM2 entry, but the principle — don't let this plugin's queries land on the
|
||||
// platform's own tables — still does.
|
||||
const POSTGRES_URL = process.env.ATELIER_POSTGRES_URL;
|
||||
|
||||
if (!POSTGRES_URL) {
|
||||
throw new Error(
|
||||
'ATELIER_POSTGRES_URL environment variable is required — set it in the platform .env to the ' +
|
||||
'`atelier` Postgres database this plugin should use. See plugins/atelier/PLUGIN.md.',
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One client per PROCESS, not one per module evaluation — see the standalone repo's original comment
|
||||
* (kept because the reasoning still holds under PM2's own occasional module reloads): a fresh pool per
|
||||
* reload abandons the previous one's open sockets, and a query in flight when the swap happens never
|
||||
* resolves and never releases its connection.
|
||||
*/
|
||||
const globalForDb = globalThis as unknown as { __atelierPgClient?: ReturnType<typeof postgres> };
|
||||
|
||||
const client = (globalForDb.__atelierPgClient ??= postgres(POSTGRES_URL, {
|
||||
max: 10,
|
||||
idle_timeout: 30,
|
||||
connect_timeout: 10,
|
||||
connection: {
|
||||
statement_timeout: 15_000,
|
||||
},
|
||||
}));
|
||||
export const db = drizzle(client, { schema });
|
||||
|
||||
/**
|
||||
* Block until Postgres answers, or give up after `timeoutMs`.
|
||||
*
|
||||
* `postgres()` is lazy — nothing fails at import when the database is not up yet — so startup work
|
||||
* that queries fires once, fails, and is swallowed by whatever `.catch()` it was written with. The
|
||||
* restart sweep that fails interrupted rows (`failInterrupted`) is exactly that kind of work.
|
||||
*/
|
||||
export async function waitForDatabase(timeoutMs = 30_000): Promise<boolean> {
|
||||
const started = Date.now();
|
||||
let announced = false;
|
||||
|
||||
for (;;) {
|
||||
try {
|
||||
await client`select 1`;
|
||||
if (announced) console.log('[atelier:db] Postgres is up');
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (Date.now() - started >= timeoutMs) {
|
||||
console.error(
|
||||
`[atelier:db] Postgres did not answer within ${Math.round(timeoutMs / 1000)}s:`,
|
||||
error instanceof Error ? error.message : error,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!announced) {
|
||||
console.log('[atelier:db] waiting for Postgres…');
|
||||
announced = true;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './schema';
|
||||
export * from './queries';
|
||||
@@ -0,0 +1,99 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
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(
|
||||
'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('generations_status_check', sql`${table.status} in ('queued', 'running', 'succeeded', 'failed')`),
|
||||
check('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('generations_status_created_idx').on(table.status, table.createdAt),
|
||||
// Every poll of a job is this lookup.
|
||||
index('generations_request_idx').on(table.requestId, table.index),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
// One `export *` per feature, and nothing else. What a feature exports is declared in its own
|
||||
// index.ts, beside the code it describes.
|
||||
//
|
||||
// `provider_settings` is NOT here. It is legacy — the one-row-per-adapter shape `providers` replaced —
|
||||
// and nothing in this sidecar reads or writes it any more (confirmed: no reference outside the
|
||||
// standalone repo's own migrate-providers.ts, which did not move). Its table and its one leftover row
|
||||
// stay in the `atelier` database, untouched. See PLUGIN.md.
|
||||
export * from './generations';
|
||||
export * from './collections';
|
||||
export * from './providers';
|
||||
|
||||
// The connection itself, so callers do not reach past the package for it.
|
||||
export { db, waitForDatabase } from './db';
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './schema';
|
||||
export * from './queries';
|
||||
@@ -0,0 +1,100 @@
|
||||
import { and, asc, eq, ne } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { providers } from './schema';
|
||||
|
||||
export type Provider = typeof providers.$inferSelect;
|
||||
export type NewProvider = typeof providers.$inferInsert;
|
||||
|
||||
export async function listProviders(): Promise<Provider[]> {
|
||||
return db.select().from(providers).orderBy(asc(providers.name));
|
||||
}
|
||||
|
||||
export async function getProvider(id: string): Promise<Provider | undefined> {
|
||||
const [row] = await db.select().from(providers).where(eq(providers.id, id)).limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function getProviderByName(name: string): Promise<Provider | undefined> {
|
||||
const [row] = await db.select().from(providers).where(eq(providers.name, name)).limit(1);
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Which provider serves a request, in the order a caller would expect.
|
||||
*
|
||||
* 1. the model, if exactly one enabled provider offers it
|
||||
* 2. the model, preferring the default when several do — two keys for the same model is an
|
||||
* ordinary thing and the tie has to break somewhere stable
|
||||
* 3. the default provider, for a model nothing is configured for
|
||||
*
|
||||
* Step 3 is deliberate rather than a 404. Vendors ship models faster than rows get added, and the
|
||||
* request still names a model the adapter will happily pass along — refusing it would make Atelier
|
||||
* the reason a brand-new model cannot be used.
|
||||
*/
|
||||
export async function resolveProviderForModel(model: string | undefined): Promise<Provider | undefined> {
|
||||
if (model) {
|
||||
const candidates = await db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.model, model), eq(providers.enabled, true)))
|
||||
.orderBy(asc(providers.createdAt));
|
||||
|
||||
const match = candidates.find((row) => row.isDefault) ?? candidates[0];
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
return defaultProvider();
|
||||
}
|
||||
|
||||
export async function defaultProvider(): Promise<Provider | undefined> {
|
||||
const [byFlag] = await db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(and(eq(providers.isDefault, true), eq(providers.enabled, true)))
|
||||
.limit(1);
|
||||
if (byFlag) return byFlag;
|
||||
|
||||
// No flag set — the oldest enabled row, so a single-provider install needs no configuration at all.
|
||||
const [oldest] = await db
|
||||
.select()
|
||||
.from(providers)
|
||||
.where(eq(providers.enabled, true))
|
||||
.orderBy(asc(providers.createdAt))
|
||||
.limit(1);
|
||||
return oldest;
|
||||
}
|
||||
|
||||
export async function createProvider(values: NewProvider): Promise<Provider> {
|
||||
const [row] = await db.insert(providers).values(values).returning();
|
||||
if (row?.isDefault) await clearOtherDefaults(row.id);
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function updateProvider(id: string, values: Partial<NewProvider>): Promise<Provider | undefined> {
|
||||
const [row] = await db
|
||||
.update(providers)
|
||||
.set({ ...values, updatedAt: new Date() })
|
||||
.where(eq(providers.id, id))
|
||||
.returning();
|
||||
|
||||
if (row?.isDefault) await clearOtherDefaults(id);
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function deleteProvider(id: string): Promise<void> {
|
||||
await db.delete(providers).where(eq(providers.id, id));
|
||||
}
|
||||
|
||||
/**
|
||||
* Exactly one default.
|
||||
*
|
||||
* Enforced by clearing the others on write rather than by a partial unique index, because the write
|
||||
* that sets a new default would otherwise have to clear the old one first — two statements racing to
|
||||
* leave zero defaults instead of two.
|
||||
*/
|
||||
async function clearOtherDefaults(keepId: string): Promise<void> {
|
||||
await db
|
||||
.update(providers)
|
||||
.set({ isDefault: false })
|
||||
.where(and(eq(providers.isDefault, true), ne(providers.id, keepId)));
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { pgTable, uuid, text, boolean, timestamp, index } from 'drizzle-orm/pg-core';
|
||||
|
||||
/**
|
||||
* A configured connection to a model: adapter + model + credentials.
|
||||
*
|
||||
* ── Why this is a row and the adapter is not ──
|
||||
*
|
||||
* "OpenAI" is not a thing you can generate with. "OpenAI's gpt-image-1, with this key" is. The two
|
||||
* differ in how often they change: an ADAPTER is code that learns a vendor's HTTP API and is written
|
||||
* once; a PROVIDER is a decision — which model, whose key, is it on — and you make several of those
|
||||
* per adapter and change them at runtime.
|
||||
*
|
||||
* So `openai + gpt-image-1` and `openai + dall-e-3` are two rows sharing one adapter and possibly one
|
||||
* key. The previous design keyed configuration on the adapter name, which made that unsayable: there
|
||||
* was exactly one OpenAI, and its model was a property of it rather than the point of it.
|
||||
*
|
||||
* Nothing here constrains `model` against the adapter. Vendors ship models faster than a list in this
|
||||
* repo is updated, and refusing a new one on release day is a worse failure than passing it through
|
||||
* and letting the vendor answer.
|
||||
*/
|
||||
export const providers = pgTable(
|
||||
'providers',
|
||||
{
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
|
||||
/** What a human calls it — "OpenAI · gpt-image-1". Unique, so it can be named in a request. */
|
||||
name: text('name').notNull().unique(),
|
||||
|
||||
/** Which adapter drives it. Text, not an enum: adding an adapter must not be a schema change. */
|
||||
adapter: text('adapter').notNull(),
|
||||
|
||||
model: text('model').notNull(),
|
||||
|
||||
/** AES-256-GCM via servers/crypto.ts. Never leaves the server and never appears in a response. */
|
||||
apiKeyEncrypted: text('api_key_encrypted'),
|
||||
|
||||
enabled: boolean('enabled').notNull().default(true),
|
||||
|
||||
/**
|
||||
* The one to use when a request names no provider and no model, or names a model nothing serves.
|
||||
*
|
||||
* A column rather than "the oldest row" because it is a decision the owner makes and should be
|
||||
* able to change without deleting and recreating rows. Exactly one row should carry it; the
|
||||
* queries below enforce that by clearing the others when one is set.
|
||||
*/
|
||||
isDefault: boolean('is_default').notNull().default(false),
|
||||
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
// Resolving a request by model is the hot path: every generation does it once.
|
||||
index('providers_model_idx').on(table.model, table.enabled),
|
||||
],
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
// What drizzle-kit creates, and the runtime's view of the tables — in the SEPARATE `atelier` database,
|
||||
// not officer_db. See PLUGIN.md for why this plugin keeps its own database rather than folding into
|
||||
// officer_db's <appname>_-prefixed convention: both `collections` and `provider_settings` had rows on
|
||||
// the machine this was extracted from, and losing someone's generated images to a schema decision is
|
||||
// not a trade to make unattended.
|
||||
//
|
||||
// `provider_settings` is deliberately not exported here — see ./index.ts.
|
||||
export * from './generations/schema';
|
||||
export * from './collections/schema';
|
||||
export * from './providers/schema';
|
||||
Reference in New Issue
Block a user