apistockdocs
v0.4 GitHub apistock.dev
Technical/Subsystems

Database

apistock.dev/modules/postgres connects apps to PostgreSQL. Decisions: ADR-0005 (PostgreSQL, goose), ADR-0028 (Docker), ADR-0032 (hand-written SQL).

Connecting#

go
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:

output
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#

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#

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#

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#

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 const next to the method that runs it; values are always placeholders, never concatenated.
  • Translate expected driver conditions into domain errors; wrap everything else with %v so pgx errors never become part of the module's API (ADR-0018).
  • A dynamic ORDER BY uses 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#

go
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
})
  • InTx commits 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 tx after it returns or put it in a context (ADR-0030).
  • InTxWithOptions(ctx, pool, pgx.TxOptions{IsoLevel: pgx.Serializable}, fn); retry the whole function when IsRetryable(err).
  • In generated apps, use cases reach transactions through a TxManager port and never import pgx (ADR-0022).

Migrations#

  • Goose SQL files in db/migrations, named <timestamp>_<description>.sql, with -- +goose Up sections.
  • 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 running cmd/migrate: an empty migration is recorded as applied.
  • cmd/migrate applies them with postgres.Migrate(ctx, pool, migrations.FS), then River's migrations with jobs.Migrate. Apps never migrate at startup (ADR-0017).
  • postgres.Migrate takes a PostgreSQL advisory lock, so concurrent migrators apply each migration once.
  • postgres.Migrations(ctx, pool, fsys) reports Current, Latest and Pending without 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#

output
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 TxManager port (postgres.InTx underneath): 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; the UPDATE … WHERE version = $n fails 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#

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

esc
↑↓ move↵ openesc close