Files
at-container-registry/pkg/appview/db/queries.go
T
Evan JarrettandClaude Opus 5 4c04983e23 appview: stop the backfill claiming every user was just active
last_seen means "this user did something recently". The backfill walks every
historical record in the network, so stamping it there recorded when the
backfill ran, not when the user was active — for every user at once, on every
run. That destroys the only signal the column carries, and it is the one column
in users that nothing upstream can rebuild.

It is now written on the two paths that represent real activity: an interactive
login, and a live commit event on the firehose, which does mean the user just
wrote a record. The backfill still corrects handle, PDS endpoint and avatar,
which is why it re-resolves rather than trusting a cache; it just no longer
claims the user was present.

UpsertUser grows an options form rather than a fourth named variant, since the
avatar and last_seen decisions are independent and all four combinations occur.

Anyone computing MAU from this column should know it was unreliable for every
backfill run before this change.

Also corrects docs/HORIZONTAL_SCALING.md, which claimed oci_client and
registry_domain were local-only preferences. They are fields on
io.atcr.sailor.profile: settings writes them to the user's PDS and
ProcessSailorProfile refreshes the local cache. users is fully derived apart
from last_seen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 09:24:26 -05:00

3232 lines
107 KiB
Go

package db
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/url"
"regexp"
"strings"
"time"
)
// BlobCDNURL returns the CDN URL for an ATProto blob
// This is a local copy to avoid importing atproto (prevents circular dependencies)
func BlobCDNURL(did, cid string) string {
return fmt.Sprintf("https://imgs.blue/%s/%s", did, cid)
}
// activeTakedownClause returns a SQL fragment ready to drop into a `WHERE NOT
// EXISTS (...)` filter for excluding rows whose `(did, repository)` pair is currently
// taken down. The `alias` argument is the outer table alias (e.g. "m" for manifests,
// "lm" for latest_manifests) and must already be in scope at the use site. Mirrors
// `IsTakenDown` so listings stay consistent with the per-repo page check.
func activeTakedownClause(alias string) string {
return `NOT EXISTS (
SELECT 1 FROM taken_down_subjects t
WHERE t.did = ` + alias + `.did
AND (t.repo = ` + alias + `.repository OR t.repo = '')
)`
}
// accessibleHoldsSubquery returns SQL that evaluates to the set of hold DIDs
// the viewer is allowed to see in listings. Requires the viewerDID to be
// passed twice as query arguments (once for the owner_did check and once
// for the crew membership check). Empty viewerDID (anonymous) naturally
// matches no owner or crew rows, so only public + self-service holds
// (allow_all_crew=1) are returned.
const accessibleHoldsSubquery = `(
SELECT hold_did FROM hold_captain_records
WHERE public = 1
OR allow_all_crew = 1
OR owner_did = ?
OR hold_did IN (SELECT hold_did FROM hold_crew_members WHERE member_did = ?)
)`
// Artifact type values stored in manifests.artifact_type and returned by
// GetArtifactType. Container-image is the default for OCI/Docker images and
// manifest lists; helm-chart is for OCI helm chart artifacts.
const (
ArtifactTypeContainerImage = "container-image"
ArtifactTypeHelmChart = "helm-chart"
ArtifactTypeUnknown = "unknown"
)
// GetArtifactType determines the artifact type based on config media type
// Returns: "helm-chart", "container-image", or "unknown"
func GetArtifactType(configMediaType string) string {
switch {
case strings.Contains(configMediaType, "helm.config"):
return ArtifactTypeHelmChart
case strings.Contains(configMediaType, "oci.image.config") ||
strings.Contains(configMediaType, "docker.container.image"):
return ArtifactTypeContainerImage
case configMediaType == "":
// Manifest lists don't have a config - treat as container-image
return ArtifactTypeContainerImage
default:
return ArtifactTypeUnknown
}
}
// escapeLikePattern escapes SQL LIKE wildcards (%, _) and backslash for safe searching.
// It also sanitizes the input to prevent injection attacks via special characters.
func escapeLikePattern(s string) string {
// Remove NULL bytes (could truncate query in C-based databases like SQLite)
s = strings.ReplaceAll(s, "\x00", "")
// Remove other control characters that could cause issues
s = strings.Map(func(r rune) rune {
// Keep printable characters, spaces, and common punctuation
if r < 32 && r != '\t' && r != '\n' && r != '\r' {
return -1 // Remove control characters
}
return r
}, s)
// Escape LIKE wildcards - order matters! Backslash must be first
s = strings.ReplaceAll(s, "\\", "\\\\") // Escape backslash first
s = strings.ReplaceAll(s, "%", "\\%") // Escape % wildcard
s = strings.ReplaceAll(s, "_", "\\_") // Escape _ wildcard
return strings.TrimSpace(s)
}
// SearchRepositories searches for repositories matching the query across handles, DIDs, repositories, and annotations
// Returns RepoCardData (one per repository) instead of individual pushes/tags
func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID string) ([]RepoCardData, int, error) {
// Escape LIKE wildcards so they're treated literally
query = escapeLikePattern(query)
// Prepare search pattern for LIKE queries (case-insensitive)
searchPattern := "%" + query + "%"
sqlQuery := `
WITH latest_manifests AS (
SELECT did, repository, manifest_key AS latest_key FROM (
SELECT did, repository, manifest_key,
ROW_NUMBER() OVER (
PARTITION BY did, repository
ORDER BY created_at DESC, manifest_key DESC
) AS rn
FROM manifests
WHERE hold_endpoint IN ` + accessibleHoldsSubquery + `
) WHERE rn = 1
),
matching_repos AS (
SELECT DISTINCT lm.did, lm.repository, lm.latest_key
FROM latest_manifests lm
JOIN users u ON lm.did = u.did
WHERE (u.handle LIKE ? ESCAPE '\'
OR u.did = ?
OR lm.repository LIKE ? ESCAPE '\'
OR EXISTS (
SELECT 1 FROM repository_annotations ra
WHERE ra.did = lm.did AND ra.repository = lm.repository
AND ra.value LIKE ? ESCAPE '\'
))
AND ` + activeTakedownClause("lm") + `
),
repo_stats AS (
SELECT
mr.did,
mr.repository,
COALESCE(rs.pull_count, 0) as pull_count,
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = mr.did AND repository = mr.repository), 0) as star_count
FROM matching_repos mr
LEFT JOIN repository_stats rs ON mr.did = rs.did AND mr.repository = rs.repository
)
SELECT
m.did,
u.handle,
COALESCE(u.avatar, ''),
m.repository,
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
repo_stats.star_count,
repo_stats.pull_count,
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
COALESCE(m.artifact_type, 'container-image'),
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
COALESCE(m.digest, ''),
MAX(COALESCE(rs.last_push, m.created_at), m.created_at),
COALESCE(rp.avatar_cid, '')
FROM matching_repos mr
JOIN manifests m ON mr.latest_key = m.manifest_key
JOIN users u ON m.did = u.did
JOIN repo_stats ON m.did = repo_stats.did AND m.repository = repo_stats.repository
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
ORDER BY MAX(COALESCE(rs.last_push, m.created_at), m.created_at) DESC
LIMIT ? OFFSET ?
`
rows, err := db.Query(sqlQuery, currentUserDID, currentUserDID, searchPattern, query, searchPattern, searchPattern, currentUserDID, limit, offset)
if err != nil {
return nil, 0, err
}
defer rows.Close()
var cards []RepoCardData
for rows.Next() {
var c RepoCardData
var ownerDID string
var isStarredInt int
var avatarCID string
var lastUpdatedStr sql.NullString
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.OwnerAvatarURL, &c.Repository, &c.Title, &c.Description, &c.IconURL,
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
return nil, 0, err
}
c.IsStarred = isStarredInt > 0
if lastUpdatedStr.Valid {
if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
c.LastUpdated = t
}
}
// Prefer repo page avatar over annotation icon
if avatarCID != "" {
c.IconURL = BlobCDNURL(ownerDID, avatarCID)
}
cards = append(cards, c)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
// Get total count of matching repositories
countQuery := `
WITH latest_manifests AS (
SELECT did, repository, manifest_key AS latest_key FROM (
SELECT did, repository, manifest_key,
ROW_NUMBER() OVER (
PARTITION BY did, repository
ORDER BY created_at DESC, manifest_key DESC
) AS rn
FROM manifests
WHERE hold_endpoint IN ` + accessibleHoldsSubquery + `
) WHERE rn = 1
)
SELECT COUNT(DISTINCT lm.did || '/' || lm.repository)
FROM latest_manifests lm
JOIN users u ON lm.did = u.did
WHERE u.handle LIKE ? ESCAPE '\'
OR u.did = ?
OR lm.repository LIKE ? ESCAPE '\'
OR EXISTS (
SELECT 1 FROM repository_annotations ra
WHERE ra.did = lm.did AND ra.repository = lm.repository
AND ra.value LIKE ? ESCAPE '\'
)
`
var total int
if err := db.QueryRow(countQuery, currentUserDID, currentUserDID, searchPattern, query, searchPattern, searchPattern).Scan(&total); err != nil {
return nil, 0, err
}
if err := PopulateRepoCardTags(db, cards); err != nil {
return nil, 0, err
}
return cards, total, nil
}
// GetUserRepositories fetches all repositories for a user.
// viewerDID scopes results to repositories whose manifests live on holds the
// viewer can access (empty viewerDID = anonymous → public + self-service only).
//
// Implementation: one summary query for the accessible repository set, then
// four bulk queries (tags, manifests, annotations, repo_pages) all keyed by
// did. Results are grouped in Go and assembled per repo. Total: 5 queries
// regardless of how many repos the user owns.
func GetUserRepositories(db DBTX, did string, viewerDID string) ([]Repository, error) {
// Step 1: summary query. Both tags and manifests are filtered via join
// onto manifests.hold_endpoint so repositories where every row lives on
// an inaccessible hold drop out.
rows, err := db.Query(`
SELECT
repository,
COUNT(DISTINCT tag) as tag_count,
COUNT(DISTINCT digest) as manifest_count,
MAX(created_at) as last_push
FROM (
SELECT t.repository, t.tag, t.digest, t.created_at
FROM tags t
JOIN manifests tm ON t.did = tm.did AND t.repository = tm.repository AND t.digest = tm.digest
WHERE t.did = ? AND tm.hold_endpoint IN `+accessibleHoldsSubquery+`
UNION
SELECT m.repository, NULL, m.digest, m.created_at
FROM manifests m
WHERE m.did = ? AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
)
GROUP BY repository
ORDER BY last_push DESC
`, did, viewerDID, viewerDID, did, viewerDID, viewerDID)
if err != nil {
return nil, err
}
type repoSummary struct {
Name string
TagCount int
ManifestCount int
LastPushStr string
}
var summaries []repoSummary
for rows.Next() {
var s repoSummary
if err := rows.Scan(&s.Name, &s.TagCount, &s.ManifestCount, &s.LastPushStr); err != nil {
rows.Close()
return nil, err
}
summaries = append(summaries, s)
}
rows.Close()
if len(summaries) == 0 {
return nil, nil
}
// Build the set of accessible repo names for filtering bulk-fetched rows
// against repos that the viewer can't see (rows for repos owned by `did`
// but stored on inaccessible holds).
accessible := make(map[string]bool, len(summaries))
for _, s := range summaries {
accessible[s.Name] = true
}
// Step 2: bulk-fetch tags for all repos owned by did, grouped by repo.
tagsByRepo, err := bulkTagsByRepo(db, did, accessible)
if err != nil {
return nil, err
}
// Step 3: bulk-fetch manifests, grouped by repo.
manifestsByRepo, err := bulkManifestsByRepo(db, did, accessible)
if err != nil {
return nil, err
}
// Step 4: bulk-fetch annotations, grouped by repo.
annotationsByRepo, err := GetRepositoryAnnotationsByDID(db, did)
if err != nil {
return nil, err
}
// Step 5: bulk-fetch repo pages (existing helper), keyed by repo.
pages, err := GetRepoPagesByDID(db, did)
if err != nil {
return nil, err
}
pagesByRepo := make(map[string]*RepoPage, len(pages))
for i := range pages {
pagesByRepo[pages[i].Repository] = &pages[i]
}
// Assemble results in summary order (preserves last_push DESC).
repos := make([]Repository, 0, len(summaries))
for _, s := range summaries {
r := Repository{
Name: s.Name,
TagCount: s.TagCount,
ManifestCount: s.ManifestCount,
LastPush: parseRepoTimestamp(s.LastPushStr),
Tags: tagsByRepo[s.Name],
Manifests: manifestsByRepo[s.Name],
}
annotations := annotationsByRepo[s.Name]
r.Title = annotations["org.opencontainers.image.title"]
r.Description = annotations["org.opencontainers.image.description"]
r.SourceURL = annotations["org.opencontainers.image.source"]
r.DocumentationURL = annotations["org.opencontainers.image.documentation"]
r.Licenses = annotations["org.opencontainers.image.licenses"]
r.IconURL = annotations["io.atcr.icon"]
r.ReadmeURL = annotations["io.atcr.readme"]
// Repo page avatar overrides annotation icon when present.
if page, ok := pagesByRepo[s.Name]; ok && page.AvatarCID != "" {
r.IconURL = BlobCDNURL(did, page.AvatarCID)
}
repos = append(repos, r)
}
return repos, nil
}
// bulkTagsByRepo fetches every tag owned by did and groups by repository,
// dropping repos not in the accessible set. Result preserves created_at DESC
// ordering within each repo.
func bulkTagsByRepo(db DBTX, did string, accessible map[string]bool) (map[string][]Tag, error) {
rows, err := db.Query(`
SELECT repository, tag, digest, created_at
FROM tags
WHERE did = ?
ORDER BY repository, created_at DESC
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string][]Tag)
for rows.Next() {
var t Tag
t.DID = did
if err := rows.Scan(&t.Repository, &t.Tag, &t.Digest, &t.CreatedAt); err != nil {
return nil, err
}
if !accessible[t.Repository] {
continue
}
out[t.Repository] = append(out[t.Repository], t)
}
return out, rows.Err()
}
// bulkManifestsByRepo fetches every manifest owned by did and groups by
// repository, dropping repos not in the accessible set. Result preserves
// created_at DESC ordering within each repo.
func bulkManifestsByRepo(db DBTX, did string, accessible map[string]bool) (map[string][]Manifest, error) {
rows, err := db.Query(`
SELECT manifest_key, repository, digest, hold_endpoint, schema_version, media_type,
config_digest, config_size, artifact_type, created_at
FROM manifests
WHERE did = ?
ORDER BY repository, created_at DESC
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string][]Manifest)
for rows.Next() {
var m Manifest
m.DID = did
if err := rows.Scan(&m.Key, &m.Repository, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
&m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.ArtifactType, &m.CreatedAt); err != nil {
return nil, err
}
if !accessible[m.Repository] {
continue
}
out[m.Repository] = append(out[m.Repository], m)
}
return out, rows.Err()
}
// parseRepoTimestamp tolerates the several timestamp formats SQLite/libsql
// can return for MAX(created_at) depending on driver and schema history.
func parseRepoTimestamp(s string) time.Time {
if s == "" {
return time.Time{}
}
formats := []string{
time.RFC3339Nano, // 2006-01-02T15:04:05.999999999Z07:00
"2006-01-02 15:04:05.999999999-07:00", // SQLite with microseconds and timezone
"2006-01-02 15:04:05.999999999", // SQLite with microseconds
time.RFC3339, // 2006-01-02T15:04:05Z07:00
"2006-01-02 15:04:05", // SQLite default
}
for _, format := range formats {
if t, err := time.Parse(format, s); err == nil {
return t
}
}
return time.Time{}
}
// GetRepositoryMetadata retrieves metadata for a repository from annotations table
// Returns a map of annotation key -> value for easy access in templates and handlers
func GetRepositoryMetadata(db DBTX, did string, repository string) (map[string]string, error) {
return GetRepositoryAnnotations(db, did, repository)
}
// GetUserByDID retrieves a user by DID
func GetUserByDID(db DBTX, did string) (*User, error) {
var user User
var avatar, defaultHoldDID, ociClient, registryDomain sql.NullString
err := db.QueryRow(`
SELECT did, handle, pds_endpoint, avatar, default_hold_did, oci_client, registry_domain, last_seen
FROM users
WHERE did = ?
`, did).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &ociClient, &registryDomain, &user.LastSeen)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if avatar.Valid {
user.Avatar = avatar.String
}
if defaultHoldDID.Valid {
user.DefaultHoldDID = defaultHoldDID.String
}
if ociClient.Valid {
user.OciClient = ociClient.String
}
if registryDomain.Valid {
user.RegistryDomain = registryDomain.String
}
return &user, nil
}
// GetUserByHandle retrieves a user by handle
func GetUserByHandle(db DBTX, handle string) (*User, error) {
var user User
var avatar, defaultHoldDID, ociClient, registryDomain sql.NullString
err := db.QueryRow(`
SELECT did, handle, pds_endpoint, avatar, default_hold_did, oci_client, registry_domain, last_seen
FROM users
WHERE handle = ?
`, handle).Scan(&user.DID, &user.Handle, &user.PDSEndpoint, &avatar, &defaultHoldDID, &ociClient, &registryDomain, &user.LastSeen)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if avatar.Valid {
user.Avatar = avatar.String
}
if defaultHoldDID.Valid {
user.DefaultHoldDID = defaultHoldDID.String
}
if ociClient.Valid {
user.OciClient = ociClient.String
}
if registryDomain.Valid {
user.RegistryDomain = registryDomain.String
}
return &user, nil
}
// InsertUserIfNotExists inserts a user record only if it doesn't already exist.
// Used by non-profile collections to avoid unnecessary writes during backfill.
func InsertUserIfNotExists(db DBTX, user *User) error {
// Clear handle from any other DID that currently holds it.
// In ATProto, a handle belongs to exactly one DID at a time —
// if a new DID claims this handle, the old association is stale.
_, _ = db.Exec(`UPDATE users SET handle = did WHERE handle = ? AND did != ?`,
user.Handle, user.DID)
_, err := db.Exec(`
INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(did) DO NOTHING
`, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen)
return err
}
// UserUpsertOptions controls which columns an upsert overwrites on an existing
// row. The insert path always writes every column.
type UserUpsertOptions struct {
// UpdateAvatar overwrites a stored avatar. Leave false when the avatar
// fetch failed, so an empty string does not replace a good value.
UpdateAvatar bool
// UpdateLastSeen stamps last_seen. Set it only when the user actually did
// something: an interactive login, or a live record write observed on the
// firehose.
//
// It is deliberately NOT set by the backfill. The backfill walks every
// historical record in the network, so stamping there records when the
// backfill ran, not when the user was last active, and it does so for every
// user at once. That makes last_seen useless as an activity signal, which is
// exactly what it is for.
UpdateLastSeen bool
}
// UpsertUser inserts or updates a user record, stamping last_seen. Use it on
// paths that represent real user activity; see UpsertUserObserved for indexing.
func UpsertUser(db DBTX, user *User) error {
return UpsertUserWithOptions(db, user, UserUpsertOptions{UpdateAvatar: true, UpdateLastSeen: true})
}
// UpsertUserObserved records identity seen while indexing, without claiming the
// user did anything. Handle, PDS endpoint and avatar are still corrected, which
// is why the backfill re-resolves rather than trusting a cache.
func UpsertUserObserved(db DBTX, user *User, updateAvatar bool) error {
return UpsertUserWithOptions(db, user, UserUpsertOptions{UpdateAvatar: updateAvatar, UpdateLastSeen: false})
}
// UpsertUserWithOptions inserts or updates a user record.
func UpsertUserWithOptions(db DBTX, user *User, opts UserUpsertOptions) error {
// Clear handle from any other DID that currently holds it.
// In ATProto, a handle belongs to exactly one DID at a time —
// if a new DID claims this handle, the old association is stale.
_, _ = db.Exec(`UPDATE users SET handle = did WHERE handle = ? AND did != ?`,
user.Handle, user.DID)
updates := []string{
"handle = excluded.handle",
"pds_endpoint = excluded.pds_endpoint",
}
if opts.UpdateAvatar {
updates = append(updates, "avatar = excluded.avatar")
}
if opts.UpdateLastSeen {
updates = append(updates, "last_seen = excluded.last_seen")
}
_, err := db.Exec(`
INSERT INTO users (did, handle, pds_endpoint, avatar, last_seen)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(did) DO UPDATE SET
`+strings.Join(updates, ",\n ")+`
`, user.DID, user.Handle, user.PDSEndpoint, user.Avatar, user.LastSeen)
return err
}
// UpsertUserIgnoreAvatar inserts or updates a user record, but preserves existing avatar on update
// This is useful when avatar fetch fails, and we don't want to overwrite an existing avatar with empty string
func UpsertUserIgnoreAvatar(db DBTX, user *User) error {
return UpsertUserWithOptions(db, user, UserUpsertOptions{UpdateAvatar: false, UpdateLastSeen: true})
}
// UpdateUserLastSeen updates only the last_seen timestamp for a user
// This is more efficient than UpsertUser when only updating activity timestamp
func UpdateUserLastSeen(db DBTX, did string) error {
_, err := db.Exec(`
UPDATE users SET last_seen = ? WHERE did = ?
`, time.Now(), did)
return err
}
// UpdateUserHandle updates a user's handle when an identity change event is received
// This is called when Jetstream receives an identity event indicating a handle change
func UpdateUserHandle(db DBTX, did string, newHandle string) error {
// Never blank a cached handle. handle is NOT NULL UNIQUE, so writing "" both
// discards good data and collides the moment a second user does the same.
// Callers that see an empty/unresolved handle should leave the cache untouched.
if newHandle == "" {
return nil
}
_, err := db.Exec(`
UPDATE users SET handle = ?, last_seen = ? WHERE did = ?
`, newHandle, time.Now(), did)
return err
}
// UpdateUserDefaultHold updates a user's cached default hold DID
// This is called when Jetstream receives a sailor profile update
func UpdateUserDefaultHold(db DBTX, did string, holdDID string) error {
_, err := db.Exec(`
UPDATE users SET default_hold_did = ? WHERE did = ?
`, holdDID, did)
return err
}
// UpdateUserOciClient updates a user's cached OCI client preference
func UpdateUserOciClient(db DBTX, did string, ociClient string) error {
_, err := db.Exec(`
UPDATE users SET oci_client = ? WHERE did = ?
`, ociClient, did)
return err
}
// UpdateUserRegistryDomain updates a user's cached preferred registry domain.
// An empty string means "use the primary (first configured) domain".
func UpdateUserRegistryDomain(db DBTX, did string, registryDomain string) error {
_, err := db.Exec(`
UPDATE users SET registry_domain = ? WHERE did = ?
`, registryDomain, did)
return err
}
// GetUserDefaultHoldDID returns a user's explicitly-set default hold DID from
// the cached profile, or "" if unset. Unlike GetUserHoldDID it does NOT fall
// back to a manifest's hold_endpoint (which may be a URL, not a DID): callers
// gating on managed-hold membership need the clean default-hold signal where
// "" means the user falls back to the operator's primary managed hold.
func GetUserDefaultHoldDID(db DBTX, did string) string {
var holdDID sql.NullString
_ = db.QueryRow(`SELECT default_hold_did FROM users WHERE did = ?`, did).Scan(&holdDID)
if holdDID.Valid {
return holdDID.String
}
return ""
}
// GetUserHoldDID returns the hold DID for a user. Uses cached default_hold_did
// if available, otherwise falls back to the most recent manifest's hold_endpoint.
func GetUserHoldDID(db DBTX, did string) string {
// Try cached default hold first
var holdDID sql.NullString
_ = db.QueryRow(`SELECT default_hold_did FROM users WHERE did = ?`, did).Scan(&holdDID)
if holdDID.Valid && holdDID.String != "" {
return holdDID.String
}
// Fallback: most recent manifest's hold
var manifestHold string
err := db.QueryRow(`
SELECT hold_endpoint FROM manifests
WHERE did = ?
ORDER BY created_at DESC
LIMIT 1
`, did).Scan(&manifestHold)
if err != nil {
return ""
}
return manifestHold
}
// UpdateUserAvatar updates a user's avatar URL when a profile change is detected
// This is called when Jetstream receives an app.bsky.actor.profile update
func UpdateUserAvatar(db DBTX, did string, avatarURL string) error {
_, err := db.Exec(`
UPDATE users SET avatar = ?, last_seen = ? WHERE did = ?
`, avatarURL, time.Now(), did)
return err
}
// GetManifestDigestsForDID returns all manifest digests for a DID
func GetManifestDigestsForDID(db DBTX, did string) ([]string, error) {
rows, err := db.Query(`
SELECT digest FROM manifests WHERE did = ?
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
var digests []string
for rows.Next() {
var digest string
if err := rows.Scan(&digest); err != nil {
return nil, err
}
digests = append(digests, digest)
}
return digests, rows.Err()
}
// DeleteManifestsNotInList deletes all manifests for a DID that are not in the provided list
func DeleteManifestsNotInList(db DBTX, did string, keepDigests []string) error {
if len(keepDigests) == 0 {
// No manifests to keep - delete all for this DID
_, err := db.Exec(`DELETE FROM manifests WHERE did = ?`, did)
return err
}
// Build placeholders for IN clause
placeholders := make([]string, len(keepDigests))
args := []any{did}
for i, digest := range keepDigests {
placeholders[i] = "?"
args = append(args, digest)
}
query := fmt.Sprintf(`
DELETE FROM manifests
WHERE did = ? AND digest NOT IN (%s)
`, strings.Join(placeholders, ","))
_, err := db.Exec(query, args...)
return err
}
// GetTagsForDID returns all (repository, tag) pairs for a DID
func GetTagsForDID(db DBTX, did string) ([]struct{ Repository, Tag string }, error) {
rows, err := db.Query(`
SELECT repository, tag FROM tags WHERE did = ?
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
var tags []struct{ Repository, Tag string }
for rows.Next() {
var t struct{ Repository, Tag string }
if err := rows.Scan(&t.Repository, &t.Tag); err != nil {
return nil, err
}
tags = append(tags, t)
}
return tags, rows.Err()
}
// DeleteTagsNotInList deletes all tags for a DID that are not in the provided list.
// Atomicity is provided by the caller's transaction when used during backfill.
//
// Tags are identified by (did, repository, tag), which is their natural key and
// the table's primary key. This used to select surrogate ids, filter them in Go
// with a nested loop, and then issue one DELETE per row.
func DeleteTagsNotInList(db DBTX, did string, keepTags []struct{ Repository, Tag string }) error {
if len(keepTags) == 0 {
// No tags to keep - delete all for this DID
_, err := db.Exec(`DELETE FROM tags WHERE did = ?`, did)
return err
}
// Work out what to remove in Go rather than with a NOT IN over the keep
// list. A single NOT IN would need two placeholders per kept tag and break
// past the driver's parameter ceiling for a user with enough tags, and it
// cannot be split into chunks: each chunk would delete the tags every other
// chunk meant to keep.
keep := make(map[struct{ Repository, Tag string }]struct{}, len(keepTags))
for _, k := range keepTags {
keep[k] = struct{}{}
}
rows, err := db.Query(`SELECT repository, tag FROM tags WHERE did = ?`, did)
if err != nil {
return err
}
var toDelete []struct{ Repository, Tag string }
for rows.Next() {
var t struct{ Repository, Tag string }
if err := rows.Scan(&t.Repository, &t.Tag); err != nil {
rows.Close()
return err
}
if _, ok := keep[t]; !ok {
toDelete = append(toDelete, t)
}
}
rows.Close()
if err := rows.Err(); err != nil {
return err
}
// An explicit delete list chunks safely, unlike the NOT IN above.
for i := 0; i*BatchSize < len(toDelete); i++ {
start, end := chunk(len(toDelete), i)
batch := toDelete[start:end]
args := make([]any, 0, 1+2*len(batch))
args = append(args, did)
pairs := make([]string, 0, len(batch))
for _, t := range batch {
pairs = append(pairs, "(?, ?)")
args = append(args, t.Repository, t.Tag)
}
query := `DELETE FROM tags WHERE did = ? AND (repository, tag) IN (` +
strings.Join(pairs, ", ") + `)`
if _, err := db.Exec(query, args...); err != nil {
return err
}
}
return nil
}
// InsertManifest inserts or updates a manifest record.
// Uses UPSERT to update core metadata if manifest already exists.
// Returns the manifest key, which the caller could equally compute itself.
// Note: Annotations are stored separately in repository_annotations table
func InsertManifest(db DBTX, manifest *Manifest) (string, error) {
key := ManifestKey(manifest.DID, manifest.Repository, manifest.Digest)
_, err := db.Exec(`
INSERT INTO manifests
(manifest_key, did, repository, digest, hold_endpoint, schema_version,
media_type, config_digest, config_size, artifact_type, subject_digest,
created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(did, repository, digest) DO UPDATE SET
hold_endpoint = excluded.hold_endpoint,
schema_version = excluded.schema_version,
media_type = excluded.media_type,
config_digest = excluded.config_digest,
config_size = excluded.config_size,
artifact_type = excluded.artifact_type,
subject_digest = excluded.subject_digest
WHERE excluded.hold_endpoint != manifests.hold_endpoint
OR excluded.schema_version != manifests.schema_version
OR excluded.media_type != manifests.media_type
OR excluded.config_digest IS NOT manifests.config_digest
OR excluded.config_size IS NOT manifests.config_size
OR excluded.artifact_type != manifests.artifact_type
OR excluded.subject_digest IS NOT manifests.subject_digest
`, key, manifest.DID, manifest.Repository, manifest.Digest, manifest.HoldEndpoint,
manifest.SchemaVersion, manifest.MediaType, manifest.ConfigDigest,
manifest.ConfigSize, manifest.ArtifactType,
sql.NullString{String: manifest.SubjectDigest, Valid: manifest.SubjectDigest != ""},
manifest.CreatedAt)
if err != nil {
return "", err
}
return key, nil
}
// InsertLayer inserts a layer record, skipping if it already exists.
// Layers are immutable — once created, their digest/size/media_type never change.
func InsertLayer(db DBTX, layer *Layer) error {
var annotationsJSON *string
if len(layer.Annotations) > 0 {
b, err := json.Marshal(layer.Annotations)
if err != nil {
return fmt.Errorf("failed to marshal layer annotations: %w", err)
}
s := string(b)
annotationsJSON = &s
}
_, err := db.Exec(`
INSERT INTO layers (manifest_key, digest, size, media_type, layer_index, annotations)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(manifest_key, layer_index) DO NOTHING
`, layer.ManifestKey, layer.Digest, layer.Size, layer.MediaType, layer.LayerIndex, annotationsJSON)
return err
}
// UpsertTag inserts or updates a tag record
func UpsertTag(db DBTX, tag *Tag) error {
_, err := db.Exec(`
INSERT INTO tags (did, repository, tag, digest, created_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(did, repository, tag) DO UPDATE SET
digest = excluded.digest,
created_at = excluded.created_at
WHERE excluded.digest != tags.digest
OR excluded.created_at != tags.created_at
`, tag.DID, tag.Repository, tag.Tag, tag.Digest, tag.CreatedAt)
return err
}
// DeleteTag deletes a tag record
func DeleteTag(db DBTX, did, repository, tag string) error {
_, err := db.Exec(`
DELETE FROM tags WHERE did = ? AND repository = ? AND tag = ?
`, did, repository, tag)
return err
}
// LatestTagInfo holds the most recent tag name and its artifact type.
type LatestTagInfo struct {
Tag string
ArtifactType string
}
// MostRecentTagInfo holds the newest tag for a repo, including its digest and hold endpoint.
type MostRecentTagInfo struct {
Tag string
Digest string
HoldEndpoint string
CreatedAt time.Time
}
// GetMostRecentTag returns the most recently created tag with its digest and hold endpoint.
// Returns nil, nil if no tags exist.
func GetMostRecentTag(db DBTX, did, repository string) (*MostRecentTagInfo, error) {
var info MostRecentTagInfo
err := db.QueryRow(`
SELECT t.tag, t.digest, COALESCE(m.hold_endpoint, ''), t.created_at
FROM tags t
LEFT JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
WHERE t.did = ? AND t.repository = ?
ORDER BY t.created_at DESC LIMIT 1
`, did, repository).Scan(&info.Tag, &info.Digest, &info.HoldEndpoint, &info.CreatedAt)
if err != nil {
return nil, nil // no tags is not an error
}
return &info, nil
}
// RepositoryExists checks if any manifests exist for a given repository.
func RepositoryExists(db DBTX, did, repository string) (bool, error) {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM manifests WHERE did = ? AND repository = ? LIMIT 1`, did, repository).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// GetLatestTag returns the most recently created tag and its artifact type for a repository.
// Returns nil if no tags exist.
func GetLatestTag(db DBTX, did, repository string) (*LatestTagInfo, error) {
var info LatestTagInfo
err := db.QueryRow(`
SELECT t.tag, COALESCE(m.artifact_type, 'container-image')
FROM tags t
JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
WHERE t.did = ? AND t.repository = ?
ORDER BY t.created_at DESC LIMIT 1
`, did, repository).Scan(&info.Tag, &info.ArtifactType)
if err != nil {
return nil, nil // no tags is not an error
}
return &info, nil
}
// CountTags returns the total number of tags for a repository.
func CountTags(db DBTX, did, repository string) (int, error) {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM tags WHERE did = ? AND repository = ?`, did, repository).Scan(&count)
return count, err
}
// GetTagsWithPlatforms returns tags for a repository with platform information
// Only multi-arch tags (manifest lists) have platform info in manifest_references
// Single-arch tags will have empty Platforms slice (platform is obvious for single-arch)
// Attestation references (unknown/unknown platforms) are filtered out but tracked via HasAttestations
func GetTagsWithPlatforms(db DBTX, did, repository string, limit, offset int, viewerDID string) ([]TagWithPlatforms, error) {
return getTagsWithPlatformsFiltered(db, did, repository, "", limit, offset, viewerDID, true)
}
// getTagsWithPlatformsFiltered is the shared implementation for GetTagsWithPlatforms and GetTagByName.
// If tagName is non-empty, only that specific tag is returned.
// When applyHoldFilter is true, rows are filtered by hold access for viewerDID.
func getTagsWithPlatformsFiltered(db DBTX, did, repository, tagName string, limit, offset int, viewerDID string, applyHoldFilter bool) ([]TagWithPlatforms, error) {
var tagFilter string
var holdFilter string
var args []any
args = append(args, did, repository)
if tagName != "" {
tagFilter = "AND t.tag = ?"
args = append(args, tagName)
}
if applyHoldFilter {
holdFilter = "AND m.hold_endpoint IN " + accessibleHoldsSubquery
args = append(args, viewerDID, viewerDID)
}
args = append(args, limit, offset)
query := `
WITH paged_tags AS (
SELECT t.did, t.repository, t.tag, t.digest, t.created_at
FROM tags t
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
WHERE t.did = ? AND t.repository = ?
` + tagFilter + `
` + holdFilter + `
ORDER BY t.created_at DESC
LIMIT ? OFFSET ?
)
SELECT
t.did,
t.repository,
t.tag,
t.digest,
t.created_at,
m.media_type,
m.artifact_type,
m.hold_endpoint,
COALESCE(mr.platform_os, '') as platform_os,
COALESCE(mr.platform_architecture, '') as platform_architecture,
COALESCE(mr.platform_variant, '') as platform_variant,
COALESCE(mr.platform_os_version, '') as platform_os_version,
COALESCE(mr.is_attestation, 0) as is_attestation,
COALESCE(mr.digest, '') as child_digest,
COALESCE(child_m.hold_endpoint, m.hold_endpoint, '') as child_hold_endpoint,
COALESCE((SELECT SUM(l.size) FROM layers l WHERE l.manifest_key = COALESCE(child_m.manifest_key, m.manifest_key)), 0) as compressed_size
FROM paged_tags t
JOIN manifests m ON t.digest = m.digest AND t.did = m.did AND t.repository = m.repository
LEFT JOIN manifest_references mr ON m.manifest_key = mr.manifest_key
LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = t.did AND child_m.repository = t.repository
ORDER BY t.created_at DESC, mr.reference_index`
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
// Group platforms by tag
tagMap := make(map[string]*TagWithPlatforms)
var tagOrder []string // Preserve order
for rows.Next() {
var t Tag
var mediaType, artifactType, holdEndpoint string
var platformOS, platformArch, platformVariant, platformOSVersion string
var isAttestation bool
var childDigest, childHoldEndpoint string
var compressedSize int64
if err := rows.Scan(&t.DID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt,
&mediaType, &artifactType, &holdEndpoint,
&platformOS, &platformArch, &platformVariant, &platformOSVersion,
&isAttestation, &childDigest, &childHoldEndpoint, &compressedSize); err != nil {
return nil, err
}
// Get or create TagWithPlatforms
tagKey := t.Tag
if _, exists := tagMap[tagKey]; !exists {
tagMap[tagKey] = &TagWithPlatforms{
Tag: t,
HoldEndpoint: holdEndpoint,
Platforms: []PlatformInfo{},
ArtifactType: artifactType,
CompressedSize: compressedSize, // for single-arch (no manifest_references row)
}
tagOrder = append(tagOrder, tagKey)
}
// Track if manifest list has attestations
if isAttestation {
tagMap[tagKey].HasAttestations = true
// Skip attestation references in platform display
continue
}
// Add platform info if present (only for multi-arch manifest lists)
if platformOS != "" || platformArch != "" {
tagMap[tagKey].Platforms = append(tagMap[tagKey].Platforms, PlatformInfo{
OS: platformOS,
Architecture: platformArch,
Variant: platformVariant,
OSVersion: platformOSVersion,
Digest: childDigest,
HoldEndpoint: childHoldEndpoint,
CompressedSize: compressedSize,
})
}
}
// Convert map to slice, preserving order and setting IsMultiArch
result := make([]TagWithPlatforms, 0, len(tagMap))
for _, tagKey := range tagOrder {
tag := tagMap[tagKey]
tag.IsMultiArch = len(tag.Platforms) > 1
result = append(result, *tag)
}
return result, nil
}
// DeleteManifest deletes a manifest and its associated layers.
// Also deletes any attestation manifests that reference this manifest via subject_digest.
// If repository is empty, deletes all manifests matching did and digest.
func DeleteManifest(db DBTX, did, repository, digest string) error {
var err error
if repository == "" {
// Delete attestation children first, then the manifest itself
_, _ = db.Exec(`DELETE FROM manifests WHERE did = ? AND subject_digest = ?`, did, digest)
_, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND digest = ?`, did, digest)
} else {
_, _ = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND subject_digest = ?`, did, repository, digest)
_, err = db.Exec(`DELETE FROM manifests WHERE did = ? AND repository = ? AND digest = ?`, did, repository, digest)
}
return err
}
// DeleteUserData deletes all cached data for a user.
// This is used when an account is permanently deleted or when we discover
// the account no longer exists (e.g., RepoNotFound during backfill).
//
// Due to ON DELETE CASCADE in the schema, deleting from users will automatically
// cascade to: manifests, tags, layers, references, annotations, stars, repo_pages, etc.
func DeleteUserData(db DBTX, did string) (bool, error) {
result, err := db.Exec(`DELETE FROM users WHERE did = ?`, did)
if err != nil {
return false, fmt.Errorf("failed to delete user: %w", err)
}
rowsAffected, _ := result.RowsAffected()
return rowsAffected > 0, nil
}
// GetManifest fetches a single manifest by digest
// Note: Annotations are stored separately in repository_annotations table
func GetManifest(db DBTX, digest string) (*Manifest, error) {
var m Manifest
err := db.QueryRow(`
SELECT manifest_key, did, repository, digest, hold_endpoint, schema_version,
media_type, config_digest, config_size, created_at
FROM manifests
WHERE digest = ?
`, digest).Scan(&m.Key, &m.DID, &m.Repository, &m.Digest, &m.HoldEndpoint,
&m.SchemaVersion, &m.MediaType, &m.ConfigDigest, &m.ConfigSize,
&m.CreatedAt)
if err != nil {
return nil, err
}
return &m, nil
}
// GetNewestManifestForRepo returns the newest manifest for a specific repository
// Used by backfill to ensure annotations come from the most recent manifest
func GetNewestManifestForRepo(db DBTX, did, repository string) (*Manifest, error) {
var m Manifest
err := db.QueryRow(`
SELECT manifest_key, did, repository, digest, hold_endpoint, schema_version, media_type,
config_digest, config_size, created_at
FROM manifests
WHERE did = ? AND repository = ?
ORDER BY created_at DESC
LIMIT 1
`, did, repository).Scan(
&m.Key, &m.DID, &m.Repository, &m.Digest,
&m.HoldEndpoint, &m.SchemaVersion, &m.MediaType,
&m.ConfigDigest, &m.ConfigSize, &m.CreatedAt,
)
if err != nil {
return nil, err
}
return &m, nil
}
// GetLatestHoldDIDForRepo returns the hold DID from the most recent manifest for a repository
// Returns empty string if no manifests exist (e.g., first push)
// This is used instead of the in-memory cache to determine which hold to use for blob operations
func GetLatestHoldDIDForRepo(db DBTX, did, repository string) (string, error) {
var holdDID string
err := db.QueryRow(`
SELECT hold_endpoint
FROM manifests
WHERE did = ? AND repository = ?
ORDER BY created_at DESC
LIMIT 1
`, did, repository).Scan(&holdDID)
if err == sql.ErrNoRows {
// No manifests yet - return empty string (first push case)
return "", nil
}
if err != nil {
return "", err
}
return holdDID, nil
}
// GetRepositoriesForDID returns all unique repository names for a DID
// Used by backfill to reconcile annotations for all repositories
func GetRepositoriesForDID(db DBTX, did string) ([]string, error) {
rows, err := db.Query(`
SELECT DISTINCT repository
FROM manifests
WHERE did = ?
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
var repositories []string
for rows.Next() {
var repo string
if err := rows.Scan(&repo); err != nil {
return nil, err
}
repositories = append(repositories, repo)
}
return repositories, rows.Err()
}
// GetLayersForManifest fetches all layers for a manifest
func GetLayersForManifest(db DBTX, manifestKey string) ([]Layer, error) {
rows, err := db.Query(`
SELECT manifest_key, digest, size, media_type, layer_index, annotations
FROM layers
WHERE manifest_key = ?
ORDER BY layer_index
`, manifestKey)
if err != nil {
return nil, err
}
defer rows.Close()
var layers []Layer
for rows.Next() {
var l Layer
var annotationsJSON sql.NullString
if err := rows.Scan(&l.ManifestKey, &l.Digest, &l.Size, &l.MediaType, &l.LayerIndex, &annotationsJSON); err != nil {
return nil, err
}
if annotationsJSON.Valid && annotationsJSON.String != "" {
if err := json.Unmarshal([]byte(annotationsJSON.String), &l.Annotations); err != nil {
return nil, fmt.Errorf("failed to unmarshal layer annotations: %w", err)
}
}
layers = append(layers, l)
}
return layers, nil
}
// InsertManifestReference inserts a new manifest reference record (for manifest lists/indexes)
func InsertManifestReference(db DBTX, ref *ManifestReference) error {
_, err := db.Exec(`
INSERT INTO manifest_references (manifest_key, digest, size, media_type,
platform_architecture, platform_os,
platform_variant, platform_os_version,
is_attestation, reference_index)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, ref.ManifestKey, ref.Digest, ref.Size, ref.MediaType,
ref.PlatformArchitecture, ref.PlatformOS,
ref.PlatformVariant, ref.PlatformOSVersion,
ref.IsAttestation, ref.ReferenceIndex)
return err
}
// GetManifestReferencesForManifest fetches all manifest references for a manifest list/index
func GetManifestReferencesForManifest(db DBTX, manifestKey string) ([]ManifestReference, error) {
rows, err := db.Query(`
SELECT manifest_key, digest, size, media_type,
platform_architecture, platform_os, platform_variant, platform_os_version,
reference_index
FROM manifest_references
WHERE manifest_key = ?
ORDER BY reference_index
`, manifestKey)
if err != nil {
return nil, err
}
defer rows.Close()
var refs []ManifestReference
for rows.Next() {
var r ManifestReference
var arch, os, variant, osVersion sql.NullString
if err := rows.Scan(&r.ManifestKey, &r.Digest, &r.Size, &r.MediaType,
&arch, &os, &variant, &osVersion,
&r.ReferenceIndex); err != nil {
return nil, err
}
// Convert nullable strings
if arch.Valid {
r.PlatformArchitecture = arch.String
}
if os.Valid {
r.PlatformOS = os.String
}
if variant.Valid {
r.PlatformVariant = variant.String
}
if osVersion.Valid {
r.PlatformOSVersion = osVersion.String
}
refs = append(refs, r)
}
return refs, nil
}
// GetTopLevelManifests returns only manifest lists and orphaned single-arch manifests
// Filters out platform-specific manifests that are referenced by manifest lists
// Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them
func GetTopLevelManifests(db DBTX, did, repository string, limit, offset int, viewerDID string) ([]ManifestWithMetadata, error) {
rows, err := db.Query(`
WITH manifest_list_children AS (
-- Get all digests that are children of manifest lists
SELECT DISTINCT mr.digest
FROM manifest_references mr
JOIN manifests m ON mr.manifest_key = m.manifest_key
WHERE m.did = ? AND m.repository = ?
)
SELECT
m.manifest_key, m.did, m.repository, m.digest, m.media_type,
m.schema_version, m.created_at,
m.config_digest, m.config_size, m.hold_endpoint, m.artifact_type,
GROUP_CONCAT(DISTINCT t.tag) as tags,
COUNT(DISTINCT mr.digest) as platform_count
FROM manifests m
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
LEFT JOIN manifest_references mr ON m.manifest_key = mr.manifest_key
WHERE m.did = ? AND m.repository = ?
AND m.subject_digest IS NULL
AND m.artifact_type != 'unknown'
AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
AND (
-- Include manifest lists
m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
OR
-- Include single-arch NOT referenced by any list
m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL)
)
GROUP BY m.manifest_key
ORDER BY m.created_at DESC
LIMIT ? OFFSET ?
`, did, repository, did, repository, viewerDID, viewerDID, limit, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var manifests []ManifestWithMetadata
for rows.Next() {
var m ManifestWithMetadata
var tags, configDigest sql.NullString
var configSize sql.NullInt64
if err := rows.Scan(
&m.Key, &m.DID, &m.Repository, &m.Digest, &m.MediaType,
&m.SchemaVersion, &m.CreatedAt,
&configDigest, &configSize, &m.HoldEndpoint, &m.ArtifactType,
&tags, &m.PlatformCount,
); err != nil {
return nil, err
}
// Set nullable fields
if configDigest.Valid {
m.ConfigDigest = configDigest.String
}
if configSize.Valid {
m.ConfigSize = configSize.Int64
}
// Parse tags
if tags.Valid && tags.String != "" {
m.Tags = strings.Split(tags.String, ",")
}
// Determine if manifest list
m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list")
manifests = append(manifests, m)
}
// Fetch platform details for multi-arch manifests AFTER closing the main query
for i := range manifests {
if manifests[i].IsManifestList {
platformRows, err := db.Query(`
SELECT
COALESCE(mr.platform_os, '') as platform_os,
COALESCE(mr.platform_architecture, '') as platform_architecture,
COALESCE(mr.platform_variant, '') as platform_variant,
COALESCE(mr.platform_os_version, '') as platform_os_version,
COALESCE(mr.is_attestation, 0) as is_attestation,
COALESCE(mr.digest, '') as child_digest,
COALESCE(child_m.hold_endpoint, '') as child_hold_endpoint,
COALESCE((SELECT SUM(l.size) FROM layers l WHERE l.manifest_key = child_m.manifest_key), 0) as compressed_size
FROM manifest_references mr
LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = ? AND child_m.repository = ?
WHERE mr.manifest_key = ?
ORDER BY mr.reference_index
`, manifests[i].DID, manifests[i].Repository, manifests[i].Key)
if err != nil {
return nil, err
}
manifests[i].Platforms = []PlatformInfo{}
for platformRows.Next() {
var p PlatformInfo
var isAttestation bool
if err := platformRows.Scan(&p.OS, &p.Architecture, &p.Variant, &p.OSVersion,
&isAttestation, &p.Digest, &p.HoldEndpoint, &p.CompressedSize); err != nil {
platformRows.Close()
return nil, err
}
// Track if manifest list has attestations
if isAttestation {
manifests[i].HasAttestations = true
continue
}
manifests[i].Platforms = append(manifests[i].Platforms, p)
}
platformRows.Close()
manifests[i].PlatformCount = len(manifests[i].Platforms)
}
}
return manifests, nil
}
// GetManifestDetail returns a manifest with full platform details and tags
// Note: Annotations are stored separately in repository_annotations table - use GetRepositoryMetadata to fetch them
func GetManifestDetail(db DBTX, did, repository, digest string) (*ManifestWithMetadata, error) {
// First, get the manifest and its tags
var m ManifestWithMetadata
var tags, configDigest sql.NullString
var configSize sql.NullInt64
err := db.QueryRow(`
SELECT
m.manifest_key, m.did, m.repository, m.digest, m.media_type,
m.schema_version, m.created_at,
m.config_digest, m.config_size, m.hold_endpoint, m.artifact_type,
GROUP_CONCAT(DISTINCT t.tag) as tags
FROM manifests m
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
WHERE m.did = ? AND m.repository = ? AND m.digest = ?
GROUP BY m.manifest_key
`, did, repository, digest).Scan(
&m.Key, &m.DID, &m.Repository, &m.Digest, &m.MediaType,
&m.SchemaVersion, &m.CreatedAt,
&configDigest, &configSize, &m.HoldEndpoint, &m.ArtifactType,
&tags,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, fmt.Errorf("manifest not found")
}
return nil, err
}
// Set nullable fields
if configDigest.Valid {
m.ConfigDigest = configDigest.String
}
if configSize.Valid {
m.ConfigSize = configSize.Int64
}
// Parse tags
if tags.Valid && tags.String != "" {
m.Tags = strings.Split(tags.String, ",")
}
// Determine if manifest list
m.IsManifestList = strings.Contains(m.MediaType, "index") || strings.Contains(m.MediaType, "manifest.list")
// If this is a manifest list, get platform details with child digests and sizes
if m.IsManifestList {
platforms, err := db.Query(`
SELECT
COALESCE(mr.platform_os, '') as platform_os,
COALESCE(mr.platform_architecture, '') as platform_architecture,
COALESCE(mr.platform_variant, '') as platform_variant,
COALESCE(mr.platform_os_version, '') as platform_os_version,
COALESCE(mr.is_attestation, 0) as is_attestation,
COALESCE(mr.digest, '') as child_digest,
COALESCE(child_m.hold_endpoint, '') as child_hold_endpoint,
COALESCE((SELECT SUM(l.size) FROM layers l WHERE l.manifest_key = child_m.manifest_key), 0) as compressed_size
FROM manifest_references mr
LEFT JOIN manifests child_m ON mr.digest = child_m.digest AND child_m.did = ? AND child_m.repository = ?
WHERE mr.manifest_key = ?
ORDER BY mr.reference_index
`, m.DID, m.Repository, m.Key)
if err != nil {
return nil, err
}
defer platforms.Close()
m.Platforms = []PlatformInfo{}
for platforms.Next() {
var p PlatformInfo
var isAttestation bool
if err := platforms.Scan(&p.OS, &p.Architecture, &p.Variant, &p.OSVersion,
&isAttestation, &p.Digest, &p.HoldEndpoint, &p.CompressedSize); err != nil {
return nil, err
}
if isAttestation {
m.HasAttestations = true
continue
}
m.Platforms = append(m.Platforms, p)
}
m.PlatformCount = len(m.Platforms)
}
return &m, nil
}
// GetFirehoseCursor retrieves the current firehose cursor
func GetFirehoseCursor(db DBTX) (int64, error) {
var cursor int64
err := db.QueryRow("SELECT cursor FROM firehose_cursor WHERE id = 1").Scan(&cursor)
if err == sql.ErrNoRows {
return 0, nil
}
return cursor, err
}
// UpdateFirehoseCursor updates the firehose cursor
func UpdateFirehoseCursor(db DBTX, cursor int64) error {
_, err := db.Exec(`
INSERT INTO firehose_cursor (id, cursor, updated_at)
VALUES (1, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
cursor = excluded.cursor,
updated_at = excluded.updated_at
`, cursor)
return err
}
// StripeEventSeen reports whether a Stripe event has already been successfully
// processed (idempotency guard for webhook redelivery).
func StripeEventSeen(db DBTX, eventID string) (bool, error) {
var one int
err := db.QueryRow("SELECT 1 FROM stripe_processed_events WHERE event_id = ?", eventID).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// LatestStripeEventCreatedForCustomer returns the highest event_created
// timestamp recorded for a customer's successfully processed events, or 0 if
// none. Used to drop stale out-of-order deliveries.
func LatestStripeEventCreatedForCustomer(db DBTX, customerID string) (int64, error) {
if customerID == "" {
return 0, nil
}
var created sql.NullInt64
err := db.QueryRow(
"SELECT MAX(event_created) FROM stripe_processed_events WHERE customer_id = ?",
customerID,
).Scan(&created)
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
if err != nil {
return 0, err
}
return created.Int64, nil
}
// RecordStripeEvent records a successfully processed Stripe event. Idempotent:
// a duplicate event_id is ignored rather than erroring.
func RecordStripeEvent(db DBTX, eventID, customerID string, created int64) error {
_, err := db.Exec(`
INSERT INTO stripe_processed_events (event_id, customer_id, event_created)
VALUES (?, ?, ?)
ON CONFLICT(event_id) DO NOTHING
`, eventID, customerID, created)
return err
}
// GetChildManifestPlatform returns the platform info for a manifest that is
// referenced as a child of a manifest list. Returns nil if the digest is not
// a child of any manifest list (i.e., it's a top-level single-arch manifest).
// Used by the diff handler to match architectures when comparing a platform
// child digest against a manifest list (or another child).
func GetChildManifestPlatform(db DBTX, did, repository, digest string) (*PlatformInfo, error) {
var p PlatformInfo
var arch, os, variant, osVersion sql.NullString
err := db.QueryRow(`
SELECT
COALESCE(mr.platform_os, ''),
COALESCE(mr.platform_architecture, ''),
COALESCE(mr.platform_variant, ''),
COALESCE(mr.platform_os_version, '')
FROM manifest_references mr
JOIN manifests m ON mr.manifest_key = m.manifest_key
WHERE m.did = ? AND m.repository = ? AND mr.digest = ?
LIMIT 1
`, did, repository, digest).Scan(&os, &arch, &variant, &osVersion)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, err
}
p.OS = os.String
p.Architecture = arch.String
p.Variant = variant.String
p.OSVersion = osVersion.String
p.Digest = digest
return &p, nil
}
// IsManifestReferenced checks if a manifest digest is referenced as a child of
// any manifest list for the given user. Used to protect manifest list children
// from auto-removal (they are untagged but still needed by their parent list).
func IsManifestReferenced(db DBTX, did, digest string) (bool, error) {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM manifest_references mr
JOIN manifests m ON mr.manifest_key = m.manifest_key
WHERE mr.digest = ? AND m.did = ?
LIMIT 1
`, digest, did).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// GetTagDigest returns the manifest digest that a tag points to.
// Returns sql.ErrNoRows if the tag does not exist.
func GetTagDigest(db DBTX, did, repository, tag string) (string, error) {
var digest string
err := db.QueryRow(`
SELECT digest FROM tags
WHERE did = ? AND repository = ? AND tag = ?
`, did, repository, tag).Scan(&digest)
return digest, err
}
// ShouldCascadeDeleteManifest returns true iff a manifest can be safely
// deleted after a tag is removed: it has no remaining tags in ANY of the
// user's repositories AND is not referenced by any manifest list (multi-arch
// parent). Manifest-list children must be preserved even when untagged, since
// their parent index still depends on them.
//
// The tag check is deliberately DID-wide rather than repository-scoped. The
// io.atcr.manifest record this gates is keyed by digest alone, so a single
// record backs every repository holding identical content; a repo-scoped check
// would cascade a delete in one repo into another repo's live image and purge
// the shared layers on the hold.
func ShouldCascadeDeleteManifest(db DBTX, did, digest string) (bool, error) {
tagged, err := IsManifestTaggedAnyRepo(db, did, digest)
if err != nil {
return false, err
}
if tagged {
return false, nil
}
referenced, err := IsManifestReferenced(db, did, digest)
if err != nil {
return false, err
}
return !referenced, nil
}
// IsManifestTaggedAnyRepo reports whether a digest still carries a tag in any
// of the user's repositories. Use this to gate deletion of per-digest state
// (the io.atcr.manifest record, hold layer records); use IsManifestTagged when
// the question is genuinely about one repository, such as a confirmation
// prompt listing the tags a user is about to remove.
func IsManifestTaggedAnyRepo(db DBTX, did, digest string) (bool, error) {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM tags
WHERE did = ? AND digest = ?
`, did, digest).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// IsManifestTagged checks if a manifest has any tags in the given repository
func IsManifestTagged(db DBTX, did, repository, digest string) (bool, error) {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM tags
WHERE did = ? AND repository = ? AND digest = ?
`, did, repository, digest).Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
// GetManifestTags retrieves all tags for a manifest
func GetManifestTags(db DBTX, did, repository, digest string) ([]string, error) {
rows, err := db.Query(`
SELECT tag FROM tags
WHERE did = ? AND repository = ? AND digest = ?
ORDER BY tag
`, did, repository, digest)
if err != nil {
return nil, err
}
defer rows.Close()
var tags []string
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
return nil, err
}
tags = append(tags, tag)
}
if err := rows.Err(); err != nil {
return nil, err
}
return tags, nil
}
// GetAllUntaggedManifestDigests returns digests of all untagged manifests eligible for deletion.
// Returns children of untagged manifest lists first (bottom-up) so the handler can delete
// children before parents, avoiding orphaned manifests from cascade-deleted references.
// Uses the same filtering logic as GetTopLevelManifests (manifest lists + orphaned single-arch).
func GetAllUntaggedManifestDigests(db DBTX, did, repository string) ([]string, error) {
rows, err := db.Query(`
WITH manifest_list_children AS (
SELECT DISTINCT mr.digest
FROM manifest_references mr
JOIN manifests m ON mr.manifest_key = m.manifest_key
WHERE m.did = ? AND m.repository = ?
),
untagged_top_level AS (
SELECT m.manifest_key, m.digest,
CASE WHEN m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
THEN 1 ELSE 0 END as is_list
FROM manifests m
LEFT JOIN tags t ON m.digest = t.digest AND m.did = t.did AND m.repository = t.repository
WHERE m.did = ? AND m.repository = ?
AND m.subject_digest IS NULL
AND (
m.media_type LIKE '%index%' OR m.media_type LIKE '%manifest.list%'
OR
m.digest NOT IN (SELECT digest FROM manifest_list_children WHERE digest IS NOT NULL)
)
GROUP BY m.manifest_key
HAVING COUNT(t.tag) = 0
),
untagged_children AS (
SELECT DISTINCT mr.digest
FROM untagged_top_level ul
JOIN manifest_references mr ON ul.manifest_key = mr.manifest_key
JOIN manifests child_m ON mr.digest = child_m.digest
AND child_m.did = ? AND child_m.repository = ?
LEFT JOIN tags ct ON child_m.digest = ct.digest
AND child_m.did = ct.did AND child_m.repository = ct.repository
WHERE ul.is_list = 1 AND ct.tag IS NULL
AND mr.digest NOT IN (
SELECT mr2.digest FROM manifest_references mr2
JOIN manifests m2 ON mr2.manifest_key = m2.manifest_key
JOIN tags t2 ON m2.digest = t2.digest AND m2.did = t2.did AND m2.repository = t2.repository
WHERE m2.did = ? AND m2.repository = ?
)
)
SELECT digest FROM untagged_children
UNION ALL
SELECT digest FROM untagged_top_level
`, did, repository, did, repository, did, repository, did, repository)
if err != nil {
return nil, err
}
defer rows.Close()
var digests []string
for rows.Next() {
var digest string
if err := rows.Scan(&digest); err != nil {
return nil, err
}
digests = append(digests, digest)
}
if err := rows.Err(); err != nil {
return nil, err
}
return digests, nil
}
// GetAttestationDetails returns attestation manifests and their layers for a manifest list.
// Joins manifest_references (is_attestation=true) → manifests → layers.
func GetAttestationDetails(db DBTX, did, repository, manifestListDigest string) ([]AttestationDetail, error) {
// Step 1: Get the manifest list key and hold endpoint
var manifestListKey string
var parentHoldEndpoint string
err := db.QueryRow(`
SELECT manifest_key, hold_endpoint FROM manifests
WHERE did = ? AND repository = ? AND digest = ?
`, did, repository, manifestListDigest).Scan(&manifestListKey, &parentHoldEndpoint)
if err != nil {
return nil, err
}
// Step 2: Get attestation references and join to their manifest records
rows, err := db.Query(`
SELECT mr.digest, mr.media_type, mr.size, m.manifest_key
FROM manifest_references mr
LEFT JOIN manifests m ON m.digest = mr.digest AND m.did = ? AND m.repository = ?
WHERE mr.manifest_key = ? AND mr.is_attestation = 1
ORDER BY mr.reference_index
`, did, repository, manifestListKey)
if err != nil {
return nil, err
}
defer rows.Close()
type refRow struct {
digest string
mediaType string
size int64
manifestKey string // empty if the attestation manifest is not indexed yet
}
var refs []refRow
for rows.Next() {
var r refRow
var key sql.NullString
if err := rows.Scan(&r.digest, &r.mediaType, &r.size, &key); err != nil {
return nil, err
}
r.manifestKey = key.String
refs = append(refs, r)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Step 3: For each attestation manifest, fetch its layers
// Use the parent manifest list's hold endpoint — attestation blobs are in the same hold
details := make([]AttestationDetail, 0, len(refs))
for _, ref := range refs {
detail := AttestationDetail{
Digest: ref.digest,
MediaType: ref.mediaType,
Size: ref.size,
HoldEndpoint: parentHoldEndpoint,
}
if ref.manifestKey != "" {
layers, err := GetLayersForManifest(db, ref.manifestKey)
if err != nil {
return nil, err
}
detail.Layers = layers
}
details = append(details, detail)
}
return details, nil
}
// BackfillState represents the backfill progress
type BackfillState struct {
StartCursor int64
CurrentCursor int64
Completed bool
UpdatedAt time.Time
}
// GetBackfillState retrieves the backfill state
func GetBackfillState(db DBTX) (*BackfillState, error) {
var state BackfillState
var updatedAtStr string
err := db.QueryRow(`
SELECT start_cursor, current_cursor, completed, updated_at
FROM backfill_state
WHERE id = 1
`).Scan(&state.StartCursor, &state.CurrentCursor, &state.Completed, &updatedAtStr)
if err == sql.ErrNoRows {
return nil, nil // No backfill state exists
}
if err != nil {
return nil, err
}
// Parse timestamp
if updatedAtStr != "" {
formats := []string{
time.RFC3339Nano,
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999999",
time.RFC3339,
"2006-01-02 15:04:05",
}
for _, format := range formats {
if t, err := time.Parse(format, updatedAtStr); err == nil {
state.UpdatedAt = t
break
}
}
}
return &state, nil
}
// UpsertBackfillState updates or creates backfill state
func UpsertBackfillState(db DBTX, state *BackfillState) error {
_, err := db.Exec(`
INSERT INTO backfill_state (id, start_cursor, current_cursor, completed, updated_at)
VALUES (1, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
start_cursor = excluded.start_cursor,
current_cursor = excluded.current_cursor,
completed = excluded.completed,
updated_at = excluded.updated_at
`, state.StartCursor, state.CurrentCursor, state.Completed)
return err
}
// UpdateBackfillCursor updates just the current cursor position
func UpdateBackfillCursor(db DBTX, cursor int64) error {
_, err := db.Exec(`
UPDATE backfill_state
SET current_cursor = ?, updated_at = datetime('now')
WHERE id = 1
`, cursor)
return err
}
// MarkBackfillCompleted marks the backfill as completed
func MarkBackfillCompleted(db DBTX) error {
_, err := db.Exec(`
UPDATE backfill_state
SET completed = 1, updated_at = datetime('now')
WHERE id = 1
`)
return err
}
// GetRepository fetches a specific repository for a user
func GetRepository(db DBTX, did, repository string) (*Repository, error) {
// Get repository summary
var r Repository
r.Name = repository
var tagCount, manifestCount int
var lastPushStr string
err := db.QueryRow(`
SELECT
COUNT(DISTINCT tag) as tag_count,
COUNT(DISTINCT digest) as manifest_count,
MAX(created_at) as last_push
FROM (
SELECT tag, digest, created_at FROM tags WHERE did = ? AND repository = ?
UNION
SELECT NULL, digest, created_at FROM manifests WHERE did = ? AND repository = ?
)
`, did, repository, did, repository).Scan(&tagCount, &manifestCount, &lastPushStr)
if err != nil {
return nil, err
}
r.TagCount = tagCount
r.ManifestCount = manifestCount
// Parse the timestamp string into time.Time
if lastPushStr != "" {
formats := []string{
time.RFC3339Nano,
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999999",
time.RFC3339,
"2006-01-02 15:04:05",
}
for _, format := range formats {
if t, err := time.Parse(format, lastPushStr); err == nil {
r.LastPush = t
break
}
}
}
// Get tags for this repo
tagRows, err := db.Query(`
SELECT tag, digest, created_at
FROM tags
WHERE did = ? AND repository = ?
ORDER BY created_at DESC
`, did, repository)
if err != nil {
return nil, err
}
for tagRows.Next() {
var t Tag
t.DID = did
t.Repository = repository
if err := tagRows.Scan(&t.Tag, &t.Digest, &t.CreatedAt); err != nil {
tagRows.Close()
return nil, err
}
r.Tags = append(r.Tags, t)
}
tagRows.Close()
// Get manifests for this repo
manifestRows, err := db.Query(`
SELECT manifest_key, digest, hold_endpoint, schema_version, media_type,
config_digest, config_size, artifact_type, created_at
FROM manifests
WHERE did = ? AND repository = ?
ORDER BY created_at DESC
`, did, repository)
if err != nil {
return nil, err
}
for manifestRows.Next() {
var m Manifest
m.DID = did
m.Repository = repository
if err := manifestRows.Scan(&m.Key, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
&m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.ArtifactType, &m.CreatedAt); err != nil {
manifestRows.Close()
return nil, err
}
r.Manifests = append(r.Manifests, m)
}
manifestRows.Close()
// Fetch repository-level annotations from annotations table
annotations, err := GetRepositoryAnnotations(db, did, repository)
if err != nil {
return nil, err
}
r.Title = annotations["org.opencontainers.image.title"]
r.Description = annotations["org.opencontainers.image.description"]
r.SourceURL = annotations["org.opencontainers.image.source"]
r.DocumentationURL = annotations["org.opencontainers.image.documentation"]
r.Licenses = annotations["org.opencontainers.image.licenses"]
r.IconURL = annotations["io.atcr.icon"]
r.ReadmeURL = annotations["io.atcr.readme"]
return &r, nil
}
// GetRepositoryStats fetches stats for a repository
func GetRepositoryStats(db DBTX, did, repository string) (*RepositoryStats, error) {
var stats RepositoryStats
var lastPullStr, lastPushStr sql.NullString
// Get pull/push stats from repository_stats, and star count from stars table
err := db.QueryRow(`
SELECT
COALESCE(rs.did, ?) as did,
COALESCE(rs.repository, ?) as repository,
(SELECT COUNT(*) FROM stars WHERE owner_did = ? AND repository = ?) as star_count,
COALESCE(rs.pull_count, 0) as pull_count,
rs.last_pull,
COALESCE(rs.push_count, 0) as push_count,
rs.last_push
FROM (SELECT ? as did, ? as repository) AS placeholder
LEFT JOIN repository_stats rs ON rs.did = ? AND rs.repository = ?
`, did, repository, did, repository, did, repository, did, repository).Scan(&stats.DID, &stats.Repository, &stats.StarCount, &stats.PullCount, &lastPullStr, &stats.PushCount, &lastPushStr)
if err == sql.ErrNoRows {
// Return zero stats if no record exists yet
return &RepositoryStats{
DID: did,
Repository: repository,
StarCount: 0,
PullCount: 0,
PushCount: 0,
}, nil
}
if err != nil {
return nil, err
}
// Parse timestamps
if lastPullStr.Valid {
t, err := parseTimestamp(lastPullStr.String)
if err == nil {
stats.LastPull = &t
}
}
if lastPushStr.Valid {
t, err := parseTimestamp(lastPushStr.String)
if err == nil {
stats.LastPush = &t
}
}
return &stats, nil
}
// UpsertRepositoryStats inserts or updates repository stats
// Note: star_count is calculated dynamically from the stars table, not stored here
func UpsertRepositoryStats(db DBTX, stats *RepositoryStats) error {
_, err := db.Exec(`
INSERT INTO repository_stats (did, repository, pull_count, last_pull, push_count, last_push)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(did, repository) DO UPDATE SET
pull_count = excluded.pull_count,
last_pull = excluded.last_pull,
push_count = excluded.push_count,
last_push = excluded.last_push
WHERE excluded.pull_count != repository_stats.pull_count
OR excluded.last_pull IS NOT repository_stats.last_pull
OR excluded.push_count != repository_stats.push_count
OR excluded.last_push IS NOT repository_stats.last_push
`, stats.DID, stats.Repository, stats.PullCount, stats.LastPull, stats.PushCount, stats.LastPush)
return err
}
// UpsertStar inserts a star record, skipping if it already exists.
// Stars are immutable — once created, they don't change.
func UpsertStar(db DBTX, starrerDID, ownerDID, repository string, createdAt time.Time) error {
_, err := db.Exec(`
INSERT INTO stars (starrer_did, owner_did, repository, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(starrer_did, owner_did, repository) DO NOTHING
`, starrerDID, ownerDID, repository, createdAt)
return err
}
// DeleteStar deletes a star record
func DeleteStar(db DBTX, starrerDID, ownerDID, repository string) error {
_, err := db.Exec(`
DELETE FROM stars
WHERE starrer_did = ? AND owner_did = ? AND repository = ?
`, starrerDID, ownerDID, repository)
return err
}
// RebuildStarCount rebuilds the star count for a specific repository from the stars table
func RebuildStarCount(db DBTX, ownerDID, repository string) error {
_, err := db.Exec(`
INSERT INTO repository_stats (did, repository, star_count)
VALUES (?, ?, (
SELECT COUNT(*) FROM stars
WHERE owner_did = ? AND repository = ?
))
ON CONFLICT(did, repository) DO UPDATE SET
star_count = (
SELECT COUNT(*) FROM stars
WHERE owner_did = ? AND repository = ?
)
`, ownerDID, repository, ownerDID, repository, ownerDID, repository)
return err
}
// GetStarsForDID returns all stars created by a specific DID (for backfill reconciliation)
// Returns a map of (ownerDID, repository) -> createdAt
func GetStarsForDID(db DBTX, starrerDID string) (map[string]time.Time, error) {
rows, err := db.Query(`
SELECT owner_did, repository, created_at
FROM stars
WHERE starrer_did = ?
`, starrerDID)
if err != nil {
return nil, err
}
defer rows.Close()
stars := make(map[string]time.Time)
for rows.Next() {
var ownerDID, repository string
var createdAt time.Time
if err := rows.Scan(&ownerDID, &repository, &createdAt); err != nil {
return nil, err
}
key := fmt.Sprintf("%s/%s", ownerDID, repository)
stars[key] = createdAt
}
return stars, rows.Err()
}
// CleanupOrphanedTags removes tags whose manifest digest no longer exists
// This handles cases where manifests were deleted but tags pointing to them remain
func CleanupOrphanedTags(db DBTX, did string) error {
_, err := db.Exec(`
DELETE FROM tags
WHERE did = ?
AND NOT EXISTS (
SELECT 1 FROM manifests
WHERE manifests.did = tags.did
AND manifests.digest = tags.digest
)
`, did)
return err
}
// DeleteStarsNotInList deletes stars from the database that are not in the provided list
// This is used during backfill reconciliation to remove stars that no longer exist on PDS
func DeleteStarsNotInList(db DBTX, starrerDID string, foundStars map[string]time.Time) error {
// Get current stars in DB
currentStars, err := GetStarsForDID(db, starrerDID)
if err != nil {
return fmt.Errorf("failed to get current stars: %w", err)
}
// Find stars to delete (in DB but not on PDS)
var toDelete []struct{ ownerDID, repository string }
for key := range currentStars {
if _, exists := foundStars[key]; !exists {
parts := strings.SplitN(key, "/", 2)
if len(parts) == 2 {
toDelete = append(toDelete, struct{ ownerDID, repository string }{
ownerDID: parts[0],
repository: parts[1],
})
}
}
}
// Delete orphaned stars
for _, star := range toDelete {
if err := DeleteStar(db, starrerDID, star.ownerDID, star.repository); err != nil {
return fmt.Errorf("failed to delete star: %w", err)
}
}
return nil
}
// parseTimestamp parses a timestamp string with multiple format attempts
func parseTimestamp(s string) (time.Time, error) {
formats := []string{
time.RFC3339Nano,
"2006-01-02 15:04:05.999999999-07:00",
"2006-01-02 15:04:05.999999999",
time.RFC3339,
"2006-01-02 15:04:05",
}
for _, format := range formats {
if t, err := time.Parse(format, s); err == nil {
return t, nil
}
}
return time.Time{}, fmt.Errorf("unable to parse timestamp: %s", s)
}
// UpdateManifestHoldDID rewrites the hold_endpoint column for all manifests
// belonging to a user that currently point to oldHoldDID, changing them to newHoldDID.
// Returns the number of rows affected.
func UpdateManifestHoldDID(db DBTX, did, oldHoldDID, newHoldDID string) (int64, error) {
result, err := db.Exec(`
UPDATE manifests SET hold_endpoint = ?
WHERE did = ? AND hold_endpoint = ?
`, newHoldDID, did, oldHoldDID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
// HoldDIDDB wraps a sql.DB and implements the HoldDIDLookup interface for middleware
// This is a minimal wrapper that only provides hold DID lookups for blob routing
type HoldDIDDB struct {
db DBTX
}
// NewHoldDIDDB creates a new hold DID database wrapper
func NewHoldDIDDB(db DBTX) *HoldDIDDB {
return &HoldDIDDB{db: db}
}
// GetLatestHoldDIDForRepo returns the hold DID from the most recent manifest for a repository
func (h *HoldDIDDB) GetLatestHoldDIDForRepo(did, repository string) (string, error) {
return GetLatestHoldDIDForRepo(h.db, did, repository)
}
// UpdateManifestHoldDID rewrites hold_endpoint for all manifests belonging to a user
func (h *HoldDIDDB) UpdateManifestHoldDID(did, oldHoldDID, newHoldDID string) (int64, error) {
return UpdateManifestHoldDID(h.db, did, oldHoldDID, newHoldDID)
}
// GetDistinctManifestHoldDIDs returns all distinct hold DIDs referenced by a user's manifests.
func GetDistinctManifestHoldDIDs(db DBTX, did string) ([]string, error) {
rows, err := db.Query(`
SELECT DISTINCT hold_endpoint FROM manifests
WHERE did = ? AND hold_endpoint != ''
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
var holds []string
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
return nil, err
}
holds = append(holds, h)
}
return holds, rows.Err()
}
// GetDistinctManifestHoldDIDs wraps the package-level function.
func (h *HoldDIDDB) GetDistinctManifestHoldDIDs(did string) ([]string, error) {
return GetDistinctManifestHoldDIDs(h.db, did)
}
// IsManifestReferenced checks if a digest is a child of any manifest list for the user.
// Implements storage.ManifestReferenceChecker.
func (h *HoldDIDDB) IsManifestReferenced(did, digest string) (bool, error) {
return IsManifestReferenced(h.db, did, digest)
}
// RepoCardSortOrder specifies how repo cards should be sorted
type RepoCardSortOrder string
const (
// SortByScore sorts by combined stars and pulls (for Featured)
SortByScore RepoCardSortOrder = "score"
// SortByLastUpdate sorts by most recent push (for What's New)
SortByLastUpdate RepoCardSortOrder = "last_update"
)
// GetRepoCards fetches repository cards with full data including Tag, Digest, and LastUpdated
func GetRepoCards(db DBTX, limit int, currentUserDID string, sortOrder RepoCardSortOrder) ([]RepoCardData, error) {
// Build ORDER BY clause based on sort order
var orderBy string
switch sortOrder {
case SortByLastUpdate:
orderBy = "MAX(COALESCE(rs.last_push, m.created_at), m.created_at) DESC"
default: // SortByScore
orderBy = "(COALESCE(rs.pull_count, 0) + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = m.did AND repository = m.repository), 0) * 10) DESC, m.created_at DESC"
}
query := `
WITH latest_manifests AS (
SELECT did, repository, manifest_key AS latest_key FROM (
SELECT did, repository, manifest_key,
ROW_NUMBER() OVER (
PARTITION BY did, repository
ORDER BY created_at DESC, manifest_key DESC
) AS rn
FROM manifests
WHERE hold_endpoint IN ` + accessibleHoldsSubquery + `
) WHERE rn = 1
)
SELECT
m.did,
u.handle,
COALESCE(u.avatar, ''),
m.repository,
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = m.did AND repository = m.repository), 0),
COALESCE(rs.pull_count, 0),
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
COALESCE(m.artifact_type, 'container-image'),
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
COALESCE(m.digest, ''),
MAX(COALESCE(rs.last_push, m.created_at), m.created_at),
COALESCE(rp.avatar_cid, '')
FROM latest_manifests lm
JOIN manifests m ON lm.latest_key = m.manifest_key
JOIN users u ON m.did = u.did
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
WHERE ` + activeTakedownClause("m") + `
ORDER BY ` + orderBy + `
LIMIT ?
`
rows, err := db.Query(query, currentUserDID, currentUserDID, currentUserDID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var cards []RepoCardData
for rows.Next() {
var c RepoCardData
var ownerDID string
var isStarredInt int
var avatarCID string
var lastUpdatedStr sql.NullString
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.OwnerAvatarURL, &c.Repository, &c.Title, &c.Description, &c.IconURL,
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
return nil, err
}
c.IsStarred = isStarredInt > 0
if lastUpdatedStr.Valid {
if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
c.LastUpdated = t
}
}
// Prefer repo page avatar over annotation icon
if avatarCID != "" {
c.IconURL = BlobCDNURL(ownerDID, avatarCID)
}
cards = append(cards, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := PopulateRepoCardTags(db, cards); err != nil {
return nil, err
}
return cards, nil
}
// GetUserRepoCards fetches repository cards for a specific user with full data
func GetUserRepoCards(db DBTX, userDID string, currentUserDID string) ([]RepoCardData, error) {
query := `
WITH latest_manifests AS (
SELECT did, repository, manifest_key AS latest_key FROM (
SELECT did, repository, manifest_key,
ROW_NUMBER() OVER (
PARTITION BY did, repository
ORDER BY created_at DESC, manifest_key DESC
) AS rn
FROM manifests
WHERE did = ?
AND hold_endpoint IN ` + accessibleHoldsSubquery + `
) WHERE rn = 1
)
SELECT
m.did,
u.handle,
COALESCE(u.avatar, ''),
m.repository,
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = m.did AND repository = m.repository), 0),
COALESCE(rs.pull_count, 0),
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
COALESCE(m.artifact_type, 'container-image'),
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
COALESCE(m.digest, ''),
MAX(COALESCE(rs.last_push, m.created_at), m.created_at),
COALESCE(rp.avatar_cid, '')
FROM latest_manifests lm
JOIN manifests m ON lm.latest_key = m.manifest_key
JOIN users u ON m.did = u.did
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
WHERE ` + activeTakedownClause("m") + `
ORDER BY MAX(COALESCE(rs.last_push, m.created_at), m.created_at) DESC
`
rows, err := db.Query(query, userDID, currentUserDID, currentUserDID, currentUserDID)
if err != nil {
return nil, err
}
defer rows.Close()
var cards []RepoCardData
for rows.Next() {
var c RepoCardData
var ownerDID string
var isStarredInt int
var avatarCID string
var lastUpdatedStr sql.NullString
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.OwnerAvatarURL, &c.Repository, &c.Title, &c.Description, &c.IconURL,
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID); err != nil {
return nil, err
}
c.IsStarred = isStarredInt > 0
if lastUpdatedStr.Valid {
if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
c.LastUpdated = t
}
}
// Prefer repo page avatar over annotation icon
if avatarCID != "" {
c.IconURL = BlobCDNURL(ownerDID, avatarCID)
}
cards = append(cards, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := PopulateRepoCardTags(db, cards); err != nil {
return nil, err
}
return cards, nil
}
// GetStarredRepoCards fetches repository cards for repositories starred by
// starrerDID. Stars whose target repo no longer has a manifest, lives on a
// hold the viewer can't access, or is currently taken down are silently
// dropped via the joins and filters. Ordered by star creation time DESC.
func GetStarredRepoCards(db DBTX, starrerDID string, currentUserDID string) ([]RepoCardData, error) {
query := `
WITH starred AS (
SELECT owner_did AS did, repository, created_at AS starred_at
FROM stars
WHERE starrer_did = ?
),
latest_manifests AS (
SELECT did, repository, manifest_key AS latest_key FROM (
SELECT m.did, m.repository, m.manifest_key,
ROW_NUMBER() OVER (
PARTITION BY m.did, m.repository
ORDER BY m.created_at DESC, m.manifest_key DESC
) AS rn
FROM manifests m
JOIN starred st ON m.did = st.did AND m.repository = st.repository
WHERE m.hold_endpoint IN ` + accessibleHoldsSubquery + `
) WHERE rn = 1
)
SELECT
m.did,
u.handle,
COALESCE(u.avatar, ''),
m.repository,
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.title'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'org.opencontainers.image.description'), ''),
COALESCE((SELECT value FROM repository_annotations WHERE did = m.did AND repository = m.repository AND key = 'io.atcr.icon'), ''),
COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = m.did AND repository = m.repository), 0),
COALESCE(rs.pull_count, 0),
COALESCE((SELECT COUNT(*) FROM stars WHERE starrer_did = ? AND owner_did = m.did AND repository = m.repository), 0),
COALESCE(m.artifact_type, 'container-image'),
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
COALESCE(m.digest, ''),
MAX(COALESCE(rs.last_push, m.created_at), m.created_at),
COALESCE(rp.avatar_cid, ''),
st.starred_at
FROM latest_manifests lm
JOIN manifests m ON lm.latest_key = m.manifest_key
JOIN users u ON m.did = u.did
JOIN starred st ON st.did = m.did AND st.repository = m.repository
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
WHERE ` + activeTakedownClause("m") + `
ORDER BY st.starred_at DESC
`
rows, err := db.Query(query, starrerDID, currentUserDID, currentUserDID, currentUserDID)
if err != nil {
return nil, err
}
defer rows.Close()
var cards []RepoCardData
for rows.Next() {
var c RepoCardData
var ownerDID string
var isStarredInt int
var avatarCID string
var lastUpdatedStr sql.NullString
var starredAtStr sql.NullString
if err := rows.Scan(&ownerDID, &c.OwnerHandle, &c.OwnerAvatarURL, &c.Repository, &c.Title, &c.Description, &c.IconURL,
&c.StarCount, &c.PullCount, &isStarredInt, &c.ArtifactType, &c.Tag, &c.Digest, &lastUpdatedStr, &avatarCID, &starredAtStr); err != nil {
return nil, err
}
c.IsStarred = isStarredInt > 0
if lastUpdatedStr.Valid {
if t, err := parseTimestamp(lastUpdatedStr.String); err == nil {
c.LastUpdated = t
}
}
if avatarCID != "" {
c.IconURL = BlobCDNURL(ownerDID, avatarCID)
}
cards = append(cards, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
if err := PopulateRepoCardTags(db, cards); err != nil {
return nil, err
}
return cards, nil
}
// RepoPage represents a repository page record cached from PDS
type RepoPage struct {
DID string
Repository string
Description string
AvatarCID string
UserEdited bool
CreatedAt time.Time
UpdatedAt time.Time
}
// UpsertRepoPage inserts or updates a repo page record
func UpsertRepoPage(db DBTX, did, repository, description, avatarCID string, userEdited bool, createdAt, updatedAt time.Time) error {
_, err := db.Exec(`
INSERT INTO repo_pages (did, repository, description, avatar_cid, user_edited, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(did, repository) DO UPDATE SET
description = excluded.description,
avatar_cid = excluded.avatar_cid,
user_edited = excluded.user_edited,
updated_at = excluded.updated_at
WHERE excluded.description IS NOT repo_pages.description
OR excluded.avatar_cid IS NOT repo_pages.avatar_cid
OR excluded.user_edited IS NOT repo_pages.user_edited
`, did, repository, description, avatarCID, userEdited, createdAt, updatedAt)
return err
}
// GetRepoPage retrieves a repo page record
func GetRepoPage(db DBTX, did, repository string) (*RepoPage, error) {
var rp RepoPage
err := db.QueryRow(`
SELECT did, repository, description, avatar_cid, user_edited, created_at, updated_at
FROM repo_pages
WHERE did = ? AND repository = ?
`, did, repository).Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.UserEdited, &rp.CreatedAt, &rp.UpdatedAt)
if err != nil {
return nil, err
}
return &rp, nil
}
// DeleteRepoPage deletes a repo page record
func DeleteRepoPage(db DBTX, did, repository string) error {
_, err := db.Exec(`
DELETE FROM repo_pages WHERE did = ? AND repository = ?
`, did, repository)
return err
}
// GetRepoPagesByDID returns all repo pages for a DID
func GetRepoPagesByDID(db DBTX, did string) ([]RepoPage, error) {
rows, err := db.Query(`
SELECT did, repository, description, avatar_cid, user_edited, created_at, updated_at
FROM repo_pages
WHERE did = ?
`, did)
if err != nil {
return nil, err
}
defer rows.Close()
var pages []RepoPage
for rows.Next() {
var rp RepoPage
if err := rows.Scan(&rp.DID, &rp.Repository, &rp.Description, &rp.AvatarCID, &rp.UserEdited, &rp.CreatedAt, &rp.UpdatedAt); err != nil {
return nil, err
}
pages = append(pages, rp)
}
return pages, rows.Err()
}
// --- Webhook types and queries ---
// Webhook represents a webhook configuration stored in the appview DB.
//
// Triggers is a packed integer: bits 0-7 are trigger flags (see
// pkg/appview/webhooks.Trigger* constants) and bits 8-15 are the quota
// threshold percent (only meaningful when TriggerQuota is set). Use the
// webhooks.TriggerFlags() and webhooks.ThresholdPct() helpers to decode.
//
// LastFiredAt is generic edge-trigger state — currently used by quota webhooks
// to dedupe (NULL = armed, set = already fired and still above threshold).
// Future trigger types can adopt it for rate-limiting without schema changes.
type Webhook struct {
ID string `json:"id"`
UserDID string `json:"userDid"`
URL string `json:"url"`
Secret string `json:"-"`
Triggers int `json:"triggers"`
HasSecret bool `json:"hasSecret"`
CreatedAt time.Time `json:"createdAt"`
LastFiredAt *time.Time `json:"lastFiredAt,omitempty"`
}
// CountWebhooks returns the number of webhooks configured for a user
func CountWebhooks(db DBTX, userDID string) (int, error) {
var count int
err := db.QueryRow(`SELECT COUNT(*) FROM webhooks WHERE user_did = ?`, userDID).Scan(&count)
return count, err
}
// ListWebhooks returns webhook configurations for display (masked URLs, no secrets)
func ListWebhooks(db DBTX, userDID string) ([]Webhook, error) {
rows, err := db.Query(`
SELECT id, user_did, url, secret, triggers, created_at, last_fired_at
FROM webhooks WHERE user_did = ? ORDER BY created_at ASC, id ASC
`, userDID)
if err != nil {
return nil, err
}
defer rows.Close()
var webhooks []Webhook
for rows.Next() {
var w Webhook
var secret string
var lastFired sql.NullTime
if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &secret, &w.Triggers, &w.CreatedAt, &lastFired); err != nil {
continue
}
w.HasSecret = secret != ""
w.URL = maskWebhookURL(w.URL)
if lastFired.Valid {
t := lastFired.Time
w.LastFiredAt = &t
}
webhooks = append(webhooks, w)
}
if webhooks == nil {
webhooks = []Webhook{}
}
return webhooks, rows.Err()
}
// GetWebhookByID returns a single webhook with full URL and secret (for dispatch/test)
func GetWebhookByID(db DBTX, id string) (*Webhook, error) {
var w Webhook
var lastFired sql.NullTime
err := db.QueryRow(`
SELECT id, user_did, url, secret, triggers, created_at, last_fired_at
FROM webhooks WHERE id = ?
`, id).Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt, &lastFired)
if err != nil {
return nil, err
}
w.HasSecret = w.Secret != ""
if lastFired.Valid {
t := lastFired.Time
w.LastFiredAt = &t
}
return &w, nil
}
// InsertWebhook creates a new webhook record
func InsertWebhook(db DBTX, w *Webhook) error {
_, err := db.Exec(`
INSERT INTO webhooks (id, user_did, url, secret, triggers, created_at)
VALUES (?, ?, ?, ?, ?, ?)
`, w.ID, w.UserDID, w.URL, w.Secret, w.Triggers, w.CreatedAt)
return err
}
// DeleteWebhook deletes a webhook by ID, validating ownership
func DeleteWebhook(db DBTX, id, userDID string) error {
result, err := db.Exec(`DELETE FROM webhooks WHERE id = ? AND user_did = ?`, id, userDID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("webhook not found or not owned by user")
}
return nil
}
// GetWebhooksForUser returns all webhooks with full URL+secret for dispatch
func GetWebhooksForUser(db DBTX, userDID string) ([]Webhook, error) {
rows, err := db.Query(`
SELECT id, user_did, url, secret, triggers, created_at, last_fired_at
FROM webhooks WHERE user_did = ?
ORDER BY created_at ASC, id ASC
`, userDID)
if err != nil {
return nil, err
}
defer rows.Close()
var webhooks []Webhook
for rows.Next() {
var w Webhook
var lastFired sql.NullTime
if err := rows.Scan(&w.ID, &w.UserDID, &w.URL, &w.Secret, &w.Triggers, &w.CreatedAt, &lastFired); err != nil {
continue
}
w.HasSecret = w.Secret != ""
if lastFired.Valid {
t := lastFired.Time
w.LastFiredAt = &t
}
webhooks = append(webhooks, w)
}
return webhooks, rows.Err()
}
// UpdateWebhookLastFiredAt records when a webhook fired. Used by edge-triggered
// dispatch (currently just quota webhooks) to suppress re-fires while a
// condition remains true.
func UpdateWebhookLastFiredAt(db DBTX, id string, t time.Time) error {
_, err := db.Exec(`UPDATE webhooks SET last_fired_at = ? WHERE id = ?`, t, id)
return err
}
// ClearWebhookLastFiredAt re-arms an edge-triggered webhook. For quota
// webhooks this is called when usage drops back below the threshold.
func ClearWebhookLastFiredAt(db DBTX, id string) error {
_, err := db.Exec(`UPDATE webhooks SET last_fired_at = NULL WHERE id = ?`, id)
return err
}
// maskWebhookURL masks a URL for display (shows scheme + host, hides path/query)
func maskWebhookURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
if len(rawURL) > 30 {
return rawURL[:30] + "***"
}
return rawURL
}
masked := u.Scheme + "://" + u.Host
if u.Path != "" && u.Path != "/" {
masked += "/***"
}
return masked
}
// --- Scan types and queries ---
// Scan represents a cached scan record from Jetstream
type Scan struct {
HoldDID string
ManifestDigest string
UserDID string
Repository string
Critical int
High int
Medium int
Low int
Total int
ScannerVersion string
ScannedAt time.Time
}
// UpsertScan inserts or updates a scan record, returning the previous scan for change detection
func UpsertScan(db DBTX, scan *Scan) (*Scan, error) {
// Fetch previous scan (if any) before upserting
var prev *Scan
var p Scan
err := db.QueryRow(`
SELECT hold_did, manifest_digest, user_did, repository, critical, high, medium, low, total, scanner_version, scanned_at
FROM scans WHERE hold_did = ? AND manifest_digest = ?
`, scan.HoldDID, scan.ManifestDigest).Scan(
&p.HoldDID, &p.ManifestDigest, &p.UserDID, &p.Repository,
&p.Critical, &p.High, &p.Medium, &p.Low, &p.Total,
&p.ScannerVersion, &p.ScannedAt,
)
if err == nil {
prev = &p
}
// Upsert the new scan
_, err = db.Exec(`
INSERT INTO scans (hold_did, manifest_digest, user_did, repository, critical, high, medium, low, total, scanner_version, scanned_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(hold_did, manifest_digest) DO UPDATE SET
user_did = excluded.user_did,
repository = excluded.repository,
critical = excluded.critical,
high = excluded.high,
medium = excluded.medium,
low = excluded.low,
total = excluded.total,
scanner_version = excluded.scanner_version,
scanned_at = excluded.scanned_at
WHERE excluded.critical != scans.critical
OR excluded.high != scans.high
OR excluded.medium != scans.medium
OR excluded.low != scans.low
OR excluded.total != scans.total
OR excluded.scanner_version IS NOT scans.scanner_version
OR excluded.scanned_at != scans.scanned_at
`, scan.HoldDID, scan.ManifestDigest, scan.UserDID, scan.Repository,
scan.Critical, scan.High, scan.Medium, scan.Low, scan.Total,
scan.ScannerVersion, scan.ScannedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to upsert scan: %w", err)
}
return prev, nil
}
// GetTagByDigest returns the most recent tag for a manifest digest in a user's repository
func GetTagByDigest(db DBTX, userDID, repository, digest string) (string, error) {
var tag string
err := db.QueryRow(`
SELECT tag FROM tags
WHERE did = ? AND repository = ? AND digest = ?
ORDER BY created_at DESC LIMIT 1
`, userDID, repository, digest).Scan(&tag)
if err != nil {
return "", err
}
return tag, nil
}
// IsHoldCaptain returns true if userDID is the owner of any hold in the managedHolds list.
func IsHoldCaptain(db DBTX, userDID string, managedHolds []string) (bool, error) {
if userDID == "" || len(managedHolds) == 0 {
return false, nil
}
placeholders := make([]string, len(managedHolds))
args := make([]any, 0, len(managedHolds)+1)
args = append(args, userDID)
for i, did := range managedHolds {
placeholders[i] = "?"
args = append(args, did)
}
var exists int
err := db.QueryRow(
`SELECT 1 FROM hold_captain_records WHERE owner_did = ? AND hold_did IN (`+strings.Join(placeholders, ",")+`) LIMIT 1`,
args...,
).Scan(&exists)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
// GetTagByName returns a single tag with platform information by tag name.
// Returns nil, nil if the tag doesn't exist.
func GetTagByName(db DBTX, did, repository, tagName string) (*TagWithPlatforms, error) {
tags, err := getTagsWithPlatformsFiltered(db, did, repository, tagName, 1, 0, "", false)
if err != nil {
return nil, err
}
if len(tags) == 0 {
return nil, nil
}
return &tags[0], nil
}
// GetRepoHoldDIDs returns the distinct hold DIDs that host manifests for a
// given repository, restricted to holds the viewer can access.
func GetRepoHoldDIDs(db DBTX, did, repository string, viewerDID string) ([]string, error) {
rows, err := db.Query(`
SELECT DISTINCT m.hold_endpoint
FROM manifests m
WHERE m.did = ? AND m.repository = ?
AND m.hold_endpoint != ''
AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
`, did, repository, viewerDID, viewerDID)
if err != nil {
return nil, err
}
defer rows.Close()
var holds []string
for rows.Next() {
var h string
if err := rows.Scan(&h); err != nil {
return nil, err
}
holds = append(holds, h)
}
return holds, rows.Err()
}
// TagNameDigest is a lightweight (tag, digest) pair used for dropdown
// population and default-selection heuristics.
type TagNameDigest struct {
Name string
Digest string
}
// shaTagPattern matches CI-style git-sha tags like "sha-937fa4c".
var shaTagPattern = regexp.MustCompile(`^sha-[0-9a-f]{6,40}$`)
// PickDefaultTag chooses the best display tag from a list of (name, digest)
// pairs ordered most-recent first.
//
// 1. Start with the newest tag.
// 2. If that newest tag looks like a git-sha tag, look for a sibling with
// the same digest that doesn't — happyview-style repos push both
// "sha-937fa4c" and "2.0.0-dev.45" pointing at the same image; we'd
// rather show the semver name.
// 3. If "latest" exists AND points to the same digest as the chosen tag,
// prefer "latest" as the friendliest label. A stale "latest" pointing
// at an old digest is bypassed.
func PickDefaultTag(tags []TagNameDigest) string {
if len(tags) == 0 {
return ""
}
chosen := tags[0]
if shaTagPattern.MatchString(chosen.Name) {
for _, t := range tags[1:] {
if t.Digest == chosen.Digest && !shaTagPattern.MatchString(t.Name) {
chosen = t
break
}
}
}
if chosen.Name != "latest" {
for _, t := range tags {
if t.Name == "latest" && t.Digest == chosen.Digest {
return "latest"
}
}
}
return chosen.Name
}
// PopulateRepoCardTags overrides each card's Tag field with the best display
// tag chosen by PickDefaultTag. Issues one batch query for all (handle, repository)
// pairs in the slice. No-op for an empty slice.
//
// RepoCardData doesn't carry the owner DID, so we join through users.handle.
// This is fine because (handle, repository) is unique within the appview.
func PopulateRepoCardTags(db DBTX, cards []RepoCardData) error {
if len(cards) == 0 {
return nil
}
type key struct{ handle, repo string }
placeholders := make([]string, 0, len(cards))
args := make([]any, 0, len(cards)*2)
for _, c := range cards {
placeholders = append(placeholders, "(?, ?)")
args = append(args, c.OwnerHandle, c.Repository)
}
q := `
SELECT u.handle, t.repository, t.tag, t.digest
FROM tags t
JOIN users u ON t.did = u.did
WHERE (u.handle, t.repository) IN (VALUES ` + strings.Join(placeholders, ",") + `)
ORDER BY t.repository, t.created_at DESC
`
rows, err := db.Query(q, args...)
if err != nil {
return err
}
defer rows.Close()
groups := make(map[key][]TagNameDigest)
for rows.Next() {
var handle, repo, tag, digest string
if err := rows.Scan(&handle, &repo, &tag, &digest); err != nil {
return err
}
k := key{handle, repo}
groups[k] = append(groups[k], TagNameDigest{Name: tag, Digest: digest})
}
if err := rows.Err(); err != nil {
return err
}
for i := range cards {
k := key{cards[i].OwnerHandle, cards[i].Repository}
if g, ok := groups[k]; ok && len(g) > 0 {
cards[i].Tag = PickDefaultTag(g)
}
}
return nil
}
// GetAllTagNames returns all tag names for a repository, ordered by most recent first.
// Filters out tags whose manifests live on holds the viewer can't access.
func GetAllTagNames(db DBTX, did, repository string, viewerDID string) ([]string, error) {
pairs, err := GetAllTagsWithDigests(db, did, repository, viewerDID)
if err != nil {
return nil, err
}
names := make([]string, len(pairs))
for i, p := range pairs {
names[i] = p.Name
}
return names, nil
}
// GetAllTagsWithDigests returns all tags for a repository with their manifest
// digests, ordered by most recent first. Filters out tags whose manifests live
// on holds the viewer can't access.
func GetAllTagsWithDigests(db DBTX, did, repository string, viewerDID string) ([]TagNameDigest, error) {
rows, err := db.Query(`
SELECT t.tag, t.digest FROM tags t
JOIN manifests m ON t.did = m.did AND t.repository = m.repository AND t.digest = m.digest
WHERE t.did = ? AND t.repository = ?
AND m.hold_endpoint IN `+accessibleHoldsSubquery+`
ORDER BY t.created_at DESC
`, did, repository, viewerDID, viewerDID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []TagNameDigest
for rows.Next() {
var p TagNameDigest
if err := rows.Scan(&p.Name, &p.Digest); err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// GetLayerCountForManifest returns the number of layers for a manifest identified by digest.
func GetLayerCountForManifest(db DBTX, did, repository, digest string) (int, error) {
var count int
err := db.QueryRow(`
SELECT COUNT(*) FROM layers l
JOIN manifests m ON l.manifest_key = m.manifest_key
WHERE m.did = ? AND m.repository = ? AND m.digest = ?
`, did, repository, digest).Scan(&count)
return count, err
}
// GetAdvisorSuggestions returns cached AI advisor suggestions for a manifest digest.
// Returns sql.ErrNoRows if no cached suggestions exist.
func GetAdvisorSuggestions(db DBTX, manifestDigest string) (suggestionsJSON string, createdAt time.Time, err error) {
err = db.QueryRow(
`SELECT suggestions_json, created_at FROM advisor_suggestions WHERE manifest_digest = ?`,
manifestDigest,
).Scan(&suggestionsJSON, &createdAt)
return
}
// UpsertAdvisorSuggestions caches AI advisor suggestions for a manifest digest.
func UpsertAdvisorSuggestions(db DBTX, manifestDigest, suggestionsJSON string) error {
_, err := db.Exec(
`INSERT OR REPLACE INTO advisor_suggestions (manifest_digest, suggestions_json, created_at) VALUES (?, ?, CURRENT_TIMESTAMP)`,
manifestDigest, suggestionsJSON,
)
return err
}
// UpsertDailyStats inserts or updates daily repository stats
func UpsertDailyStats(db DBTX, stats *DailyStats) error {
_, err := db.Exec(`
INSERT INTO repository_stats_daily (did, repository, date, pull_count, push_count)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(did, repository, date) DO UPDATE SET
pull_count = excluded.pull_count,
push_count = excluded.push_count
WHERE excluded.pull_count != repository_stats_daily.pull_count
OR excluded.push_count != repository_stats_daily.push_count
`, stats.DID, stats.Repository, stats.Date, stats.PullCount, stats.PushCount)
return err
}
// GetDailyStats retrieves daily stats for a repository within a date range
// startDate and endDate should be in YYYY-MM-DD format
func GetDailyStats(db DBTX, did, repository, startDate, endDate string) ([]DailyStats, error) {
rows, err := db.Query(`
SELECT did, repository, date, pull_count, push_count
FROM repository_stats_daily
WHERE did = ? AND repository = ? AND date >= ? AND date <= ?
ORDER BY date ASC
`, did, repository, startDate, endDate)
if err != nil {
return nil, err
}
defer rows.Close()
var stats []DailyStats
for rows.Next() {
var s DailyStats
if err := rows.Scan(&s.DID, &s.Repository, &s.Date, &s.PullCount, &s.PushCount); err != nil {
return nil, err
}
stats = append(stats, s)
}
return stats, rows.Err()
}