Files
pastilhas f23ec1283b 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.
2026-08-20 03:53:53 +00:00

96 lines
3.6 KiB
TypeScript

import type { Collection, CollectionWithCount, Generation } from './db';
/**
* The one place a database row becomes JSON.
*
* Every API serialiser lives in this file — a structural choice, not tidiness: it is the one place
* that can enforce snake_case across every endpoint. See the standalone repo's test for the incident
* this guarded against (`serialize.test.ts`, ported below).
*
* Snake case throughout, because the response body is deliberately OpenAI-shaped and OpenAI is snake.
*/
/**
* `/files/<name>`, relative from this plugin's own mount — NOT an absolute `PUBLIC_URL` any more.
*
* The standalone service served images unauthenticated with a durable absolute URL; inside Officer the
* route is behind the permission gate like everything else, so there is no host to bake in and no
* reason to. An API caller already holding a bearer token re-fetches this path with the same
* `Authorization` header they used to get here. The Officer frontend (which cannot set that header on
* an `<img src>`) appends its own `?token=` — see `plugins/atelier/web/lib/api.ts` — the same escape
* hatch `FileViewer` and the music plugin already use for exactly this reason.
*/
const imageUrl = (row: Generation) => (row.imagePath ? `/files/${row.imagePath}` : null);
/** What every image reports about itself, wherever it appears. */
const measured = (row: Generation) => ({
width: row.width,
height: row.height,
// `has_alpha` is whether the file carries an alpha channel; `opaque` is whether every pixel in it is
// fully opaque. Both, because an alpha channel the model never used is the failure worth catching.
has_alpha: row.hasAlpha,
opaque: row.opaque,
cost_usd: row.costUsd,
duration_ms: row.durationMs,
});
/** The compact form, for `atelier.images[]` — no prompt, which at n:10 would repeat 32000 chars ten times. */
export const serializeGenerationImage = (row: Generation) => ({
id: row.id,
status: row.status,
url: imageUrl(row),
...measured(row),
error: row.error,
});
/**
* The full row, for history and collections.
*
* `prompt`, `options` and `input_images` are here deliberately and are not to be normalised or
* truncated: every disappointing result so far has been traced to what was asked for, and these three
* are what make that diagnosis possible after the fact.
*/
export const serializeGeneration = (row: Generation) => ({
id: row.id,
request_id: row.requestId,
index: row.index,
operation: row.operation,
status: row.status,
provider: row.provider,
model: row.model,
prompt: row.prompt,
options: row.options,
input_images: row.inputImages,
mask_image: row.maskImage,
image_path: row.imagePath,
url: imageUrl(row),
...measured(row),
error: row.error,
created_at: row.createdAt,
started_at: row.startedAt,
finished_at: row.finishedAt,
});
/**
* A collection, as the API returns it.
*
* Drizzle hands back camelCase because that is what the schema declares in TypeScript. Returning the
* row untouched is how a camelCase field would reach the wire in a snake_case API — this is where one
* becomes the other.
*/
export const serializeCollection = (row: Collection) => ({
id: row.id,
name: row.name,
description: row.description,
created_at: row.createdAt,
updated_at: row.updatedAt,
});
/** The list form, with the counts the index screen needs. */
export const serializeCollectionSummary = (row: CollectionWithCount) => ({
...serializeCollection(row),
item_count: row.itemCount,
cover_image_path: row.coverImagePath,
cover_url: row.coverImagePath ? `/files/${row.coverImagePath}` : null,
});