Database and migrations¶
Bor stores all server state in PostgreSQL. This page covers how the server connects, how schema migrations are written and applied, the repository pattern used by the rest of the code, the conventions for queries and transactions, and how database code is tested.
Connection¶
database.New (server/internal/database/database.go) opens a
database/sql pool with the github.com/lib/pq driver from the
DB_HOST, DB_PORT, DB_USER, DB_PASSWORD, DB_NAME and DB_SSLMODE
settings (or their server.yaml equivalents):
- A host that starts with
/is a Unix socket directory. Port and password are omitted andsslmodedefaults todisable, which suits peer authentication on the same machine. - The pool is bounded to 25 open and 25 idle connections, with a 5-minute
connection lifetime, so a burst of reconnecting agents cannot exhaust
PostgreSQL's
max_connections. - The connection is pinged at start-up; failure is fatal.
For local development, make dev starts PostgreSQL in a container on
localhost:5432 with the credentials from .env.example (bor / bor,
database bor). See Development environment.
Migrations¶
Layout and naming¶
Migrations live in server/internal/database/migrations/:
000001_initial_schema.up.sql
000001_initial_schema.down.sql
000002_seed_data.up.sql
000002_seed_data.down.sql
...
000036_sso_link_approval.up.sql
000036_sso_link_approval.down.sql
- File names are
NNNNNN_short_name.up.sqlandNNNNNN_short_name.down.sql, with a six-digit, zero-padded number. The numbers have gaps (older migrations were consolidated); that is harmless. - Each
.up.sqlhas a matching.down.sqlthat reverses it. - Start each file with a comment explaining why the change exists.
The migration runner¶
Bor does not use an external migration tool. DB.RunMigrations runs on
every server start, before any repository is used:
- Create
schema_migrations (version VARCHAR(255) PRIMARY KEY, applied_at TIMESTAMP)if it does not exist. -
Read the
.up.sqlfiles embedded in the binary: -
Sort the file names lexically.
- For each file, the version is the file name without
.up.sql(for example000031_restart_safe_state). If that version is already inschema_migrations, skip it. - Otherwise run the whole file and insert the version in one transaction. On error the transaction is rolled back and the server refuses to start.
Consequences you need to know:
- No checksums. The runner only compares names. Editing an applied migration has no effect on existing databases, and a fresh database gets the edited version, so the two diverge silently.
- Renaming a file re-runs it, because the version is the file name.
- Ordering is by name, so the zero padding matters.
- Down migrations are not embedded and never run by the server. They
exist for developers and operators who need to roll a schema back by
hand, for example with
psql -f. - Each file is one transaction. Do not put
BEGIN/COMMITin a migration, and avoid statements PostgreSQL refuses inside a transaction (for exampleCREATE INDEX CONCURRENTLY). - No cross-instance lock. Two server instances starting against an empty database at the same time can race; start one instance first when upgrading a multi-instance setup.
make migrate-up / make migrate-down
The Makefile has these targets, but they call go run ./cmd/migrate,
and there is no server/cmd/migrate in the tree. Migrations are applied
by starting the server.
Server extension modules keep their own migrations and apply them with
DB.RunModuleMigrations(module, fsys, dir), which uses the same rules and
records versions in ext_schema_migrations under the module name.
Adding a migration safely¶
-
Take the next free number after the highest existing one:
-
Create both files, for example
000037_widget_settings.up.sqland000037_widget_settings.down.sql. - Write the up migration so it works on a database with real data:
- add columns as nullable or with a
DEFAULT; - backfill in the same file if the code needs the data;
- add foreign keys with an explicit
ON DELETEbehavior; - name indexes
idx_<table>_<columns>.
- add columns as nullable or with a
- Write the down migration to undo exactly that (
DROP ... IF EXISTSin reverse order). - Never change the behavior of a migration that has been released. Fix mistakes with a new migration.
- If the change adds permissions or roles, insert them in the migration
(see
000028_granular_user_role_permsand000030_disk_encryptionfor examples) so existing installations receive them. - Start the server against a scratch database and check the log line
Applied migration: 000037_widget_settings. Then apply the down migration by hand and the up migration again to verify they are symmetric. - Run
make test-server;TestMigrationFilesEmbeddedchecks that the embedded files sort in the expected order.
Example from 000031_restart_safe_state.up.sql:
-- Single-row high-water mark for the PolicyHub revision counter. Persisting
-- it keeps revision numbers monotonic across server restarts, ...
CREATE TABLE policy_hub_state (
id SMALLINT PRIMARY KEY DEFAULT 1 CHECK (id = 1),
revision BIGINT NOT NULL DEFAULT 0
);
INSERT INTO policy_hub_state (id, revision) VALUES (1, 0);
and its down migration:
Repository pattern¶
All SQL lives in server/internal/database. Each file holds one repository
type that wraps *database.DB:
type PolicyHubStateRepository struct {
db *DB
}
func NewPolicyHubStateRepository(db *DB) *PolicyHubStateRepository {
return &PolicyHubStateRepository{db: db}
}
- Repositories are created once in
app.Runand injected into services. Services never build SQL, and handlers never call repositories for business operations. - Methods take a
context.Contextfirst and use the...Contextvariants (QueryRowContext,ExecContext,QueryContext) so request cancellation reaches the database. - They return models from
server/internal/models. A missing row is usuallynil, nil(or afoundflag), not an error; check the existing methods of the repository you extend and follow them. - Errors are wrapped with context:
fmt.Errorf("failed to load policy hub revision: %w", err).
Queries and transactions¶
- Always use placeholders (
$1,$2, ...) for values. Never build SQL by concatenating input.lib/pqsends parameterized queries as prepared statements, so values never become SQL. - Dynamic
ORDER BYand filters go through allowlists. For example the node, compliance and audit-log list queries map a requested sort field to a fixed column expression and fall back to a default for anything unknown; the tests innodes_pagination_test.go,compliance_pagination_test.goandaudit_logs_sort_test.gofeed injection strings to prove it. -
Multi-step changes use a transaction. The pattern used across the package:
Examples: dconf.go and polkit.go (catalogue upserts), roles.go,
flatpak.go, luks.go.
- Make single statements atomic where possible instead of
read-then-write. Consuming an enrollment token is one
DELETE ... RETURNING, and saving the hub revision is an upsert with
GREATEST, so concurrent callers cannot race.
- Sensitive values are stored hashed or encrypted. Enrollment tokens are
stored as SHA-256 hashes, LUKS recovery keys are envelope-encrypted under
a KEK held outside the database, and local passwords use
PBKDF2-HMAC-SHA256 with 600,000 iterations (hashPassword in
server/internal/services/auth.go).
Testing database code¶
There is no live-database test suite in the repository. The tests in
server/internal/database are pure unit tests that do not connect to
PostgreSQL:
database_test.gochecks that the migrations are embedded, sort correctly and include the initial schema and seed files.- The
*_pagination_test.go,*_sort_test.goandflatpak_test.gotests exercise the query builders (order-by allowlists, filter construction, full-text query escaping) and assert on the generated SQL fragments.
Logic above the repositories is tested in server/internal/services and
server/internal/api with in-memory fakes of the narrow interfaces the
services depend on. When you add a query builder, add table-driven tests in
the same style, including hostile input. For schema changes, verify by
hand against a real PostgreSQL as described in
Adding a migration safely, and see
Testing and linting for the rest of the test setup.