Files
galaxy-game/gamemaster/internal/adapters/postgres/engineversionstore/store.go
T
2026-05-03 07:59:03 +02:00

417 lines
13 KiB
Go

// Package engineversionstore implements the PostgreSQL-backed adapter
// for `ports.EngineVersionStore`.
//
// The package owns the on-disk shape of the `engine_versions` table
// defined in
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`
// and translates the schema-agnostic `ports.EngineVersionStore`
// interface declared in `internal/ports/engineversionstore.go` into
// concrete go-jet/v2 statements driven by the pgx driver.
//
// Insert maps PostgreSQL unique violations to engineversion.ErrConflict;
// Update applies a partial UPDATE driven by the non-nil pointer fields
// of UpdateEngineVersionInput; Deprecate is idempotent on the
// already-deprecated row; IsReferencedByActiveRuntime probes the
// runtime_records table for non-finished references.
package engineversionstore
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"galaxy/gamemaster/internal/adapters/postgres/internal/sqlx"
pgtable "galaxy/gamemaster/internal/adapters/postgres/jet/gamemaster/table"
"galaxy/gamemaster/internal/domain/engineversion"
"galaxy/gamemaster/internal/domain/runtime"
"galaxy/gamemaster/internal/ports"
pg "github.com/go-jet/jet/v2/postgres"
)
// emptyOptionsJSON is the default value persisted when a caller hands
// us an empty Options slice. It matches the SQL column default.
var emptyOptionsJSON = []byte("{}")
// Config configures one PostgreSQL-backed engine-version store. The
// store does not own the underlying *sql.DB lifecycle.
type Config struct {
DB *sql.DB
OperationTimeout time.Duration
}
// Store persists Game Master engine-version registry rows in
// PostgreSQL.
type Store struct {
db *sql.DB
operationTimeout time.Duration
}
// New constructs one PostgreSQL-backed engine-version store from cfg.
func New(cfg Config) (*Store, error) {
if cfg.DB == nil {
return nil, errors.New("new postgres engine version store: db must not be nil")
}
if cfg.OperationTimeout <= 0 {
return nil, errors.New("new postgres engine version store: operation timeout must be positive")
}
return &Store{
db: cfg.DB,
operationTimeout: cfg.OperationTimeout,
}, nil
}
// engineVersionSelectColumns matches scanRow's column order.
var engineVersionSelectColumns = pg.ColumnList{
pgtable.EngineVersions.Version,
pgtable.EngineVersions.ImageRef,
pgtable.EngineVersions.Options,
pgtable.EngineVersions.Status,
pgtable.EngineVersions.CreatedAt,
pgtable.EngineVersions.UpdatedAt,
}
// Get returns the row identified by version. Returns
// engineversion.ErrNotFound when no row exists.
func (store *Store) Get(ctx context.Context, version string) (engineversion.EngineVersion, error) {
if store == nil || store.db == nil {
return engineversion.EngineVersion{}, errors.New("get engine version: nil store")
}
if strings.TrimSpace(version) == "" {
return engineversion.EngineVersion{}, fmt.Errorf("get engine version: version must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get engine version", store.operationTimeout)
if err != nil {
return engineversion.EngineVersion{}, err
}
defer cancel()
stmt := pg.SELECT(engineVersionSelectColumns).
FROM(pgtable.EngineVersions).
WHERE(pgtable.EngineVersions.Version.EQ(pg.String(version)))
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
got, err := scanRow(row)
if sqlx.IsNoRows(err) {
return engineversion.EngineVersion{}, engineversion.ErrNotFound
}
if err != nil {
return engineversion.EngineVersion{}, fmt.Errorf("get engine version: %w", err)
}
return got, nil
}
// List returns every row whose status matches statusFilter (when
// non-nil), ordered by version ASC.
func (store *Store) List(ctx context.Context, statusFilter *engineversion.Status) ([]engineversion.EngineVersion, error) {
if store == nil || store.db == nil {
return nil, errors.New("list engine versions: nil store")
}
if statusFilter != nil && !statusFilter.IsKnown() {
return nil, fmt.Errorf("list engine versions: status %q is unsupported", *statusFilter)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list engine versions", store.operationTimeout)
if err != nil {
return nil, err
}
defer cancel()
stmt := pg.SELECT(engineVersionSelectColumns).
FROM(pgtable.EngineVersions)
if statusFilter != nil {
stmt = stmt.WHERE(pgtable.EngineVersions.Status.EQ(pg.String(string(*statusFilter))))
}
stmt = stmt.ORDER_BY(pgtable.EngineVersions.Version.ASC())
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return nil, fmt.Errorf("list engine versions: %w", err)
}
defer rows.Close()
versions := make([]engineversion.EngineVersion, 0)
for rows.Next() {
got, err := scanRow(rows)
if err != nil {
return nil, fmt.Errorf("list engine versions: scan: %w", err)
}
versions = append(versions, got)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list engine versions: %w", err)
}
if len(versions) == 0 {
return nil, nil
}
return versions, nil
}
// Insert installs record into the registry. Returns
// engineversion.ErrConflict when a row with the same version already
// exists.
func (store *Store) Insert(ctx context.Context, record engineversion.EngineVersion) error {
if store == nil || store.db == nil {
return errors.New("insert engine version: nil store")
}
if err := record.Validate(); err != nil {
return fmt.Errorf("insert engine version: %w", err)
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "insert engine version", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
options := record.Options
if len(options) == 0 {
options = emptyOptionsJSON
}
stmt := pgtable.EngineVersions.INSERT(
pgtable.EngineVersions.Version,
pgtable.EngineVersions.ImageRef,
pgtable.EngineVersions.Options,
pgtable.EngineVersions.Status,
pgtable.EngineVersions.CreatedAt,
pgtable.EngineVersions.UpdatedAt,
).VALUES(
record.Version,
record.ImageRef,
string(options),
string(record.Status),
record.CreatedAt.UTC(),
record.UpdatedAt.UTC(),
)
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
if sqlx.IsUniqueViolation(err) {
return fmt.Errorf("insert engine version: %w", engineversion.ErrConflict)
}
return fmt.Errorf("insert engine version: %w", err)
}
return nil
}
// Update applies a partial update to one engine-version row.
// updated_at is always refreshed from input.Now. Returns
// engineversion.ErrNotFound when the row is absent.
func (store *Store) Update(ctx context.Context, input ports.UpdateEngineVersionInput) error {
if store == nil || store.db == nil {
return errors.New("update engine version: nil store")
}
if err := input.Validate(); err != nil {
return err
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update engine version", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
now := input.Now.UTC()
assignments := []any{
pgtable.EngineVersions.UpdatedAt.SET(pg.TimestampzT(now)),
}
if input.ImageRef != nil {
assignments = append(assignments,
pgtable.EngineVersions.ImageRef.SET(pg.String(*input.ImageRef)))
}
if input.Options != nil {
options := *input.Options
if len(options) == 0 {
options = emptyOptionsJSON
}
assignments = append(assignments,
pgtable.EngineVersions.Options.SET(
pg.StringExp(pg.CAST(pg.String(string(options))).AS("jsonb")),
))
}
if input.Status != nil {
assignments = append(assignments,
pgtable.EngineVersions.Status.SET(pg.String(string(*input.Status))))
}
stmt := pgtable.EngineVersions.UPDATE(pgtable.EngineVersions.UpdatedAt).
SET(assignments[0], assignments[1:]...).
WHERE(pgtable.EngineVersions.Version.EQ(pg.String(input.Version)))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("update engine version: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("update engine version: rows affected: %w", err)
}
if affected == 0 {
return engineversion.ErrNotFound
}
return nil
}
// Deprecate sets `status=deprecated` and refreshes `updated_at` for
// version. Returns engineversion.ErrNotFound when no row exists.
// Calling Deprecate on an already deprecated row succeeds with no
// further mutation (idempotent).
func (store *Store) Deprecate(ctx context.Context, version string, now time.Time) error {
if store == nil || store.db == nil {
return errors.New("deprecate engine version: nil store")
}
if strings.TrimSpace(version) == "" {
return fmt.Errorf("deprecate engine version: version must not be empty")
}
if now.IsZero() {
return fmt.Errorf("deprecate engine version: now must not be zero")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "deprecate engine version", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
// Pre-check the row's existence so we can surface a precise
// ErrNotFound; a 0-row affected from the UPDATE alone could mean
// "missing" or "already deprecated".
current, err := store.Get(operationCtx, version)
if err != nil {
return err
}
if current.Status == engineversion.StatusDeprecated {
return nil
}
stmt := pgtable.EngineVersions.UPDATE(pgtable.EngineVersions.Status).
SET(
pgtable.EngineVersions.Status.SET(pg.String(string(engineversion.StatusDeprecated))),
pgtable.EngineVersions.UpdatedAt.SET(pg.TimestampzT(now.UTC())),
).
WHERE(pgtable.EngineVersions.Version.EQ(pg.String(version)))
query, args := stmt.Sql()
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
return fmt.Errorf("deprecate engine version: %w", err)
}
return nil
}
// Delete removes the row identified by version. Returns
// engineversion.ErrNotFound when no row matches. The adapter does not
// inspect runtime_records; the service layer guards against active
// references through IsReferencedByActiveRuntime before issuing Delete.
func (store *Store) Delete(ctx context.Context, version string) error {
if store == nil || store.db == nil {
return errors.New("delete engine version: nil store")
}
if strings.TrimSpace(version) == "" {
return fmt.Errorf("delete engine version: version must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete engine version", store.operationTimeout)
if err != nil {
return err
}
defer cancel()
stmt := pgtable.EngineVersions.DELETE().
WHERE(pgtable.EngineVersions.Version.EQ(pg.String(version)))
query, args := stmt.Sql()
result, err := store.db.ExecContext(operationCtx, query, args...)
if err != nil {
return fmt.Errorf("delete engine version: %w", err)
}
affected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("delete engine version: rows affected: %w", err)
}
if affected == 0 {
return engineversion.ErrNotFound
}
return nil
}
// IsReferencedByActiveRuntime reports whether any non-finished and
// non-stopped runtime row currently references version through
// `current_engine_version`.
func (store *Store) IsReferencedByActiveRuntime(ctx context.Context, version string) (bool, error) {
if store == nil || store.db == nil {
return false, errors.New("is referenced by active runtime: nil store")
}
if strings.TrimSpace(version) == "" {
return false, fmt.Errorf("is referenced by active runtime: version must not be empty")
}
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "is referenced by active runtime", store.operationTimeout)
if err != nil {
return false, err
}
defer cancel()
stmt := pg.SELECT(pg.Int32(1).AS("present")).
FROM(pgtable.RuntimeRecords).
WHERE(pg.AND(
pgtable.RuntimeRecords.CurrentEngineVersion.EQ(pg.String(version)),
pgtable.RuntimeRecords.Status.NOT_IN(
pg.String(string(runtime.StatusFinished)),
pg.String(string(runtime.StatusStopped)),
),
)).
LIMIT(1)
query, args := stmt.Sql()
row := store.db.QueryRowContext(operationCtx, query, args...)
var present int32
if err := row.Scan(&present); err != nil {
if sqlx.IsNoRows(err) {
return false, nil
}
return false, fmt.Errorf("is referenced by active runtime: %w", err)
}
return true, nil
}
// rowScanner abstracts *sql.Row and *sql.Rows so scanRow can be shared
// across single-row and iterated reads.
type rowScanner interface {
Scan(dest ...any) error
}
// scanRow scans one engine_versions row from rs.
func scanRow(rs rowScanner) (engineversion.EngineVersion, error) {
var (
version string
imageRef string
options string
status string
createdAt time.Time
updatedAt time.Time
)
if err := rs.Scan(&version, &imageRef, &options, &status, &createdAt, &updatedAt); err != nil {
return engineversion.EngineVersion{}, err
}
return engineversion.EngineVersion{
Version: version,
ImageRef: imageRef,
Options: []byte(options),
Status: engineversion.Status(status),
CreatedAt: createdAt.UTC(),
UpdatedAt: updatedAt.UTC(),
}, nil
}
// Ensure Store satisfies the ports.EngineVersionStore interface at
// compile time.
var _ ports.EngineVersionStore = (*Store)(nil)