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