Database
apistock.dev/modules/postgres connects apps to PostgreSQL. Decisions: ADR-0005 (PostgreSQL, goose), ADR-0028 (Docker), ADR-0032 (hand-written SQL).
Connecting#
pool, err := postgres.Open(ctx, cfg.DatabaseURL, // config.Secret from DATABASE_URL
postgres.WithMaxConns(10),
postgres.WithApplicationName("acme-api"),
postgres.WithTracerProvider(tel.TracerProvider()),
)
if err != nil {
return err
}
cleanup.Add("postgres", func(context.Context) error { pool.Close(); return nil })
checker.Add(postgres.HealthCheck(pool)) // /readyz
| Option | Default | Notes |
|---|---|---|
WithMaxConns(n) |
max(4, CPUs) | Pool size per instance |
WithMinConns(n) |
0 | Idle connections kept open |
WithMaxConnLifetime(d) |
1 hour | Moves load to new hosts after failover |
WithMaxConnIdleTime(d) |
30 minutes | |
WithConnectTimeout(d) |
5 seconds | Bounds each connection attempt and the startup ping |
WithApplicationName(s) |
none | Shown in pg_stat_activity |
WithTracerProvider(tp) |
global | Every query becomes a client span with its SQL text, never its arguments |
Open pings the database, so a wrong URL fails at startup. Errors never include the URL, which may contain a password.
Repositories#
Repositories use hand-written SQL, one file per operation, like this:
internal/modules/users/repository/
├── store.go UserStore{db postgres.DBTX}; NewUserStore(db)
├── errors.go constraint names → domain errors
├── scan.go shared row mapping (when several reads return the same columns)
├── insert_user.go const insertUserSQL + (*UserStore).InsertUser
├── update_user.go
├── delete_user.go
├── select_user.go SelectUserByID, SelectUsers
└── *_test.go every method tested against a real database
store.go#
// UserStore persists users. It works on the pool or inside a transaction.
type UserStore struct {
db postgres.DBTX
}
func NewUserStore(db postgres.DBTX) *UserStore { return &UserStore{db: db} }
postgres.DBTX is implemented by *pgxpool.Pool, *pgxpool.Conn, *pgx.Conn and pgx.Tx.
insert_user.go#
const insertUserSQL = `
INSERT INTO users (id, name, email, password_hash, role, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING created_at, updated_at`
// InsertUser inserts a new user row.
func (s *UserStore) InsertUser(ctx context.Context, u *domain.User) error {
err := s.db.QueryRow(ctx, insertUserSQL,
u.ID, u.Name, u.Email, u.PasswordHash, u.Role, u.Status,
).Scan(&u.CreatedAt, &u.UpdatedAt)
if constraint, ok := postgres.UniqueViolation(err); ok && constraint == usersEmailKey {
return domain.ErrDuplicateEmail
}
if err != nil {
return fmt.Errorf("insert user: %v", err)
}
return nil
}
select_user.go#
const selectUserByIDSQL = `
SELECT id, name, email, role, status, created_at, updated_at
FROM users
WHERE id = $1 AND deleted_at IS NULL`
func (s *UserStore) SelectUserByID(ctx context.Context, id string) (*domain.User, error) {
var u domain.User
err := s.db.QueryRow(ctx, selectUserByIDSQL, id).
Scan(&u.ID, &u.Name, &u.Email, &u.Role, &u.Status, &u.CreatedAt, &u.UpdatedAt)
if postgres.IsNoRows(err) {
return nil, domain.ErrUserNotFound
}
if err != nil {
return nil, fmt.Errorf("select user: %v", err)
}
return &u, nil
}
For wide or shared rows, scan into an unexported row struct with pgx.CollectRows(rows, pgx.RowToStructByName[userRow]) in scan.go, so columns are matched by name. Domain structs never get db tags.
delete_user.go#
const deleteUserSQL = `
UPDATE users SET deleted_at = now(), updated_at = now()
WHERE id = $1 AND deleted_at IS NULL`
func (s *UserStore) DeleteUser(ctx context.Context, id string) error {
tag, err := s.db.Exec(ctx, deleteUserSQL, id)
if err != nil {
return fmt.Errorf("delete user: %v", err)
}
if tag.RowsAffected() == 0 {
return domain.ErrUserNotFound
}
return nil
}
Rules#
- SQL lives in an unexported
constnext to the method that runs it; values are always placeholders, never concatenated. - Translate expected driver conditions into domain errors; wrap everything else with
%vso pgx errors never become part of the module's API (ADR-0018). - A dynamic
ORDER BYuses an allowlist of column names.
Error helpers#
| Helper | Returns true for | Also returns |
|---|---|---|
IsNoRows(err) |
QueryRow(...).Scan found nothing |
|
UniqueViolation(err) |
SQLSTATE 23505 | constraint name |
ForeignKeyViolation(err) |
23503 | constraint name |
CheckViolation(err) |
23514 | constraint name |
NotNullViolation(err) |
23502 | column name |
IsRetryable(err) |
serialization failure or deadlock |
Transactions#
err := postgres.InTx(ctx, pool, func(tx pgx.Tx) error {
users := repository.NewUserStore(tx)
if err := users.InsertUser(ctx, u); err != nil {
return err // rolled back; the domain error is returned unchanged
}
_, err := jobsClient.InsertTx(ctx, tx, welcome.Args{UserID: u.ID}, nil) // job commits with the user
return err
})
InTxcommits when the function returns nil and rolls back on an error or a panic (the panic continues).- Build tx-bound stores inside the function; never keep
txafter it returns or put it in a context (ADR-0030). InTxWithOptions(ctx, pool, pgx.TxOptions{IsoLevel: pgx.Serializable}, fn); retry the whole function whenIsRetryable(err).- In generated apps, use cases reach transactions through a
TxManagerport and never import pgx (ADR-0022).
Migrations#
- Goose SQL files in
db/migrations, named<timestamp>_<description>.sql, with-- +goose Upsections. - One ordered history for the app's tables and apistock module tables (modules ship theirs as
settings.Migrations,jobs.Migrations,auditpg.Migrations, and they are copied in). - Released migrations are never edited; changes are new files, forward-only.
aps gen migration <name>creates an empty one that runs after the existing ones (CLI guide). Write its SQL before runningcmd/migrate: an empty migration is recorded as applied.cmd/migrateapplies them withpostgres.Migrate(ctx, pool, migrations.FS), then River's migrations withjobs.Migrate. Apps never migrate at startup (ADR-0017).postgres.Migratetakes a PostgreSQL advisory lock, so concurrent migrators apply each migration once.postgres.Migrations(ctx, pool, fsys)reportsCurrent,LatestandPendingwithout changing anything.
Running PostgreSQL locally#
| Generated app | apistock repository | |
|---|---|---|
| Defined in | The app's compose.yaml, service postgres |
Root compose.yaml |
| Image | postgres:18 |
postgres:18 |
| Address | 127.0.0.1:${POSTGRES_PORT:-5432} |
127.0.0.1:${APISTOCK_POSTGRES_PORT:-55432} |
| User, password, database | The app's name, all three | apistock |
| Data | Named volume postgres-data |
Named volume |
| Start | aps dev, or docker compose up -d --wait |
docker compose up -d --wait |
Ports bind to 127.0.0.1 only, so the development password is never reachable from the network.
| Task | Command (in the app's folder) |
|---|---|
| Open a SQL shell | docker compose exec postgres psql -U acme-api acme-api |
| See the tables | \dt in psql |
| See applied migrations | SELECT version_id, is_applied, tstamp FROM goose_db_version ORDER BY id; |
| Stop, keep data | docker compose down |
| Reset: delete all data | docker compose down -v, then aps dev (migrates and seeds again) |
| Dump | docker compose exec postgres pg_dump -U acme-api acme-api > dump.sql |
Commands#
| Command | What it does | When |
|---|---|---|
go run ./cmd/migrate |
Opens DATABASE_URL, applies pending goose migrations from db/migrations under an advisory lock, then River's queue migrations; prints applied migration <version> for each and nothing when up to date |
After pulling or generating migrations; before each release in production (/migrate in the image) |
go run ./cmd/seed |
Creates the development administrator and example data through the modules' use cases. Refuses when APP_ENV=production; needs AUTH_ENCRYPTION_KEYS; does nothing when admin@example.com exists |
Development, after migrating; aps dev runs it |
aps gen migration <name> |
Creates an empty migration that sorts last | Schema changes outside aps gen resource |
aps gen resource … |
Creates a module with its own migration | New resources |
Each needs the environment: aps dev provides it, or set -a; . ./.env; set +a.
Rollbacks. There are no down migrations (ADR-0005). To undo a released migration, write a new one that reverses it. Locally, before a migration is shared, edit it and reset with docker compose down -v. A database's schema and data are restored from backups, not by running migrations backwards.
Schema of a Full app#
The tables a new single-tenant Full app creates, by migration. Library modules own their tables' shape; the app owns its modules' tables. Every ID is text with a type prefix (usr_…), and every timestamp is timestamptz in UTC.
| Migration | Table | Owner | Holds |
|---|---|---|---|
…0001_settings |
settings_values |
modules/settings |
Runtime settings changed from their defaults: key, JSON value, version |
settings_history |
modules/settings |
Every change: old and new value, actor, reason | |
…0002_jobs_definitions |
jobs_definitions |
modules/jobs |
Runtime configuration of each job: enabled, schedule, timeout, attempts, queue, priority, version |
jobs_definition_history |
modules/jobs |
Every configuration change | |
…0003_audit_events |
audit_events |
modules/auditpg |
Append-only audit log: action, actor, resource, outcome, request ID, redacted metadata |
…0001_auth |
auth_users |
App auth module |
Accounts: email and its normalized form, argon2id password hash, verification and deletion times |
auth_sessions |
App auth module |
Sessions: SHA-256 of the token, idle and absolute expiry, last seen, user agent, revocation time and reason | |
auth_codes |
App auth module |
Email verification and password reset codes: SHA-256, purpose, expiry, attempts | |
auth_user_roles |
App auth module |
Platform roles (org_id NULL) and organisation-scoped roles |
|
…0002_projects |
projects |
App projects module |
The example resource, owned by a user |
…0003_release_instances |
release_instances |
modules/releases |
Each running or stopped instance: version, commit, start, heartbeat, clean stop |
…0004_auth_mfa |
auth_totp |
App auth module |
One TOTP secret per user, encrypted, with its key ID and last used step |
auth_recovery_codes |
App auth module |
SHA-256 of each recovery code, and when it was used | |
auth_mfa_challenges |
App auth module |
Pending second-factor sign-ins: token hash, attempts and limit, expiry, consumption | |
…0005_auth_passkeys |
auth_passkeys |
App auth module |
Passkeys: credential ID, verified credential record (public key, flags, counter), backup flags, name |
auth_webauthn_ceremonies |
App auth module |
Single-use WebAuthn challenges for registration, sign-in and verification | |
…0006_auth_social |
auth_identities |
App auth module |
Google and Apple identities linked to users; Apple refresh tokens encrypted |
auth_oauth_states |
App auth module |
Web sign-in in progress: state, PKCE verifier, nonce, return address | |
auth_social_nonces |
App auth module |
Single-use nonces for native ID-token sign-in | |
River (jobs.Migrate) |
river_job, river_leader, river_queue, … |
River | The job queue |
| goose | goose_db_version |
goose | Applied migrations |
A multi-tenant app also has orgs, org_members and org_invitations (…orgs.sql), and its projects belong to an organisation (org_id, created_by) rather than a user.
Relationships#
auth_users ─┬─< auth_sessions ON DELETE CASCADE
├─< auth_codes ON DELETE CASCADE
├─< auth_user_roles ON DELETE CASCADE
├── auth_totp (1:1) ON DELETE CASCADE
├─< auth_recovery_codes ON DELETE CASCADE
├─< auth_mfa_challenges ─< auth_webauthn_ceremonies
├─< auth_passkeys ON DELETE CASCADE
├─< auth_identities ON DELETE CASCADE, UNIQUE (provider, subject)
└─< projects (owner_id) ON DELETE CASCADE
multi-tenant:
orgs ─┬─< org_members >── auth_users PRIMARY KEY (org_id, user_id)
├─< org_invitations
└─< projects (org_id) ON DELETE CASCADE, UNIQUE (org_id, id)
Settings, job definitions, audit events and release instances have no foreign keys to users: audit history outlives the accounts it mentions. Deleting a user is soft first (deleted_at); the auth_cleanup job purges accounts after auth.deleted_account_retention, and the cascades remove their rows.
Indexes worth knowing#
| Index | Why |
|---|---|
auth_users_email UNIQUE on email_normalized WHERE deleted_at IS NULL |
One live account per address; a deleted account frees it |
auth_sessions_user, auth_sessions_expiry |
List a user's sessions; purge expired ones |
auth_identities (provider, subject) UNIQUE |
One account per Google or Apple identity |
settings_values_platform_key / _org_key, partial on org_id |
One value per key, platform-wide or per organisation |
audit_events_action, _actor, _resource, _org, _request |
The filters of GET /ops/audit, newest first by id DESC |
projects_owner_name UNIQUE on (owner_id, lower(name)) |
Names unique per owner, ignoring case |
projects_owner_created, _updated, _name_sort |
One per sort of GET /v1/projects, ending in id for stable keyset pagination |
Partial *_expiry indexes |
Cleanup jobs delete expired rows without scanning |
Transactions and concurrency#
- Use cases that write more than one row do it in one transaction through their
TxManagerport (postgres.InTxunderneath): a row and its audit event (auditpg.RecordTx), a user and their verification code, a row and a job (jobs.Client.InsertTx). - Updates to resources, settings and job definitions carry a
version; theUPDATE … WHERE version = $nfails with a conflict when someone changed the row first, so concurrent edits never overwrite each other silently. - Reads that must not change before a write lock the row (
SELECT … FOR UPDATE) inside the transaction. - Single-use values (codes, challenges, nonces, OAuth states) are consumed with a conditional
UPDATE … WHERE consumed_at IS NULL, so two concurrent uses can't both succeed, on any number of instances. - Migrations take an advisory lock; River elects a single leader for periodic jobs.
Testing with pgtest#
func TestInsertUser(t *testing.T) {
pool := pgtest.New(t, pgtest.WithMigrations(migrations.FS))
store := repository.NewUserStore(pool)
// ...
}
| Function | Returns | Use for |
|---|---|---|
pgtest.New(t, opts...) |
*pgxpool.Pool on a fresh database |
Repository and module tests |
pgtest.NewDatabase(t, opts...) |
Connection URL of a fresh database | Tests that start a whole app from config |
pgtest.URL(t) |
The server URL | Tests that need the server itself |
Options: WithMigrations(fsys) (applied once into a template per distinct set of files), WithMaxConns(n) (default 4). Databases are dropped when the test ends. See local development for the environment variables.