← repoman internals

internal/db

import "git.griefed.de/griefed/repoman/internal/db"
package db // import "git.griefed.de/griefed/repoman/internal/db"

Package db provides repoman's persistence layer:

  - Open a SQLite database with project-wide PRAGMA settings.
  - Apply embedded SQL migrations on startup.
  - Concrete implementations of the repository interfaces declared in the auth,
    audit, and migrationconfig packages.

Implementation choice: hand-written queries against database/sql. We
deliberately do NOT use an ORM. The schema is small enough that typed queries by
hand are clearer than generated code, and they keep the build process simple (no
codegen step).

Concurrency: SQLite serialises writes per database. We rely on WAL mode (set via
PRAGMA below) for reasonable read concurrency during writes. The application
layer ensures only one migration-run writes at a time via the run-queue.

VARIABLES

var ErrEmailTaken = errors.New("a user with that email already exists")
    ErrEmailTaken reports a profile update whose email collides with another
    user's (the partial-unique email index). Surfaced to the user as a friendly
    "email already in use" message.

var ErrThemeNameTaken = errors.New("a theme with that name already exists")
    ErrThemeNameTaken reports a create/update that collides with an existing
    theme's name (themes.name is UNIQUE COLLATE NOCASE). Handlers match it with
    errors.Is to show a friendly message instead of a raw SQL error.


FUNCTIONS

func Open(path string) (*sql.DB, error)
    Open creates or opens the SQLite database at the given path and applies
    any pending migrations. The returned *sql.DB has WAL mode and foreign-key
    enforcement enabled.

    path is the filesystem path to the SQLite file; the parent directory must
    already exist (callers typically create it during config-file bootstrap).
    Use ":memory:" for an in-memory DB during tests.

    DSN parameters (modernc.org/sqlite syntax — each PRAGMA is applied to
    every pooled connection at open time via a _pragma=name(value) query param;
    foreign_keys and busy_timeout are per-connection, so DSN-level is the
    correct place for them):
      - foreign_keys(1) — enforce ON DELETE clauses
      - journal_mode(WAL) — concurrent reads during writes
      - busy_timeout(5000) — wait up to 5s before SQLITE_BUSY
      - synchronous(NORMAL) — durability/perf compromise OK with WAL

    The mattn/go-sqlite3 equivalent is
    `?_foreign_keys=on&_journal_mode=WAL&...`.


TYPES

type BackupPreview struct {
	Users      int // total user accounts
	Admins     int // accounts with the admin role
	Configs    int // migration configs
	Migrations int // applied schema migrations (a rough schema-version marker)
}
    BackupPreview is the at-a-glance summary of an uploaded backup, shown before
    a restore is confirmed so an admin can identify the backup by its contents
    (not just its file name and timestamp).

func InspectBackup(path string) (*BackupPreview, error)
    InspectBackup opens path read-only and returns a BackupPreview, verifying
    the file is actually a repoman database first. It never mutates the file
    (it opens query-only and applies no migrations), so it is safe to call on
    an uploaded file before deciding whether to restore it. A file that is not a
    readable SQLite database, or lacks repoman's core tables, is rejected with a
    clear error.

type ChangeHistoryEntry struct {
	ID                int64
	SnapshotJSON      string
	Comment           string
	CreatedAt         time.Time
	CreatedByUserID   *int64
	CreatedByUsername string
}
    ChangeHistoryEntry is one recorded change to an entity: a redacted
    full-state snapshot taken after the change, with who/when/note. The history
    view diffs an entry against its predecessor to show what changed.

type ChangeHistoryRepo struct {
	// Has unexported fields.
}
    ChangeHistoryRepo is the SQLite store for the generic change_history log.
    It holds no secrets (the recorder builds redacted snapshots), so it takes no
    encryptor.

func NewChangeHistoryRepo(db *sql.DB) *ChangeHistoryRepo
    NewChangeHistoryRepo constructs a ChangeHistoryRepo over the given
    connection.

func (r *ChangeHistoryRepo) List(
	ctx context.Context, entityType string, entityID int64, limit int,
) ([]*ChangeHistoryEntry, error)
    List returns an entity's history newest-first, capped at limit (defaulting
    to 50 when limit <= 0 so a forgotten parameter can't load everything).

func (r *ChangeHistoryRepo) Record(
	ctx context.Context,
	entityType string, entityID int64,
	snapshotJSON, comment string,
	userID *int64, username string,
) error
    Record appends a snapshot for (entityType, entityID). userID may be nil
    for non-interactive changes; username is the actor's display name for the
    history.

type CommentLink struct {
	ID               int64
	IssueLinkID      int64
	ForgejoCommentID int64
	SourcePlatform   string
	SourceCommentID  string

	ForgejoBaselineHash string
	SourceBaselineHash  string

	CreatedAt time.Time
	UpdatedAt time.Time
}
    CommentLink maps one Forgejo comment to its counterpart on a source
    platform, under an issue link. Baselines mirror IssueLink's.

type ConfigTxRunner struct {
	// Has unexported fields.
}
    ConfigTxRunner runs migration-config writes — the config row itself plus
    any ownership reassignments the operator's Decisions imply — inside a single
    SQLite transaction.

    This closes Bug #2: Service.Create/Update previously persisted the config
    and *then* called AssignOwnership in a separate statement, so a failure
    between the two left a config row with ownership only half-applied. With
    this runner either both land or neither does.

    Implements migrationconfig.TxRunner.

