database

package
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 10, 2026 License: Apache-2.0 Imports: 13 Imported by: 0

Documentation

Overview

Package database is wowapi's persistence kernel: the pgx pool, the TxManager that is the ONLY door to tenant data, and the RLS session plumbing (SET LOCAL app.tenant_id inside a transaction, never on a pooled connection). Contracts in docs/blueprint/05-http-and-persistence.md §2; tenant-isolation model in 03 §1.

TenantDB starts as the sqlc DBTX facade and grows the per-tx service bundle (Outbox/Audit/Resources) alongside the phases that deliver those capabilities (D-0024).

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrNoTenantContext: a tenant-scoped transaction was requested without
	// database.WithTenantID on the context. Fails closed — there is no
	// "default tenant".
	ErrNoTenantContext = errors.New("database: no tenant in context (WithTenant requires database.WithTenantID)")

	// ErrVersionConflict: an optimistic-locking UPDATE matched zero rows —
	// the aggregate changed since it was read (HTTP 409/412 upstream).
	ErrVersionConflict = errors.New("database: version conflict")
)

Sentinel errors. These map into the kernel error taxonomy when kernel/errors lands in Phase 3 (D-0024).

Functions

func ActorIDFrom

func ActorIDFrom(ctx context.Context) (uuid.UUID, bool)

ActorIDFrom extracts the actor id; ok=false when absent.

func AssertRLSEnforced

func AssertRLSEnforced(ctx context.Context, pool *pgxpool.Pool) error

AssertRLSEnforced verifies that a runtime pool's effective role cannot bypass row-level security. FORCE RLS does not apply to superusers or BYPASSRLS roles, so a runtime pool wired over an over-privileged DSN (or with no runtime role set) would silently run every tenant query with RLS disabled and no signal. app.Boot calls this so that misconfiguration fails LOUDLY at startup rather than leaking at runtime — making RLS enforcement safe-by-default even when a product forgets the per-connection (WithConnRLSGuard) or per-tx (WithRLSGuard) guards (SEC-12, finding M3). It probes a real pooled connection, so it reflects the effective role (incl. any WithSetRole applied at connect time).

func ExpectOneRow

func ExpectOneRow(tag pgconn.CommandTag, entity string) error

ExpectOneRow asserts a versioned UPDATE/DELETE matched exactly one row:

tag, err := db.Exec(ctx, "UPDATE … WHERE id=$1 AND version=$2", id, v)
if err != nil { return err }
if err := database.ExpectOneRow(tag, "request"); err != nil { return err }

0 rows is the optimistic-lock conflict (ErrVersionConflict → 409/412). More than 1 row is NOT a conflict — it means the WHERE clause was too broad (a missing id predicate, a fan-out UPDATE on a versioned aggregate); that is a programming bug and must surface as an internal error (500), never be masked as a benign conflict (review finding ARCH-20).

func MigrateReset

func MigrateReset(ctx context.Context, pool *pgxpool.Pool, src fs.FS, source string) (int64, error)

MigrateReset rolls every applied migration in src back to version 0 (goose Down, newest-first). It is the mirror of Migrate for the migration reversibility drill (roadmap O2) and for tearing a test database down; it must NEVER run against a production database. Returns the version afterwards (0 on a full rollback). Down blocks (`-- +goose Down`) must be present and correct for every migration, which is exactly what the drill verifies.

func NewPool

func NewPool(ctx context.Context, dsn string, cfg config.DB, opts ...Option) (*pgxpool.Pool, error)

NewPool builds the process pool. The DSN arrives as a plain string: the composition root (app / cmd) reveals the config Secret — kernel code never calls Reveal (boundary lint).

func TenantIDFrom

func TenantIDFrom(ctx context.Context) (uuid.UUID, bool)

TenantIDFrom extracts the tenant id; ok=false when absent.

func WithActorID

func WithActorID(ctx context.Context, id uuid.UUID) context.Context

WithActorID returns a context carrying the acting user for audit attribution (app.actor_id).

func WithTenantID

func WithTenantID(ctx context.Context, id uuid.UUID) context.Context

WithTenantID returns a context carrying the tenant the following database work is scoped to. Set by auth middleware (Phase 4) and job runners; tests set it directly.

Types

type DB

type DB interface {
	DBTX
	// contains filtered or unexported methods
}

