db

package
v0.18.30 Latest Latest
Warning

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

Go to latest
Published: May 14, 2026 License: MIT Imports: 15 Imported by: 0

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

Constants

This section is empty.

Variables

View Source
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

func CountAdmins(ctx context.Context, d *sql.DB) (int, error)

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

func DeleteUser(ctx context.Context, d *sql.DB, uid uuid.UUID) error

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

func EnsureUser(ctx context.Context, d *sql.DB, username string) (uuid.UUID, error)

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

func GetNeedsReauth(ctx context.Context, d *sql.DB, userID uuid.UUID) (bool, string, error)

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

func IsDisabled(ctx context.Context, d *sql.DB, uid uuid.UUID) (bool, error)

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

func ListUsersWithRivianSession(ctx context.Context, db *sql.DB) ([]uuid.UUID, error)

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

func LookupUsername(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)

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

func Migrate(ctx context.Context, db *sql.DB) error

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

func OnboardingCompleted(ctx context.Context, d *sql.DB, uid uuid.UUID) (bool, error)

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

func Open(ctx context.Context, dsn string) (*sql.DB, error)

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

func OwnsRivianID(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianID string) (bool, error)

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

func RawUsernameByID(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)

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

func Rebind(query string) string

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

func RoleFor(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)

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

func SetDisabled(ctx context.Context, d *sql.DB, uid uuid.UUID, disabled bool) error

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

func SetNeedsReauth(ctx context.Context, d *sql.DB, userID uuid.UUID, reason string) error

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

func SetOnboardingCompleted(ctx context.Context, d *sql.DB, uid uuid.UUID) error

SetOnboardingCompleted marks the user's onboarding stepper as finished. Idempotent — safe to call twice.

func SetRole

func SetRole(ctx context.Context, d *sql.DB, uid uuid.UUID, role string) error

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

func UserIDFor(username string) uuid.UUID

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

func VehicleOwner(ctx context.Context, db *sql.DB, vehicleID uuid.UUID) (uuid.UUID, error)

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

func VehicleOwnerByRivianID(ctx context.Context, db *sql.DB, rivianID string) (uuid.UUID, error)

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

func ListUsersForAdmin(ctx context.Context, d *sql.DB) ([]AdminUserRow, error)

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

type VehicleOwnership struct {
	UserID    uuid.UUID
	VehicleID string // rivian gateway id
}

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

func ListVehiclesWithOwners(ctx context.Context, db *sql.DB) ([]VehicleOwnership, error)

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

func (r *VehicleResolver) Resolve(ctx context.Context, rivianID string) (uuid.UUID, error)

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.

func (*VehicleResolver) RivianID

func (r *VehicleResolver) RivianID(ctx context.Context, id uuid.UUID) (string, error)

RivianID returns the Rivian gateway string for an internal UUID. Used by readers that need to present vehicle_id in the API shape the UI expects. Uncached — read-path uses are not hot enough to justify an inverse cache.

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

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

func ListUserVehicles(ctx context.Context, d *sql.DB, userID uuid.UUID) ([]VehicleSummary, error)

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.

Jump to

Keyboard shortcuts

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