officer_db with atelier_ prefixes, like every other plugin

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.
This commit is contained in:
2026-08-20 17:30:53 +00:00
parent ccfde75aae
commit c892f2e700
7 changed files with 66 additions and 31 deletions
+26 -3
View File
@@ -56,7 +56,7 @@ liveness, matching every other HTTP sidecar in this codebase.
Decision 3 said to check whether `collections` and `provider_settings` were empty before folding them Decision 3 said to check whether `collections` and `provider_settings` were empty before folding them
into `officer_db`. Both had rows (2 and 1), so the `atelier` Postgres database stays separate — the into `officer_db`. Both had rows (2 and 1), so the `atelier` Postgres database stays separate — the
sidecar keeps its own connection (`sidecar/db/db.ts`), reading `ATELIER_POSTGRES_URL` from the platform sidecar keeps its own connection (`sidecar/db/db.ts`), reading `OFFICERDEV_POSTGRES_URL` from the platform
`.env`. **This is new territory for the plugin system**: `db/schema.ts` at a plugin's root is the `.env`. **This is new territory for the plugin system**: `db/schema.ts` at a plugin's root is the
`officer_db`-prefixed-tables convention (`pushPluginSchema`, `<appname>_` prefix), and this plugin has no `officer_db`-prefixed-tables convention (`pushPluginSchema`, `<appname>_` prefix), and this plugin has no
such file — `discoverPlugins()` correctly reports `schema: null` for it, and the install flow's schema such file — `discoverPlugins()` correctly reports `schema: null` for it, and the install flow's schema
@@ -65,7 +65,7 @@ step is simply skipped. Nothing in `src/servers/plugins/` needed to change for t
self-managed database is indistinguishable from a plugin with no tables at all, as far as the platform self-managed database is indistinguishable from a plugin with no tables at all, as far as the platform
is concerned. Worth knowing before assuming every plugin's data lives in `officer_db`. is concerned. Worth knowing before assuming every plugin's data lives in `officer_db`.
**Not set automatically.** `ATELIER_POSTGRES_URL` has no default — see `BLOCKERS.md` for why (mainly: **Not set automatically.** `OFFICERDEV_POSTGRES_URL` has no default — see `BLOCKERS.md` for why (mainly:
pointing it at the live production `atelier` database while that database's own standalone service is pointing it at the live production `atelier` database while that database's own standalone service is
still running risks two processes racing the same generation queue). The owner has to add it before still running risks two processes racing the same generation queue). The owner has to add it before
installing. installing.
@@ -168,7 +168,7 @@ expected, not a bug.
1. **Publish to `gitea.officer.dev/plugins/atelier` and register it in the marketplace.** Not done — see 1. **Publish to `gitea.officer.dev/plugins/atelier` and register it in the marketplace.** Not done — see
`BLOCKERS.md` for why and the exact commands. `BLOCKERS.md` for why and the exact commands.
2. **`ATELIER_POSTGRES_URL` has to be set before install.** No default, on purpose. 2. **`OFFICERDEV_POSTGRES_URL` has to be set before install.** No default, on purpose.
3. **The `atelier` → `officer_db` migration**, described above, once someone can watch it happen. 3. **The `atelier` → `officer_db` migration**, described above, once someone can watch it happen.
4. **`assets/icon.png` and `assets/splash.png` are `[placeholder]`** flat swatches — see 4. **`assets/icon.png` and `assets/splash.png` are `[placeholder]`** flat swatches — see
`assets/PLACEHOLDER.md`. `assets/PLACEHOLDER.md`.
@@ -176,3 +176,26 @@ expected, not a bug.
touch the owner's live session or shared database. touch the owner's live session or shared database.
6. **Deciding whether and when to cut the mobile app over** from `atelier.officer.dev` to the plugin's 6. **Deciding whether and when to cut the mobile app over** from `atelier.officer.dev` to the plugin's
route, and what happens to the standalone service afterward. route, and what happens to the standalone service afterward.
## Reversed 2026-08-20: officer_db, not a separate database
The extraction kept Atelier's own `atelier` Postgres database, because `BLOCKERS.md` decision 3 said to
when the tables already had rows. The owner reversed that afterwards, and the reasoning is worth keeping:
**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 that was
bought by anything except avoiding a migration.
So: four `atelier_`-prefixed tables in `officer_db` — `atelier_collections`, `atelier_collection_items`,
`atelier_providers`, `atelier_generations` — created empty by `bun db:push`, like every other plugin.
Index and check constraint names carry the prefix too; those are global in Postgres and would otherwise
be the one thing that collides.
`ATELIER_POSTGRES_URL` is gone. `sidecar/db/db.ts` reads `OFFICERDEV_POSTGRES_URL ?? POSTGRES_URL`, the
house convention, which the platform already sets.
**The standalone service's data is NOT here.** It is still in the old `atelier` database, and the old
service is still running against it, untouched. Migrating those rows — 2 collections, 73 items, 5
providers, 87 generations — is a deliberate, watched step, done after the plugin is installed and proven
on alpha. Nothing about this plugin depends on it happening.
+2 -2
View File
@@ -30,7 +30,7 @@ talks to). This plugin is a fresh extraction into Officer, not a replacement for
## Requirements ## Requirements
- An Officer server, version 1.0.0 or later. - An Officer server, version 1.0.0 or later.
- A **separate Postgres database**, and its own environment variable: `ATELIER_POSTGRES_URL`. Atelier's - A **separate Postgres database**, and its own environment variable: `OFFICERDEV_POSTGRES_URL`. Atelier's
tables (`generations`, `collections`, `collection_items`, `providers`) do not live in `officer_db` — tables (`generations`, `collections`, `collection_items`, `providers`) do not live in `officer_db` —
see `PLUGIN.md` for why, and set this before installing. There is no default. see `PLUGIN.md` for why, and set this before installing. There is no default.
- An OpenAI API key, set with `bun run secret:set atelier-openai-key`, or configured per-provider in - An OpenAI API key, set with `bun run secret:set atelier-openai-key`, or configured per-provider in
@@ -40,7 +40,7 @@ talks to). This plugin is a fresh extraction into Officer, not a replacement for
## Install ## Install
From the Officer marketplace: find Atelier, install. That mounts `/api/atelier` and `/atelier`, starts From the Officer marketplace: find Atelier, install. That mounts `/api/atelier` and `/atelier`, starts
the `officer-atelier` sidecar under PM2, and connects it to whatever `ATELIER_POSTGRES_URL` points at — the `officer-atelier` sidecar under PM2, and connects it to whatever `OFFICERDEV_POSTGRES_URL` points at —
which has to already have the schema (the standalone repo's own `bun db:push` creates it; this plugin which has to already have the schema (the standalone repo's own `bun db:push` creates it; this plugin
does not run migrations against a database it does not own the schema of). does not run migrations against a database it does not own the schema of).
+15
View File
@@ -0,0 +1,15 @@
// The plugin's tables, where the platform looks for them.
//
// `scripts/gen-plugin-schemas.ts` writes `plugin-schemas.gen.ts` from every plugin's `db/schema.ts`, and
// that generated barrel is what `bun db:push` reads. So this file exists to be FOUND — the definitions
// themselves live beside the code that queries them, in `sidecar/db/`, and are re-exported here rather
// than duplicated.
//
// Everything is `atelier_`-prefixed and lives in officer_db, exactly like every other plugin. The
// extraction originally kept Atelier's own `atelier` database, on the reasoning that its tables already
// had rows. That was reversed on 2026-08-20: the plugin starts EMPTY here, and the standalone service's
// data is migrated deliberately afterwards, with someone watching. A plugin that behaves differently
// from every other plugin is a plugin nobody can reason about.
export * from '../sidecar/db/collections/schema';
export * from '../sidecar/db/generations/schema';
export * from '../sidecar/db/providers/schema';
+3 -4
View File
@@ -8,7 +8,7 @@ import { generations } from '../generations/schema';
* collection by name and creates it when it does not exist, so without the constraint a second * 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. * request with the same name would quietly start a second collection and split the batch in two.
*/ */
export const collections = pgTable('collections', { export const collections = pgTable('atelier_collections', {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(), name: text('name').notNull().unique(),
description: text('description'), description: text('description'),
@@ -27,8 +27,7 @@ export const collections = pgTable('collections', {
* is a way of looking at generations, not a container that owns them. Deleting a generation removes * 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. * its memberships, since a row pointing at nothing is only a way to crash a join later.
*/ */
export const collectionItems = pgTable( export const collectionItems = pgTable('atelier_collection_items',
'collection_items',
{ {
collectionId: uuid('collection_id') collectionId: uuid('collection_id')
.notNull() .notNull()
@@ -41,6 +40,6 @@ export const collectionItems = pgTable(
(table) => [ (table) => [
// The composite key is what makes adding an image twice a no-op instead of a duplicate. // The composite key is what makes adding an image twice a no-op instead of a duplicate.
primaryKey({ columns: [table.collectionId, table.generationId] }), primaryKey({ columns: [table.collectionId, table.generationId] }),
index('collection_items_generation_idx').on(table.generationId), index('atelier_collection_items_generation_idx').on(table.generationId),
], ],
); );
+13 -13
View File
@@ -2,22 +2,22 @@ import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; import postgres from 'postgres';
import * as schema from './schema'; import * as schema from './schema';
// The `atelier` Postgres database — separate from officer_db, on purpose. See PLUGIN.md. // officer_db, like every other plugin.
// //
// `ATELIER_POSTGRES_URL` rather than officer's own `OFFICERDEV_POSTGRES_URL`/`POSTGRES_URL`: this // This originally connected to a separate `atelier` database via `ATELIER_POSTGRES_URL`, carried over
// plugin talks to a different database on (most likely) the same Postgres server, and the platform's // from the standalone service. Reversed on 2026-08-20 by the owner: the tables are `atelier_`-prefixed
// own connection string must never be reused here by accident — the standalone service's // in officer_db, the plugin starts empty, and the standalone service's rows are migrated afterwards
// `atelier_db/src/env.ts` had a whole enforcement mechanism for exactly this mistake ("a development // with someone watching.
// 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 // The reason is not tidiness. A second database meant a second connection string to set, a second thing
// platform's own tables — still does. // to back up, and a plugin whose failure modes nobody else's plugin shares. `OFFICERDEV_POSTGRES_URL ??
const POSTGRES_URL = process.env.ATELIER_POSTGRES_URL; // POSTGRES_URL` is the house convention (src/servers/env-prefix.ts), and the standalone repo's whole
// "a development process cannot reach production" enforcement stops applying once this is one process
// under one PM2 entry.
const POSTGRES_URL = process.env.OFFICERDEV_POSTGRES_URL ?? process.env.POSTGRES_URL;
if (!POSTGRES_URL) { if (!POSTGRES_URL) {
throw new Error( throw new Error('OFFICERDEV_POSTGRES_URL (or POSTGRES_URL) is required — the platform sets it in .env.');
'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.',
);
} }
/** /**
+5 -6
View File
@@ -32,8 +32,7 @@ export type GenerationOperation = (typeof GENERATION_OPERATIONS)[number];
* unauthenticated as ordinary images, so they need to be a file a static route can hand to * 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. * `new Response(Bun.file(...))` — not a column something has to decode first.
*/ */
export const generations = pgTable( export const generations = pgTable('atelier_generations',
'generations',
{ {
/** /**
* Also the filename on disk. One id means one image, so a row can be turned into a served URL * Also the filename on disk. One id means one image, so a row can be turned into a served URL
@@ -123,11 +122,11 @@ export const generations = pgTable(
finishedAt: timestamp('finished_at', { withTimezone: true }), finishedAt: timestamp('finished_at', { withTimezone: true }),
}, },
(table) => [ (table) => [
check('generations_status_check', sql`${table.status} in ('queued', 'running', 'succeeded', 'failed')`), check('atelier_generations_status_check', sql`${table.status} in ('queued', 'running', 'succeeded', 'failed')`),
check('generations_operation_check', sql`${table.operation} in ('generate', 'edit')`), 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. // 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), index('atelier_generations_status_created_idx').on(table.status, table.createdAt),
// Every poll of a job is this lookup. // Every poll of a job is this lookup.
index('generations_request_idx').on(table.requestId, table.index), index('atelier_generations_request_idx').on(table.requestId, table.index),
], ],
); );
+2 -3
View File
@@ -18,8 +18,7 @@ import { pgTable, uuid, text, boolean, timestamp, index } from 'drizzle-orm/pg-c
* repo is updated, and refusing a new one on release day is a worse failure than passing it through * repo is updated, and refusing a new one on release day is a worse failure than passing it through
* and letting the vendor answer. * and letting the vendor answer.
*/ */
export const providers = pgTable( export const providers = pgTable('atelier_providers',
'providers',
{ {
id: uuid('id').primaryKey().defaultRandom(), id: uuid('id').primaryKey().defaultRandom(),
@@ -50,6 +49,6 @@ export const providers = pgTable(
}, },
(table) => [ (table) => [
// Resolving a request by model is the hot path: every generation does it once. // Resolving a request by model is the hot path: every generation does it once.
index('providers_model_idx').on(table.model, table.enabled), index('atelier_providers_model_idx').on(table.model, table.enabled),
], ],
); );