func NewConfigTxRunner(db *sql.DB, enc crypto.Encryptor) *ConfigTxRunner
    NewConfigTxRunner constructs a ConfigTxRunner over the given pool. enc is
    needed because the tx-bound config repo encrypts token overrides exactly as
    the standalone repo does.

func (t *ConfigTxRunner) RunInTx(ctx context.Context, fn func(migrationconfig.Tx) error) error
    RunInTx begins a transaction, hands fn a migrationconfig.Tx whose Configs
    and Owners repos both write through that transaction, and commits on success
    or rolls back on any error.

type GlobalConfigRepo struct {
	// Has unexported fields.
}
    GlobalConfigRepo is the SQLite implementation for global_configurations.
    It encrypts ContentJSON at write time and decrypts at read time so the rest
    of the application always works with plaintext credentials.

func NewGlobalConfigRepo(db *sql.DB, enc crypto.Encryptor) *GlobalConfigRepo
    NewGlobalConfigRepo constructs a GlobalConfigRepo. enc must not be nil.

func (r *GlobalConfigRepo) Active(ctx context.Context) (*globalconfig.Snapshot, error)
    Active returns the most recently saved global configuration snapshot,
    or (nil, nil) when no configuration has been stored yet.

func (r *GlobalConfigRepo) GetByID(ctx context.Context, id int64) (*globalconfig.Snapshot, error)
    GetByID returns the configuration snapshot with the given ID.

func (r *GlobalConfigRepo) List(ctx context.Context, limit int) ([]*globalconfig.Snapshot, error)
    List returns all configuration snapshots, newest first.

func (r *GlobalConfigRepo) Save(
	ctx context.Context,
	contentJSON, comment string,
	userID *int64,
	username string,
	at time.Time,
) (int64, error)
    Save encrypts ContentJSON and inserts a new versioned snapshot. Every save
    creates a new row; previous snapshots are retained as history. Returns the
    ID of the new row.

type InventoryRow struct {
	RepoID           int64
	ForgejoOwner     string
	ForgejoName      string
	SourcePlatform   string
	SourceNamespace  string
	OwningConfigID   *int64
	OwningConfigName string
	LastSyncAt       *time.Time
	LastSyncStatus   string
}
    InventoryRow is the projection used by the inventory page. It joins
    repositories with migration_configs and the most recent run per config to
    show source, target, owner, and sync status.

type IssueLink struct {
	ID              int64
	ForgejoOwner    string
	ForgejoRepo     string
	ForgejoIndex    int64
	SourcePlatform  string
	SourceNamespace string
	SourceRepo      string
	SourceNumber    int64

	ForgejoBaselineHash string
	SourceBaselineHash  string
	ForgejoUpdatedAt    time.Time // zero when never synced
	SourceUpdatedAt     time.Time

	CreatedAt time.Time
	UpdatedAt time.Time
}
    IssueLink maps a Forgejo issue (the canonical hub) to the same issue on a
    source platform. The baseline hashes are the content last propagated to
    BOTH sides; the sync engine compares each side's current content hash to
    its baseline to decide which side changed (a 3-way merge) and to break echo
    loops. See docs/issue-hub.md.

type IssueSyncRepo struct {
	// Has unexported fields.
}
    IssueSyncRepo is the SQLite store for the issue-hub mapping (issue_links
    + comment_links). The mapping carries no secrets, so — like ThemeRepo — it
    takes no encryptor.

func NewIssueSyncRepo(db *sql.DB) *IssueSyncRepo
    NewIssueSyncRepo constructs an IssueSyncRepo over the given connection.

func (r *IssueSyncRepo) CommentLinkByForgejo(ctx context.Context, issueLinkID int64,
	platform string, forgejoCommentID int64) (*CommentLink, error)
    CommentLinkByForgejo resolves a comment mapping from a Forgejo comment id
    (the key a Forgejo webhook carries). Returns (nil, nil) when none exists.

func (r *IssueSyncRepo) CommentLinkBySource(ctx context.Context, issueLinkID int64,
	platform, sourceCommentID string) (*CommentLink, error)
    CommentLinkBySource resolves a comment mapping from a source comment id (the
    key a source webhook carries). Returns (nil, nil) when none exists.

func (r *IssueSyncRepo) CreateCommentLink(ctx context.Context, link *CommentLink) (int64, error)
    CreateCommentLink inserts a new comment mapping and returns its ID.

func (r *IssueSyncRepo) CreateIssueLink(ctx context.Context, link *IssueLink) (int64, error)
    CreateIssueLink inserts a new issue mapping and returns its ID. created_at
    and updated_at are set to now; the caller supplies the identity + any
    baselines.

func (r *IssueSyncRepo) IssueLinkByForgejo(ctx context.Context, owner, repo string, index int64,
	platform, namespace, sourceRepo string) (*IssueLink, error)
    IssueLinkByForgejo resolves the link for a Forgejo issue and a specific
    source repo (the full natural key). Returns (nil, nil) when none exists,
    so callers can use it for idempotent import.

func (r *IssueSyncRepo) IssueLinkBySource(ctx context.Context, platform, namespace, sourceRepo string,
	number int64) (*IssueLink, error)
    IssueLinkBySource resolves the link for a source issue (the key a
    source-side webhook carries). Returns (nil, nil) when none exists.

func (r *IssueSyncRepo) ListAllIssueLinks(ctx context.Context) ([]*IssueLink, error)
    ListAllIssueLinks returns every issue link across all repos — the full set
    the reconcile poll sweeps, ordered so links sharing a Forgejo repo (and a
    source repo) are adjacent, which lets the reconciler batch its list calls.

