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),
|
||||
],
|
||||
);
|
||||
Reference in New Issue
Block a user