Files
anchorage/internal/pkg/store/store.go
T
57_WolveandClaude Opus 4.7 90ac4b169c
Security / Vulnerability Check (push) Successful in 1m47s
Test / Build & Unit Tests (push) Successful in 5m4s
Test / Lint (push) Successful in 27s
Test / Integration Tests (push) Failing after 1m58s
ci: fix first CI run — tidy, gofmt, ordering test, govulncheck pin
- ids: TestIDsAreTimeOrdered asserted strict lexicographic ordering of
  back-to-back UUIDv7s, but the sub-ms tail is random and not required
  to be monotonic. Sleep between samples so each ID lands in a distinct
  millisecond — the property that actually gives Postgres index
  locality on (org_id, id desc).
- go.mod/go.sum: run go mod tidy. keyfunc/v3, prometheus/client_golang
  and testcontainers-go/modules/postgres are imported directly and
  should not be marked // indirect; also drops stale sum entries.
- gofmt -w across 12 files flagged by the lint job.
- security.yml: pin govulncheck to v1.2.0. @latest triggers a proxy
  lookup every run, which is the step that hung for 16m on the Gitea
  runner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 09:18:49 -05:00

295 lines
10 KiB
Go

// Package store defines the interfaces anchorage uses to reach its
// source-of-truth Postgres database. Every domain package (org, pin,
// token, ...) depends on the interface here, never on the concrete
// pgx-backed implementation in store/postgres. This keeps the domain
// layer testable with in-memory fakes.
package store
import (
"context"
"errors"
"time"
"anchorage/internal/pkg/ids"
)
// ErrNotFound is the canonical "row not present" sentinel. Concrete
// implementations translate driver-specific errors (pgx.ErrNoRows, etc.)
// into this.
var ErrNotFound = errors.New("store: not found")
// ErrConflict is returned when a write violates a uniqueness invariant
// the caller should handle (duplicate slug, duplicate email, existing
// live pin with the same org+cid).
var ErrConflict = errors.New("store: conflict")
// Store is the root aggregate interface. A single implementation pins
// all sub-stores to the same Postgres pool and transaction context so
// composite operations can be atomic.
type Store interface {
Orgs() OrgStore
Users() UserStore
Memberships() MembershipStore
Tokens() TokenStore
Nodes() NodeStore
Pins() PinStore
Audit() AuditStore
// WithOrgContext returns a context whose queries run with the
// anchorage.org_id GUC set, so Postgres RLS applies. The returned
// context MUST be passed to every subsequent Store call inside the
// same request so the GUC is honored. Concrete implementations use
// it to set the GUC per pool connection / transaction.
WithOrgContext(ctx context.Context, orgID ids.OrgID) (context.Context, error)
// Tx runs fn inside a Postgres transaction. The closure receives a
// tx-scoped context (so per-statement cancellation stays tied to the
// tx's lifetime and any GUC set on the tx is honored) and a Store
// whose sub-stores share that single tx handle.
Tx(ctx context.Context, fn func(txCtx context.Context, tx Store) error) error
}
// Org is the domain-level organisation record.
type Org struct {
ID ids.OrgID
Slug string
Name string
CreatedAt time.Time
UpdatedAt time.Time
}
// OrgStore holds organisations.
type OrgStore interface {
Create(ctx context.Context, id ids.OrgID, slug, name string) (*Org, error)
GetByID(ctx context.Context, id ids.OrgID) (*Org, error)
GetBySlug(ctx context.Context, slug string) (*Org, error)
UpdateName(ctx context.Context, id ids.OrgID, name string) (*Org, error)
List(ctx context.Context, limit, offset int) ([]*Org, error)
}
// User is the domain-level user record.
type User struct {
ID ids.UserID
AuthentikSub string
Email string
DisplayName string
IsSysadmin bool
CreatedAt time.Time
UpdatedAt time.Time
}
// UserStore holds users.
type UserStore interface {
UpsertByAuthentikSub(ctx context.Context, id ids.UserID, sub, email, displayName string, isSysadmin bool) (*User, error)
GetByID(ctx context.Context, id ids.UserID) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
PromoteSysadmin(ctx context.Context, id ids.UserID) error
}
// Membership pairs a user with an org + role.
type Membership struct {
OrgID ids.OrgID
UserID ids.UserID
Role string // "orgadmin" | "member"
OrgSlug string // populated by List*, empty on raw gets
OrgName string
Created time.Time
}
// Role constants.
const (
RoleOrgAdmin = "orgadmin"
RoleMember = "member"
)
// MembershipStore manages the user↔org join.
type MembershipStore interface {
Add(ctx context.Context, orgID ids.OrgID, userID ids.UserID, role string) error
Remove(ctx context.Context, orgID ids.OrgID, userID ids.UserID) error
ListForUser(ctx context.Context, userID ids.UserID) ([]*Membership, error)
}
// APIToken is the metadata record for an issued JWT. The signed JWT
// value itself is never stored — revocation is handled via the denylist.
type APIToken struct {
JTI ids.TokenID
OrgID ids.OrgID
UserID ids.UserID
Label string
Scopes []string
ExpiresAt time.Time
RevokedAt *time.Time
LastUsedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
// TokenStore manages API token metadata and the denylist.
type TokenStore interface {
Create(ctx context.Context, t *APIToken) error
GetByJTI(ctx context.Context, jti ids.TokenID) (*APIToken, error)
ListForUser(ctx context.Context, orgID ids.OrgID, userID ids.UserID) ([]*APIToken, error)
Revoke(ctx context.Context, jti ids.TokenID) error
TouchLastUsed(ctx context.Context, jti ids.TokenID) error
AddDenylist(ctx context.Context, jti ids.TokenID, expiresAt time.Time, reason string) error
IsDenied(ctx context.Context, jti ids.TokenID) (bool, error)
PruneDenylist(ctx context.Context) error
}
// NodeStatus values.
const (
NodeStatusUp = "up"
NodeStatusDown = "down"
NodeStatusDrained = "drained"
)
// Node is a cluster member's registry entry.
type Node struct {
ID ids.NodeID
DisplayName string
Multiaddrs []string
RPCURL string
Status string
LastSeenAt time.Time
JoinedAt time.Time
UpdatedAt time.Time
}
// NodeStore manages the cluster registry.
type NodeStore interface {
Upsert(ctx context.Context, n *Node) error
Get(ctx context.Context, id ids.NodeID) (*Node, error)
ListAll(ctx context.Context) ([]*Node, error)
ListLive(ctx context.Context) ([]*Node, error)
TouchHeartbeat(ctx context.Context, id ids.NodeID) error
MarkStaleDown(ctx context.Context, staleAfter time.Duration) ([]ids.NodeID, error)
Drain(ctx context.Context, id ids.NodeID) error
Uncordon(ctx context.Context, id ids.NodeID) error
}
// PinStatus values (matching the IPFS Pinning API spec).
const (
PinStatusQueued = "queued"
PinStatusPinning = "pinning"
PinStatusPinned = "pinned"
PinStatusFailed = "failed"
)
// Pin is the logical pin record (what the API exposes as PinStatus).
type Pin struct {
RequestID ids.PinID
OrgID ids.OrgID
CID string
Name *string
Meta map[string]any
Origins []string
Status string
FailureReason *string
Created time.Time
UpdatedAt time.Time
}
// Placement is the per-node scheduling row for a pin.
type Placement struct {
RequestID ids.PinID
NodeID ids.NodeID
Status string
FailureReason *string
Attempts int
Fence int64
CreatedAt time.Time
UpdatedAt time.Time
// Multiaddrs is populated by joined reads; empty on raw fetches.
Multiaddrs []string
}
// PinFilter is the spec-compliant filter surface for GET /v1/pins.
//
// Every field is optional; empty values disable that clause. `Match` is
// interpreted only when `Name` is set:
//
// "exact" — case-sensitive equality
// "iexact" — case-insensitive equality (default when name is set)
// "partial" — case-sensitive substring
// "ipartial" — case-insensitive substring
//
// Before and After are both inclusive of their endpoint semantics:
// `after <= created < before`.
type PinFilter struct {
CIDs []string
Name string
Match string
Status []string
Before *time.Time
After *time.Time
Meta map[string]any
Limit int
Offset int
}
// PinStore manages pins and their placements + refcounts.
type PinStore interface {
Create(ctx context.Context, p *Pin) error
Get(ctx context.Context, orgID ids.OrgID, requestID ids.PinID) (*Pin, error)
// GetByRequestID fetches a pin without an org filter.
//
// This is used by the scheduler / rebalancer / sweeper which walk
// placements cluster-wide and need to look up the parent pin without
// already knowing its org. Do not use from the HTTP API path —
// routes there are tenant-scoped and must go through Get(orgID, rid).
GetByRequestID(ctx context.Context, requestID ids.PinID) (*Pin, error)
GetLiveByCID(ctx context.Context, orgID ids.OrgID, cid string) (*Pin, error)
UpdateStatus(ctx context.Context, requestID ids.PinID, status string, failureReason *string) error
Delete(ctx context.Context, orgID ids.OrgID, requestID ids.PinID) error
List(ctx context.Context, orgID ids.OrgID, limit, offset int) ([]*Pin, error)
// Filter runs the spec's filtered list. An empty PinFilter is
// equivalent to List with default pagination.
Filter(ctx context.Context, orgID ids.OrgID, f PinFilter) ([]*Pin, error)
// Replace swaps a pin's CID / name / meta / origins atomically.
// Caller is responsible for reshuffling placements + refcounts
// inside the same transaction (the CID change means the existing
// pin_refcount rows are stale).
Replace(ctx context.Context, orgID ids.OrgID, requestID ids.PinID, newCID string, name *string, meta map[string]any, origins []string) (*Pin, error)
InsertPlacement(ctx context.Context, requestID ids.PinID, nodeID ids.NodeID, fence int64) (*Placement, error)
GetPlacement(ctx context.Context, requestID ids.PinID, nodeID ids.NodeID) (*Placement, error)
ListPlacements(ctx context.Context, requestID ids.PinID) ([]*Placement, error)
ListPlacementsForNode(ctx context.Context, nodeID ids.NodeID, status string) ([]*Placement, error)
UpdatePlacementFenced(ctx context.Context, requestID ids.PinID, nodeID ids.NodeID, status string, failureReason *string, fence int64) (int64, error)
ReplacePlacement(ctx context.Context, requestID ids.PinID, oldNode, newNode ids.NodeID) (*Placement, error)
StuckPlacements(ctx context.Context, stuckAfter time.Duration) ([]Placement, error)
IncRefcount(ctx context.Context, nodeID ids.NodeID, cid string) error
DecRefcount(ctx context.Context, nodeID ids.NodeID, cid string) (int, error)
DeleteRefcountIfZero(ctx context.Context, nodeID ids.NodeID, cid string) error
// CountPlacementsByStatus returns a histogram of placement rows
// grouped by status. Used by the Prometheus gauge refresh loop;
// unqueried statuses are omitted from the map (a zero gauge is the
// Prometheus convention).
CountPlacementsByStatus(ctx context.Context) (map[string]int, error)
}
// AuditEntry is a single audit_log row.
type AuditEntry struct {
ID int64
OrgID *ids.OrgID
ActorUserID *ids.UserID
ActorTokenJTI *string
Action string
Target string
Result string // "ok" | "error"
Detail map[string]any
Created time.Time
}
// AuditStore is append-only. Writes must be fire-and-forget-safe —
// callers don't want audit failures to fail user requests, so the
// concrete implementation should log rather than return on error paths
// where appropriate.
type AuditStore interface {
Insert(ctx context.Context, e *AuditEntry) error
List(ctx context.Context, orgID ids.OrgID, limit, offset int) ([]*AuditEntry, error)
}