func (r *IssueSyncRepo) ListCommentLinks(ctx context.Context, issueLinkID int64) ([]*CommentLink, error)
    ListCommentLinks returns every comment mapping under an issue link.

func (r *IssueSyncRepo) ListIssueLinksForForgejoIssue(ctx context.Context, owner, repo string, index int64) ([]*IssueLink, error)
    ListIssueLinksForForgejoIssue returns every link for one Forgejo issue — one
    per source the issue is mirrored to. A Forgejo-side webhook resolves this
    set and propagates the edit to each linked source.

func (r *IssueSyncRepo) ListIssueLinksForRepo(ctx context.Context, owner, repo string) ([]*IssueLink, error)
    ListIssueLinksForRepo returns every link for a Forgejo repo — the set the
    reconcile poll walks.

func (r *IssueSyncRepo) UpdateCommentBaselines(ctx context.Context, id int64,
	forgejoHash, sourceHash string) error
    UpdateCommentBaselines records the content hashes last propagated to both
    sides for a comment. Returns sql.ErrNoRows when no link has the given ID.

func (r *IssueSyncRepo) UpdateIssueBaselines(ctx context.Context, id int64,
	forgejoHash, sourceHash string, forgejoUpdated, sourceUpdated time.Time) error
    UpdateIssueBaselines records the content hashes + updated-at watermarks last
    propagated to both sides, after a successful sync. Returns sql.ErrNoRows
    when no link has the given ID.

type MigrationConfigRepo struct {
	// Has unexported fields.
}
    MigrationConfigRepo is the SQLite implementation of
    migrationconfig.Repository AND migrationconfig.ConfigReader.

    exec is the executor every statement runs through; db is the pool used
    only to open a transaction in standalone Update. When the repo is tx-bound
    (see newMigrationConfigRepoTx) db is nil and exec is the caller's *sql.Tx,
    so Update shares that transaction instead of opening a nested one.

func NewMigrationConfigRepo(db *sql.DB, enc crypto.Encryptor) *MigrationConfigRepo
    NewMigrationConfigRepo constructs a MigrationConfigRepo over the
    given database, using enc to encrypt/decrypt the per-source tokens in
    sources_json.

func (r *MigrationConfigRepo) AssignOwner(ctx context.Context, configID, ownerUserID int64) error
    AssignOwner claims a config for ownerUserID: it sets owner_user_id and,
    only when the original creator is unknown (created_by_user_id NULL — the
    legacy/CLI-created case), backfills created_by_user_id too via COALESCE so
    it never overwrites a known creator (history stays truthful). This is the
    claim mechanism for an orphaned config; token resolution and management
    access then key on the owner through MigrationConfig.EffectiveOwnerUserID.
    Returns ErrNotFound when no row matches.

func (r *MigrationConfigRepo) Create(ctx context.Context, config *migrationconfig.MigrationConfig) (int64, error)
    Create inserts a new migration config (encrypting its source tokens) and
    returns the generated id, defaulting unset created/updated timestamps to
    now.

func (r *MigrationConfigRepo) Delete(ctx context.Context, id int64) error
    Delete removes the config with the given id, returning
    migrationconfig.ErrNotFound if no row matched.

func (r *MigrationConfigRepo) GetByID(ctx context.Context, id int64) (*migrationconfig.MigrationConfig, error)
    GetByID returns the config with the given id, or
    migrationconfig.ErrNotFound.

func (r *MigrationConfigRepo) GetByName(ctx context.Context, name string) (*migrationconfig.MigrationConfig, error)
    GetByName returns the config with the given name, or
    migrationconfig.ErrNotFound.

func (r *MigrationConfigRepo) List(ctx context.Context) ([]*migrationconfig.MigrationConfig, error)
    List returns all migration configs ordered by name.

func (r *MigrationConfigRepo) MarkConfirmed(ctx context.Context, id int64, at time.Time) error
    MarkConfirmed stamps the namespace-confirmation time, recording that the
    user reviewed and confirmed the config's target namespace.

func (r *MigrationConfigRepo) Update(
	ctx context.Context,
	config *migrationconfig.MigrationConfig,
	comment string,
	modifierUserID *int64,
) error
    Update replaces a config and writes a snapshot of the PRE-update state to
    migration_config_versions.

    Both writes share a single transaction so a failure mid-way leaves the
    DB consistent (either the version is recorded AND the row is updated,
    or neither).

func (r *MigrationConfigRepo) UpdateScheduleTimestamps(
	ctx context.Context, id int64, lastRun, nextRun *time.Time,
) error
    UpdateScheduleTimestamps records the config's last-run and next-run times
    (either may be nil to clear), keeping the dashboard's schedule display
    current.

type MigrationConfigVersionRepo struct {
	// Has unexported fields.
}
    MigrationConfigVersionRepo is the SQLite implementation of
    migrationconfig.VersionRepository.

func NewMigrationConfigVersionRepo(db *sql.DB) *MigrationConfigVersionRepo
    NewMigrationConfigVersionRepo constructs the version repo over the pool.

func (r *MigrationConfigVersionRepo) Create(
	ctx context.Context, configID int64,
	snapshotJSON, comment string, userID *int64, at time.Time,
) (int64, error)
    Create stores a token-redacted config snapshot as a new version row and
    returns its id; userID may be nil for non-interactive (e.g. CLI) changes.

func (r *MigrationConfigVersionRepo) GetByID(ctx context.Context, id int64) (*migrationconfig.Version, error)
    GetByID returns the version snapshot with the given id, or
    migrationconfig.ErrNotFound.

