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 ¶
- Variables
- func ActorIDFrom(ctx context.Context) (uuid.UUID, bool)
- func AssertRLSEnforced(ctx context.Context, pool *pgxpool.Pool) error
- func ExpectOneRow(tag pgconn.CommandTag, entity string) error
- func MigrateReset(ctx context.Context, pool *pgxpool.Pool, src fs.FS, source string) (int64, error)
- func NewPool(ctx context.Context, dsn string, cfg config.DB, opts ...Option) (*pgxpool.Pool, error)
- func TenantIDFrom(ctx context.Context) (uuid.UUID, bool)
- func WithActorID(ctx context.Context, id uuid.UUID) context.Context
- func WithTenantID(ctx context.Context, id uuid.UUID) context.Context
- type DB
- type DBTX
- type IdemStore
- type Manager
- func (m *Manager) Platform(ctx context.Context, fn func(ctx context.Context, db DB) error) error
- func (m *Manager) WithTenant(ctx context.Context, fn func(ctx context.Context, db TenantDB) error) error
- func (m *Manager) WithTenantRO(ctx context.Context, fn func(ctx context.Context, db TenantDB) error) error
- type ManagerOption
- type MigrateResult
- type Option
- type PgIdemStore
- func (s *PgIdemStore) Begin(ctx context.Context, db TenantDB, actorScope, key, requestHash string, ...) (Replay, error)
- func (s *PgIdemStore) Complete(ctx context.Context, db TenantDB, actorScope, key string, status int, ...) error
- func (s *PgIdemStore) Discard(ctx context.Context, db TenantDB, actorScope, key string) error
- func (s *PgIdemStore) SweepExpired(ctx context.Context, plat TxManager, before time.Time) (int64, error)
- type Replay
- type TenantDB
- type TxManager
Constants ¶
This section is empty.
Variables ¶
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 ¶
ActorIDFrom extracts the actor id; ok=false when absent.
func AssertRLSEnforced ¶
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 ¶
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 ¶
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 ¶
TenantIDFrom extracts the tenant id; ok=false when absent.
func WithActorID ¶
WithActorID returns a context carrying the acting user for audit attribution (app.actor_id).
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 ¶
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) WithTenant ¶
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 ¶
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 ¶
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) Discard ¶
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.