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:
2026-08-20 03:53:53 +00:00
commit f23ec1283b
36 changed files with 2759 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
+133
View File
@@ -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;
}
+46
View File
@@ -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),
],
);
+72
View File
@@ -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));
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
+99
View File
@@ -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;
}
+133
View File
@@ -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),
],
);
+13
View File
@@ -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';
+2
View File
@@ -0,0 +1,2 @@
export * from './schema';
export * from './queries';
+100
View File
@@ -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)));
}
+55
View File
@@ -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),
],
);
+10
View File
@@ -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';