func (r *MigrationConfigVersionRepo) ListForConfig(
	ctx context.Context, configID int64, limit int,
) ([]*migrationconfig.Version, error)
    ListForConfig returns a config's version snapshots newest-first, capped at
    limit (defaulting to 100 when limit <= 0 so a forgotten parameter can't load
    everything).

type Notification struct {
	ID        int64
	UserID    int64
	Kind      string
	Title     string
	Body      string
	Link      string
	ReadAt    time.Time
	CreatedAt time.Time
}
    Notification is one in-app notification row. ReadAt is the zero time while
    unread. Link is a relative in-app path the UI deep-links to.

type NotificationRepo struct {
	// Has unexported fields.
}
    NotificationRepo persists per-user in-app notifications. No encryptor:
    a notification carries no secrets.

func NewNotificationRepo(db *sql.DB) *NotificationRepo
    NewNotificationRepo constructs a NotificationRepo.

func (r *NotificationRepo) Create(ctx context.Context, note *Notification) (int64, error)
    Create inserts a notification for a user and returns its id.

func (r *NotificationRepo) ListForUser(ctx context.Context, userID int64, limit int) ([]*Notification, error)
    ListForUser returns a user's notifications, newest first, up to limit.

func (r *NotificationRepo) MarkAllRead(ctx context.Context, userID int64) error
    MarkAllRead marks every unread notification of a user read.

func (r *NotificationRepo) MarkRead(ctx context.Context, userID, id int64) error
    MarkRead marks one of a user's notifications read (scoped to the user
    so a caller can't mark another user's notification). No-op when already
    read/absent.

func (r *NotificationRepo) UnreadCount(ctx context.Context, userID int64) (int, error)
    UnreadCount returns how many unread notifications a user has.

type PasswordResetTokenRepo struct {
	// Has unexported fields.
}
    PasswordResetTokenRepo persists short-lived reset tokens.

func NewPasswordResetTokenRepo(db *sql.DB) *PasswordResetTokenRepo
    NewPasswordResetTokenRepo constructs the repo over the given pool.

func (r *PasswordResetTokenRepo) CountRecentForEmail(ctx context.Context, email string, since time.Time) (int, error)
    CountRecentForEmail returns how many tokens have been issued for the user
    with the given email since `since`. Used to rate-limit reset requests so an
    attacker can't flood a user's inbox.

    The query joins through users since password_reset_tokens does not store
    the email itself (only the user_id). Email comparison is case-sensitive — we
    don't normalise emails because RFC 5321 leaves the local part case-sensitive
    in principle. Real-world deployments rarely care, but defaulting to the
    strict behaviour is safer.

func (r *PasswordResetTokenRepo) Create(ctx context.Context, token *auth.PasswordResetToken) error
    Create inserts a password-reset token row (storing only its hash) and
    back-fills the generated id onto the passed token.

func (r *PasswordResetTokenRepo) GetValidByID(ctx context.Context, id int64, now time.Time) (*auth.PasswordResetToken, error)
    GetValidByID returns the unused, unexpired reset token with the given id,
    or auth.ErrNotFound if it doesn't exist, was already used, or has expired.

func (r *PasswordResetTokenRepo) MarkUsed(ctx context.Context, id int64, at time.Time) error
    MarkUsed stamps the token's used_at so it cannot be redeemed a second time.

type RepoIdentity struct {
	RepoID       int64
	ForgejoOwner string
	ForgejoName  string
}
    RepoIdentity is the minimal repositories projection the repo-download
    feature needs: which Forgejo owner/name a repository ID maps to, so the
    handler can build the clone URL for that one repository.

type RepositoryRepo struct {
	// Has unexported fields.
}
    RepositoryRepo provides read access to the repositories table for the
    conflict analyzer. Kept minimal — only ListByForgejoOwner is needed by the
    analyzer; full repository CRUD comes later when the migration engine writes
    to it.

func NewRepositoryRepo(db *sql.DB) *RepositoryRepo
    NewRepositoryRepo constructs a RepositoryRepo over the given pool.

func (r *RepositoryRepo) AssignOwnership(
	ctx context.Context, repositoryID int64, configID *int64,
) error
    AssignOwnership sets owning_migration_config_id for a repo. Pass nil to
    unassign (orphan the repo). Implements migrationconfig.OwnershipWriter.

func (r *RepositoryRepo) ListByForgejoOwner(
	ctx context.Context, owner string,
) ([]*migrationconfig.RepoSummary, error)
    ListByForgejoOwner returns summaries of repositories synced under the given
    Forgejo owner, ordered by name; used to scope the inventory to a viewer's
    target.

func (r *RepositoryRepo) ListInventory(ctx context.Context) ([]*InventoryRow, error)
    ListInventory returns all known repositories joined with their owning
    config and their most recent outbound sync state. Ordered by forgejo_owner,
    forgejo_name.

    Sync state lives in sync_targets (one row per repo+target), not on
    repositories, so the last-sync timestamp/status come from that repo's
    newest-by-last_sync_at target (NULL columns when the repo has never synced).

func (r *RepositoryRepo) RepoByID(ctx context.Context, id int64) (*RepoIdentity, error)
    RepoByID returns the Forgejo owner/name for a single repository row. Returns
    sql.ErrNoRows when no repository has that ID, so the caller can answer 404
    for a stale or forged inventory link.

