Database
The check store persists every check and answers the dashboard’s queries. It is engine-agnostic: SQLite for local and self-hosted single-container deployments, Postgres for provisioned/managed databases and the multi-tenant SaaS path.
Choosing an engine
Section titled “Choosing an engine”The -db value decides:
stylist serve -db stylist.db # SQLite filestylist serve -db 'postgres://user:pw@db.internal/stylist' # PostgresAnything starting postgres:// (or postgresql://) selects the
Postgres driver; anything else is treated as a SQLite path. Both are
first-class: the entire query layer is one shared implementation
written in portable SQL. Per engine, only the driver, the placeholder
style, and the migration files differ.
SQLite is the right default when the server owns its data directory: zero ops, one file, trivially backed up. Choose Postgres when your platform team mandates managed databases, when multiple server replicas must share a store, or for multi-tenant deployments.
Migrations
Section titled “Migrations”Schema is managed Flyway-style, without Flyway: versioned SQL files are
embedded in the binary (internal/store/migrations/<dialect>/),
applied in filename order, and tracked in a schema_migrations table.
The server migrates automatically at startup — upgrading the binary
upgrades the schema.
For DBAs who review or apply schema changes manually:
stylist migrate -db 'postgres://…' -print # show pending SQL, change nothingstylist migrate -db 'postgres://…' # apply pending migrations-print emits exactly the SQL that would run, with migration filenames
as comments, so it can go through your change-management process.
Schema
Section titled “Schema”checks— one row per check: timestamp, day (for trend grouping), ruleset, asset, repo, adapter, pass, and anorgcolumn (defaultfor every self-hosted/single-tenant deployment; see Multi-org isolation below).violations— one row per violation, referencing its check: check name, severity, message, snippet, line, baselined flag.sessions— dashboard sign-in state, keyed by a hash of the cookie token so a table read grants nothing.rulesets— org-scoped ruleset storage for the hosted/multi-tenant path (each row is a ruleset’s YAML content). Published, viewed, and deleted through the control plane. Self-hosted deployments keep using a rulesets directory (-rulesets) instead; this table is additive, not a replacement.api_tokens— opaque per-org API tokens for multi-org mode, keyed by a hash of the token so a table read grants nothing. Minted and revoked through the control plane, a separate admin service.stylist serveonly ever reads this table to authenticate requests.
Multi-org isolation
Section titled “Multi-org isolation”Postgres deployments running -multi-org
get row-level security (RLS) on checks, violations, and rulesets:
Postgres itself enforces that a query only sees its own org’s rows,
rather than relying on every query remembering a WHERE org = ?
condition. A query that forgets the condition still can’t see another
org’s data: it just sees nothing extra, instead of leaking it.
This is Postgres-only (SQLite has no row-level security) and only takes effect for connections that establish an org scope. A plain, unscoped connection (self-hosted deployments; every deployment before this feature) sees every row exactly as before, so existing behavior is unaffected either way.
RLS only protects you if the server’s database role can’t bypass it. A
Postgres superuser or BYPASSRLS role always bypasses row-level
security, regardless of policy. This can’t be turned off per-table.
-multi-org checks this at startup and refuses to run against such a
role, but the same rule applies to anything else that connects with that
role’s credentials (a DBA’s ad hoc query, a backup/reporting tool): treat
the multi-org runtime credential as scoped and ordinary, never as an
admin account, if you want the isolation guarantee to mean anything
outside the server itself.
Provisioning the runtime role. stylist migrate still runs as an
admin/superuser role, since it needs to create tables and policies. The
role stylist serve -multi-org actually connects as must be different:
ordinary, not a superuser, not BYPASSRLS. Table-level grants alone are
not enough — the server also touches schema_migrations at startup to
confirm the schema is current, which needs schema-level privileges too:
CREATE ROLE stylist_app LOGIN PASSWORD '<pick one>' NOSUPERUSER NOBYPASSRLS;GRANT USAGE, CREATE ON SCHEMA public TO stylist_app;GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO stylist_app;GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO stylist_app;Run this once, after stylist migrate has created the schema, then
point -db at stylist_app (not the admin role) for the running
server.
Scope: /v1/check always; the dashboard only when configured for
it. RLS isolation always takes effect for a check submitted through an
org-scoped connection, so that check is correctly recorded under its
org. The dashboard (/dash) only establishes an org scope when the
server is started with -oidc-org-claim (see
Sign-in). A self-host or other single-org
deployment that never sets this reads across every org (which is fine,
since there is only one org). Without -oidc-org-claim, don’t treat
-multi-org as isolating the dashboard between tenants: it still
isolates what gets recorded at the database level, just not what a
signed-in dashboard user can see.
Adding another engine
Section titled “Adding another engine”- Import its
database/sqldriver. - Add
internal/store/migrations/<dialect>/0001_init.sql(same version numbering as the others). - Add the DSN dispatch and placeholder style in
store.Open.
No new store implementation is needed — the query layer is shared.
Testing against real engines
Section titled “Testing against real engines”The store test suite runs against SQLite always, and against a real
Postgres when STYLIST_TEST_POSTGRES_DSN is set:
docker run -d --name pg -e POSTGRES_PASSWORD=test -p 5433:5432 postgres:16-alpineSTYLIST_TEST_POSTGRES_DSN='postgres://postgres:test@127.0.0.1:5433/postgres?sslmode=disable' \ go test ./internal/store/