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
- Variables
- func AcceptTerms(ctx context.Context, d *sql.DB, uid uuid.UUID, version string) error
- func AppleRevokeFailedAgain(ctx context.Context, d *sql.DB, id uuid.UUID, errMsg string) error
- func CancelAppleRevoke(ctx context.Context, d *sql.DB, uid uuid.UUID) error
- func CancelInactiveWarnings(ctx context.Context, d *sql.DB) (int, error)
- func ClaimInactiveWarning(ctx context.Context, d *sql.DB, uid uuid.UUID) (bool, error)
- func ClaimOTAFetch(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianVehicleID string, ...) (bool, error)
- func ClaimOTANotice(ctx context.Context, d *sql.DB, uid uuid.UUID, ...) (bool, error)
- func ClaimReauthNudge(ctx context.Context, d *sql.DB, userID uuid.UUID, cooldown time.Duration) (bool, error)
- func ClaimSetupNudge(ctx context.Context, d *sql.DB, userID uuid.UUID, attempt int) (bool, error)
- func ClaimWelcome(ctx context.Context, d *sql.DB, userID uuid.UUID) (bool, error)
- func ClearEndedR2ArrivalMonths(ctx context.Context, d *sql.DB, now time.Time) (int64, error)
- func ClearIdPDeleteFailure(ctx context.Context, d *sql.DB, username string) error
- func ClearR2ArrivalMonth(ctx context.Context, d *sql.DB, uid uuid.UUID) error
- func ClearR2ArrivalMonthFor(ctx context.Context, d *sql.DB, uid uuid.UUID, month time.Time) error
- func ConnectWithRetry(ctx context.Context, dsn string, wait time.Duration, log *slog.Logger) (*sql.DB, error)
- func CountAdmins(ctx context.Context, d *sql.DB) (int, error)
- func CountInactiveCandidates(ctx context.Context, d *sql.DB) (warnDue, deleteDue, warnedWaiting int, err error)
- func CountUnwarnableDue(ctx context.Context, d *sql.DB) (n int, err error)
- func CountUsers(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 DeleteInactiveAccount(ctx context.Context, d *sql.DB, uid uuid.UUID) (username string, deleted bool, err error)
- func DeleteUnwarnableAccount(ctx context.Context, d *sql.DB, uid uuid.UUID) (username string, deleted bool, err error)
- func DeleteUser(ctx context.Context, d *sql.DB, uid uuid.UUID) error
- func DeleteUserAccount(ctx context.Context, d *sql.DB, uid uuid.UUID) error
- func DeleteUserArchiveBefore(ctx context.Context, d *sql.DB, uid uuid.UUID, cutoff time.Time) error
- func DeleteUserHistoryBefore(ctx context.Context, d *sql.DB, uid uuid.UUID, cutoff time.Time) error
- func DoNotTrack(ctx context.Context) bool
- func EngagementEmailsOff(ctx context.Context, d *sql.DB, userID uuid.UUID) (bool, error)
- func EnsureAppGrants(ctx context.Context, d *sql.DB, role string, logger *slog.Logger)
- func EnsureReadonlyGrants(ctx context.Context, d *sql.DB, role string, logger *slog.Logger)
- 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 EnsureUserVerified(ctx context.Context, d *sql.DB, username, email, displayName string, ...) (uuid.UUID, error)
- func ExpireAppleRevokes(ctx context.Context, d *sql.DB) (int64, error)
- func GetNeedsReauth(ctx context.Context, d *sql.DB, userID uuid.UUID) (bool, string, error)
- func GrantApp(ctx context.Context, d *sql.DB, role string) (bool, error)
- func GrantReadonly(ctx context.Context, d *sql.DB, role string) (bool, error)
- func IdPDeleteFailureUsername(ctx context.Context, d *sql.DB, id uuid.UUID) (string, error)
- func IsDisabled(ctx context.Context, d *sql.DB, uid uuid.UUID) (bool, error)
- func LatestApplied(ctx context.Context, d *sql.DB) (string, error)
- func LatestEmbedded() (string, error)
- func ListInactiveDeleteDue(ctx context.Context, d *sql.DB, limit int) ([]uuid.UUID, error)
- func ListSubscribableVehicleIDs(ctx context.Context, d *sql.DB) ([]string, error)
- func ListUnwarnableDeleteDue(ctx context.Context, d *sql.DB, limit int) ([]uuid.UUID, error)
- func ListUserRivianVehicleIDs(ctx context.Context, d *sql.DB, userID uuid.UUID) ([]string, error)
- func ListUsersWithRivianSession(ctx context.Context, db *sql.DB) ([]uuid.UUID, error)
- func ListVehicleOTAs(ctx context.Context, d *sql.DB, userID uuid.UUID) (map[string]json.RawMessage, error)
- func LookupUserEmail(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)
- func LookupUsername(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)
- func MarkInactiveWarned(ctx context.Context, d *sql.DB, uid uuid.UUID) error
- func MarkOTAUnavailable(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianVehicleID string) error
- func MarkStripeCustomerClosed(ctx context.Context, d *sql.DB, customer string) error
- func Migrate(ctx context.Context, db *sql.DB) error
- func MigrateOnStart(getenv func(string) string) bool
- func OnboardingCompleted(ctx context.Context, d *sql.DB, uid uuid.UUID) (bool, error)
- func Open(ctx context.Context, dsn string) (*sql.DB, error)
- func OpenNoMigrate(ctx context.Context, dsn string) (*sql.DB, error)
- func OpenWithRetry(ctx context.Context, dsn string, wait time.Duration, log *slog.Logger) (*sql.DB, error)
- func OwnsRivianID(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianID string) (bool, error)
- func PrepareRequestRole(ctx context.Context, owner *sql.DB, role string) error
- func PurgeClosedStripeCustomers(ctx context.Context, d *sql.DB) (int64, error)
- func R2ArrivalStillWanted(ctx context.Context, d *sql.DB, uid uuid.UUID, month time.Time) (bool, error)
- func RaiseNeedsReauth(ctx context.Context, d *sql.DB, userID uuid.UUID, reason string) (transitioned bool, err error)
- func RaiseNeedsReauthIfCurrent(ctx context.Context, d *sql.DB, userID uuid.UUID, reason string, ...) (transitioned bool, err error)
- func RawUsernameByID(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)
- func ReauthRaisedAt(ctx context.Context, d *sql.DB, userID uuid.UUID) (at time.Time, flagged bool, err error)
- func Rebind(query string) string
- func RecordAppleRevokeFailure(ctx context.Context, d *sql.DB, uid uuid.UUID, sealed []byte, errMsg string) (uuid.UUID, error)
- func RecordIdPDeleteFailure(ctx context.Context, d *sql.DB, username, errMsg string) (uuid.UUID, error)
- func RecordServerEvent(ctx context.Context, d *sql.DB, uid uuid.UUID, name string, ...)
- func RecordStripeCancelFailure(ctx context.Context, d *sql.DB, customer, errMsg string) (uuid.UUID, error)
- func RequestConnHook(userFrom func(context.Context) (uuid.UUID, bool)) func(context.Context, *pgx.Conn) (bool, error)
- func RequestPoolMaxConns(getenv func(string) string) int32
- func RequireSchema(ctx context.Context, d *sql.DB, wait time.Duration, log *slog.Logger) error
- func RequireTables(ctx context.Context, d *sql.DB, tables ...string) error
- func ResetUserArchive(ctx context.Context, d *sql.DB, uid uuid.UUID) error
- func ResolveIdPDeleteFailure(ctx context.Context, d *sql.DB, id uuid.UUID) error
- func RivianExpiryEmailsOff(ctx context.Context, d *sql.DB, userID uuid.UUID) (bool, error)
- func RoleFor(ctx context.Context, d *sql.DB, uid uuid.UUID) (string, error)
- func SeedOTANotices(ctx context.Context, d *sql.DB, uid uuid.UUID, ...) (bool, error)
- func ServerEventsEnabled() bool
- func SetDisabled(ctx context.Context, d *sql.DB, uid uuid.UUID, disabled bool) error
- func SetEngagementEmailsOff(ctx context.Context, d *sql.DB, userID uuid.UUID, off 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 SetRivianExpiryEmailsOff(ctx context.Context, d *sql.DB, userID uuid.UUID, off bool) error
- func SetRole(ctx context.Context, d *sql.DB, uid uuid.UUID, role string) error
- func SetVehicleImageURL(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianID, url string) error
- func SetVehicleOTA(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianVehicleID string, ...) error
- func SetVehiclePackKWh(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianID string, kwh float64) error
- func SetVehicleProfile(ctx context.Context, d *sql.DB, userID, vehicleID uuid.UUID, p VehicleProfile) error
- func StripeCancelBacklog(ctx context.Context, d *sql.DB) (pending int, oldest time.Duration, err error)
- func StripeCancelFailedAgain(ctx context.Context, d *sql.DB, id uuid.UUID, errMsg string) error
- func SystemPoolSize(getenv func(string) string) (n int, reason string)
- func TableExists(ctx context.Context, d *sql.DB, name string) (bool, error)
- func UpsertVehicleFromSync(ctx context.Context, d *sql.DB, userID uuid.UUID, v VehicleSync) error
- func UserExistsByUsername(ctx context.Context, d *sql.DB, username string) (bool, error)
- func UserIDFor(username string) uuid.UUID
- func UsernameExists(ctx context.Context, d *sql.DB, username string) (bool, error)
- 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 WithDoNotTrack(ctx context.Context) context.Context
- func WithSignupMethod(ctx context.Context, method string) context.Context
- func WithUserScope(ctx context.Context, pool *sql.DB, userID uuid.UUID, ...) error
- type AdminSignupSnapshot
- type AdminUserDetail
- type AdminUserRow
- type AdminUserVehicle
- type AppleRevokeItem
- type DBTX
- type IdPDeleteFailure
- type InactiveWarning
- type OnboardingEmails
- type R2ArrivalDue
- type ReauthNudge
- type RequestPool
- type RequestPoolOption
- type SchemaState
- type SetupNudge
- type StripeCancelItem
- type Terms
- type VehicleOwnership
- type VehicleProfile
- type VehicleResolver
- type VehicleResolverFactory
- type VehicleSummary
- type VehicleSync
- type Welcome
Constants ¶
const ( AppleRevokeMaxAge = 30 * 24 * time.Hour AppleRevokeMaxAttempts = 20 )
AppleRevokeMaxAge and AppleRevokeMaxAttempts bound how long a queued revoke (and its sealed token) is kept.
const ( DefaultSystemMaxConnsRequestPoolOff = 20 DefaultSystemMaxConnsRequestPoolOn = 10 )
const ( InactiveWarnAfter = 90 * 24 * time.Hour InactiveDeleteAfter = 120 * 24 * time.Hour // InactiveWarnGrace is the least time between the warning and the // deletion: the date the email promises. InactiveWarnGrace = 30 * 24 * time.Hour )
Inactive-account purge (migration 0056). An account that never connected a vehicle is warned at day 90 and deleted at day 120 after signup. Only connecting Rivian (a vehicle row or a stored Rivian session) cancels it, the account is then out of scope; signing in does not. Admins and disabled accounts are never touched.
const ( OTAAvailable = "available" OTAInstalled = "installed" )
OTA notice kinds.
const ( EventSignupComplete = "signup_complete" EventRivianConnectOK = "rivian_connect_ok" )
Server-side product events: funnel steps the server sees itself. They go into the same events table as the browser's, with anon_id 'server' and the user's id as the only link: no IP, location, amount or email. The vocabulary is closed (name -> allowed props); anything else is dropped.
const ( ServerEventRecorded = "recorded" ServerEventSkippedDNT = "skipped_dnt" // the request carried Do Not Track / Global Privacy Control ServerEventSkippedDisabled = "skipped_disabled" // events or server events are off ServerEventSkippedOther = "skipped_other" // demo user, no user, no database ServerEventFailed = "failed" // the insert failed )
Outcomes of RecordServerEvent, for ServerEventObserver.
const ( NudgeNoVehicle = "no_vehicle" // never connected Rivian NudgeNoData = "no_data" // connected, but no state, drive or charge arrived )
Setup-email kinds: the nudge copy differs by what is missing.
const DefaultRequestMaxConns int32 = 10
DefaultRequestMaxConns is the request pool's size per process. database/sql keeps no limit of its own here, so this is the real cap. The role has a connection limit (50 on preview) and Postgres has max_connections (100): replicas, plus one for the surge of a rolling update, times this must stay inside the role's limit, and with each pod's system pool (20) inside max_connections. 10 per pod keeps three pods at 30 of 50.
const DemoUsername = "rivolt-public-demo"
DemoUsername is the public demo account's username (internal/demo). Server events are never recorded for it.
const MaxSetupNudges = 2
MaxSetupNudges caps the setup reminder emails per user: a first one and one follow-up. With the welcome email that is three emails ever. More than that stops being a reminder.
OTAUnavailableBackoff is how long a vehicle Rivian won't give OTA details for (FORBIDDEN or unsupported, e.g. some R2s) is left alone.
const PoolIdleTimeout = 2 * time.Minute
The system pool's default size per process depends on what else serves requests.
With the request pool off (RIVOLT_REQUEST_DATABASE_URL unset: every self-hosted install, preview and prod until row-level security stage 3), the system pool is the only pool and serves every request as well as the background work, so it keeps today's 20 (prod's 7-day peak was 53 connections across 2 pods, about 26 each). With the request pool on, requests run on their own pool (10) and the system pool serves only sweeps, the monitor's writes and migrations, so 10 is enough: (replicas + a surge pod) x (10 + 10) stays inside max_connections. PoolIdleTimeout closes a pooled connection that has sat idle this long, in both pools, so a quiet pod gives its connections back and only connections in use count against max_connections (DB connection budget, DA Oct 30). The pools reopen on demand.
const RequestConnBudget = 45
RequestConnBudget is what the request pool may use of the role's connection limit (50): per pod times pods (replicas plus a surge pod) stays at or under it, leaving headroom for the grant loop and a hand-run migrate.
const WelcomeDelay = 10 * time.Minute
WelcomeDelay is how long after signup the welcome email goes out, so a user still in the middle of connecting is not emailed in the same minute.
Variables ¶
var ErrIdPFailureNotFound = errors.New("idp delete failure not found")
ErrIdPFailureNotFound is returned for an unknown failure id.
var ErrRequestRoleBypassesRLS = errors.New("request pool: the role bypasses row-level security")
ErrRequestRoleBypassesRLS is returned when the request pool's role would see past the tenant policies: the tables' owner or a member of the owner role (which inherits its rights), a superuser, or a role with BYPASSRLS. Row-level security would be silently dormant, so the pool refuses to open.
var ErrRequestRoleMissing = fmt.Errorf("the request role does not exist")
ErrRequestRoleMissing means the request role does not exist yet (CNPG creates managed roles on its own schedule).
var ErrSchemaBehind = errors.New(`schema is behind this binary: run "rivolt migrate", or set RIVOLT_MIGRATE_ON_START=true for a single-node install`)
ErrSchemaBehind names the fix in one line; the server prints it and exits.
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.
var ServerEventObserver func(event, outcome string)
ServerEventObserver, when set (once, at startup, before serving), is told the outcome of every RecordServerEvent call: a metrics hook that makes "why is there no signup_complete row?" answerable. It sees the event name and the outcome, never a user id.
var UnwarnablePolicyStart = time.Date(2026, 10, 19, 5, 0, 0, 0, time.UTC) // 2026-10-19 00:00 CDT
UnwarnablePolicyStart is when the privacy policy line that deletes unwarnable accounts at day 120 without a warning takes effect (the deploy date of the policy text). Accounts that signed up under the earlier text were promised a warning, so their 120 days count from the later of signup and this date. Keep it equal to the "Last updated" date of the privacy page that carries the line. It is the start of that day in the owner's zone (US Central), so it is never before the deploy. A var only so tests can move it.
Functions ¶
func AcceptTerms ¶ added in v0.36.10
AcceptTerms records that the user accepted version. Idempotent: accepting the version already on file keeps the original time.
func AppleRevokeFailedAgain ¶ added in v0.39.3
AppleRevokeFailedAgain counts another failed attempt for a queued revocation.
func CancelAppleRevoke ¶ added in v0.39.3
CancelAppleRevoke drops the queued revocation for an account that turned out not to be deleted.
func CancelInactiveWarnings ¶ added in v0.36.8
CancelInactiveWarnings clears the warning of accounts that connected Rivian (a vehicle or a stored session) since it was sent, so the record doesn't linger. It returns how many were cancelled.
func ClaimInactiveWarning ¶ added in v0.36.8
ClaimInactiveWarning takes the send lease for the account's warning and reports whether this caller won it. It re-checks eligibility, so an account that connected a vehicle since the list was built is not claimed.
func ClaimOTAFetch ¶ added in v0.36.5
func ClaimOTAFetch(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianVehicleID string, minInterval time.Duration) (bool, error)
ClaimOTAFetch records an attempt to fetch the vehicle's OTA details and reports whether this caller won it: no attempt in the last minInterval, across pods and restarts. It matches only the user's own vehicle.
func ClaimOTANotice ¶ added in v0.36.10
func ClaimOTANotice(ctx context.Context, d *sql.DB, uid uuid.UUID, rivianVehicleID, kind, version string) (bool, error)
ClaimOTANotice records that the (vehicle, kind, version) alert is being sent and reports whether this caller won it: false means it was already announced, so nothing is sent again (restarts, a second pod, a version that flaps back).
func ClaimReauthNudge ¶ added in v0.25.0
func ClaimReauthNudge(ctx context.Context, d *sql.DB, userID uuid.UUID, cooldown time.Duration) (bool, error)
ClaimReauthNudge atomically records that this pod is about to send a stuck-session nudge, returning true only if the claim was won. The UPDATE's WHERE re-checks the due predicate, so when two replicas race on the same user Postgres' row lock lets exactly one flip needs_reauth_notified_at and send; the loser sees 0 rows and skips. Same rising-edge dedup shape as RaiseNeedsReauth, applied to the sweep instead of the classifier.
func ClaimSetupNudge ¶ added in v0.25.0
ClaimSetupNudge atomically records that setup reminder number attempt is being sent, returning true only if the claim was won. The WHERE re-checks eligibility and pins the count to attempt-1, so data that arrived since the candidate list was built, an opt-out, or a peer replica that got there first makes the claim a no-op. Each attempt can be claimed once.
func ClaimWelcome ¶ added in v0.36.15
ClaimWelcome records that the welcome email is being sent, true only if this caller won (once per user, safe on every replica). It re-checks eligibility so an opt-out or a connect since the list was built wins.
func ClearEndedR2ArrivalMonths ¶ added in v0.40.14
ClearEndedR2ArrivalMonths deletes every month that has ended in any zone (before the previous UTC month): it has served its purpose and is not kept. One statement; returns how many.
func ClearIdPDeleteFailure ¶ added in v0.32.0
ClearIdPDeleteFailure drops any pending failure for username (an IdP deletion for it just succeeded).
func ClearR2ArrivalMonth ¶ added in v0.40.14
ClearR2ArrivalMonth removes the user's chosen month (the email's one-click link, and Settings' Delete). Idempotent.
func ClearR2ArrivalMonthFor ¶ added in v0.40.14
ClearR2ArrivalMonthFor removes the user's month only if it is still this one: after the email for it is claimed, so the user stops matching the due list (the list is capped, so finished rows must not stay in it). A month changed since is left alone.
func ConnectWithRetry ¶ added in v0.39.7
func ConnectWithRetry(ctx context.Context, dsn string, wait time.Duration, log *slog.Logger) (*sql.DB, error)
ConnectWithRetry dials Postgres and pings, retrying with backoff for up to wait, and never changes the schema. A new pod can briefly have no route to Postgres (NetworkPolicy programming lags behind pod start), and on a fresh install the database may still be initialising; exiting there turns a rollout into restarts and 502s.
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 CountInactiveCandidates ¶ added in v0.36.8
func CountInactiveCandidates(ctx context.Context, d *sql.DB) (warnDue, deleteDue, warnedWaiting int, err error)
CountInactiveCandidates counts, for the dry run, the accounts due a warning and the accounts due deletion, plus warned accounts still waiting. Counts only: no ids, no emails.
func CountUnwarnableDue ¶ added in v0.38.0
CountUnwarnableDue counts, for the dry run, the unwarnable accounts due deletion. A count only.
func CountUsers ¶ added in v0.21.0
CountUsers returns the total number of user rows. Used by the signup-cap gate in the OIDC callback and the admin UI's "N of M seats used" indicator.
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 DeleteInactiveAccount ¶ added in v0.36.8
func DeleteInactiveAccount(ctx context.Context, d *sql.DB, uid uuid.UUID) (username string, deleted bool, err error)
DeleteInactiveAccount deletes the account through the account-delete path (the same cascade and signup_requests cleanup as DeleteUserAccount), but only if it is still eligible: the row is locked and every condition re-checked in the transaction, so a vehicle connected since the list was built wins. It returns the username (for the identity-provider removal) and whether the account was deleted.
func DeleteUnwarnableAccount ¶ added in v0.38.0
func DeleteUnwarnableAccount(ctx context.Context, d *sql.DB, uid uuid.UUID) (username string, deleted bool, err error)
DeleteUnwarnableAccount deletes an unwarnable account through the account-delete path, only if it still is one: the row is locked and the scope and age re-checked in the transaction, so a Rivian connected since the list was built, or an email verified since, wins. It returns the username (for the identity-provider removal) and whether the account was deleted.
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.
subscription_leases is the one exception: it's keyed by rivian_vehicle_id with no FK back to users, so the cascade misses it. A surviving lease is renewed forever by its owning pod, whose recorder then fails FK inserts against the now-deleted vehicle indefinitely. Drop the leases in the same transaction, while the vehicles rows still exist to resolve their ids, so the owning pod sees them vanish on its next renew and tears the subscription down.
charges_deleted_backup (repair-phantom-charges' undo copies) has no FK either: it must outlive the charges it backs up. A deleted user's copies go with them.
func DeleteUserAccount ¶ added in v0.32.0
DeleteUserAccount is the account-deletion path (self-service and admin): DeleteUser plus the signup_requests rows filed under the user's email or username, matched case-insensitively, in the same transaction. signup_requests has no FK to users (a request exists before its account does), so the cascade never reached it and a deleted person's email, message and signup token lingered.
Rollback paths (an IdP create that failed right after the rivolt row was written) must keep using DeleteUser: the request is still live there and the person retries with the same token.
func DeleteUserArchiveBefore ¶ added in v0.40.16
DeleteUserArchiveBefore is "delete the history before month" for the telemetry archive: for every archived month earlier than cutoff (which must be the first of a month, UTC, so no month is cut in half) it queues that month's objects for deletion and removes the archive_months row, and it clears the user's rows older than cutoff from thinning's detached full copies. Months from cutoff on are untouched. The user's data key stays: the later months still need it. Like ResetUserArchive it takes the archive lock, so an object being written finishes first.
func DeleteUserHistoryBefore ¶ added in v0.40.16
DeleteUserHistoryBefore removes the per-time history that hangs off charges and drives without a foreign key (or outlives them) and is dated before cutoff: Rivian receipts (amount, vendor, city, charger), the undo copies of deleted charges, trip-plan links (the plan snapshot has the route), stored push notification text and left-open alerts. Used by "delete my history before X" and by the full history reset (a cutoff in the future). Rows that cascade with a charge (restamps, pack-health samples) go with it.
func DoNotTrack ¶ added in v0.36.15
DoNotTrack reports whether ctx carries the opt-out.
func EngagementEmailsOff ¶ added in v0.36.15
EngagementEmailsOff reports whether the user turned the welcome and setup emails off.
func EnsureAppGrants ¶ added in v0.38.28
EnsureAppGrants is GrantApp with logging, for the boot and periodic callers. A missing role is logged at info: it's normal until CNPG has created it, and on installs that don't use a request role.
func EnsureReadonlyGrants ¶ added in v0.31.3
EnsureReadonlyGrants is GrantReadonly with logging, for the boot and periodic callers. A missing role is logged once at info, not as an error: on preview or a fresh install the role may legitimately not exist.
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 EnsureUserVerified for callers that cannot vouch for the email: it never promotes the bootstrap admin.
func EnsureUserVerified ¶ added in v0.32.1
func EnsureUserVerified(ctx context.Context, d *sql.DB, username, email, displayName string, emailVerified bool) (uuid.UUID, error)
EnsureUserVerified 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 ExpireAppleRevokes ¶ added in v0.39.3
ExpireAppleRevokes drops queued revocations (and their sealed tokens) older than AppleRevokeMaxAge or past AppleRevokeMaxAttempts, and returns how many went.
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 GrantApp ¶ added in v0.38.28
GrantApp applies the request role's grants. It returns (false, nil) without doing anything when the role doesn't exist yet, so callers can retry later. Idempotent.
func GrantReadonly ¶ added in v0.31.3
GrantReadonly grants SELECT on the allowlist to role. It returns (false, nil) without doing anything when the role doesn't exist yet, so callers can retry later.
func IdPDeleteFailureUsername ¶ added in v0.32.0
IdPDeleteFailureUsername returns the username of a pending failure.
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 LatestApplied ¶ added in v0.33.0
LatestApplied is the newest migration id recorded in the database ("" when the migrations table does not exist). Read-only.
func LatestEmbedded ¶ added in v0.33.0
LatestEmbedded is the id of the newest migration this binary ships.
func ListInactiveDeleteDue ¶ added in v0.36.8
ListInactiveDeleteDue returns the ids of warned accounts whose clock is at least InactiveDeleteAfter old and whose warning is at least InactiveWarnGrace old, oldest first, at most limit.
func ListSubscribableVehicleIDs ¶ added in v0.26.0
ListSubscribableVehicleIDs returns every Rivian gateway vehicle-id whose owner currently has a stored Rivian session, i.e. a vehicle we have any business holding a subscription lease for.
This is the lease coordinator's authoritative set (SetAuthoritative): a lease whose vehicle is absent from it is reaped and unsubscribed. Gating on the *stored session* - not merely the vehicles row - is what makes disconnect take effect across replicas. Logout deletes the user_secrets 'rivian.session' row; on the next reconcile every pod sees the vehicle drop out of this set and tears the WS subscription down within one cycle. Without it, a logout on one pod left the lease-owning pod streaming from its in-memory session until the next restart happened to land on it (a user kept being recorded for days after disconnecting).
The session predicate is byte-identical to ListUsersWithRivianSession (the boot hydrate), so what gets a monitor at startup and what stays leased can never disagree. Synthetic electrafi-<hash> import rows are excluded - they're not real gateway VINs and never carry a session.
func ListUnwarnableDeleteDue ¶ added in v0.38.0
ListUnwarnableDeleteDue returns the ids of unwarnable accounts (see unwarnableScope) whose clock is at least InactiveDeleteAfter old, oldest first, at most limit. Candidates only: DeleteUnwarnableAccount re-checks each.
func ListUserRivianVehicleIDs ¶ added in v0.36.5
ListUserRivianVehicleIDs returns the user's rivian_vehicle_id values.
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 ListVehicleOTAs ¶ added in v0.36.5
func ListVehicleOTAs(ctx context.Context, d *sql.DB, userID uuid.UUID) (map[string]json.RawMessage, error)
ListVehicleOTAs returns the stored OTA details by rivian_vehicle_id for the user's vehicles that have any.
func LookupUserEmail ¶ added in v0.24.5
LookupUserEmail returns the user's email address, or "" when the row has no email (admin-created accounts may lack one) or the UUID is unknown. Used to address transactional mail (re-auth notice) to the account owner.
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 MarkInactiveWarned ¶ added in v0.36.8
MarkInactiveWarned records that the warning email was sent. Only a sent warning counts: a failed send leaves it NULL and the lease expires.
func MarkOTAUnavailable ¶ added in v0.36.6
func MarkOTAUnavailable(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianVehicleID string) error
MarkOTAUnavailable records that Rivian said OTA details are not available for the vehicle, which keeps ClaimOTAFetch from winning for a week.
func MarkStripeCustomerClosed ¶ added in v0.40.16
MarkStripeCustomerClosed remembers that the customer belonged to a deleted account (so its leftover events are acknowledged).
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 MigrateOnStart ¶ added in v0.39.7
MigrateOnStart reports whether the server migrates the schema itself at startup (RIVOLT_MIGRATE_ON_START). Unset means yes for now: the chart turns it off where a migration Job runs, and a later release flips the default.
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 OpenNoMigrate ¶ added in v0.33.0
OpenNoMigrate connects and pings but never changes the schema. Repair and maintenance commands use it: a binary newer than the database must not apply pending migrations as a side effect.
func OpenWithRetry ¶ added in v0.36.10
func OpenWithRetry(ctx context.Context, dsn string, wait time.Duration, log *slog.Logger) (*sql.DB, error)
OpenWithRetry is the server's startup path: ConnectWithRetry, then either Migrate (RIVOLT_MIGRATE_ON_START, the default for now) or RequireSchema (a migration job does the migrating; the server only checks, waiting up to 2 minutes for a job that is finishing). The server only starts listening after this returns, so readiness stays false until the database is reachable and current. Migrations run once, after the first successful connect, and are not retried.
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 PrepareRequestRole ¶ added in v0.39.7
PrepareRequestRole applies the request role's grants synchronously: call it after the migrations have run and before the request pool opens, so a table a migration just created is reachable the moment requests start. (The periodic loop in main only catches later changes, such as a partition created at runtime.) ErrRequestRoleMissing when the role is not there, so the caller chooses between waiting (the migration job warns) and refusing to start (the server, when the request pool is on).
func PurgeClosedStripeCustomers ¶ added in v0.40.16
PurgeClosedStripeCustomers drops tombstones older than 30 days (Stripe stops retrying a failed event after about three).
func R2ArrivalStillWanted ¶ added in v0.40.14
func R2ArrivalStillWanted(ctx context.Context, d *sql.DB, uid uuid.UUID, month time.Time) (bool, error)
R2ArrivalStillWanted is the send-time re-check: the user still has this month set, still has a verified address, has not turned engagement emails off and still has no vehicle. A change since the list was built wins.
func RaiseNeedsReauth ¶ added in v0.24.5
func RaiseNeedsReauth(ctx context.Context, d *sql.DB, userID uuid.UUID, reason string) (transitioned bool, err error)
RaiseNeedsReauth sets the gate and reports whether THIS call flipped it from false to true (the rising edge). The UPDATE is guarded on `needs_reauth = FALSE`, so when two replicas classify the same user concurrently only the one that wins the transition gets transitioned = true. Callers use that to fire a one-time side effect (the re-auth email) without double-sending across pods. The reason/at columns are only written on the winning transition; a user already flagged keeps their original reason, which is fine — the flag, not the text, is load-bearing.
func RaiseNeedsReauthIfCurrent ¶ added in v0.40.7
func RaiseNeedsReauthIfCurrent(ctx context.Context, d *sql.DB, userID uuid.UUID, reason string, loaded time.Time) (transitioned bool, err error)
RaiseNeedsReauthIfCurrent is RaiseNeedsReauth for a rejection seen by a client that held at loaded (the session row's updated_at, the version the rejected CALL used: see rivian.LiveClient.SessionVersionFor). The flag is raised only if the stored session's version is exactly loaded, checked by the update itself under a lock on the user's row: if it was replaced since (the user signed in again on another pod and this client has not caught up) the rejection says nothing about the real session, and the flag is not raised and no email follows (transitioned is false). There is no read-then-write gap for a sign-in to land in, and a sign-in that is committing while this runs is waited for, not raced (see the lock below). The stored session must also still exist: a user who disconnected or signed out on another pod (the row is deleted) is not flagged, nor told to reconnect, by a stale client's rejection. A zero loaded means the client does not know which stored session it holds: nothing is raised (false, nil) and the next sync, which records the version, decides; a real rejection is raised on the next call.
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 ReauthRaisedAt ¶ added in v0.40.21
func ReauthRaisedAt(ctx context.Context, d *sql.DB, userID uuid.UUID) (at time.Time, flagged bool, err error)
ReauthRaisedAt is the time the user's current re-auth flag was raised (users.needs_reauth_at), and whether the user is flagged at all. The re-auth email compares it before and after its grace: a flag that was cleared, or cleared and raised again (a new time), is not the raise the email was started for.
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 RecordAppleRevokeFailure ¶ added in v0.39.3
func RecordAppleRevokeFailure(ctx context.Context, d *sql.DB, uid uuid.UUID, sealed []byte, errMsg string) (uuid.UUID, error)
RecordAppleRevokeFailure queues (or, for an account already queued, replaces the token of and counts another attempt for) an Apple revocation that failed. errMsg must not contain the token.
func RecordIdPDeleteFailure ¶ added in v0.32.0
func RecordIdPDeleteFailure(ctx context.Context, d *sql.DB, username, errMsg string) (uuid.UUID, error)
RecordIdPDeleteFailure records (or, for a username already pending, updates) a failed IdP deletion and returns the row id, which is what logs should carry instead of the username (an email address).
func RecordServerEvent ¶ added in v0.36.15
func RecordServerEvent(ctx context.Context, d *sql.DB, uid uuid.UUID, name string, props map[string]any)
RecordServerEvent writes one server event for a user. Best effort: a failure is logged and never returned, so analytics can't break signup or recording. Does nothing when events are off, for the demo user, or for an unknown event name; props not allowed for the event are dropped.
func RecordStripeCancelFailure ¶ added in v0.40.16
func RecordStripeCancelFailure(ctx context.Context, d *sql.DB, customer, errMsg string) (uuid.UUID, error)
RecordStripeCancelFailure queues (or, for a customer already queued, counts another failed attempt of) the cancellation of a deleted account's Stripe subscriptions. errMsg must not contain a key. The row names the Stripe customer only.
func RequestConnHook ¶ added in v0.38.28
func RequestConnHook(userFrom func(context.Context) (uuid.UUID, bool)) func(context.Context, *pgx.Conn) (bool, error)
RequestConnHook is the PrepareConn hook: it sets app.user_id to the context's user, or to ” (no rows under the tenant policies) when there is none, on every connection before it's used.
func RequestPoolMaxConns ¶ added in v0.39.7
RequestPoolMaxConns reads RIVOLT_REQUEST_DB_MAX_CONNS (1 to 100); anything else is DefaultRequestMaxConns.
func RequireSchema ¶ added in v0.39.7
RequireSchema is the server's startup check when it does not migrate itself: every migration this binary ships must already be applied. A schema that is behind is polled for up to wait (a migration job may be finishing), then it is an error. A schema that is AHEAD (applied migrations this binary does not ship) is fine: that is a rollback to an older image, which the expand-only rule for migrations makes safe, and it is logged.
func RequireTables ¶ added in v0.33.0
RequireTables verifies, read-only, that every named table exists, and returns an error that prints the database's and the binary's latest migration when one does not. Repair commands call it instead of migrating.
func ResetUserArchive ¶ added in v0.37.7
ResetUserArchive is "delete your history" for the telemetry archive: it queues uid's objects for deletion and removes their archive_months rows and the user's data key, so the objects are unreadable at once and gone after the deletion job's next run. A later archive run creates a new key. It also clears uid's rows from thinning's detached full copies.
func ResolveIdPDeleteFailure ¶ added in v0.32.0
ResolveIdPDeleteFailure removes a failure once the IdP deletion has succeeded (nothing about the person is kept).
func RivianExpiryEmailsOff ¶ added in v0.40.8
RivianExpiryEmailsOff reports whether the user stopped the Rivian session expiry reminders.
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 SeedOTANotices ¶ added in v0.36.10
func SeedOTANotices(ctx context.Context, d *sql.DB, uid uuid.UUID, rivianVehicleID, installed, available string) (bool, error)
SeedOTANotices records a vehicle's first-seen versions as the silent baseline, and reports whether it did. It does nothing (false) once the vehicle has any notice, so only a vehicle's very first sync is seeded and nobody is alerted about versions that predate the feature. An empty version is skipped.
func ServerEventsEnabled ¶ added in v0.36.15
func ServerEventsEnabled() bool
ServerEventsEnabled reports whether server events are on: product events must be on (RIVOLT_EVENTS, the browser endpoint's switch) AND RIVOLT_SERVER_EVENTS=true. The second switch is separate and defaults off as a safety net, so these account-linked events start only when ops flips it.
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 SetEngagementEmailsOff ¶ added in v0.36.15
SetEngagementEmailsOff turns the welcome and setup emails off or on. Idempotent: turning off again keeps the original time.
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 SetRivianExpiryEmailsOff ¶ added in v0.40.8
SetRivianExpiryEmailsOff stops or resumes the Rivian session expiry reminders. Idempotent: stopping again keeps the original time.
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 SetVehicleImageURL ¶ added in v0.36.3
func SetVehicleImageURL(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianID, url string) error
SetVehicleImageURL stores the hero image once the (slower) image lookup has landed, only touching a row whose value differs.
func SetVehicleOTA ¶ added in v0.36.5
func SetVehicleOTA(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianVehicleID string, ota json.RawMessage) error
SetVehicleOTA stores the OTA details (a JSON object) for the user's vehicle, keeping every other metadata key.
func SetVehiclePackKWh ¶ added in v0.40.13
func SetVehiclePackKWh(ctx context.Context, d *sql.DB, userID uuid.UUID, rivianID string, kwh float64) error
SetVehiclePackKWh mirrors the vehicle-reported pack capacity into vehicles.pack_kwh (the monitor's battery-capacity hook), only touching a row whose value differs. The hook's context carries the group's fence token, so a pod that lost the group writes nothing (fence.ErrFenced).
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 StripeCancelBacklog ¶ added in v0.40.16
func StripeCancelBacklog(ctx context.Context, d *sql.DB) (pending int, oldest time.Duration, err error)
StripeCancelBacklog is how many cancellations are still queued and how long the oldest has waited: what the alert watches.
func StripeCancelFailedAgain ¶ added in v0.40.16
StripeCancelFailedAgain counts another failed attempt for a queued cancellation.
func SystemPoolSize ¶ added in v0.40.8
SystemPoolSize is the system pool's size and why: RIVOLT_DB_MAX_CONNS (1 to 100) when set to a valid value, else the default for the mode (see above).
func TableExists ¶ added in v0.33.0
TableExists reports whether a table exists in the public schema.
func UpsertVehicleFromSync ¶ added in v0.36.3
UpsertVehicleFromSync creates or refreshes the vehicles row for one vehicle the user owns. Existing values are kept when the sync has none (COALESCE), including trim and image_url, which the upsert used to drop. With a fence token in ctx (the monitor's vehicle-info hook) the write carries the group lease and a refused one is fence.ErrFenced; connect and /api/vehicles call it without one, as before.
func UserExistsByUsername ¶ added in v0.21.0
UserExistsByUsername reports whether a row already exists for the given identity key (post-normalisation). Cap-guard reads this to exempt returning users from the limit.
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 UsernameExists ¶ added in v0.32.0
UsernameExists reports whether a Rivolt account with this username (case-insensitive) exists.
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 WithDoNotTrack ¶ added in v0.36.15
WithDoNotTrack marks ctx as belonging to a request whose browser sent Do Not Track or Global Privacy Control. Server events recorded under it are dropped, the same promise the browser endpoint keeps.
func WithSignupMethod ¶ added in v0.36.15
WithSignupMethod tags ctx with how the account is being created ("oidc" or "email") for the signup_complete event.
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 AdminSignupSnapshot ¶ added in v0.18.61
type AdminSignupSnapshot struct {
RequestedAt time.Time `json:"requested_at"`
DecidedAt *time.Time `json:"decided_at,omitempty"`
Status string `json:"status"`
Message string `json:"message,omitempty"`
}
AdminSignupSnapshot captures how this user came to exist when the signup_requests waitlist was the entry path. Nil for users created directly via the admin "create user" flow or seed scripts.
type AdminUserDetail ¶ added in v0.18.61
type AdminUserDetail 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"`
OnboardingDone bool `json:"onboarding_completed"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Rivian state (DB-only — runtime in-memory state is per-pod and
// not authoritative; admins should trust what's persisted).
RivianConnected bool `json:"rivian_connected"`
RivianSessionAt *time.Time `json:"rivian_session_at,omitempty"`
NeedsReauth bool `json:"needs_reauth"`
NeedsReauthReason string `json:"needs_reauth_reason,omitempty"`
NeedsReauthAt *time.Time `json:"needs_reauth_at,omitempty"`
// Activity rollups.
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
ActiveSessions int `json:"active_sessions"`
DriveCount int `json:"drive_count"`
DriveMilesTotal float64 `json:"drive_miles_total"`
ChargeCount int `json:"charge_count"`
ChargeKWhTotal float64 `json:"charge_kwh_total"`
SampleCount int64 `json:"sample_count"`
OldestSampleAt *time.Time `json:"oldest_sample_at,omitempty"`
NewestSampleAt *time.Time `json:"newest_sample_at,omitempty"`
ImportCount int `json:"import_count"`
Vehicles []AdminUserVehicle `json:"vehicles"`
SignupRequest *AdminSignupSnapshot `json:"signup_request,omitempty"`
}
AdminUserDetail is the deep-dive bundle the admin drawer renders when the operator drills into a single user. Everything that helps answer "is this user healthy / why aren't they seeing data?" in one round-trip: identity, Rivian session age, per-vehicle telemetry freshness, lifetime totals, signup source, active session count.
func GetUserDetailForAdmin ¶ added in v0.18.61
GetUserDetailForAdmin assembles the per-user bundle as several short queries against the same connection. Multi-statement single query would be marginally faster but harder to reason about; with <100 users and an indexed (user_id) on every dependent table the fan-out cost is invisible.
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"`
// NeedsReauth surfaces stuck Rivian sessions in the user table
// itself — these users' drives have stopped recording and they
// won't see the in-app banner unless they log in.
NeedsReauth bool `json:"needs_reauth"`
NeedsReauthAt *time.Time `json:"needs_reauth_at,omitempty"`
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
// LastDataAt is the newest vehicle_state row across the user's vehicles
// (null when there is none): "is data still arriving".
LastDataAt *time.Time `json:"last_data_at"`
// Leased: one of the user's vehicles holds an unexpired subscription
// lease, i.e. a pod is streaming it (not the finance lease).
Leased bool `json:"leased"`
// ReceiptsOptIn: the user opted in to reading Rivian charging receipts.
ReceiptsOptIn bool `json:"receipts_opt_in"`
// PurgeWarnedAt: when the inactive-account warning went out (null: not warned).
PurgeWarnedAt *time.Time `json:"purge_warned_at"`
// PurgeAt is when the inactive-account purge will delete the account,
// as the job itself would schedule it: the later of signup+120 days and
// warning+30 days, where an unsent warning is taken as going out at the
// next run (signup+90 days, or now if that has passed). Null when the
// account is out of the job's scope (admin, disabled, connected Rivian).
// An account with no verified address has no warning and is due signup+120 days. It does not say whether the flag is on: see purge_enabled.
PurgeAt *time.Time `json:"purge_at"`
}
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 AdminUserVehicle ¶ added in v0.18.61
type AdminUserVehicle 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"`
DriveCount int `json:"drive_count"`
ChargeCount int `json:"charge_count"`
LastSampleAt *time.Time `json:"last_sample_at,omitempty"`
}
AdminUserVehicle is one vehicle row enriched with the bits the admin drawer wants — telemetry recency and per-vehicle counts.
type AppleRevokeItem ¶ added in v0.39.3
type AppleRevokeItem struct {
ID uuid.UUID
UserID uuid.UUID // the deleted account (the seal's associated data)
Sealed []byte
Attempts int
}
AppleRevokeItem is a queued Apple revocation.
func ClaimDueAppleRevokes ¶ added in v0.39.3
ClaimDueAppleRevokes returns up to limit queued Apple revocations whose backoff has elapsed (the same schedule as ClaimDueIdPDeleteFailures) and stamps them as tried now, so another replica skips them.
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 IdPDeleteFailure ¶ added in v0.32.0
type IdPDeleteFailure struct {
ID uuid.UUID `json:"id"`
Username string `json:"username"`
Error string `json:"error"`
Attempts int `json:"attempts"`
FailedAt time.Time `json:"failed_at"`
LastTried time.Time `json:"last_tried"`
// AccountExists is true when a Rivolt account with this username
// exists again (the person signed back in through the IdP identity
// we failed to delete). Retrying would lock them out, so the retry
// endpoint refuses it.
AccountExists bool `json:"account_exists"`
}
IdPDeleteFailure is a pending identity-provider deletion.
func ClaimDueIdPDeleteFailures ¶ added in v0.32.0
func ClaimDueIdPDeleteFailures(ctx context.Context, d *sql.DB, limit int) ([]IdPDeleteFailure, error)
ClaimDueIdPDeleteFailures returns up to limit pending failures whose backoff has elapsed (5 min × 2^(attempts-1), capped at 24 h; the exponent is capped at 9 first, so a row failing daily for months can't overflow the interval and break the query for every row) and stamps them as tried now, so another replica running the same loop skips them (FOR UPDATE SKIP LOCKED).
func ListIdPDeleteFailures ¶ added in v0.32.0
ListIdPDeleteFailures returns pending IdP deletions, oldest first.
type InactiveWarning ¶ added in v0.36.8
type InactiveWarning struct {
UserID uuid.UUID
Email string
// DeleteOn is the date the email promises: warning time plus the grace.
DeleteOn time.Time
}
InactiveWarning is an account due its warning email.
func ListInactiveWarnDue ¶ added in v0.36.8
ListInactiveWarnDue returns accounts whose clock is at least InactiveWarnAfter old, with a deliverable email and no sent warning, oldest first, at most limit. Candidates only: ClaimInactiveWarning each.
type OnboardingEmails ¶ added in v0.36.15
OnboardingEmails is what we hold about the welcome and setup emails for one user (the data export includes it).
func OnboardingEmailsFor ¶ added in v0.36.15
func OnboardingEmailsFor(ctx context.Context, d *sql.DB, userID uuid.UUID) (OnboardingEmails, error)
OnboardingEmailsFor reads it; the zero value for an unknown user.
type R2ArrivalDue ¶ added in v0.40.14
type R2ArrivalDue struct {
UserID uuid.UUID
Email string
// Month is the chosen month, the first of it, in UTC.
Month time.Time
}
R2ArrivalDue is a user who asked for the R2 arrival-month email and may be due it.
func ListR2ArrivalDue ¶ added in v0.40.14
ListR2ArrivalDue returns the users whose chosen month is in [from, to] (first-of-month dates) and who are eligible. Candidate list only: the job picks the moment in the user's time zone and Engage claims each send.
type ReauthNudge ¶ added in v0.25.0
ReauthNudge is a user still stuck needing Rivian re-auth who is a candidate for a (re-)notification email.
func ListUsersDueForReauthNudge ¶ added in v0.25.0
func ListUsersDueForReauthNudge(ctx context.Context, d *sql.DB, cooldown time.Duration) ([]ReauthNudge, error)
ListUsersDueForReauthNudge returns users still flagged needs_reauth whose last nudge is either absent (NULL — includes everyone stranded before the email feature shipped) or older than cooldown. This is the candidate list only; the caller must ClaimReauthNudge each one before sending, which is what makes the sweep safe to run on every replica. The partial index users_needs_reauth_idx keeps this scan proportional to the flagged set, not the whole users table.
type RequestPool ¶ added in v0.38.28
RequestPool is the request pool: DB is what stores take; Close closes it and the pgxpool under it (which closing the *sql.DB alone doesn't).
func OpenRequestPool ¶ added in v0.38.28
func OpenRequestPool(ctx context.Context, dsn string, maxConns int32, userFrom func(context.Context) (uuid.UUID, bool), opts ...RequestPoolOption) (*RequestPool, error)
OpenRequestPool opens the request pool at dsn (the request role's credentials) and checks the role: it returns ErrRequestRoleBypassesRLS (wrapped) if the role owns the tables, is a superuser or has BYPASSRLS. maxConns <= 0 means DefaultRequestMaxConns.
type RequestPoolOption ¶ added in v0.39.7
type RequestPoolOption func(*requestPoolOptions)
RequestPoolOption configures OpenRequestPool.
func WithNoUserCounter ¶ added in v0.39.7
func WithNoUserCounter(fn func(context.Context)) RequestPoolOption
WithNoUserCounter calls fn each time the pool hands out a connection for a context with no user. fn must be cheap and must not query the pool. The server passes the metrics counter, labelled by the route pattern of the request (never its path).
type SchemaState ¶ added in v0.39.7
type SchemaState struct {
Pending []string // shipped here, not applied there (oldest first)
Unknown []string // applied there, not shipped here: the database is ahead (a rolled-back binary)
}
SchemaState is what a database has against what this binary ships.
func CheckSchema ¶ added in v0.39.7
CheckSchema reads, and changes nothing. A database with no migrations table has everything pending.
type SetupNudge ¶ added in v0.25.0
SetupNudge is a user due a setup reminder. Attempt is which reminder this will be (1 or 2) so the sender can vary the copy.
func ListUsersDueForSetupNudge ¶ added in v0.25.0
func ListUsersDueForSetupNudge(ctx context.Context, d *sql.DB, minAge, followUpAfter time.Duration) ([]SetupNudge, error)
ListUsersDueForSetupNudge returns users who still have no data (no vehicle, or a vehicle that has produced no state, drive or charge), are emailable, and are due their next setup reminder:
- the first once the account is at least minAge old, and
- the follow-up once followUpAfter has passed since the first.
Onboarding state is deliberately not a filter: many users who stall never finish onboarding either. Candidate list only; the caller must ClaimSetupNudge each one so the sweep is safe on every replica.
type StripeCancelItem ¶ added in v0.40.16
StripeCancelItem is a queued Stripe cancellation: the customer whose subscriptions must be cancelled.
func ClaimDueStripeCancels ¶ added in v0.40.16
ClaimDueStripeCancels returns up to limit queued cancellations whose backoff has elapsed (5 min doubling per attempt, capped at ONE HOUR: a subscription still billing a deleted person is not something to wait a day on) and stamps them as tried now, so another replica skips them.
type Terms ¶ added in v0.36.10
type Terms struct {
Version *string
At *time.Time
CreatedAt time.Time // when the account was created
}
Terms is what a user accepted: Version is nil when never.
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"`
// NativeNACS overrides the model-year heuristic for whether the
// car has a native NACS port. nil = auto (model_year >= 2026 →
// native), true = native, false = CCS (needs Tesla adapter).
NativeNACS *bool `json:"native_nacs,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-000000000") 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.
type VehicleSync ¶ added in v0.36.3
type VehicleSync struct {
RivianID string
VIN string
Name string
Model string
ModelYear int
// Trim is Rivian's trimOption.optionId (e.g. "PKG-ADV", "LRG-DM-PRFM"):
// InferPackKWh and the pack-health "documented" figure key on it.
Trim string
// PackKWh is the InferPackKWh guess; it only seeds a null pack_kwh,
// because the vehicle-reported capacity may already be stored.
PackKWh float64
ImageURL string
}
VehicleSync is what a Rivian sync knows about one vehicle. Zero values mean "not known": they never overwrite a stored value.
type Welcome ¶ added in v0.36.15
type Welcome struct {
UserID uuid.UUID
Email string
// HasApp: the account has a live iPhone-app session, so the welcome leaves the app link out.
HasApp bool
}
Welcome is a user due the welcome email.
func ListUsersDueForWelcome ¶ added in v0.36.15
ListUsersDueForWelcome returns users due the welcome email: at least WelcomeDelay old, never welcomed, emailable, and not yet connected (a user who connected within the delay skips it). Candidate list only; the caller must ClaimWelcome each one.