func (r *RepositoryRepo) UpsertRepo(
	ctx context.Context,
	forgejoOwner, forgejoName, forgejoOwnerType string,
	owningConfigID *int64,
	sourcePlatform, sourceNamespace, sourceOwnerType string,
	isPrivate, isArchived bool,
) error
    UpsertRepo inserts or updates a row in the repositories table. The unique
    key is (forgejo_owner, forgejo_name). On conflict it updates the mutable
    fields but leaves first_imported_at unchanged so the original import
    timestamp is preserved.

type RunEventRepo struct {
	// Has unexported fields.
}
    RunEventRepo implements run.EventRepository against run_events.

func NewRunEventRepo(db *sql.DB) *RunEventRepo
    NewRunEventRepo constructs a RunEventRepo.

func (r *RunEventRepo) Append(ctx context.Context, events []run.Event) error
    Append inserts a batch of run events in one transaction; an empty batch is a
    no-op.

func (r *RunEventRepo) ListForRun(ctx context.Context, runID, afterID int64) ([]run.Event, error)
    ListForRun returns a run's events with id greater than afterID,
    oldest-first; afterID lets the SSE stream resume incrementally without
    re-sending earlier lines.

func (r *RunEventRepo) ListForRunPage(ctx context.Context, runID, before int64, limit int) ([]run.Event, bool, error)
    ListForRunPage returns one page of a run's events for the lazy log view:
    the newest `limit` events older than `before` (before<=0 → newest overall),
    oldest-first. It fetches limit+1 newest-first to learn whether still-older
    events remain (hasMore) without a second COUNT query, then drops the extra
    and reverses to ascending. So the monitor loads only a viewport-worth
    at a time and pages older lines in on scroll-up, never shipping a whole
    multi-thousand-line log at once.

func (r *RunEventRepo) RunProgress(ctx context.Context, runID int64) (bool, time.Time, error)
    RunProgress reports a run's resumable-progress facts for the recovery
    loop-guard (see run.EventRepository): whether it logged at least one
    OKLinePrefix line (a freshly migrated repository) and the timestamp of its
    most recent event. Two cheap aggregate queries — no full event load — so it
    stays light at boot.

type RunQueueRepo struct {
	// Has unexported fields.
}
    RunQueueRepo implements run.QueueRepository against run_queue.

func NewRunQueueRepo(db *sql.DB) *RunQueueRepo
    NewRunQueueRepo constructs a RunQueueRepo.

func (r *RunQueueRepo) Enqueue(ctx context.Context, entry *run.QueueEntry, dedup bool) error
    Enqueue appends a queue entry; when dedup is set and the config is already
    queued, it silently no-ops so a config can't be queued twice.

func (r *RunQueueRepo) HasForConfig(ctx context.Context, configID int64) (bool, error)
    Len returns the number of entries currently waiting in the queue.
    HasForConfig reports whether a pending entry exists for one config,
    as a single EXISTS query — the wizard's status poll asks this every few
    seconds per open page, and reading the whole queue to answer it scales with
    the queue.

func (r *RunQueueRepo) Len(ctx context.Context) (int, error)

func (r *RunQueueRepo) List(ctx context.Context) ([]*run.QueueEntry, error)
    List returns every pending queue entry in FIFO order (oldest first) without
    removing any, so the dashboard and run-history views can show queued-but-
    unstarted runs before the worker pops one and creates its run row.

func (r *RunQueueRepo) Pop(ctx context.Context) (*run.QueueEntry, error)
    Pop atomically removes and returns the oldest queue entry (FIFO), or (nil,
    nil) when the queue is empty; the select+delete run in one transaction.

type RunRepo struct {
	// Has unexported fields.
}
    RunRepo implements run.Repository against the runs table.

func NewRunRepo(db *sql.DB) *RunRepo
    NewRunRepo constructs a RunRepo.

func (r *RunRepo) Create(ctx context.Context, runRecord *run.Run) (int64, error)
    Create inserts a new run row and returns its generated id.

func (r *RunRepo) GetByID(ctx context.Context, id int64) (*run.Run, error)
    GetByID returns the run with the given id, or a "not found" error if none
    exists.

func (r *RunRepo) ListForConfig(ctx context.Context, configID int64, limit int) ([]*run.Run, error)
    ListForConfig returns a config's runs newest-first, capped at limit
    (defaulting to 50 when limit <= 0).

func (r *RunRepo) ListRecentAcrossConfigs(ctx context.Context, configIDs []int64, limit int) ([]*run.Run, error)
    ListRecentAcrossConfigs returns the most recent runs across the given
    configs, newest-first, in one ordered query (replacing a per-config
    fan-out + in-memory merge). A limit <= 0 returns every matching run —
    the dashboard's "all" option; an empty configIDs returns nothing without
    querying.

func (r *RunRepo) ListUnfinished(ctx context.Context) ([]*run.Run, error)
    ListUnfinished returns every run still recorded as running, oldest-first.
    After a clean boot these are orphans left by an interrupted previous process
    (the finalizing write never landed), which Worker.RecoverOrphans reconciles.

func (r *RunRepo) UpdateFinished(
	ctx context.Context,
	id int64,
	at time.Time,
	status run.ExitStatus,
	counters run.Counters,
) error
    UpdateFinished records a run's terminal state: finish time, exit status,
    and the per-run summary counters.

func (r *RunRepo) UpdateStatus(ctx context.Context, id int64, status run.ExitStatus) error
    UpdateStatus changes only a run's exit status (e.g. marking it failed or
    cancelled).

type SessionRepo struct {
	// Has unexported fields.
}
    SessionRepo is the SQLite implementation of auth.SessionRepository.

