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