DB is the platform-scope facade (global tables only). Kernel services only; it is never exposed through module.Context.

type DBTX

type DBTX interface {
	Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
	Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
	QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}

DBTX is the query surface sqlc-generated code targets. Both TenantDB and DB satisfy it; nothing in module code ever sees a raw pool or connection.

type IdemStore

type IdemStore interface {
	// Begin claims the key. It returns Found with the stored response when the
	// key already completed with a MATCHING request hash; a KindConflict error
	// when the hash differs (same key, different request); a
	// KindIdempotencyInFlight error when another request holds the key
	// unfinished; otherwise Fresh (the caller claimed the key and should
	// perform the operation, then call Complete in the same tx).
	Begin(ctx context.Context, db TenantDB, actorScope, key, requestHash string, ttl time.Duration) (Replay, error)
	// Complete records the final response for a key claimed by Begin.
	Complete(ctx context.Context, db TenantDB, actorScope, key string, status int, body []byte) error
	// Discard removes a claim without storing a response — used when the
	// operation did not succeed and should remain retryable (not idempotent).
	Discard(ctx context.Context, db TenantDB, actorScope, key string) error
}

IdemStore persists idempotency keys and their stored responses, scoped to the current tenant via RLS (the table is tenant-scoped). All methods run inside the caller's tenant transaction so the key row and the business writes commit atomically (blueprint 05 §1–2).

type Manager

type Manager struct {
	// contains filtered or unexported fields
}

Manager implements TxManager over a pgx pool.

func NewManager

func NewManager(pool *pgxpool.Pool, cfg config.DB, opts ...ManagerOption) *Manager

NewManager wires the manager; cfg travels by value (immutable, 12 §6). QueryTimeout out of the validated 100ms..60s band is clamped to the compiled default rather than silently disabling the server-side statement ceiling — NewManager takes a raw struct and bypasses config.Validate (SEC-14).

func (*Manager) Platform

func (m *Manager) Platform(ctx context.Context, fn func(ctx context.Context, db DB) error) error

func (*Manager) WithTenant

func (m *Manager) WithTenant(ctx context.Context, fn func(ctx context.Context, db TenantDB) error) error

func (*Manager) WithTenantRO

func (m *Manager) WithTenantRO(ctx context.Context, fn func(ctx context.Context, db TenantDB) error) error

type ManagerOption

type ManagerOption func(*Manager)

ManagerOption customizes a Manager.

func WithRLSGuard

func WithRLSGuard() ManagerOption

WithRLSGuard makes every tenant transaction assert that its effective role is non-superuser and lacks BYPASSRLS before running caller code. FORCE row level security does not apply to superusers or BYPASSRLS roles, so without this a pool wired against an over-privileged DSN (or with no role set) would silently execute tenant queries with RLS disabled and no signal (SEC-12). Deployed processes MUST enable this.

func WithRole

func WithRole(role string) ManagerOption

WithRole re-binds the given role at the start of every tenant transaction with SET LOCAL ROLE. Unlike a once-per-connection session SET ROLE, this is transaction-scoped, so a prior transaction that left the pooled connection in a different role (a buggy or hostile module issuing RESET ROLE / SET ROLE) cannot leak that state into the next tenant's work — the role is re-established from scratch each tx and reverts at COMMIT/ROLLBACK (SEC-11).

type MigrateResult

type MigrateResult struct {
	// Version is the source's highest applied migration version afterwards.
	Version int64
	// Applied counts migrations run by THIS call — 0 means the source was
	// already up to date (the idempotent rerun case).
	Applied int
}

MigrateResult reports what a Migrate call did.

func Migrate

func Migrate(ctx context.Context, pool *pgxpool.Pool, src fs.FS, source string) (MigrateResult, error)

Migrate applies every pending migration from src (an fs.FS of goose NNNNN_name.sql files, e.g. migrations.Kernel()) under a history table dedicated to source. Because each source has its own history table, independently-numbered sources (kernel "wowapi", each product module) coexist without version collisions (blueprint 03 §5; review finding ARCH-16). Reruns are no-ops (Applied == 0) — migration idempotency is a Phase 2 acceptance criterion.

The pool should carry migration-owner credentials (app_migrate / config.DB.MigrateDSN); runtime processes never hold them (12 §7).

type Option

type Option func(*pgxpool.Config)