func NewSessionRepo(db *sql.DB) *SessionRepo
    NewSessionRepo constructs a SessionRepo over the given pool.

func (r *SessionRepo) Create(ctx context.Context, session *auth.Session) error
    Create inserts a new session row.

func (r *SessionRepo) Delete(ctx context.Context, id string) error
    Delete removes a single session by id (e.g. on logout).

func (r *SessionRepo) DeleteAllForUser(ctx context.Context, userID int64) error
    DeleteAllForUser removes every session belonging to a user (e.g. on lock or
    password reset).

func (r *SessionRepo) DeleteAllForUserExcept(ctx context.Context, userID int64, exceptID string) error
    DeleteAllForUserExcept removes a user's sessions except the given one, so a
    password change can revoke other devices while keeping the current session.

func (r *SessionRepo) DeleteExpired(ctx context.Context, now time.Time) (int, error)
    DeleteExpired removes all sessions whose expiry has passed and returns the
    count deleted; called periodically by the cleanup goroutine.

func (r *SessionRepo) GetByID(ctx context.Context, id string) (*auth.Session, error)
    GetByID returns the session with the given id, or auth.ErrNotFound if none
    exists.

func (r *SessionRepo) UpdateActivity(ctx context.Context, id string, at time.Time) error
    UpdateActivity records the last genuine user-activity time for a session, as
    RFC3339 via formatTime. Bumped only on navigations + interaction keepalives,
    so background polling/SSE never resets the server-side idle clock.

func (r *SessionRepo) UpdateLastSeen(ctx context.Context, id string, lastSeen, expiresAt time.Time) error
    UpdateLastSeen persists the rolling-window last-seen and (already-capped)
    expiry timestamps for a session, both as RFC3339 via formatTime.

    expiresAt is the already-capped value SessionManager computed; we must
    NOT re-derive it here with SQLite's datetime(), which would (a) return a
    space-separated timestamp parseTime cannot read back and (b) ignore the
    rolling-window setting and absolute cap.

func (r *SessionRepo) UpdatePending2FA(ctx context.Context, id string, pending bool) error
    UpdatePending2FA sets whether the session is still awaiting a second-factor
    challenge.

type ThemeRepo struct {
	// Has unexported fields.
}
    ThemeRepo is the SQLite store for custom UI themes. Values are not secrets
    (they are served to every user as CSS), so unlike the credential repos it
    takes no encryptor.

func NewThemeRepo(db *sql.DB) *ThemeRepo
    NewThemeRepo constructs a ThemeRepo over the given connection.

