Help us shape the future of Go! Take the 2026 Go Developer Survey

db

package
v0.40.22 Latest Latest
Warning

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

Go to latest
Published: Nov 2, 2026 License: MIT Imports: 21 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

View Source
const (
	AppleRevokeMaxAge      = 30 * 24 * time.Hour
	AppleRevokeMaxAttempts = 20
)

AppleRevokeMaxAge and AppleRevokeMaxAttempts bound how long a queued revoke (and its sealed token) is kept.

View Source
const (
	DefaultSystemMaxConnsRequestPoolOff = 20
	DefaultSystemMaxConnsRequestPoolOn  = 10
)
View Source
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.

View Source
const (
	OTAAvailable = "available"
	OTAInstalled = "installed"
)

OTA notice kinds.

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

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

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

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

View Source
const DemoUsername = "rivolt-public-demo"

DemoUsername is the public demo account's username (internal/demo). Server events are never recorded for it.

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

View Source
const OTAUnavailableBackoff = 7 * 24 * time.Hour

OTAUnavailableBackoff is how long a vehicle Rivian won't give OTA details for (FORBIDDEN or unsupported, e.g. some R2s) is left alone.

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

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

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

View Source
var ErrIdPFailureNotFound = errors.New("idp delete failure not found")

ErrIdPFailureNotFound is returned for an unknown failure id.

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

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

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

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.

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

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

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

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

func AppleRevokeFailedAgain(ctx context.Context, d *sql.DB, id uuid.UUID, errMsg string) error

AppleRevokeFailedAgain counts another failed attempt for a queued revocation.

func CancelAppleRevoke added in v0.39.3

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

CancelAppleRevoke drops the queued revocation for an account that turned out not to be deleted.

func CancelInactiveWarnings added in v0.36.8

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

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

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

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

func ClaimSetupNudge(ctx context.Context, d *sql.DB, userID uuid.UUID, attempt int) (bool, error)

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

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

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

func ClearEndedR2ArrivalMonths(ctx context.Context, d *sql.DB, now time.Time) (int64, error)

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

func ClearIdPDeleteFailure(ctx context.Context, d *sql.DB, username string) error

ClearIdPDeleteFailure drops any pending failure for username (an IdP deletion for it just succeeded).

func ClearR2ArrivalMonth added in v0.40.14

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

ClearR2ArrivalMonth removes the user's chosen month (the email's one-click link, and Settings' Delete). Idempotent.

func ClearR2ArrivalMonthFor added in v0.40.14

func ClearR2ArrivalMonthFor(ctx context.Context, d *sql.DB, uid uuid.UUID, month time.Time) error

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

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

func CountUnwarnableDue(ctx context.Context, d *sql.DB) (n int, err error)

CountUnwarnableDue counts, for the dry run, the unwarnable accounts due deletion. A count only.

func CountUsers added in v0.21.0

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

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

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.

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

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

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

func DeleteUserArchiveBefore(ctx context.Context, d *sql.DB, uid uuid.UUID, cutoff time.Time) error

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

func DeleteUserHistoryBefore(ctx context.Context, d *sql.DB, uid uuid.UUID, cutoff time.Time) error

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

func DoNotTrack(ctx context.Context) bool

DoNotTrack reports whether ctx carries the opt-out.

func EngagementEmailsOff added in v0.36.15

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

EngagementEmailsOff reports whether the user turned the welcome and setup emails off.

func EnsureAppGrants added in v0.38.28

func EnsureAppGrants(ctx context.Context, d *sql.DB, role string, logger *slog.Logger)

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

func EnsureReadonlyGrants(ctx context.Context, d *sql.DB, role string, logger *slog.Logger)

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

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

func ExpireAppleRevokes(ctx context.Context, d *sql.DB) (int64, error)

ExpireAppleRevokes drops queued revocations (and their sealed tokens) older than AppleRevokeMaxAge or past AppleRevokeMaxAttempts, and returns how many went.

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 GrantApp added in v0.38.28

func GrantApp(ctx context.Context, d *sql.DB, role string) (bool, error)

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

func GrantReadonly(ctx context.Context, d *sql.DB, role string) (bool, error)

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

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

IdPDeleteFailureUsername returns the username of a pending failure.

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 LatestApplied added in v0.33.0

func LatestApplied(ctx context.Context, d *sql.DB) (string, error)

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

func LatestEmbedded() (string, error)

LatestEmbedded is the id of the newest migration this binary ships.

func ListInactiveDeleteDue added in v0.36.8

func ListInactiveDeleteDue(ctx context.Context, d *sql.DB, limit int) ([]uuid.UUID, error)

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

func ListSubscribableVehicleIDs(ctx context.Context, d *sql.DB) ([]string, error)

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

func ListUnwarnableDeleteDue(ctx context.Context, d *sql.DB, limit int) ([]uuid.UUID, error)

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

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

ListUserRivianVehicleIDs returns the user's rivian_vehicle_id values.

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

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

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

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 MarkInactiveWarned added in v0.36.8

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

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