Option customizes pool construction.

func WithConnRLSGuard

func WithConnRLSGuard() Option

WithConnRLSGuard rejects, at connect time, any connection whose effective role is a superuser or has BYPASSRLS — such a role silently defeats FORCE row level security. Chain it AFTER WithSetRole so it checks the assumed role. This backstops the per-transaction guard (Manager's WithRLSGuard) for the "over-privileged DSN, no role set" misconfiguration, failing pool construction instead of serving tenant traffic with RLS disabled (SEC-12).

func WithSetRole

func WithSetRole(role string) Option

WithSetRole makes every pooled connection assume the given role after connecting (SET ROLE). This establishes the session-level baseline role so that even queries outside a TxManager transaction (e.g. testkit raw probes) run RLS-constrained. It is how local/test environments run as app_rt without a second login (D-0023); production may instead provision a dedicated login in the DSN. It is NOT sufficient on its own — the TxManager re-asserts the role per transaction (WithRole) to survive session-state leaks (SEC-11).

type PgIdemStore

type PgIdemStore struct {
	// contains filtered or unexported fields
}

PgIdemStore is the Postgres-backed IdemStore over idempotency_keys.

func NewIdemStore

func NewIdemStore() *PgIdemStore

NewIdemStore builds a store using the wall clock.

func NewIdemStoreWithClock

func NewIdemStoreWithClock(now func() time.Time) *PgIdemStore

NewIdemStoreWithClock builds a store with an injected clock (tests).

func (*PgIdemStore) Begin

func (s *PgIdemStore) Begin(ctx context.Context, db TenantDB, actorScope, key, requestHash string, ttl time.Duration) (Replay, error)

func (*PgIdemStore) Complete

func (s *PgIdemStore) Complete(ctx context.Context, db TenantDB, actorScope, key string, status int, body []byte) error

func (*PgIdemStore) Discard

func (s *PgIdemStore) Discard(ctx context.Context, db TenantDB, actorScope, key string) error

Discard removes an in_progress claim so the operation stays retryable.

func (*PgIdemStore) SweepExpired

func (s *PgIdemStore) SweepExpired(ctx context.Context, plat TxManager, before time.Time) (int64, error)

SweepExpired deletes every idempotency key whose expires_at has passed, across ALL tenants, in a single platform transaction (roadmap S5). It runs as app_platform via TxManager.Platform — the tenant-scoped app_rt lifecycle is unchanged; only this cross-tenant maintenance path may purge other tenants' rows (migration 00012). Returns the number of rows removed. Safe alongside request traffic: DELETE takes row locks, so a key still held by a live claim blocks until that request commits rather than vanishing under it. It is scheduled on the leader-safe recurring scheduler at boot (app/maintenance.go registers "kernel.idempotency.sweep").

type Replay

type Replay struct {
	Fresh          bool   // no prior record — proceed with the operation
	Found          bool   // a completed response exists — replay it
	ResponseStatus int    // valid when Found
	ResponseBody   []byte // valid when Found
}

Replay is the outcome of IdemStore.Begin: either this is the first time the key is seen (Fresh), or a completed response is available to replay (Found), or the same key is still being processed by a concurrent request (InFlight).

type TenantDB

type TenantDB interface {
	DBTX
	// contains filtered or unexported methods
}

TenantDB is the facade module repositories receive inside a tenant transaction. It cannot outlive the tx and cannot be constructed outside this package. Per-tx services (Outbox, Audit, Resources) attach here in Phases 4/6.

type TxManager

type TxManager interface {
	// WithTenant runs fn in a read-write transaction bound to the tenant in
	// ctx (database.WithTenantID). Missing tenant = ErrNoTenantContext.
	WithTenant(ctx context.Context, fn func(ctx context.Context, db TenantDB) error) error
	// WithTenantRO is WithTenant with BEGIN READ ONLY — list/get paths.
	WithTenantRO(ctx context.Context, fn func(ctx context.Context, db TenantDB) error) error
	// Platform runs fn against global tables with NO tenant binding. Kernel
	// services only; never exposed through module.Context.
	Platform(ctx context.Context, fn func(ctx context.Context, db DB) error) error
}

TxManager is the only door to the database for tenant work: one transaction per unit of work, tenant identity bound with SET LOCAL so RLS scopes every statement, automatic rollback on error or panic.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL