Documentation
¶
Overview ¶
Package db owns the Rivolt Postgres connection pool, the shared migration runner, and a handful of identity helpers used by every store.
Identity model ¶
A Rivolt user is identified by a username — sourced from an OIDC IdP for any real deployment, or hard-wired to "local" for the legacy single-tenant docker-compose UX. The stable user_id is a deterministic UUIDv5 over the lowercased username, so the same login always resolves to the same tenant across restarts, exports and future federated installs.
There is no default user. Stores expect a concrete user_id from an authenticated request context; unauthenticated writes are a bug, not a fallback.
Runtime model
- One pgx pool per process. main opens it, hands *sql.DB to every store. Stores never dial Postgres themselves.
- pgx is used through the database/sql stdlib adapter (github.com/jackc/pgx/v5/stdlib) so store code keeps using the stdlib *sql.DB API. The only Postgres-specific wart is placeholder style — we keep `?` in SQL strings and rewrite to `$1,$2,…` via Rebind at call time.
Index ¶
- Variables
- func CountAdmins(ctx context.Context, d *sql.DB) (int, error)
- func CreateUser(ctx context.Context, d *sql.DB, username, email, displayName, role string) (uuid.UUID, error)
- func DeleteUser(ctx context.Context, d *sql.DB, uid uuid.UUID) error
- func EnsureUser(ctx context.Context, d *sql.DB, username string) (uuid.UUID, error)
- func EnsureUserFull(ctx context.Context, d *sql.DB, username, email, displayName string) (uuid.UUID, error)
- func GetNeedsReauth(ctx context.Context, d *sql.DB, userID uuid.UUID) (bool, string, error)
- func IsDisabled(ctx context.Context, d *sql.DB, uid uuid.UUID) (bool, error)
- func ListUsersWithRivianSession(ctx context.Context, db *sql.DB) ([]uuid.UUID, error)
- func LookupUsername(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)
- func Migrate(ctx context.Context, db *sql.DB) error
- func OnboardingCompleted(ctx context.Context, d *sql.DB, uid uuid.UUID) (bool, error)
- func Open(ctx context.Context, dsn string) (*sql.DB, error)
- func OwnsRivianID(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianID string) (bool, error)
- func RawUsernameByID(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)
- func Rebind(query string) string
- func RoleFor(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)
- func SetDisabled(ctx context.Context, d *sql.DB, uid uuid.UUID, disabled bool) error
- func SetNeedsReauth(ctx context.Context, d *sql.DB, userID uuid.UUID, reason string) error
- func SetOnboardingCompleted(ctx context.Context, d *sql.DB, uid uuid.UUID) error
- func SetRole(ctx context.Context, d *sql.DB, uid uuid.UUID, role string) error
- func SetVehicleProfile(ctx context.Context, d *sql.DB, userID, vehicleID uuid.UUID, p VehicleProfile) error
- func UserIDFor(username string) uuid.UUID
- func VehicleOwner(ctx context.Context, db *sql.DB, vehicleID uuid.UUID) (uuid.UUID, error)
- func VehicleOwnerByRivianID(ctx context.Context, db *sql.DB, rivianID string) (uuid.UUID, error)
- func WithUserScope(ctx context.Context, pool *sql.DB, userID uuid.UUID, ...) error
- type AdminUserRow
- type DBTX
- type VehicleOwnership
- type VehicleProfile
- type VehicleResolver
- type VehicleResolverFactory
- type VehicleSummary
Constants ¶
This section is empty.
Variables ¶
var ErrUserExists = fmt.Errorf("user already exists")
ErrUserExists is returned by CreateUser when the username is already taken. Distinguished from a generic insert error so the admin endpoint can surface a clean 409 instead of a 500.
Functions ¶
func CountAdmins ¶
CountAdmins returns the number of users with role='admin'. Used by the admin endpoint to refuse demoting / deleting the last admin (which would lock the install out of /api/admin/*).
func CreateUser ¶
func CreateUser(ctx context.Context, d *sql.DB, username, email, displayName, role string) (uuid.UUID, error)
CreateUser pre-provisions a user row from the admin endpoint. Auth is OIDC-only, so this does NOT issue a password — the row just reserves the deterministic UUIDv5 for the username and stamps role/email/display_name. When the user later signs in via OIDC with the same preferred_username, EnsureUserFull's upsert lands on this row and the pre-set role survives.
Returns ErrUserExists if a row with the same id is already present; callers should NOT silently turn that into an upsert, because the admin's intent is "create new", not "edit existing".
func DeleteUser ¶
DeleteUser removes a user row. ON DELETE CASCADE on every dependent table (vehicles, drives, charges, samples, user_settings, user_secrets, sessions, push_subscriptions, …) means this is sufficient to fully evict the tenant; the admin endpoint relies on that contract instead of a hand-rolled transactional sweep.
func EnsureUser ¶
EnsureUser upserts the user row for a username and returns the stable UUID. Idempotent — safe to call on every boot (for the static operator credential) and on every sign-in (for future real auth) without coordination.
Returns an error if username is empty; the all-zero UUID would otherwise act as a silent multi-tenant footgun.
func EnsureUserFull ¶
func EnsureUserFull(ctx context.Context, d *sql.DB, username, email, displayName string) (uuid.UUID, error)
EnsureUserFull is EnsureUser plus optional email and display_name columns. OIDC sign-in uses this so the user row reflects what the IdP knows (display_name in particular is what the UI surfaces in the account menu). Empty values are not written — a later sign-in that does carry them will fill the columns in, but a sign-in that doesn't (e.g. an IdP misconfigured to omit `name`) won't blank out a previously-good value.
Admin promotion: on first-ever insert (table empty) the 0015_users_role bootstrap trigger flips role to 'admin'. After that, RIVOLT_BOOTSTRAP_ADMIN_EMAIL — if set — promotes any user whose email matches it to admin on every sign-in. This makes it safe to set the env up-front: an IdP user with that email always becomes admin without any seed-time coordination.
func GetNeedsReauth ¶
GetNeedsReauth reads the current needs_reauth state for a user. Used by main.go at startup to prime the LiveClient's in-memory mirror so a restart doesn't silently let a locked-out user's background jobs hammer Rivian again.
A missing user row is treated as "not locked" rather than an error — the upstream call will fail on its own if the user really doesn't exist.
func IsDisabled ¶
IsDisabled reports whether the user has been marked disabled by an admin. Used by the auth middleware to refuse to honor a session cookie / OIDC sub for a disabled row, and by the admin endpoint to short-circuit role/delete operations against already-disabled users.
Returns (false, nil) for an unknown UUID — callers handling that path are already treating it as "no row, no privileges", so surfacing a sentinel error here would just bloat the call sites.
func ListUsersWithRivianSession ¶
ListUsersWithRivianSession returns every user_id that currently has a persisted rivian.session blob in user_secrets. The boot path uses this to hydrate every user's *LiveClient before the StateMonitor goroutines start, so a pod restart is invisible to the data plane regardless of which users are active.
The query reads ciphertext-presence only — it does NOT decrypt the blob, so it's safe to run before the secret-sealer is fully wired (e.g. during a future rotation window).
func LookupUsername ¶
LookupUsername returns the display-friendly name for a user UUID, preferring display_name (set by OIDC sign-in from the IdP's `name` claim) and falling back to the bare username column. Returns the empty string for an unknown UUID; callers treat that as "no username available" rather than as an error.
func Migrate ¶
Migrate runs every unapplied migration in migrationFS against db. The migrations table is self-bootstrapped on first boot so fresh Postgres databases come up clean.
Multi-pod safety: two pods booting concurrently against the same database both call Migrate. We serialise them with a session-level pg_advisory_lock — the second caller blocks until the first finishes, then re-reads the migrations table and finds everything already applied. The lock is held on a dedicated connection so migrations themselves run on the pool unaffected, and is released (best-effort) on a background context so a cancelled ctx doesn't leak the lock for the rest of the conn's lifetime.
func OnboardingCompleted ¶
OnboardingCompleted reports whether the user has finished the first-run onboarding stepper. Returns true for legacy rows that predate the onboarding_completed column (migration 0026 back-fills them to TRUE). Returns true when d is nil so callers in no-DB mode never show the stepper.
func Open ¶
Open dials Postgres and returns a ready-to-use *sql.DB with every embedded migration applied. Safe to call once per process; subsequent callers should reuse the returned handle.
No user is seeded — EnsureUser is called by the auth layer on a successful login / token validation instead. That keeps Open free of any identity concerns so it stays trivially testable.
dsn accepts any DSN pgx understands — most commonly `postgres://user:pass@host:5432/dbname?sslmode=disable`.
func OwnsRivianID ¶
OwnsRivianID reports whether the given user owns a vehicle registered under the given Rivian gateway vehicle-id.
This is deliberately a plain SELECT (no upsert) so ownership probing can't be used to silently provision rows in another user's vehicles set — the write path goes through VehicleResolver.Resolve, which is user-scoped by construction.
Used by the HTTP ownership middleware as the single seam that decides whether /api/state/{vehicleID} and friends are allowed to touch Rivian upstream on behalf of the session user. False with nil error means "not yours" and the caller must return 404 (not 403) so enumerating vehicle-ids doesn't leak existence.
func RawUsernameByID ¶
RawUsernameByID returns the raw username column for a user by id, regardless of whether display_name is set. Used by the admin delete path to identify the row in the IdP backend (Kratos), which is keyed on the email/credential identifier. Returns the empty string for an unknown UUID.
func Rebind ¶
Rebind rewrites `?` placeholders into Postgres `$N` style. Safe to call on a query that has no placeholders. Ignores `?` inside single- or double-quoted string literals so we don't clobber data.
Kept cheap and inline because it runs on every query — ~a dozen instructions per placeholder. A sync.Map cache would complicate call sites for effectively zero wall-clock benefit on Rivolt's query volume.
func RoleFor ¶
RoleFor returns the role for a user UUID ("user" or "admin"). Returns the empty string for an unknown UUID; callers treat that as "not authorized" rather than as an error.
func SetDisabled ¶
SetDisabled flips the disabled flag. The admin endpoint enforces the "can't disable the last admin / yourself" guards before calling this — keeping the SQL function dumb means tests can assert on the guard logic in isolation.
func SetNeedsReauth ¶
SetNeedsReauth persists the per-user re-auth gate to Postgres.
reason is stored verbatim in users.needs_reauth_reason for the UI to render; pass an empty string to clear the flag (the row is updated in one statement so the UI never sees a row with needs_reauth = true and an empty reason, or vice versa).
Best-effort by design: the caller is a LiveClient sink running on the hot path, and a Postgres blip shouldn't mask the original upstream error. Returned errors are for logging only.
func SetOnboardingCompleted ¶
SetOnboardingCompleted marks the user's onboarding stepper as finished. Idempotent — safe to call twice.
func SetRole ¶
SetRole updates the role on an existing user row. Used by the admin endpoint POST /api/admin/users/:id/role. Refuses to set anything other than 'user' / 'admin'; the CHECK constraint in 0015_users_role would catch it anyway, this just gives a friendlier error.
func SetVehicleProfile ¶
func SetVehicleProfile(ctx context.Context, d *sql.DB, userID, vehicleID uuid.UUID, p VehicleProfile) error
SetVehicleProfile writes the profile struct into the vehicles.metadata JSONB at the "profile" key, preserving any other top-level metadata fields the row may hold.
func UserIDFor ¶
UserIDFor returns the stable v5 UUID for a username. Case-folded and whitespace-trimmed so "Alice", "alice" and " alice " all land on the same user — usernames are display-y, IDs are identity.
Being deterministic means the same username produces the same UUID across every Rivolt install, which makes exports, imports and data migrations trivial: there is no lookup step.
func VehicleOwner ¶
VehicleOwner resolves an internal vehicles.id (UUID) back to its owning user_id. Used by the StateMonitor recorder to attribute frames to the right user even when a single pod monitors vehicles belonging to many users (post-PR-6 lease coordinator).
Returns (uuid.Nil, nil) when no row exists; callers should treat that as "stale subscription, drop the frame" rather than an error.
func VehicleOwnerByRivianID ¶
VehicleOwnerByRivianID is the Rivian-gateway-id-keyed sibling of VehicleOwner. The lease coordinator queries this when it acquires a lease so the per-user *LiveClient can be hydrated before the WS subscription starts.
func WithUserScope ¶
func WithUserScope( ctx context.Context, pool *sql.DB, userID uuid.UUID, fn func(ctx context.Context, tx DBTX) error, ) error
WithUserScope runs fn inside a transaction that has `app.user_id` set to userID at LOCAL scope. The transaction commits if fn returns nil, rolls back otherwise.
Safe to call with a nil pool (returns fn's zero error) to keep no-DB dev paths working.
Types ¶
type AdminUserRow ¶
type AdminUserRow struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Email string `json:"email,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
CreatedAt time.Time `json:"created_at"`
VehicleCount int `json:"vehicle_count"`
DriveCount int `json:"drive_count"`
ImportCount int `json:"import_count"`
RivianConnected bool `json:"rivian_connected"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
}
AdminUserRow is the projection /api/admin/users serves. The activity counters (vehicles / drives / imports / rivian_connected / last_seen_at) let the admin see "did this user actually do anything?" without a per-user drill-down. Cheap scalar subqueries at our scale (<100 users); paginate if that ever changes.
func ListUsersForAdmin ¶
ListUsersForAdmin returns every user row, role-stamped and ordered by created_at so the admin page is stable across reloads. No paging — a self-hosted install will not have enough users to need it. If that ever stops being true, add LIMIT/OFFSET parameters.
type DBTX ¶
type DBTX interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
}
DBTX is the subset of *sql.DB / *sql.Tx / *sql.Conn that every store uses. Having a named interface lets callers pass either the pool or a scoped tx without API churn.
type VehicleOwnership ¶
ListVehiclesWithOwners returns every (user_id, rivian_vehicle_id) tuple in the vehicles table. Used by the lease coordinator's vehicle source so the cluster's reconciliation set is keyed by user, not by rivian id alone — two users with the same rivian id (yes, this is possible: shared family account, dev/prod accidental reuse) are distinct lease subjects.
func ListVehiclesWithOwners ¶
ListVehiclesWithOwners returns every (user_id, rivian_vehicle_id) pair in the vehicles table. Empty rivian ids are skipped — the row is a placeholder waiting for first contact, not a subscription candidate.
type VehicleProfile ¶
type VehicleProfile struct {
TireType string `json:"tire_type,omitempty"`
WheelInches int `json:"wheel_inches,omitempty"`
Accessories []string `json:"accessories,omitempty"`
DefaultExtraLoadLb float64 `json:"default_extra_load_lb,omitempty"`
FrequentlyTows bool `json:"frequently_tows,omitempty"`
TirePlacardPSI float64 `json:"tire_placard_psi,omitempty"`
}
VehicleProfile is the user-editable per-vehicle context the efficiency analyzer factors into its breakdown. Persisted as the "profile" sub-key of the vehicles.metadata JSONB column; chosen over typed columns so future fields (e.g. windshield state, modified suspension) don't need migrations.
Field semantics:
- TireType: "all_season" | "all_terrain" | "winter" | "summer". Empty string means "unset" — the analyzer ignores it.
- WheelInches: 0 means unset. Rivian R1 ships 20/21/22.
- Accessories: free-form list ("roof_rack", "bike_rack", "rooftop_tent", "ski_box", etc.). Order is not significant.
- DefaultExtraLoadLb: persistent extra cargo carried on most drives (e.g. tools, child seat). The per-drive form on the efficiency card adds to this for trip-specific cargo.
- FrequentlyTows: hint that the vehicle is regularly used to tow. Doesn't affect the per-drive towing flag, which the analyzer reads independently.
- TirePlacardPSI: door-jamb placard pressure in psi. Optional (0 = unset). When supplied, the efficiency prompt cites the delta between current and placard so the model can confidently attribute a "Low tire pressure" factor instead of guessing the placard from generic priors. The user reads this off their door-jamb sticker once.
func GetVehicleProfile ¶
func GetVehicleProfile(ctx context.Context, d *sql.DB, userID, vehicleID uuid.UUID) (VehicleProfile, error)
GetVehicleProfile reads the "profile" sub-key from the given vehicle's metadata JSONB. Returns a zero VehicleProfile (not nil) when the key is missing or the row has no metadata — callers can treat the zero value as "unset" without nil-checking each field.
userID-scoped: a row owned by a different user returns sql.ErrNoRows to the caller, which the API layer translates to 404.
type VehicleResolver ¶
type VehicleResolver struct {
// contains filtered or unexported fields
}
VehicleResolver translates Rivian gateway vehicle-id strings (e.g. "01-242521064") into the internal vehicles.id UUID, creating the row on first sight. Results are cached in-process so the hot path (per-sample writes) doesn't hit the DB on every call.
Safe for concurrent use.
func NewVehicleResolver ¶
func NewVehicleResolver(d *sql.DB, userID uuid.UUID) *VehicleResolver
NewVehicleResolver builds a resolver scoped to a single user.
func (*VehicleResolver) Resolve ¶
Resolve returns the internal UUID for a Rivian vehicle-id string, upserting a bare vehicles row (user_id + rivian_vehicle_id) on first sight. Empty rivianID is an error — the stores should never be asked to write a row with no vehicle association.
type VehicleResolverFactory ¶
type VehicleResolverFactory struct {
// contains filtered or unexported fields
}
VehicleResolverFactory hands out per-user *VehicleResolver instances. Each user gets one resolver, cached for the process lifetime — the resolver itself caches (rivianID → internal UUID) in-memory so the recorder hot path doesn't round-trip Postgres on every sample, and per-user instances mean User A's resolver can never accidentally upsert a row attributed to User B.
Safe for concurrent use.
func NewVehicleResolverFactory ¶
func NewVehicleResolverFactory(db *sql.DB) *VehicleResolverFactory
NewVehicleResolverFactory builds a factory around the shared pool. The factory does not own the pool; main.go closes it.
func (*VehicleResolverFactory) DB ¶
func (f *VehicleResolverFactory) DB() *sql.DB
DB exposes the underlying pool for callers that need raw SQL against user-scoped tables (e.g. ownership probes). The factory retains pool ownership; do not close.
func (*VehicleResolverFactory) For ¶
func (f *VehicleResolverFactory) For(userID uuid.UUID) *VehicleResolver
For returns the resolver bound to userID, constructing one on first sight. Returns nil when userID is the zero UUID.
type VehicleSummary ¶
type VehicleSummary struct {
ID uuid.UUID `json:"id"`
RivianVehicleID string `json:"rivian_vehicle_id"`
VIN string `json:"vin,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Model string `json:"model,omitempty"`
ModelYear int `json:"model_year,omitempty"`
PackKWh float64 `json:"pack_kwh,omitempty"`
}
VehicleSummary is the per-user vehicle metadata exposed to /api/vehicles/owned (the import-picker source). It deliberately omits operational fields (created_at, updated_at) the SPA doesn't need — keeping the wire shape narrow makes the picker dropdown trivially serialisable.
func ListUserVehicles ¶
ListUserVehicles returns every real vehicle row owned by userID, excluding the legacy `electrafi-<hash>` synthetic rows left over from earlier importers. Synthetic rows linger in existing installs because their drives/charges/samples still reference them; we hide them from the picker so a fresh import can only land on a Rivian-linked vehicle.