Plugins
Storage and database
The per-plugin Postgres role and schema, what's accessible and what isn't, migrations, and the export/import backup format.
What you get#
A process plugin whose manifest declares database.schema: true gets, on confirm and reconciled
on every start:
- A dedicated Postgres role and schema, both named
plugin_<id>with every dot in your id replaced by an underscore (fliks.downloadbecomesplugin_fliks_download). - A password rotated on every spawn, never persisted, handed to your process as the
FLIKS_DB_URLconnection string. Don't cache it across a restart, there's nothing to cache. USAGEon thepublicschema, andREFERENCES-only, column-level grants on exactly the core tables your manifest'sdatabase.coreRefsnames, and only theiridcolumn. You can put a foreign key intopublic.media(id); you cannotSELECTfrom it.- Your own connection string's
search_pathset to your schema only,publicis deliberately not on it, so an unqualified query can never accidentally hit a core table.
Core never uses your tables at runtime: no core feature queries them. It provisions the role and
schema, checks your coreRefs exist as real tables with an id column before granting anything, and
otherwise touches your schema only when an admin explicitly exports, imports or uninstalls (see
below).
coreRefs#
Each name in database.coreRefs must:
- Match
^[a-z_][a-z0-9_]*$and be at most 63 characters. - Appear once.
- Actually exist as a
publicbase table with anidcolumn, checked at provision time; a table that doesn't exist yet (a core migration not yet applied) fails provisioning withdb-provision-failed.
Point a foreign key at it with ON DELETE CASCADE or ON DELETE SET NULL. Anything else (a
RESTRICT or NO ACTION FK from your schema into a core table) would block core from deleting the
referenced row. That is advised against, not enforced: on every start core checks your schema for
such keys and surfaces a warning on the plugin row and in its logs, rather than refusing to run it.
Running your own migrations#
Core provisions the schema; it never runs anything inside it. Your plugin runs its own migrations,
against the same FLIKS_DB_URL it gets at spawn, before it answers hello. The pattern the
download plugin uses (fk-plugin-download, migrations/ and src/db/migrate.ts; the notify plugin
declares no schema):
- A migration file per change, numbered (
0001_initial_schema.ts,0002_...), each exporting{ name, up, down }as plain SQL strings. - One tracking table inside your own schema, conventionally
_migrations(name, appliedAt), never inpublic. - Each migration applied in its own transaction, together with the tracking insert, so a failure partway through doesn't leave the ledger and the schema disagreeing.
- Run at boot, before the first
helloreply, so core never sees a plugin as ready with an unmigrated schema.
A table whose name starts with an underscore is yours, and core leaves it alone completely. It is never exported, never restored, and never counted when core decides whether your schema is "empty" (see export/import below). A migration ledger is exactly the kind of table this is for: restoring an old export over a freshly-migrated schema shouldn't misstate which migrations have actually run.
See Testing and debugging for how to exercise this against a real Postgres in CI, including the cross-schema grant behaviour itself (what you can and can't do as the provisioned role), not just the migrations.
What's not accessible#
- No core feature queries your schema. The only core code that reads or writes it is the admin-triggered export and import below, which touch only your own schema's tables.
- You cannot
SELECTacoreRefstable, only reference itsidin a foreign key. A query that tries fails with a permissions error from Postgres itself (42501), not a Fliks-level error. - You have no path to another plugin's schema.
- Nothing outside
database.coreRefsis visible to you at all, adding a new core dependency later means shipping a new manifest version.
Export and import#
GET /api/plugins/:id/export (admin-only, same gate as the rest of the plugins API) returns one
JSON document:
{
"formatVersion": 1,
"pluginId": "acme.tool",
"pluginVersion": "1.4.0",
"exportedAt": "2026-01-01T00:00:00.000Z",
"settings": { "plugin.acme.tool.api_key": "..." },
"tables": { "widgets": [ { "id": 1, "name": "..." } ] }
}tables is {} for a data plugin, and for a process plugin whose manifest declares no schema.
Table and column names are read live from the database catalogue on every call, never trusted from
anywhere else, and the schema queried is always exactly plugin_<id>, never public, never another
plugin's.
This document carries credentials. Anything an operator typed into a secret field lands in
settings unmasked, nothing is stripped. Handle an export like any other credential dump.
POST /api/plugins/:id/import restores that document and refuses rather than merges:
| Guard | Code | Why |
|---|---|---|
| Version mismatch | PLUGIN_EXPORT_VERSION_MISMATCH (409) |
The export's pluginVersion must equal the installed version. There's no migration-diff engine, replaying an old export onto a different version risks writing rows or setting keys that no longer match its current shape. |
| Schema not empty | PLUGIN_SCHEMA_NOT_EMPTY (409) |
Any table already holding a row (bookkeeping tables aside) refuses the import. A plugin that has only just migrated counts as empty; that's the state a restore is meant for. Uninstall and reinstall (which wipes the schema) before importing into a plugin you've already used. |
| Not yet activated | PLUGIN_NOT_READY (409) |
The installed plugin's last activation must have succeeded, since that's what proves its own migrations have already run. |
| Malformed document | PLUGIN_EXPORT_MALFORMED (400) |
Shape check failed, or formatVersion isn't 1. |
| Id mismatch | PLUGIN_EXPORT_ID_MISMATCH (400) |
The document's pluginId doesn't match the plugin you're importing into. |
| No schema to hold tables | PLUGIN_EXPORT_HAS_NO_SCHEMA (409) |
The document carries tables but this plugin declares none. |
| Setting out of scope | PLUGIN_EXPORT_SETTING_OUT_OF_SCOPE (400) |
A settings key outside plugin.<id>.*; every key is checked before any is written. |
A restore writes rows carrying their original ids and moves every serial column's sequence past the highest restored value, in the same transaction, so the plugin's first insert afterwards doesn't collide on a duplicate key.
Uninstall vs. disable#
| Postgres role and schema | Per-plugin data directory | plugin.<id>.* settings |
Live routes/UI/jobs | |
|---|---|---|---|---|
| Disable | Kept | Kept | Kept | UI and jobs dropped; declared routes stay known and answer 503 (unavailable) rather than 403 |
| Uninstall | Dropped | Deleted | Deleted | Dropped |
Uninstalling a plugin is destructive across the board, not only its database schema: its per-plugin data directory (the one place other than the database meant to survive restarts) and every one of its settings, secrets included, go with it, and so do the scopes and ingest roots the admin consented to, so a reinstall asks for them again. Export first if you might want any of it back.