func (r *ThemeRepo) Accept(ctx context.Context, id int64, finalName string, adminUserID int64) error
    Accept promotes a pending theme to global with the admin-chosen final name,
    clearing its owner (a global theme survives the submitter's deletion)
    while keeping submitted_by for attribution. Returns sql.ErrNoRows when not
    pending.

func (r *ThemeRepo) CreatePersonalTheme(ctx context.Context, ownerUserID int64, name string, vars map[string]string) (int64, error)
    CreatePersonalTheme inserts a personal theme owned by ownerUserID (visible
    only to that user until submitted and accepted). Returns its ID.

func (r *ThemeRepo) CreateTheme(ctx context.Context, name string, vars map[string]string) (int64, error)
    CreateTheme inserts a new GLOBAL custom theme (admin-managed) and returns
    its ID. The caller must have validated name and vars via internal/theme
    first.

func (r *ThemeRepo) DeleteTheme(ctx context.Context, id int64) error
    DeleteTheme removes a custom theme. Returns sql.ErrNoRows when no theme
    has the given ID. Users who had it selected fall back to the default theme
    client-side (the stored selection no longer matches any option).

func (r *ThemeRepo) Deny(ctx context.Context, id int64, adminUserID int64) error
    Deny returns a pending theme to its submitter as a personal theme (they keep
    it privately). Returns sql.ErrNoRows when not pending.

func (r *ThemeRepo) ListPendingThemes(ctx context.Context) ([]theme.Theme, error)
    ListPendingThemes returns every theme awaiting admin evaluation, oldest
    first (the order the admin works the queue).

func (r *ThemeRepo) ListPersonalThemes(ctx context.Context, ownerUserID int64) ([]theme.Theme, error)
    ListPersonalThemes returns a user's own themes (personal + pending), ordered
    by name — visible only to that user in the nav dropdown and Account page.

func (r *ThemeRepo) ListThemes(ctx context.Context) ([]theme.Theme, error)
    ListThemes returns all GLOBAL themes ordered by name — the curated list
    every user can pick and that the global stylesheet (/themes.css) renders.

func (r *ThemeRepo) Submit(ctx context.Context, id, ownerUserID int64, suggestedName string) error
    Submit moves a user's personal theme to pending, recording the suggested
    name. Scoped to the owner so a user can only submit their own. Returns
    sql.ErrNoRows when no matching personal theme exists.

func (r *ThemeRepo) ThemeByID(ctx context.Context, id int64) (*theme.Theme, error)
    ThemeByID returns one theme (any tier). Returns sql.ErrNoRows when missing.

func (r *ThemeRepo) UpdateTheme(ctx context.Context, id int64, name string, vars map[string]string) error
    UpdateTheme replaces an existing theme's name and variables. Returns
    sql.ErrNoRows when no theme has the given ID.

type TokenExpiryRecord struct {
	Scope       string
	Platform    string
	TokenHash   string
	ExpiresAt   time.Time
	Known       bool
	LastChecked time.Time
	LastError   string
	WarnedAt    time.Time
}
    TokenExpiryRecord is one token location's discovered expiry state. Scope is
    the location key (see migration 0005); TokenHash is a SHA-256 of the token
    value (never the token) used to detect rotation. Known is false when the
    platform reports no expiry. WarnedAt is the zero time until an email has
    gone out.

type TokenExpiryRepo struct {
	// Has unexported fields.
}
    TokenExpiryRepo persists token-expiry state for the warning checker.

func NewTokenExpiryRepo(db *sql.DB) *TokenExpiryRepo
    NewTokenExpiryRepo constructs a TokenExpiryRepo. No encryptor: the table
    holds no secrets — only a hash of each token, plus expiry metadata.

func (r *TokenExpiryRepo) ClearWarned(ctx context.Context, scope string) error
    ClearWarned resets a scope's warned state so the next threshold crossing
    re-emails (called when the token is rotated or its expiry changes).

func (r *TokenExpiryRepo) DeleteNotIn(ctx context.Context, keepScopes []string) error
    DeleteNotIn prunes rows whose scope is no longer present (token removed or a
    config/source deleted). An empty keep set deletes everything.

func (r *TokenExpiryRepo) GetByScope(ctx context.Context, scope string) (*TokenExpiryRecord, error)
    GetByScope returns the record for a scope, or (nil, nil) when absent.

func (r *TokenExpiryRepo) ListAll(ctx context.Context) ([]*TokenExpiryRecord, error)
    ListAll returns every tracked token, ordered by platform then scope.

func (r *TokenExpiryRepo) MarkWarned(ctx context.Context, scope string, t time.Time) error
    MarkWarned records that an email went out for a scope at time t.

func (r *TokenExpiryRepo) Upsert(ctx context.Context, rec *TokenExpiryRecord) error
    Upsert writes a probe result for a scope. It deliberately does NOT touch
    warned_at — the warned lifecycle is owned by ClearWarned/MarkWarned — so a
    re-probe that finds the same token+expiry keeps the "already warned" state
    and does not re-email. The caller clears warned_at when it detects a change.

type UserPlatformToken struct {
	UserID      int64
	Platform    string
	InstanceURL string
	Namespace   string
	OwnerType   string
	Token       string
}
    UserPlatformToken is one user's access token for one account on one platform
    instance: the platform, the normalized instance URL ("" = the platform's
    default cloud URL) and the namespace the token belongs to. Token is the
    decrypted value at the Go boundary; it is AES-256-GCM encrypted at rest.
    This is the reference model's single store of token values — a config source
    resolves its token from here, and can be filled in FROM here.

    Namespace "" is the wildcard: "any namespace on this platform+instance".
    Every token predating namespace scoping is stored that way, so one PAT
    covering several orgs keeps working; narrowing a token to a namespace is
    opt-in. OwnerType ("user" | "org") is what the namespace IS, so selecting a
    token can fill a config source completely; it is empty for a wildcard token.

type UserPlatformTokenRepo struct {
	// Has unexported fields.
}
    UserPlatformTokenRepo persists per-user source access tokens, keyed by
    (user, platform, instance URL). The token is encrypted on write / decrypted
    on read through the shared crypto.Encryptor, the same seam used for TOTP
    secrets and config tokens.

func NewUserPlatformTokenRepo(db *sql.DB, enc crypto.Encryptor) *UserPlatformTokenRepo
    NewUserPlatformTokenRepo constructs a UserPlatformTokenRepo. enc encrypts
    the token column; pass crypto.NoOpEncryptor{} only in tests that don't
    assert at-rest encryption.

func (r *UserPlatformTokenRepo) Delete(ctx context.Context, userID int64,
	platform, instanceURL, namespace string) error
    Delete removes a user's token for a platform instance (URL normalized;
    no-op when absent).

func (r *UserPlatformTokenRepo) Get(ctx context.Context, userID int64,
	platform, instanceURL, namespace string) (string, error)
    Get returns a user's decrypted token for a platform instance (URL
    normalized), or "" when none is set.

func (r *UserPlatformTokenRepo) List(ctx context.Context, userID int64) ([]UserPlatformToken, error)
    List returns a user's tokens (decrypted, non-empty), ordered by platform
    then instance URL — the account page's manageable list and the config form's
    per-platform token dropdown.

func (r *UserPlatformTokenRepo) ListAll(ctx context.Context) ([]UserPlatformToken, error)
    ListAll returns every stored token (decrypted), ordered by user, platform,
    instance URL. Used by the token-expiry checker to enumerate per-user token
    locations.

func (r *UserPlatformTokenRepo) Set(ctx context.Context, userID int64,
	platform, instanceURL, namespace, ownerType, token string) error
    Set stores a user's token for a platform instance, keyed by the NORMALIZED
    instance URL so the same instance is one row regardless of how the URL was
    typed (see migrationconfig.NormalizeInstanceURL). A blank token DELETES the
    row (the user clearing it), so reads never return empty values and a "leave
    blank to keep" form must avoid calling Set with an empty value when it means
    "keep".

func (r *UserPlatformTokenRepo) TokensFor(ctx context.Context, userID int64) (map[string]string, error)
    TokensFor returns all of a user's platform tokens as a map keyed by
    migrationconfig.OwnerTokenKey(platform, instanceURL) → decrypted token.
    It is the per-owner fallback the migrate engine, webhook ingress,
    and source-client builder resolve against; every resolver keys through
    OwnerTokenKey so a token stored for an instance is found by a source on that
    instance. A userID with no tokens yields an empty map.

