Skip to content

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.

The -db value decides:

Terminal window
stylist serve -db stylist.db # SQLite file
stylist serve -db 'postgres://user:pw@db.internal/stylist' # Postgres

Anything 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.

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:

Terminal window
stylist migrate -db 'postgres://…' -print # show pending SQL, change nothing
stylist 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.

  • checks — one row per check: timestamp, day (for trend grouping), ruleset, asset, repo, adapter, pass, and an org column (default for 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 serve only ever reads this table to authenticate requests.

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.

  1. Import its database/sql driver.
  2. Add internal/store/migrations/<dialect>/0001_init.sql (same version numbering as the others).
  3. Add the DSN dispatch and placeholder style in store.Open.

No new store implementation is needed — the query layer is shared.

The store test suite runs against SQLite always, and against a real Postgres when STYLIST_TEST_POSTGRES_DSN is set:

Terminal window
docker run -d --name pg -e POSTGRES_PASSWORD=test -p 5433:5432 postgres:16-alpine
STYLIST_TEST_POSTGRES_DSN='postgres://postgres:test@127.0.0.1:5433/postgres?sslmode=disable' \
go test ./internal/store/