app store: the catalogue, the install-state table, and what phase 0 must not foreclose

First slice, on a worktree branch so none of it touches the tree the live server runs from.

`sidecar_installs` — server-level, no userId, because a sidecar is one process serving the machine.
That is the line that keeps the model coherent for several users: installed is server-level and
owner-only, configured is per user in service_connections. A member can use Gitea without being able to
install it or point it somewhere else.

`installed` and `enabled` are separate because they answer different questions, which is what gives the
reversible middle ground: disable stops the process and keeps container, config, schema and data.
`completedSteps` makes install resumable rather than merely retryable — the failure mode being designed
against is a half-installed service that neither works nor uninstalls.

The catalogue is data, not code: no functions, no compile-time coupling, because the same shape has to
arrive as JSON from marketplace.officer.dev later. Its test pins it to the real estate — it offers
exactly the processes the light profile excludes, names processes that exist, and claims capabilities
that exist. That last check earned itself immediately: it caught `vault` (no capability at all — it is
EXEMPT because Bitwarden clients carry a Vaultwarden bearer, not a platform JWT) and `notify` (which
does have one, where I had written null).

Docker templates follow the convention already in use across 47 services in ~/dockers: a directory per
service, compose inside, relative bind mounts so data sits beside it, USER_UID/USER_GID as the owner.
An existing directory is evidence of an existing install and must be adopted, never overwritten.

Records what Phase 0 must not foreclose: a remote marketplace, sidecars moving to their own
repositories, and third-party plugins — including the note that catalogue.test.ts pins Phase 0's
invariant rather than the design's, since that relationship inverts once sidecars leave this repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:44:30 +00:00
co-authored by Claude Opus 5
parent 977d14922f
commit fc48a572d2
4 changed files with 459 additions and 4 deletions
@@ -0,0 +1,80 @@
import { pgTable, serial, text, boolean, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
// What the owner has installed from the app store, and whether it should be running.
//
// ── Why there is no userId ──
//
// A sidecar is ONE process serving the whole machine, so installing one is a server-level act, not a
// per-user one. This is the line that keeps the model coherent once several people share a server:
//
// installed server-level, owner-only — this row
// configured per user — service_connections
//
// Gitea is the worked example. The owner installs it once, and the row here says the process runs; each
// member then holds their own credential in `service_connections`, inheriting the instance URL from the
// owner's row. A member can therefore use a service without being able to install, uninstall or point it
// somewhere else — which is the same split `capabilities/registry.ts` already draws between `app` and
// `admin` kinds.
//
// ── Why `installed` and `enabled` are separate ──
//
// They answer different questions. `installed` means the thing EXISTS: a container was provisioned (or an
// existing instance was named), config was written, schema was applied. `enabled` means the process
// SHOULD BE RUNNING. Disabling is the reversible middle ground the owner asked for — stop the process,
// keep the container, the config, the tables and the data, and start again later at no cost.
//
// Uninstall then has a disposal choice rather than a fixed meaning: keep the data, drop the container,
// or drop both. None of those are this table's business beyond recording that the row is gone.
export const sidecarInstalls = pgTable(
'sidecar_installs',
{
id: serial('id').primaryKey(),
/**
* The catalogue id — 'gitea', 'photos'. Text rather than an enum for the same reason
* `service_connections.service` is: adding a sidecar must not be a schema change, and a third-party
* one cannot be in an enum we compile.
*/
sidecarId: text('sidecar_id').notNull(),
/**
* How this install was satisfied, which decides what uninstall has to undo:
*
* 'existing' — pointed at an instance the user already runs. We provisioned nothing.
* 'provisioned' — we rendered a compose file and started containers. Ours to offer to remove.
* 'config' — nothing to reach; credentials only (email, wallet).
*/
mode: text('mode').notNull(),
/**
* 'pending' | 'installing' | 'installed' | 'failed'.
*
* `installing` is a real, persisted state rather than a transient one, because install spans a
* container start and a health wait and can be interrupted by a restart in the middle. A row stuck in
* `installing` is the signal to resume, not evidence of a bug.
*/
status: text('status').notNull().default('pending'),
enabled: boolean('enabled').notNull().default(false),
/**
* Which install steps have completed, by name. This is what makes install RESUMABLE rather than
* merely retryable: re-running picks up after the last completed step instead of provisioning a
* second container or re-writing config that is already right.
*
* The failure mode being designed against is a half-installed service that neither works nor
* uninstalls — which is the one users cannot get themselves out of.
*/
completedSteps: jsonb('completed_steps').$type<string[]>().notNull().default([]),
/** Why the last attempt failed, shown in the UI. Cleared on the next successful step. */
lastError: text('last_error'),
/**
* Absolute path to the rendered compose directory, for `mode: 'provisioned'` only.
*
* Stored rather than derived because it is the user's directory and he may move it — and because
* uninstall must not guess at a path it is about to run `docker compose down -v` in.
*/
composeDir: text('compose_dir'),
installedAt: timestamp('installed_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
// One row per sidecar, machine-wide. There is no second install of the same sidecar to disambiguate,
// which is what lets every other column be read without asking "which one".
(t) => [uniqueIndex('uq_sidecar_installs_sidecar').on(t.sidecarId)],
);