type UserRepo struct {
	// Has unexported fields.
}
    UserRepo is the SQLite implementation of auth.UserRepository (and,
    conveniently, auth.TOTPUserRepository — same underlying table, both
    interfaces satisfied by one struct).

func NewUserRepo(db *sql.DB, enc crypto.Encryptor) *UserRepo
    NewUserRepo constructs a UserRepo. enc is used to encrypt/decrypt
    totp_secret and totp_recovery_codes_json at the DB boundary. Use
    crypto.NoOpEncryptor{} in tests that do not exercise secret storage.

func (r *UserRepo) ClearTOTP(ctx context.Context, userID int64) error
    ClearTOTP removes the user's TOTP secret and recovery codes, disabling 2FA.

func (r *UserRepo) CountAdmins(ctx context.Context) (int, error)
    CountAdmins returns how many active admin accounts exist. Used by the
    bootstrap path to decide whether the initial-admin CLI is needed, and by the
    user-management UI to prevent demoting the last admin.

func (r *UserRepo) CreateUser(ctx context.Context, user *auth.User) (int64, error)
    CreateUser inserts a new user and returns the assigned ID. password_hash
    MUST already be argon2id-hashed by the caller; this method does not hash on
    its own to keep the password-handling concentrated in the auth package.

func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*auth.User, error)
    GetByEmail returns the user with the given email, or auth.ErrNotFound;
    an empty email short-circuits and never matches (so a blank email column
    can't be hit).

func (r *UserRepo) GetByID(ctx context.Context, id int64) (*auth.User, error)
    GetByID returns the user with the given id, or auth.ErrNotFound if none
    exists.

func (r *UserRepo) GetByUsername(ctx context.Context, username string) (*auth.User, error)
    GetByUsername returns the user with the given username, or auth.ErrNotFound
    if none exists.

func (r *UserRepo) HardLock(ctx context.Context, userID int64) error
    HardLock deactivates the user (is_active=0) and clears any soft lock,
    so only an admin can reactivate the account.

func (r *UserRepo) IncrementFailedLogin(ctx context.Context, userID int64) (int, error)
    IncrementFailedLogin atomically bumps failed_login_count and returns the new
    value. SQLite's RETURNING clause (3.35+) makes this a single round-trip.
    We're on 3.45 via the embedded driver, so this is safe.

func (r *UserRepo) ListUsers(ctx context.Context) ([]*auth.User, error)
    ListUsers returns all users ordered by username. Used by the admin UI.
    Kept simple — no pagination yet; the user table is expected to be small
    (under a few hundred). When that assumption breaks, add a pagination wrapper
    rather than complicating this method.

func (r *UserRepo) ResetConsecutiveLockouts(ctx context.Context, userID int64) error
    ResetConsecutiveLockouts zeroes the consecutive-lockouts escalation counter.

func (r *UserRepo) ResetFailedLogin(ctx context.Context, userID int64) error
    ResetFailedLogin zeroes the user's failed-login counter after a successful
    auth.

func (r *UserRepo) SetForgejoOwner(ctx context.Context, userID int64, owner string) error
    SetForgejoOwner pins (or clears, with "") the Forgejo namespace a user's
    imports must target. The caller validates the value; admins are unrestricted
    at config-save time regardless of this field.

func (r *UserRepo) SetTOTP(ctx context.Context, userID int64, secret, recoveryCodesJSON string) error
    SetTOTP stores the user's TOTP secret and recovery codes, both AES-encrypted
    at rest.

func (r *UserRepo) SoftLock(ctx context.Context, userID int64, until, at time.Time) (int, error)
    SoftLock locks the user until the given time, stamps the lockout, increments
    the consecutive-lockouts escalation counter, and returns its new value.
    The single RETURNING statement does the increment, stamp, and failed-counter
    reset atomically.

func (r *UserRepo) Unlock(ctx context.Context, userID int64) error
    Unlock reactivates the user and clears all lock state: soft lock,
    failed-login counter, and the consecutive-lockouts escalation counter.

func (r *UserRepo) UpdateLastLogin(ctx context.Context, userID int64, at time.Time) error
    UpdateLastLogin records the timestamp of the user's most recent successful
    login.

func (r *UserRepo) UpdatePasswordHash(ctx context.Context, userID int64, hash string, mustChangeNext bool) error
    UpdatePasswordHash sets the user's password hash and must-change-next-login
    flag, stamping password_changed_at to now.

func (r *UserRepo) UpdateProfile(ctx context.Context, userID int64, displayName, email string) error
    UpdateProfile sets a user's editable profile fields: display name and email.
    An empty email is stored as NULL (consistent with CreateUser), so it never
    collides under the partial-unique email index; a non-empty email that
    another user already holds returns ErrEmailTaken. The caller validates the
    email format and gates an email change on a password re-check.

func (r *UserRepo) UpdateRole(ctx context.Context, userID int64, role auth.Role) error
    UpdateRole changes a user's role. The caller must ensure at least one active
    admin remains; this method does not enforce that invariant (the handler
    layer does, to surface a friendly error before writing).

func (r *UserRepo) UpdateTOTPRecoveryCodes(ctx context.Context, userID int64, recoveryCodesJSON string) error
    UpdateTOTPRecoveryCodes replaces the user's stored recovery codes
    (encrypted), e.g. after one is consumed during 2FA recovery.

func (r *UserRepo) UpdateTimezone(ctx context.Context, userID int64, tz string) error
    UpdateTimezone sets a user's preferred IANA timezone (empty string = UTC).
    The caller is responsible for validating the value (see auth.ValidTimezone).