func MarkStripeCustomerClosed(ctx context.Context, d *sql.DB, customer string) error

MarkStripeCustomerClosed remembers that the customer belonged to a deleted account (so its leftover events are acknowledged).

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 MigrateOnStart added in v0.39.7

func MigrateOnStart(getenv func(string) string) bool

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

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 OpenNoMigrate added in v0.33.0

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

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

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 PrepareRequestRole added in v0.39.7

func PrepareRequestRole(ctx context.Context, owner *sql.DB, role string) error

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

func PurgeClosedStripeCustomers(ctx context.Context, d *sql.DB) (int64, error)

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

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

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

func RequestPoolMaxConns(getenv func(string) string) int32

RequestPoolMaxConns reads RIVOLT_REQUEST_DB_MAX_CONNS (1 to 100); anything else is DefaultRequestMaxConns.

func RequireSchema added in v0.39.7

func RequireSchema(ctx context.Context, d *sql.DB, wait time.Duration, log *slog.Logger) error

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

func RequireTables(ctx context.Context, d *sql.DB, tables ...string) error

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

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

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

func ResolveIdPDeleteFailure(ctx context.Context, d *sql.DB, id uuid.UUID) error

ResolveIdPDeleteFailure removes a failure once the IdP deletion has succeeded (nothing about the person is kept).

func RivianExpiryEmailsOff added in v0.40.8

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

RivianExpiryEmailsOff reports whether the user stopped the Rivian session expiry reminders.

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

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 SetEngagementEmailsOff added in v0.36.15

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

SetEngagementEmailsOff turns the welcome and setup emails off or on. Idempotent: turning off again keeps the original time.

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 SetRivianExpiryEmailsOff added in v0.40.8

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

SetRivianExpiryEmailsOff stops or resumes the Rivian session expiry reminders. Idempotent: stopping again keeps the original time.

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

func StripeCancelFailedAgain(ctx context.Context, d *sql.DB, id uuid.UUID, errMsg string) error

StripeCancelFailedAgain counts another failed attempt for a queued cancellation.

func SystemPoolSize added in v0.40.8

func SystemPoolSize(getenv func(string) string) (n int, reason string)

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

func TableExists(ctx context.Context, d *sql.DB, name string) (bool, error)

TableExists reports whether a table exists in the public schema.

func UpsertVehicleFromSync added in v0.36.3

func UpsertVehicleFromSync(ctx context.Context, d *sql.DB, userID uuid.UUID, v VehicleSync) error

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

func UserExistsByUsername(ctx context.Context, d *sql.DB, username string) (bool, error)

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

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 UsernameExists added in v0.32.0

func UsernameExists(ctx context.Context, d *sql.DB, username string) (bool, error)

UsernameExists reports whether a Rivolt account with this username (case-insensitive) exists.

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 WithDoNotTrack added in v0.36.15

func WithDoNotTrack(ctx context.Context) context.Context

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

func WithSignupMethod(ctx context.Context, method string) context.Context

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

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

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

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

func ClaimDueAppleRevokes(ctx context.Context, d *sql.DB, limit int) ([]AppleRevokeItem, error)

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

func ListIdPDeleteFailures(ctx context.Context, d *sql.DB) ([]IdPDeleteFailure, error)

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

func ListInactiveWarnDue(ctx context.Context, d *sql.DB, limit int) ([]InactiveWarning, error)

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

type OnboardingEmails struct {
	WelcomeSentAt *time.Time
	NudgesSent    int
	OffAt         *time.Time
}

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

func ListR2ArrivalDue(ctx context.Context, d *sql.DB, from, to time.Time) ([]R2ArrivalDue, error)

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

type ReauthNudge struct {
	UserID uuid.UUID
	Reason string
}

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

type RequestPool struct {
	DB *sql.DB
	// contains filtered or unexported fields
}

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.

func (*RequestPool) Close added in v0.38.28

func (p *RequestPool) Close()

Close closes the pool.

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

func CheckSchema(ctx context.Context, d *sql.DB) (SchemaState, error)

CheckSchema reads, and changes nothing. A database with no migrations table has everything pending.

type SetupNudge added in v0.25.0

type SetupNudge struct {
	UserID  uuid.UUID
	Email   string
	Attempt int
	Kind    string
}

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

type StripeCancelItem struct {
	ID       uuid.UUID
	Customer string
	Attempts int
}

StripeCancelItem is a queued Stripe cancellation: the customer whose subscriptions must be cancelled.

func ClaimDueStripeCancels added in v0.40.16

func ClaimDueStripeCancels(ctx context.Context, d *sql.DB, limit int) ([]StripeCancelItem, error)

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.

func TermsFor added in v0.36.10

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

TermsFor returns the user's recorded acceptance (zero value when none or the user is unknown).

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"`
	// 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

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.

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

func ListUsersDueForWelcome(ctx context.Context, d *sql.DB) ([]Welcome, error)

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.

Jump to

Keyboard shortcuts

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