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