feat: gamemaster
This commit is contained in:
@@ -0,0 +1,416 @@
|
||||
// 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)
|
||||
@@ -0,0 +1,403 @@
|
||||
package engineversionstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/postgres/engineversionstore"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/internal/pgtest"
|
||||
"galaxy/gamemaster/internal/domain/engineversion"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) { pgtest.RunMain(m) }
|
||||
|
||||
func newStore(t *testing.T) *engineversionstore.Store {
|
||||
t.Helper()
|
||||
pgtest.TruncateAll(t)
|
||||
store, err := engineversionstore.New(engineversionstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
|
||||
// poolOnly returns the shared pool for tests that have to seed
|
||||
// runtime_records directly (e.g. TestIsReferencedByActiveRuntime).
|
||||
func poolOnly(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
pgtest.TruncateAll(t)
|
||||
return pgtest.Ensure(t).Pool()
|
||||
}
|
||||
|
||||
func validVersion(version string, createdAt time.Time, status engineversion.Status) engineversion.EngineVersion {
|
||||
return engineversion.EngineVersion{
|
||||
Version: version,
|
||||
ImageRef: "ghcr.io/galaxy/game:" + version,
|
||||
Options: []byte(`{"max_planets":120}`),
|
||||
Status: status,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidConfig(t *testing.T) {
|
||||
_, err := engineversionstore.New(engineversionstore.Config{})
|
||||
require.Error(t, err)
|
||||
|
||||
store, err := engineversionstore.New(engineversionstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: 0,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, store)
|
||||
}
|
||||
|
||||
func TestInsertGetRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
record := validVersion("v1.2.3", now, engineversion.StatusActive)
|
||||
|
||||
require.NoError(t, store.Insert(ctx, record))
|
||||
|
||||
got, err := store.Get(ctx, "v1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.Version, got.Version)
|
||||
assert.Equal(t, record.ImageRef, got.ImageRef)
|
||||
assert.JSONEq(t, `{"max_planets":120}`, string(got.Options))
|
||||
assert.Equal(t, engineversion.StatusActive, got.Status)
|
||||
assert.True(t, got.CreatedAt.Equal(now))
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
assert.Equal(t, time.UTC, got.CreatedAt.Location())
|
||||
assert.Equal(t, time.UTC, got.UpdatedAt.Location())
|
||||
}
|
||||
|
||||
func TestInsertEmptyOptionsDefaultsToObject(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
record := validVersion("v1.2.3", now, engineversion.StatusActive)
|
||||
record.Options = nil
|
||||
|
||||
require.NoError(t, store.Insert(ctx, record))
|
||||
|
||||
got, err := store.Get(ctx, "v1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, `{}`, string(got.Options))
|
||||
}
|
||||
|
||||
func TestInsertConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
record := validVersion("v1.2.3", now, engineversion.StatusActive)
|
||||
require.NoError(t, store.Insert(ctx, record))
|
||||
|
||||
err := store.Insert(ctx, record)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrConflict), "want ErrConflict, got %v", err)
|
||||
}
|
||||
|
||||
func TestGetNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.Get(ctx, "v9.9.9")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestListNoFilter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.2.0", now, engineversion.StatusDeprecated)))
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.3.0", now, engineversion.StatusActive)))
|
||||
|
||||
all, err := store.List(ctx, nil)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, all, 3)
|
||||
assert.Equal(t, "v1.2.0", all[0].Version)
|
||||
assert.Equal(t, "v1.2.3", all[1].Version)
|
||||
assert.Equal(t, "v1.3.0", all[2].Version)
|
||||
}
|
||||
|
||||
func TestListByStatusFilter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.2.0", now, engineversion.StatusDeprecated)))
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.3.0", now, engineversion.StatusActive)))
|
||||
|
||||
active := engineversion.StatusActive
|
||||
got, err := store.List(ctx, &active)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
assert.Equal(t, "v1.2.3", got[0].Version)
|
||||
assert.Equal(t, "v1.3.0", got[1].Version)
|
||||
|
||||
deprecated := engineversion.StatusDeprecated
|
||||
got, err = store.List(ctx, &deprecated)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "v1.2.0", got[0].Version)
|
||||
}
|
||||
|
||||
func TestListUnknownStatusRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
exotic := engineversion.Status("exotic")
|
||||
_, err := store.List(ctx, &exotic)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUpdateImageRefOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
|
||||
|
||||
newRef := "ghcr.io/galaxy/game:v1.2.4"
|
||||
updateAt := now.Add(time.Minute)
|
||||
require.NoError(t, store.Update(ctx, ports.UpdateEngineVersionInput{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: &newRef,
|
||||
Now: updateAt,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "v1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newRef, got.ImageRef)
|
||||
assert.Equal(t, engineversion.StatusActive, got.Status)
|
||||
assert.True(t, got.UpdatedAt.Equal(updateAt))
|
||||
}
|
||||
|
||||
func TestUpdateAllFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
|
||||
|
||||
newRef := "ghcr.io/galaxy/game:v1.2.4"
|
||||
newOptions := []byte(`{"max_planets":240,"hot_seat":true}`)
|
||||
deprecated := engineversion.StatusDeprecated
|
||||
updateAt := now.Add(time.Minute)
|
||||
require.NoError(t, store.Update(ctx, ports.UpdateEngineVersionInput{
|
||||
Version: "v1.2.3",
|
||||
ImageRef: &newRef,
|
||||
Options: &newOptions,
|
||||
Status: &deprecated,
|
||||
Now: updateAt,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "v1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newRef, got.ImageRef)
|
||||
assert.JSONEq(t, string(newOptions), string(got.Options))
|
||||
assert.Equal(t, engineversion.StatusDeprecated, got.Status)
|
||||
assert.True(t, got.UpdatedAt.Equal(updateAt))
|
||||
}
|
||||
|
||||
func TestUpdateNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
newRef := "ghcr.io/galaxy/game:v1.2.4"
|
||||
err := store.Update(ctx, ports.UpdateEngineVersionInput{
|
||||
Version: "v9.9.9",
|
||||
ImageRef: &newRef,
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestDeprecateHappy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
|
||||
|
||||
deprecateAt := now.Add(time.Hour)
|
||||
require.NoError(t, store.Deprecate(ctx, "v1.2.3", deprecateAt))
|
||||
|
||||
got, err := store.Get(ctx, "v1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, engineversion.StatusDeprecated, got.Status)
|
||||
assert.True(t, got.UpdatedAt.Equal(deprecateAt))
|
||||
}
|
||||
|
||||
func TestDeprecateIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusDeprecated)))
|
||||
|
||||
require.NoError(t, store.Deprecate(ctx, "v1.2.3", now.Add(time.Hour)))
|
||||
|
||||
got, err := store.Get(ctx, "v1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, engineversion.StatusDeprecated, got.Status)
|
||||
// updated_at must remain at the original insert value because the
|
||||
// idempotent path performs no UPDATE.
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
}
|
||||
|
||||
func TestDeprecateNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.Deprecate(ctx, "v9.9.9", time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC))
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestDeprecateRejectsZeroNow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.Deprecate(ctx, "v1.2.3", time.Time{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDeleteHappy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
|
||||
|
||||
require.NoError(t, store.Delete(ctx, "v1.2.3"))
|
||||
|
||||
_, err := store.Get(ctx, "v1.2.3")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestDeleteNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.Delete(ctx, "v9.9.9")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, engineversion.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestDeleteRejectsEmptyVersion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.Delete(ctx, "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestIsReferencedByActiveRuntime exercises the join between
|
||||
// engine_versions and runtime_records. The runtime rows are seeded by
|
||||
// inserting directly through the shared pool, since the
|
||||
// runtimerecordstore adapter lives in a sibling package.
|
||||
func TestIsReferencedByActiveRuntime(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pool := poolOnly(t)
|
||||
store, err := engineversionstore.New(engineversionstore.Config{
|
||||
DB: pool,
|
||||
OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.2.3", now, engineversion.StatusActive)))
|
||||
require.NoError(t, store.Insert(ctx, validVersion("v1.2.4", now, engineversion.StatusActive)))
|
||||
|
||||
insertRuntime(t, pool, "game-running", runtime.StatusRunning, "v1.2.3", now)
|
||||
insertRuntime(t, pool, "game-finished", runtime.StatusFinished, "v1.2.3", now)
|
||||
insertRuntime(t, pool, "game-stopped", runtime.StatusStopped, "v1.2.3", now)
|
||||
|
||||
used, err := store.IsReferencedByActiveRuntime(ctx, "v1.2.3")
|
||||
require.NoError(t, err)
|
||||
assert.True(t, used, "v1.2.3 must be reported referenced (game-running uses it)")
|
||||
|
||||
unused, err := store.IsReferencedByActiveRuntime(ctx, "v1.2.4")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, unused, "v1.2.4 has no active runtime reference")
|
||||
|
||||
missing, err := store.IsReferencedByActiveRuntime(ctx, "v9.9.9")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, missing)
|
||||
}
|
||||
|
||||
// insertRuntime seeds one runtime_records row directly via raw SQL. The
|
||||
// adapter under test is engineversionstore; using the runtimerecordstore
|
||||
// here would couple two adapter test suites unnecessarily.
|
||||
func insertRuntime(t *testing.T, pool *sql.DB, gameID string, status runtime.Status, engineVersion string, createdAt time.Time) {
|
||||
t.Helper()
|
||||
at := createdAt.UTC()
|
||||
var stoppedAt, finishedAt any
|
||||
switch status {
|
||||
case runtime.StatusStopped:
|
||||
stoppedAt = at
|
||||
case runtime.StatusFinished:
|
||||
finishedAt = at
|
||||
}
|
||||
const stmt = `
|
||||
INSERT INTO runtime_records (
|
||||
game_id, status, engine_endpoint, current_image_ref,
|
||||
current_engine_version, turn_schedule, current_turn,
|
||||
next_generation_at, skip_next_tick, engine_health,
|
||||
created_at, updated_at, started_at, stopped_at, finished_at
|
||||
) VALUES (
|
||||
$1, $2, 'http://galaxy-game-' || $1 || ':8080', 'ghcr.io/galaxy/game:' || $3,
|
||||
$3, '0 18 * * *', 0,
|
||||
NULL, false, '',
|
||||
$4, $5, $6, $7, $8
|
||||
)`
|
||||
_, err := pool.ExecContext(context.Background(), stmt,
|
||||
gameID, string(status), engineVersion,
|
||||
at, at, at, stoppedAt, finishedAt,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestIsReferencedRejectsEmptyVersion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.IsReferencedByActiveRuntime(ctx, "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetRejectsEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.Get(ctx, "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUpdateRejectsInvalidInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.Update(ctx, ports.UpdateEngineVersionInput{Version: "v1.2.3"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
// Package pgtest exposes the testcontainers-backed PostgreSQL bootstrap
|
||||
// shared by every Game Master PG adapter test. The package is regular
|
||||
// Go code — not a `_test.go` file — so it can be imported by the
|
||||
// `_test.go` files in the four sibling store packages
|
||||
// (`runtimerecordstore`, `engineversionstore`, `playermappingstore`,
|
||||
// `operationlog`).
|
||||
//
|
||||
// No production code in `cmd/gamemaster` or in the runtime imports this
|
||||
// package. The testcontainers-go dependency therefore stays out of the
|
||||
// production binary's import graph.
|
||||
package pgtest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/url"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/postgres"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/postgres/migrations"
|
||||
|
||||
testcontainers "github.com/testcontainers/testcontainers-go"
|
||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
const (
|
||||
postgresImage = "postgres:16-alpine"
|
||||
superUser = "galaxy"
|
||||
superPassword = "galaxy"
|
||||
superDatabase = "galaxy_gamemaster"
|
||||
serviceRole = "gamemasterservice"
|
||||
servicePassword = "gamemasterservice"
|
||||
serviceSchema = "gamemaster"
|
||||
containerStartup = 90 * time.Second
|
||||
|
||||
// OperationTimeout is the per-statement timeout used by every store
|
||||
// constructed via the per-package newStore helpers. Tests may pass a
|
||||
// smaller value if they need to assert deadline behaviour explicitly.
|
||||
OperationTimeout = 10 * time.Second
|
||||
)
|
||||
|
||||
// Env holds the per-process container plus the *sql.DB pool already
|
||||
// provisioned with the gamemaster schema, role, and migrations applied.
|
||||
type Env struct {
|
||||
container *tcpostgres.PostgresContainer
|
||||
pool *sql.DB
|
||||
}
|
||||
|
||||
// Pool returns the shared pool. Tests truncate per-table state before
|
||||
// each run via TruncateAll.
|
||||
func (env *Env) Pool() *sql.DB { return env.pool }
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
cur *Env
|
||||
curEr error
|
||||
)
|
||||
|
||||
// Ensure starts the PostgreSQL container on first invocation and applies
|
||||
// the embedded goose migrations. Subsequent invocations reuse the same
|
||||
// container/pool. When Docker is unavailable Ensure calls t.Skip with the
|
||||
// underlying error so the test suite still passes on machines without
|
||||
// Docker.
|
||||
func Ensure(t testing.TB) *Env {
|
||||
t.Helper()
|
||||
once.Do(func() {
|
||||
cur, curEr = start()
|
||||
})
|
||||
if curEr != nil {
|
||||
t.Skipf("postgres container start failed (Docker unavailable?): %v", curEr)
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// TruncateAll wipes every Game Master table inside the shared pool,
|
||||
// leaving the schema and indexes intact. Use it from each test that
|
||||
// needs a clean slate.
|
||||
func TruncateAll(t testing.TB) {
|
||||
t.Helper()
|
||||
env := Ensure(t)
|
||||
const stmt = `TRUNCATE TABLE runtime_records, engine_versions, player_mappings, operation_log RESTART IDENTITY CASCADE`
|
||||
if _, err := env.pool.ExecContext(context.Background(), stmt); err != nil {
|
||||
t.Fatalf("truncate gamemaster tables: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Shutdown terminates the shared container and closes the pool. It is
|
||||
// invoked from each test package's TestMain after `m.Run` returns so the
|
||||
// container is released even if individual tests panic.
|
||||
func Shutdown() {
|
||||
if cur == nil {
|
||||
return
|
||||
}
|
||||
if cur.pool != nil {
|
||||
_ = cur.pool.Close()
|
||||
}
|
||||
if cur.container != nil {
|
||||
_ = testcontainers.TerminateContainer(cur.container)
|
||||
}
|
||||
cur = nil
|
||||
}
|
||||
|
||||
// RunMain is a convenience helper for each store package's TestMain: it
|
||||
// runs the test main, captures the exit code, shuts the container down,
|
||||
// and exits. Wiring it through one helper keeps every TestMain to two
|
||||
// lines.
|
||||
func RunMain(m *testing.M) {
|
||||
code := m.Run()
|
||||
Shutdown()
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
func start() (*Env, error) {
|
||||
ctx := context.Background()
|
||||
container, err := tcpostgres.Run(ctx, postgresImage,
|
||||
tcpostgres.WithDatabase(superDatabase),
|
||||
tcpostgres.WithUsername(superUser),
|
||||
tcpostgres.WithPassword(superPassword),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(containerStartup),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseDSN, err := container.ConnectionString(ctx, "sslmode=disable")
|
||||
if err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
if err := provisionRoleAndSchema(ctx, baseDSN); err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
scopedDSN, err := dsnForServiceRole(baseDSN)
|
||||
if err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
cfg := postgres.DefaultConfig()
|
||||
cfg.PrimaryDSN = scopedDSN
|
||||
cfg.OperationTimeout = OperationTimeout
|
||||
pool, err := postgres.OpenPrimary(ctx, cfg)
|
||||
if err != nil {
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
if err := postgres.Ping(ctx, pool, OperationTimeout); err != nil {
|
||||
_ = pool.Close()
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
if err := postgres.RunMigrations(ctx, pool, migrations.FS(), "."); err != nil {
|
||||
_ = pool.Close()
|
||||
_ = testcontainers.TerminateContainer(container)
|
||||
return nil, err
|
||||
}
|
||||
return &Env{container: container, pool: pool}, nil
|
||||
}
|
||||
|
||||
func provisionRoleAndSchema(ctx context.Context, baseDSN string) error {
|
||||
cfg := postgres.DefaultConfig()
|
||||
cfg.PrimaryDSN = baseDSN
|
||||
cfg.OperationTimeout = OperationTimeout
|
||||
db, err := postgres.OpenPrimary(ctx, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
statements := []string{
|
||||
`DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gamemasterservice') THEN
|
||||
CREATE ROLE gamemasterservice LOGIN PASSWORD 'gamemasterservice';
|
||||
END IF;
|
||||
END $$;`,
|
||||
`CREATE SCHEMA IF NOT EXISTS gamemaster AUTHORIZATION gamemasterservice;`,
|
||||
`GRANT USAGE ON SCHEMA gamemaster TO gamemasterservice;`,
|
||||
}
|
||||
for _, statement := range statements {
|
||||
if _, err := db.ExecContext(ctx, statement); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func dsnForServiceRole(baseDSN string) (string, error) {
|
||||
parsed, err := url.Parse(baseDSN)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values := url.Values{}
|
||||
values.Set("search_path", serviceSchema)
|
||||
values.Set("sslmode", "disable")
|
||||
scoped := url.URL{
|
||||
Scheme: parsed.Scheme,
|
||||
User: url.UserPassword(serviceRole, servicePassword),
|
||||
Host: parsed.Host,
|
||||
Path: parsed.Path,
|
||||
RawQuery: values.Encode(),
|
||||
}
|
||||
return scoped.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
// Package sqlx contains the small set of helpers shared by every Game
|
||||
// Master PostgreSQL adapter (runtimerecordstore, engineversionstore,
|
||||
// playermappingstore, operationlog). The helpers centralise the
|
||||
// boundary translations for nullable timestamps and the pgx SQLSTATE
|
||||
// codes the adapters interpret as domain conflicts.
|
||||
package sqlx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
// PgUniqueViolationCode identifies the SQLSTATE returned by PostgreSQL
|
||||
// when a UNIQUE constraint is violated by INSERT or UPDATE.
|
||||
const PgUniqueViolationCode = "23505"
|
||||
|
||||
// IsUniqueViolation reports whether err is a PostgreSQL unique-violation,
|
||||
// regardless of constraint name.
|
||||
func IsUniqueViolation(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) {
|
||||
return false
|
||||
}
|
||||
return pgErr.Code == PgUniqueViolationCode
|
||||
}
|
||||
|
||||
// IsNoRows reports whether err is sql.ErrNoRows.
|
||||
func IsNoRows(err error) bool {
|
||||
return errors.Is(err, sql.ErrNoRows)
|
||||
}
|
||||
|
||||
// NullableTime returns t.UTC() when non-zero, otherwise nil so the column
|
||||
// is bound as SQL NULL.
|
||||
func NullableTime(t time.Time) any {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
return t.UTC()
|
||||
}
|
||||
|
||||
// NullableTimePtr returns t.UTC() when t is non-nil and non-zero,
|
||||
// otherwise nil. Companion of NullableTime for domain types that use
|
||||
// *time.Time to express absent timestamps.
|
||||
func NullableTimePtr(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return NullableTime(*t)
|
||||
}
|
||||
|
||||
// NullableString returns value when non-empty, otherwise nil so the
|
||||
// column is bound as SQL NULL.
|
||||
func NullableString(value string) any {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// StringFromNullable copies an optional sql.NullString into a domain
|
||||
// string. NULL becomes the empty string, matching the Game Master
|
||||
// domain convention that empty == NULL for nullable text columns.
|
||||
func StringFromNullable(value sql.NullString) string {
|
||||
if !value.Valid {
|
||||
return ""
|
||||
}
|
||||
return value.String
|
||||
}
|
||||
|
||||
// TimeFromNullable copies an optional sql.NullTime into a domain
|
||||
// time.Time, applying the global UTC normalisation rule. NULL values
|
||||
// become the zero time.Time.
|
||||
func TimeFromNullable(value sql.NullTime) time.Time {
|
||||
if !value.Valid {
|
||||
return time.Time{}
|
||||
}
|
||||
return value.Time.UTC()
|
||||
}
|
||||
|
||||
// TimePtrFromNullable copies an optional sql.NullTime into a domain
|
||||
// *time.Time. NULL becomes nil; non-NULL values are wrapped after UTC
|
||||
// normalisation.
|
||||
func TimePtrFromNullable(value sql.NullTime) *time.Time {
|
||||
if !value.Valid {
|
||||
return nil
|
||||
}
|
||||
t := value.Time.UTC()
|
||||
return &t
|
||||
}
|
||||
|
||||
// WithTimeout derives a child context bounded by timeout and prefixes
|
||||
// context errors with operation. Callers must always invoke the returned
|
||||
// cancel.
|
||||
func WithTimeout(ctx context.Context, operation string, timeout time.Duration) (context.Context, context.CancelFunc, error) {
|
||||
if ctx == nil {
|
||||
return nil, nil, fmt.Errorf("%s: nil context", operation)
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", operation, err)
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return nil, nil, fmt.Errorf("%s: operation timeout must be positive", operation)
|
||||
}
|
||||
bounded, cancel := context.WithTimeout(ctx, timeout)
|
||||
return bounded, cancel, nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type EngineVersions struct {
|
||||
Version string `sql:"primary_key"`
|
||||
ImageRef string
|
||||
Options string
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type GooseDbVersion struct {
|
||||
ID int32 `sql:"primary_key"`
|
||||
VersionID int64
|
||||
IsApplied bool
|
||||
Tstamp time.Time
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type OperationLog struct {
|
||||
ID int64 `sql:"primary_key"`
|
||||
GameID string
|
||||
OpKind string
|
||||
OpSource string
|
||||
SourceRef string
|
||||
Outcome string
|
||||
ErrorCode string
|
||||
ErrorMessage string
|
||||
StartedAt time.Time
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type PlayerMappings struct {
|
||||
GameID string `sql:"primary_key"`
|
||||
UserID string `sql:"primary_key"`
|
||||
RaceName string
|
||||
EnginePlayerUUID string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type RuntimeRecords struct {
|
||||
GameID string `sql:"primary_key"`
|
||||
Status string
|
||||
EngineEndpoint string
|
||||
CurrentImageRef string
|
||||
CurrentEngineVersion string
|
||||
TurnSchedule string
|
||||
CurrentTurn int32
|
||||
NextGenerationAt *time.Time
|
||||
SkipNextTick bool
|
||||
EngineHealth string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
StartedAt *time.Time
|
||||
StoppedAt *time.Time
|
||||
FinishedAt *time.Time
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var EngineVersions = newEngineVersionsTable("gamemaster", "engine_versions", "")
|
||||
|
||||
type engineVersionsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
Version postgres.ColumnString
|
||||
ImageRef postgres.ColumnString
|
||||
Options postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type EngineVersionsTable struct {
|
||||
engineVersionsTable
|
||||
|
||||
EXCLUDED engineVersionsTable
|
||||
}
|
||||
|
||||
// AS creates new EngineVersionsTable with assigned alias
|
||||
func (a EngineVersionsTable) AS(alias string) *EngineVersionsTable {
|
||||
return newEngineVersionsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new EngineVersionsTable with assigned schema name
|
||||
func (a EngineVersionsTable) FromSchema(schemaName string) *EngineVersionsTable {
|
||||
return newEngineVersionsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new EngineVersionsTable with assigned table prefix
|
||||
func (a EngineVersionsTable) WithPrefix(prefix string) *EngineVersionsTable {
|
||||
return newEngineVersionsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new EngineVersionsTable with assigned table suffix
|
||||
func (a EngineVersionsTable) WithSuffix(suffix string) *EngineVersionsTable {
|
||||
return newEngineVersionsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newEngineVersionsTable(schemaName, tableName, alias string) *EngineVersionsTable {
|
||||
return &EngineVersionsTable{
|
||||
engineVersionsTable: newEngineVersionsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newEngineVersionsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newEngineVersionsTableImpl(schemaName, tableName, alias string) engineVersionsTable {
|
||||
var (
|
||||
VersionColumn = postgres.StringColumn("version")
|
||||
ImageRefColumn = postgres.StringColumn("image_ref")
|
||||
OptionsColumn = postgres.StringColumn("options")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
allColumns = postgres.ColumnList{VersionColumn, ImageRefColumn, OptionsColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{ImageRefColumn, OptionsColumn, StatusColumn, CreatedAtColumn, UpdatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{OptionsColumn}
|
||||
)
|
||||
|
||||
return engineVersionsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
Version: VersionColumn,
|
||||
ImageRef: ImageRefColumn,
|
||||
Options: OptionsColumn,
|
||||
Status: StatusColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var GooseDbVersion = newGooseDbVersionTable("gamemaster", "goose_db_version", "")
|
||||
|
||||
type gooseDbVersionTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ID postgres.ColumnInteger
|
||||
VersionID postgres.ColumnInteger
|
||||
IsApplied postgres.ColumnBool
|
||||
Tstamp postgres.ColumnTimestamp
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type GooseDbVersionTable struct {
|
||||
gooseDbVersionTable
|
||||
|
||||
EXCLUDED gooseDbVersionTable
|
||||
}
|
||||
|
||||
// AS creates new GooseDbVersionTable with assigned alias
|
||||
func (a GooseDbVersionTable) AS(alias string) *GooseDbVersionTable {
|
||||
return newGooseDbVersionTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new GooseDbVersionTable with assigned schema name
|
||||
func (a GooseDbVersionTable) FromSchema(schemaName string) *GooseDbVersionTable {
|
||||
return newGooseDbVersionTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new GooseDbVersionTable with assigned table prefix
|
||||
func (a GooseDbVersionTable) WithPrefix(prefix string) *GooseDbVersionTable {
|
||||
return newGooseDbVersionTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new GooseDbVersionTable with assigned table suffix
|
||||
func (a GooseDbVersionTable) WithSuffix(suffix string) *GooseDbVersionTable {
|
||||
return newGooseDbVersionTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newGooseDbVersionTable(schemaName, tableName, alias string) *GooseDbVersionTable {
|
||||
return &GooseDbVersionTable{
|
||||
gooseDbVersionTable: newGooseDbVersionTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newGooseDbVersionTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newGooseDbVersionTableImpl(schemaName, tableName, alias string) gooseDbVersionTable {
|
||||
var (
|
||||
IDColumn = postgres.IntegerColumn("id")
|
||||
VersionIDColumn = postgres.IntegerColumn("version_id")
|
||||
IsAppliedColumn = postgres.BoolColumn("is_applied")
|
||||
TstampColumn = postgres.TimestampColumn("tstamp")
|
||||
allColumns = postgres.ColumnList{IDColumn, VersionIDColumn, IsAppliedColumn, TstampColumn}
|
||||
mutableColumns = postgres.ColumnList{VersionIDColumn, IsAppliedColumn, TstampColumn}
|
||||
defaultColumns = postgres.ColumnList{TstampColumn}
|
||||
)
|
||||
|
||||
return gooseDbVersionTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
VersionID: VersionIDColumn,
|
||||
IsApplied: IsAppliedColumn,
|
||||
Tstamp: TstampColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var OperationLog = newOperationLogTable("gamemaster", "operation_log", "")
|
||||
|
||||
type operationLogTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
ID postgres.ColumnInteger
|
||||
GameID postgres.ColumnString
|
||||
OpKind postgres.ColumnString
|
||||
OpSource postgres.ColumnString
|
||||
SourceRef postgres.ColumnString
|
||||
Outcome postgres.ColumnString
|
||||
ErrorCode postgres.ColumnString
|
||||
ErrorMessage postgres.ColumnString
|
||||
StartedAt postgres.ColumnTimestampz
|
||||
FinishedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type OperationLogTable struct {
|
||||
operationLogTable
|
||||
|
||||
EXCLUDED operationLogTable
|
||||
}
|
||||
|
||||
// AS creates new OperationLogTable with assigned alias
|
||||
func (a OperationLogTable) AS(alias string) *OperationLogTable {
|
||||
return newOperationLogTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new OperationLogTable with assigned schema name
|
||||
func (a OperationLogTable) FromSchema(schemaName string) *OperationLogTable {
|
||||
return newOperationLogTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new OperationLogTable with assigned table prefix
|
||||
func (a OperationLogTable) WithPrefix(prefix string) *OperationLogTable {
|
||||
return newOperationLogTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new OperationLogTable with assigned table suffix
|
||||
func (a OperationLogTable) WithSuffix(suffix string) *OperationLogTable {
|
||||
return newOperationLogTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newOperationLogTable(schemaName, tableName, alias string) *OperationLogTable {
|
||||
return &OperationLogTable{
|
||||
operationLogTable: newOperationLogTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newOperationLogTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newOperationLogTableImpl(schemaName, tableName, alias string) operationLogTable {
|
||||
var (
|
||||
IDColumn = postgres.IntegerColumn("id")
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
OpKindColumn = postgres.StringColumn("op_kind")
|
||||
OpSourceColumn = postgres.StringColumn("op_source")
|
||||
SourceRefColumn = postgres.StringColumn("source_ref")
|
||||
OutcomeColumn = postgres.StringColumn("outcome")
|
||||
ErrorCodeColumn = postgres.StringColumn("error_code")
|
||||
ErrorMessageColumn = postgres.StringColumn("error_message")
|
||||
StartedAtColumn = postgres.TimestampzColumn("started_at")
|
||||
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
|
||||
allColumns = postgres.ColumnList{IDColumn, GameIDColumn, OpKindColumn, OpSourceColumn, SourceRefColumn, OutcomeColumn, ErrorCodeColumn, ErrorMessageColumn, StartedAtColumn, FinishedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{GameIDColumn, OpKindColumn, OpSourceColumn, SourceRefColumn, OutcomeColumn, ErrorCodeColumn, ErrorMessageColumn, StartedAtColumn, FinishedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{IDColumn, SourceRefColumn, ErrorCodeColumn, ErrorMessageColumn}
|
||||
)
|
||||
|
||||
return operationLogTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
ID: IDColumn,
|
||||
GameID: GameIDColumn,
|
||||
OpKind: OpKindColumn,
|
||||
OpSource: OpSourceColumn,
|
||||
SourceRef: SourceRefColumn,
|
||||
Outcome: OutcomeColumn,
|
||||
ErrorCode: ErrorCodeColumn,
|
||||
ErrorMessage: ErrorMessageColumn,
|
||||
StartedAt: StartedAtColumn,
|
||||
FinishedAt: FinishedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var PlayerMappings = newPlayerMappingsTable("gamemaster", "player_mappings", "")
|
||||
|
||||
type playerMappingsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
GameID postgres.ColumnString
|
||||
UserID postgres.ColumnString
|
||||
RaceName postgres.ColumnString
|
||||
EnginePlayerUUID postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type PlayerMappingsTable struct {
|
||||
playerMappingsTable
|
||||
|
||||
EXCLUDED playerMappingsTable
|
||||
}
|
||||
|
||||
// AS creates new PlayerMappingsTable with assigned alias
|
||||
func (a PlayerMappingsTable) AS(alias string) *PlayerMappingsTable {
|
||||
return newPlayerMappingsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new PlayerMappingsTable with assigned schema name
|
||||
func (a PlayerMappingsTable) FromSchema(schemaName string) *PlayerMappingsTable {
|
||||
return newPlayerMappingsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new PlayerMappingsTable with assigned table prefix
|
||||
func (a PlayerMappingsTable) WithPrefix(prefix string) *PlayerMappingsTable {
|
||||
return newPlayerMappingsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new PlayerMappingsTable with assigned table suffix
|
||||
func (a PlayerMappingsTable) WithSuffix(suffix string) *PlayerMappingsTable {
|
||||
return newPlayerMappingsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newPlayerMappingsTable(schemaName, tableName, alias string) *PlayerMappingsTable {
|
||||
return &PlayerMappingsTable{
|
||||
playerMappingsTable: newPlayerMappingsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newPlayerMappingsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newPlayerMappingsTableImpl(schemaName, tableName, alias string) playerMappingsTable {
|
||||
var (
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
UserIDColumn = postgres.StringColumn("user_id")
|
||||
RaceNameColumn = postgres.StringColumn("race_name")
|
||||
EnginePlayerUUIDColumn = postgres.StringColumn("engine_player_uuid")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
allColumns = postgres.ColumnList{GameIDColumn, UserIDColumn, RaceNameColumn, EnginePlayerUUIDColumn, CreatedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{RaceNameColumn, EnginePlayerUUIDColumn, CreatedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{}
|
||||
)
|
||||
|
||||
return playerMappingsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
GameID: GameIDColumn,
|
||||
UserID: UserIDColumn,
|
||||
RaceName: RaceNameColumn,
|
||||
EnginePlayerUUID: EnginePlayerUUIDColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
import (
|
||||
"github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
var RuntimeRecords = newRuntimeRecordsTable("gamemaster", "runtime_records", "")
|
||||
|
||||
type runtimeRecordsTable struct {
|
||||
postgres.Table
|
||||
|
||||
// Columns
|
||||
GameID postgres.ColumnString
|
||||
Status postgres.ColumnString
|
||||
EngineEndpoint postgres.ColumnString
|
||||
CurrentImageRef postgres.ColumnString
|
||||
CurrentEngineVersion postgres.ColumnString
|
||||
TurnSchedule postgres.ColumnString
|
||||
CurrentTurn postgres.ColumnInteger
|
||||
NextGenerationAt postgres.ColumnTimestampz
|
||||
SkipNextTick postgres.ColumnBool
|
||||
EngineHealth postgres.ColumnString
|
||||
CreatedAt postgres.ColumnTimestampz
|
||||
UpdatedAt postgres.ColumnTimestampz
|
||||
StartedAt postgres.ColumnTimestampz
|
||||
StoppedAt postgres.ColumnTimestampz
|
||||
FinishedAt postgres.ColumnTimestampz
|
||||
|
||||
AllColumns postgres.ColumnList
|
||||
MutableColumns postgres.ColumnList
|
||||
DefaultColumns postgres.ColumnList
|
||||
}
|
||||
|
||||
type RuntimeRecordsTable struct {
|
||||
runtimeRecordsTable
|
||||
|
||||
EXCLUDED runtimeRecordsTable
|
||||
}
|
||||
|
||||
// AS creates new RuntimeRecordsTable with assigned alias
|
||||
func (a RuntimeRecordsTable) AS(alias string) *RuntimeRecordsTable {
|
||||
return newRuntimeRecordsTable(a.SchemaName(), a.TableName(), alias)
|
||||
}
|
||||
|
||||
// Schema creates new RuntimeRecordsTable with assigned schema name
|
||||
func (a RuntimeRecordsTable) FromSchema(schemaName string) *RuntimeRecordsTable {
|
||||
return newRuntimeRecordsTable(schemaName, a.TableName(), a.Alias())
|
||||
}
|
||||
|
||||
// WithPrefix creates new RuntimeRecordsTable with assigned table prefix
|
||||
func (a RuntimeRecordsTable) WithPrefix(prefix string) *RuntimeRecordsTable {
|
||||
return newRuntimeRecordsTable(a.SchemaName(), prefix+a.TableName(), a.TableName())
|
||||
}
|
||||
|
||||
// WithSuffix creates new RuntimeRecordsTable with assigned table suffix
|
||||
func (a RuntimeRecordsTable) WithSuffix(suffix string) *RuntimeRecordsTable {
|
||||
return newRuntimeRecordsTable(a.SchemaName(), a.TableName()+suffix, a.TableName())
|
||||
}
|
||||
|
||||
func newRuntimeRecordsTable(schemaName, tableName, alias string) *RuntimeRecordsTable {
|
||||
return &RuntimeRecordsTable{
|
||||
runtimeRecordsTable: newRuntimeRecordsTableImpl(schemaName, tableName, alias),
|
||||
EXCLUDED: newRuntimeRecordsTableImpl("", "excluded", ""),
|
||||
}
|
||||
}
|
||||
|
||||
func newRuntimeRecordsTableImpl(schemaName, tableName, alias string) runtimeRecordsTable {
|
||||
var (
|
||||
GameIDColumn = postgres.StringColumn("game_id")
|
||||
StatusColumn = postgres.StringColumn("status")
|
||||
EngineEndpointColumn = postgres.StringColumn("engine_endpoint")
|
||||
CurrentImageRefColumn = postgres.StringColumn("current_image_ref")
|
||||
CurrentEngineVersionColumn = postgres.StringColumn("current_engine_version")
|
||||
TurnScheduleColumn = postgres.StringColumn("turn_schedule")
|
||||
CurrentTurnColumn = postgres.IntegerColumn("current_turn")
|
||||
NextGenerationAtColumn = postgres.TimestampzColumn("next_generation_at")
|
||||
SkipNextTickColumn = postgres.BoolColumn("skip_next_tick")
|
||||
EngineHealthColumn = postgres.StringColumn("engine_health")
|
||||
CreatedAtColumn = postgres.TimestampzColumn("created_at")
|
||||
UpdatedAtColumn = postgres.TimestampzColumn("updated_at")
|
||||
StartedAtColumn = postgres.TimestampzColumn("started_at")
|
||||
StoppedAtColumn = postgres.TimestampzColumn("stopped_at")
|
||||
FinishedAtColumn = postgres.TimestampzColumn("finished_at")
|
||||
allColumns = postgres.ColumnList{GameIDColumn, StatusColumn, EngineEndpointColumn, CurrentImageRefColumn, CurrentEngineVersionColumn, TurnScheduleColumn, CurrentTurnColumn, NextGenerationAtColumn, SkipNextTickColumn, EngineHealthColumn, CreatedAtColumn, UpdatedAtColumn, StartedAtColumn, StoppedAtColumn, FinishedAtColumn}
|
||||
mutableColumns = postgres.ColumnList{StatusColumn, EngineEndpointColumn, CurrentImageRefColumn, CurrentEngineVersionColumn, TurnScheduleColumn, CurrentTurnColumn, NextGenerationAtColumn, SkipNextTickColumn, EngineHealthColumn, CreatedAtColumn, UpdatedAtColumn, StartedAtColumn, StoppedAtColumn, FinishedAtColumn}
|
||||
defaultColumns = postgres.ColumnList{CurrentTurnColumn, SkipNextTickColumn, EngineHealthColumn}
|
||||
)
|
||||
|
||||
return runtimeRecordsTable{
|
||||
Table: postgres.NewTable(schemaName, tableName, alias, allColumns...),
|
||||
|
||||
//Columns
|
||||
GameID: GameIDColumn,
|
||||
Status: StatusColumn,
|
||||
EngineEndpoint: EngineEndpointColumn,
|
||||
CurrentImageRef: CurrentImageRefColumn,
|
||||
CurrentEngineVersion: CurrentEngineVersionColumn,
|
||||
TurnSchedule: TurnScheduleColumn,
|
||||
CurrentTurn: CurrentTurnColumn,
|
||||
NextGenerationAt: NextGenerationAtColumn,
|
||||
SkipNextTick: SkipNextTickColumn,
|
||||
EngineHealth: EngineHealthColumn,
|
||||
CreatedAt: CreatedAtColumn,
|
||||
UpdatedAt: UpdatedAtColumn,
|
||||
StartedAt: StartedAtColumn,
|
||||
StoppedAt: StoppedAtColumn,
|
||||
FinishedAt: FinishedAtColumn,
|
||||
|
||||
AllColumns: allColumns,
|
||||
MutableColumns: mutableColumns,
|
||||
DefaultColumns: defaultColumns,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//
|
||||
// Code generated by go-jet DO NOT EDIT.
|
||||
//
|
||||
// WARNING: Changes to this file may cause incorrect behavior
|
||||
// and will be lost if the code is regenerated
|
||||
//
|
||||
|
||||
package table
|
||||
|
||||
// UseSchema sets a new schema name for all generated table SQL builder types. It is recommended to invoke
|
||||
// this method only once at the beginning of the program.
|
||||
func UseSchema(schema string) {
|
||||
EngineVersions = EngineVersions.FromSchema(schema)
|
||||
GooseDbVersion = GooseDbVersion.FromSchema(schema)
|
||||
OperationLog = OperationLog.FromSchema(schema)
|
||||
PlayerMappings = PlayerMappings.FromSchema(schema)
|
||||
RuntimeRecords = RuntimeRecords.FromSchema(schema)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
-- +goose Up
|
||||
-- Initial Game Master PostgreSQL schema.
|
||||
--
|
||||
-- Four tables cover the durable surface of the service:
|
||||
-- * runtime_records — one row per game with the latest known runtime
|
||||
-- status, scheduling state, and engine health summary;
|
||||
-- * engine_versions — the deployable engine version registry consumed
|
||||
-- by Lobby's start flow and the GM admin/patch flow;
|
||||
-- * player_mappings — the (game_id, user_id) → (race_name,
|
||||
-- engine_player_uuid) projection installed at register-runtime;
|
||||
-- * operation_log — append-only audit of every register-runtime,
|
||||
-- turn-generation, force-next-turn, banish, stop, patch, and
|
||||
-- engine-version mutation GM performed.
|
||||
--
|
||||
-- Schema and the matching `gamemasterservice` role are provisioned
|
||||
-- outside this script (in tests via cmd/jetgen/main.go::provisionRoleAndSchema;
|
||||
-- in production via an ops init script). This migration runs as the
|
||||
-- schema owner with `search_path=gamemaster` and only contains DDL for
|
||||
-- the service-owned tables and indexes. ARCHITECTURE.md §Database topology
|
||||
-- mandates that the per-service role's grants stay restricted to its own
|
||||
-- schema; consequently this file deliberately deviates from PLAN.md
|
||||
-- Stage 09's literal `CREATE SCHEMA IF NOT EXISTS gamemaster;` instruction.
|
||||
|
||||
-- runtime_records holds one durable record per game with the latest
|
||||
-- known runtime status, scheduling state, and engine health summary.
|
||||
-- The status enum is enforced by a CHECK so domain code can rely on it
|
||||
-- without reading every callsite. The composite (status,
|
||||
-- next_generation_at) index drives the scheduler ticker scan that
|
||||
-- selects `status='running' AND next_generation_at <= now()` once per
|
||||
-- second. next_generation_at is nullable: a row enters with
|
||||
-- status='starting' and a null tick, and only acquires a tick when the
|
||||
-- register-runtime CAS flips it to 'running'.
|
||||
CREATE TABLE runtime_records (
|
||||
game_id text PRIMARY KEY,
|
||||
status text NOT NULL,
|
||||
engine_endpoint text NOT NULL,
|
||||
current_image_ref text NOT NULL,
|
||||
current_engine_version text NOT NULL,
|
||||
turn_schedule text NOT NULL,
|
||||
current_turn integer NOT NULL DEFAULT 0,
|
||||
next_generation_at timestamptz,
|
||||
skip_next_tick boolean NOT NULL DEFAULT false,
|
||||
engine_health text NOT NULL DEFAULT '',
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
started_at timestamptz,
|
||||
stopped_at timestamptz,
|
||||
finished_at timestamptz,
|
||||
CONSTRAINT runtime_records_status_chk
|
||||
CHECK (status IN (
|
||||
'starting', 'running', 'generation_in_progress',
|
||||
'generation_failed', 'stopped', 'engine_unreachable',
|
||||
'finished'
|
||||
))
|
||||
);
|
||||
|
||||
CREATE INDEX runtime_records_status_next_gen_idx
|
||||
ON runtime_records (status, next_generation_at);
|
||||
|
||||
-- engine_versions is the deployable engine version registry. Each row
|
||||
-- ties a semver string to a Docker reference and a free-form options
|
||||
-- document; the status enum gates the start flow (active versions are
|
||||
-- accepted by Lobby's resolve, deprecated versions are rejected on new
|
||||
-- starts but remain valid for already-running games). `options` is
|
||||
-- jsonb: v1 stores it verbatim and never element-filters.
|
||||
CREATE TABLE engine_versions (
|
||||
version text PRIMARY KEY,
|
||||
image_ref text NOT NULL,
|
||||
options jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
status text NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
CONSTRAINT engine_versions_status_chk
|
||||
CHECK (status IN ('active', 'deprecated'))
|
||||
);
|
||||
|
||||
-- player_mappings carries the (game_id, user_id) → (race_name,
|
||||
-- engine_player_uuid) projection installed at register-runtime. The
|
||||
-- composite primary key both serves the lookups by (game_id, user_id)
|
||||
-- on every command/order/report request and as a leftmost-prefix index
|
||||
-- for the per-game roster reads (`WHERE game_id = $1`). The partial
|
||||
-- UNIQUE index on (game_id, race_name) enforces the one-race-per-game
|
||||
-- invariant at the storage boundary.
|
||||
CREATE TABLE player_mappings (
|
||||
game_id text NOT NULL,
|
||||
user_id text NOT NULL,
|
||||
race_name text NOT NULL,
|
||||
engine_player_uuid text NOT NULL,
|
||||
created_at timestamptz NOT NULL,
|
||||
PRIMARY KEY (game_id, user_id)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX player_mappings_game_race_uniq
|
||||
ON player_mappings (game_id, race_name);
|
||||
|
||||
-- operation_log is an append-only audit of every operation Game Master
|
||||
-- performed against a game's runtime or against the engine version
|
||||
-- registry. The (game_id, started_at DESC) index drives audit reads
|
||||
-- from the GM/Admin REST surface. finished_at is nullable for in-flight
|
||||
-- rows even though the service layer always finalises the row. The
|
||||
-- op_kind / op_source / outcome enums are enforced by CHECK constraints
|
||||
-- to keep the audit schema honest without a separate Go validator.
|
||||
CREATE TABLE operation_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
game_id text NOT NULL,
|
||||
op_kind text NOT NULL,
|
||||
op_source text NOT NULL,
|
||||
source_ref text NOT NULL DEFAULT '',
|
||||
outcome text NOT NULL,
|
||||
error_code text NOT NULL DEFAULT '',
|
||||
error_message text NOT NULL DEFAULT '',
|
||||
started_at timestamptz NOT NULL,
|
||||
finished_at timestamptz,
|
||||
CONSTRAINT operation_log_op_kind_chk
|
||||
CHECK (op_kind IN (
|
||||
'register_runtime', 'turn_generation', 'force_next_turn',
|
||||
'banish', 'stop', 'patch',
|
||||
'engine_version_create', 'engine_version_update',
|
||||
'engine_version_deprecate', 'engine_version_delete'
|
||||
)),
|
||||
CONSTRAINT operation_log_op_source_chk
|
||||
CHECK (op_source IN (
|
||||
'gateway_player', 'lobby_internal', 'admin_rest'
|
||||
)),
|
||||
CONSTRAINT operation_log_outcome_chk
|
||||
CHECK (outcome IN ('success', 'failure'))
|
||||
);
|
||||
|
||||
CREATE INDEX operation_log_game_started_idx
|
||||
ON operation_log (game_id, started_at DESC);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE IF EXISTS operation_log;
|
||||
DROP TABLE IF EXISTS player_mappings;
|
||||
DROP TABLE IF EXISTS engine_versions;
|
||||
DROP TABLE IF EXISTS runtime_records;
|
||||
@@ -0,0 +1,19 @@
|
||||
// Package migrations exposes the embedded goose migration files used by
|
||||
// Game Master to provision its `gamemaster` schema in PostgreSQL.
|
||||
//
|
||||
// The embedded filesystem is consumed by `pkg/postgres.RunMigrations`
|
||||
// during gamemaster-service startup and by `cmd/jetgen` when regenerating
|
||||
// the `internal/adapters/postgres/jet/` code against a transient
|
||||
// PostgreSQL instance.
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.sql
|
||||
var fs embed.FS
|
||||
|
||||
// FS returns the embedded filesystem containing every numbered goose
|
||||
// migration shipped with Game Master.
|
||||
func FS() embed.FS {
|
||||
return fs
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
// Package operationlog implements the PostgreSQL-backed adapter for
|
||||
// `ports.OperationLogStore`.
|
||||
//
|
||||
// The package owns the on-disk shape of the `operation_log` table
|
||||
// defined in
|
||||
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`
|
||||
// and translates the schema-agnostic `ports.OperationLogStore`
|
||||
// interface declared in `internal/ports/operationlog.go` into
|
||||
// concrete go-jet/v2 statements driven by the pgx driver.
|
||||
//
|
||||
// Append uses `INSERT ... RETURNING id` to surface the bigserial id
|
||||
// back to callers; ListByGame is index-driven by
|
||||
// `operation_log_game_started_idx`.
|
||||
package operationlog
|
||||
|
||||
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/operation"
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// Config configures one PostgreSQL-backed operation-log store.
|
||||
type Config struct {
|
||||
DB *sql.DB
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Store persists Game Master operation-log entries in PostgreSQL.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs one PostgreSQL-backed operation-log store from cfg.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("new postgres operation log store: db must not be nil")
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
return nil, errors.New("new postgres operation log store: operation timeout must be positive")
|
||||
}
|
||||
return &Store{
|
||||
db: cfg.DB,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// operationLogSelectColumns matches scanRow's column order.
|
||||
var operationLogSelectColumns = pg.ColumnList{
|
||||
pgtable.OperationLog.ID,
|
||||
pgtable.OperationLog.GameID,
|
||||
pgtable.OperationLog.OpKind,
|
||||
pgtable.OperationLog.OpSource,
|
||||
pgtable.OperationLog.SourceRef,
|
||||
pgtable.OperationLog.Outcome,
|
||||
pgtable.OperationLog.ErrorCode,
|
||||
pgtable.OperationLog.ErrorMessage,
|
||||
pgtable.OperationLog.StartedAt,
|
||||
pgtable.OperationLog.FinishedAt,
|
||||
}
|
||||
|
||||
// Append inserts entry into the operation log and returns the
|
||||
// generated bigserial id. entry is validated through
|
||||
// operation.OperationEntry.Validate before the SQL is issued.
|
||||
func (store *Store) Append(ctx context.Context, entry operation.OperationEntry) (int64, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return 0, errors.New("append operation log entry: nil store")
|
||||
}
|
||||
if err := entry.Validate(); err != nil {
|
||||
return 0, fmt.Errorf("append operation log entry: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "append operation log entry", store.operationTimeout)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.OperationLog.INSERT(
|
||||
pgtable.OperationLog.GameID,
|
||||
pgtable.OperationLog.OpKind,
|
||||
pgtable.OperationLog.OpSource,
|
||||
pgtable.OperationLog.SourceRef,
|
||||
pgtable.OperationLog.Outcome,
|
||||
pgtable.OperationLog.ErrorCode,
|
||||
pgtable.OperationLog.ErrorMessage,
|
||||
pgtable.OperationLog.StartedAt,
|
||||
pgtable.OperationLog.FinishedAt,
|
||||
).VALUES(
|
||||
entry.GameID,
|
||||
string(entry.OpKind),
|
||||
string(entry.OpSource),
|
||||
entry.SourceRef,
|
||||
string(entry.Outcome),
|
||||
entry.ErrorCode,
|
||||
entry.ErrorMessage,
|
||||
entry.StartedAt.UTC(),
|
||||
sqlx.NullableTimePtr(entry.FinishedAt),
|
||||
).RETURNING(pgtable.OperationLog.ID)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
var id int64
|
||||
if err := row.Scan(&id); err != nil {
|
||||
return 0, fmt.Errorf("append operation log entry: %w", err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ListByGame returns the most recent entries for gameID, ordered by
|
||||
// started_at descending and id descending (a tie-breaker that keeps
|
||||
// the order stable when two rows share a started_at). The result is
|
||||
// capped by limit; non-positive limit is rejected.
|
||||
func (store *Store) ListByGame(ctx context.Context, gameID string, limit int) ([]operation.OperationEntry, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("list operation log entries by game: nil store")
|
||||
}
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return nil, fmt.Errorf("list operation log entries by game: game id must not be empty")
|
||||
}
|
||||
if limit <= 0 {
|
||||
return nil, fmt.Errorf("list operation log entries by game: limit must be positive, got %d", limit)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list operation log entries by game", store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(operationLogSelectColumns).
|
||||
FROM(pgtable.OperationLog).
|
||||
WHERE(pgtable.OperationLog.GameID.EQ(pg.String(gameID))).
|
||||
ORDER_BY(pgtable.OperationLog.StartedAt.DESC(), pgtable.OperationLog.ID.DESC()).
|
||||
LIMIT(int64(limit))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
rows, err := store.db.QueryContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list operation log entries by game: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
entries := make([]operation.OperationEntry, 0)
|
||||
for rows.Next() {
|
||||
got, err := scanRow(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list operation log entries by game: scan: %w", err)
|
||||
}
|
||||
entries = append(entries, got)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list operation log entries by game: %w", err)
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return entries, 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 operation_log row from rs.
|
||||
func scanRow(rs rowScanner) (operation.OperationEntry, error) {
|
||||
var (
|
||||
id int64
|
||||
gameID string
|
||||
opKind string
|
||||
opSource string
|
||||
sourceRef string
|
||||
outcome string
|
||||
errorCode string
|
||||
errorMessage string
|
||||
startedAt time.Time
|
||||
finishedAt sql.NullTime
|
||||
)
|
||||
if err := rs.Scan(
|
||||
&id,
|
||||
&gameID,
|
||||
&opKind,
|
||||
&opSource,
|
||||
&sourceRef,
|
||||
&outcome,
|
||||
&errorCode,
|
||||
&errorMessage,
|
||||
&startedAt,
|
||||
&finishedAt,
|
||||
); err != nil {
|
||||
return operation.OperationEntry{}, err
|
||||
}
|
||||
return operation.OperationEntry{
|
||||
ID: id,
|
||||
GameID: gameID,
|
||||
OpKind: operation.OpKind(opKind),
|
||||
OpSource: operation.OpSource(opSource),
|
||||
SourceRef: sourceRef,
|
||||
Outcome: operation.Outcome(outcome),
|
||||
ErrorCode: errorCode,
|
||||
ErrorMessage: errorMessage,
|
||||
StartedAt: startedAt.UTC(),
|
||||
FinishedAt: sqlx.TimePtrFromNullable(finishedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure Store satisfies the ports.OperationLogStore interface at
|
||||
// compile time.
|
||||
var _ ports.OperationLogStore = (*Store)(nil)
|
||||
@@ -0,0 +1,190 @@
|
||||
package operationlog_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/postgres/internal/pgtest"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/operationlog"
|
||||
"galaxy/gamemaster/internal/domain/operation"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) { pgtest.RunMain(m) }
|
||||
|
||||
func newStore(t *testing.T) *operationlog.Store {
|
||||
t.Helper()
|
||||
pgtest.TruncateAll(t)
|
||||
store, err := operationlog.New(operationlog.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
|
||||
func successEntry(gameID string, kind operation.OpKind, source operation.OpSource, startedAt time.Time) operation.OperationEntry {
|
||||
finishedAt := startedAt.Add(50 * time.Millisecond)
|
||||
return operation.OperationEntry{
|
||||
GameID: gameID,
|
||||
OpKind: kind,
|
||||
OpSource: source,
|
||||
SourceRef: "req-001",
|
||||
Outcome: operation.OutcomeSuccess,
|
||||
StartedAt: startedAt,
|
||||
FinishedAt: &finishedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidConfig(t *testing.T) {
|
||||
_, err := operationlog.New(operationlog.Config{})
|
||||
require.Error(t, err)
|
||||
|
||||
store, err := operationlog.New(operationlog.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: 0,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, store)
|
||||
}
|
||||
|
||||
func TestAppendSuccessEntry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
entry := successEntry("game-001", operation.OpKindRegisterRuntime, operation.OpSourceLobbyInternal, at)
|
||||
|
||||
id, err := store.Append(ctx, entry)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, id, int64(0))
|
||||
|
||||
entries, err := store.ListByGame(ctx, "game-001", 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, entries, 1)
|
||||
got := entries[0]
|
||||
assert.Equal(t, id, got.ID)
|
||||
assert.Equal(t, entry.GameID, got.GameID)
|
||||
assert.Equal(t, entry.OpKind, got.OpKind)
|
||||
assert.Equal(t, entry.OpSource, got.OpSource)
|
||||
assert.Equal(t, entry.SourceRef, got.SourceRef)
|
||||
assert.Equal(t, operation.OutcomeSuccess, got.Outcome)
|
||||
assert.Empty(t, got.ErrorCode)
|
||||
assert.Empty(t, got.ErrorMessage)
|
||||
assert.True(t, got.StartedAt.Equal(at))
|
||||
require.NotNil(t, got.FinishedAt)
|
||||
assert.Equal(t, time.UTC, got.StartedAt.Location())
|
||||
assert.Equal(t, time.UTC, got.FinishedAt.Location())
|
||||
}
|
||||
|
||||
func TestAppendFailureEntry(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
finishedAt := at.Add(time.Second)
|
||||
entry := operation.OperationEntry{
|
||||
GameID: "game-001",
|
||||
OpKind: operation.OpKindTurnGeneration,
|
||||
OpSource: operation.OpSourceAdminRest,
|
||||
Outcome: operation.OutcomeFailure,
|
||||
ErrorCode: "engine_unreachable",
|
||||
ErrorMessage: "connection refused",
|
||||
StartedAt: at,
|
||||
FinishedAt: &finishedAt,
|
||||
}
|
||||
|
||||
_, err := store.Append(ctx, entry)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := store.ListByGame(ctx, "game-001", 1)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, operation.OutcomeFailure, got[0].Outcome)
|
||||
assert.Equal(t, "engine_unreachable", got[0].ErrorCode)
|
||||
assert.Equal(t, "connection refused", got[0].ErrorMessage)
|
||||
}
|
||||
|
||||
func TestAppendIDsAreMonotonic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
id1, err := store.Append(ctx, successEntry("game-001", operation.OpKindRegisterRuntime, operation.OpSourceLobbyInternal, at))
|
||||
require.NoError(t, err)
|
||||
|
||||
id2, err := store.Append(ctx, successEntry("game-001", operation.OpKindTurnGeneration, operation.OpSourceLobbyInternal, at.Add(time.Second)))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Greater(t, id2, id1, "bigserial ids must be monotonic across appends")
|
||||
}
|
||||
|
||||
func TestAppendValidationRejection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
bad := operation.OperationEntry{}
|
||||
_, err := store.Append(ctx, bad)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestListByGameOrderingDesc(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
_, err := store.Append(ctx, successEntry("game-001", operation.OpKindRegisterRuntime, operation.OpSourceLobbyInternal, at))
|
||||
require.NoError(t, err)
|
||||
_, err = store.Append(ctx, successEntry("game-001", operation.OpKindTurnGeneration, operation.OpSourceLobbyInternal, at.Add(time.Second)))
|
||||
require.NoError(t, err)
|
||||
_, err = store.Append(ctx, successEntry("game-001", operation.OpKindStop, operation.OpSourceAdminRest, at.Add(2*time.Second)))
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := store.ListByGame(ctx, "game-001", 10)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 3)
|
||||
assert.Equal(t, operation.OpKindStop, got[0].OpKind)
|
||||
assert.Equal(t, operation.OpKindTurnGeneration, got[1].OpKind)
|
||||
assert.Equal(t, operation.OpKindRegisterRuntime, got[2].OpKind)
|
||||
}
|
||||
|
||||
func TestListByGameRespectsLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
at := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
for index := range 5 {
|
||||
_, err := store.Append(ctx, successEntry("game-001", operation.OpKindTurnGeneration, operation.OpSourceLobbyInternal, at.Add(time.Duration(index)*time.Second)))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
got, err := store.ListByGame(ctx, "game-001", 2)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 2)
|
||||
}
|
||||
|
||||
func TestListByGameUnknownGame(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
got, err := store.ListByGame(ctx, "unknown-game", 10)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestListByGameRejectsBadArgs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.ListByGame(ctx, "", 10)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = store.ListByGame(ctx, "game-001", 0)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = store.ListByGame(ctx, "game-001", -1)
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// Package playermappingstore implements the PostgreSQL-backed adapter
|
||||
// for `ports.PlayerMappingStore`.
|
||||
//
|
||||
// The package owns the on-disk shape of the `player_mappings` table
|
||||
// defined in
|
||||
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`
|
||||
// and translates the schema-agnostic `ports.PlayerMappingStore`
|
||||
// interface declared in `internal/ports/playermappingstore.go` into
|
||||
// concrete go-jet/v2 statements driven by the pgx driver.
|
||||
//
|
||||
// BulkInsert ships every row in a single multi-row INSERT so the
|
||||
// operation is atomic — any unique-constraint violation rolls back the
|
||||
// whole batch and is mapped to playermapping.ErrConflict.
|
||||
package playermappingstore
|
||||
|
||||
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/playermapping"
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// Config configures one PostgreSQL-backed player-mapping store.
|
||||
type Config struct {
|
||||
DB *sql.DB
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Store persists Game Master player mappings in PostgreSQL.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs one PostgreSQL-backed player-mapping store from cfg.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("new postgres player mapping store: db must not be nil")
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
return nil, errors.New("new postgres player mapping store: operation timeout must be positive")
|
||||
}
|
||||
return &Store{
|
||||
db: cfg.DB,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// playerMappingSelectColumns matches scanRow's column order.
|
||||
var playerMappingSelectColumns = pg.ColumnList{
|
||||
pgtable.PlayerMappings.GameID,
|
||||
pgtable.PlayerMappings.UserID,
|
||||
pgtable.PlayerMappings.RaceName,
|
||||
pgtable.PlayerMappings.EnginePlayerUUID,
|
||||
pgtable.PlayerMappings.CreatedAt,
|
||||
}
|
||||
|
||||
// BulkInsert installs every mapping in records using a single
|
||||
// multi-row INSERT. Either every row is persisted or none of them is.
|
||||
// Any PostgreSQL unique-violation
|
||||
// (`(game_id, user_id)` PK or `(game_id, race_name)` UNIQUE) is mapped
|
||||
// to playermapping.ErrConflict.
|
||||
func (store *Store) BulkInsert(ctx context.Context, records []playermapping.PlayerMapping) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("bulk insert player mappings: nil store")
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
for index, record := range records {
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("bulk insert player mappings: record %d: %w", index, err)
|
||||
}
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "bulk insert player mappings", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.PlayerMappings.INSERT(
|
||||
pgtable.PlayerMappings.GameID,
|
||||
pgtable.PlayerMappings.UserID,
|
||||
pgtable.PlayerMappings.RaceName,
|
||||
pgtable.PlayerMappings.EnginePlayerUUID,
|
||||
pgtable.PlayerMappings.CreatedAt,
|
||||
)
|
||||
for _, record := range records {
|
||||
stmt = stmt.VALUES(
|
||||
record.GameID,
|
||||
record.UserID,
|
||||
record.RaceName,
|
||||
record.EnginePlayerUUID,
|
||||
record.CreatedAt.UTC(),
|
||||
)
|
||||
}
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
|
||||
if sqlx.IsUniqueViolation(err) {
|
||||
return fmt.Errorf("bulk insert player mappings: %w", playermapping.ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("bulk insert player mappings: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get returns the mapping identified by (gameID, userID).
|
||||
func (store *Store) Get(ctx context.Context, gameID, userID string) (playermapping.PlayerMapping, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return playermapping.PlayerMapping{}, errors.New("get player mapping: nil store")
|
||||
}
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping: game id must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping: user id must not be empty")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get player mapping", store.operationTimeout)
|
||||
if err != nil {
|
||||
return playermapping.PlayerMapping{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(playerMappingSelectColumns).
|
||||
FROM(pgtable.PlayerMappings).
|
||||
WHERE(pg.AND(
|
||||
pgtable.PlayerMappings.GameID.EQ(pg.String(gameID)),
|
||||
pgtable.PlayerMappings.UserID.EQ(pg.String(userID)),
|
||||
))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
got, err := scanRow(row)
|
||||
if sqlx.IsNoRows(err) {
|
||||
return playermapping.PlayerMapping{}, playermapping.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping: %w", err)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// GetByRace returns the mapping identified by (gameID, raceName).
|
||||
func (store *Store) GetByRace(ctx context.Context, gameID, raceName string) (playermapping.PlayerMapping, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return playermapping.PlayerMapping{}, errors.New("get player mapping by race: nil store")
|
||||
}
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping by race: game id must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(raceName) == "" {
|
||||
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping by race: race name must not be empty")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get player mapping by race", store.operationTimeout)
|
||||
if err != nil {
|
||||
return playermapping.PlayerMapping{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(playerMappingSelectColumns).
|
||||
FROM(pgtable.PlayerMappings).
|
||||
WHERE(pg.AND(
|
||||
pgtable.PlayerMappings.GameID.EQ(pg.String(gameID)),
|
||||
pgtable.PlayerMappings.RaceName.EQ(pg.String(raceName)),
|
||||
))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
got, err := scanRow(row)
|
||||
if sqlx.IsNoRows(err) {
|
||||
return playermapping.PlayerMapping{}, playermapping.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return playermapping.PlayerMapping{}, fmt.Errorf("get player mapping by race: %w", err)
|
||||
}
|
||||
return got, nil
|
||||
}
|
||||
|
||||
// ListByGame returns every mapping owned by gameID, ordered by user_id
|
||||
// ascending.
|
||||
func (store *Store) ListByGame(ctx context.Context, gameID string) ([]playermapping.PlayerMapping, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("list player mappings by game: nil store")
|
||||
}
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return nil, fmt.Errorf("list player mappings by game: game id must not be empty")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list player mappings by game", store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(playerMappingSelectColumns).
|
||||
FROM(pgtable.PlayerMappings).
|
||||
WHERE(pgtable.PlayerMappings.GameID.EQ(pg.String(gameID))).
|
||||
ORDER_BY(pgtable.PlayerMappings.UserID.ASC())
|
||||
|
||||
query, args := stmt.Sql()
|
||||
rows, err := store.db.QueryContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list player mappings by game: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
mappings := make([]playermapping.PlayerMapping, 0)
|
||||
for rows.Next() {
|
||||
got, err := scanRow(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list player mappings by game: scan: %w", err)
|
||||
}
|
||||
mappings = append(mappings, got)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("list player mappings by game: %w", err)
|
||||
}
|
||||
if len(mappings) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
// DeleteByGame removes every mapping owned by gameID. The call is
|
||||
// idempotent: it returns nil even when no rows were deleted.
|
||||
func (store *Store) DeleteByGame(ctx context.Context, gameID string) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("delete player mappings by game: nil store")
|
||||
}
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return fmt.Errorf("delete player mappings by game: game id must not be empty")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete player mappings by game", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.PlayerMappings.DELETE().
|
||||
WHERE(pgtable.PlayerMappings.GameID.EQ(pg.String(gameID)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
|
||||
return fmt.Errorf("delete player mappings by game: %w", err)
|
||||
}
|
||||
return 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 player_mappings row from rs.
|
||||
func scanRow(rs rowScanner) (playermapping.PlayerMapping, error) {
|
||||
var (
|
||||
gameID string
|
||||
userID string
|
||||
raceName string
|
||||
enginePlayerUUID string
|
||||
createdAt time.Time
|
||||
)
|
||||
if err := rs.Scan(&gameID, &userID, &raceName, &enginePlayerUUID, &createdAt); err != nil {
|
||||
return playermapping.PlayerMapping{}, err
|
||||
}
|
||||
return playermapping.PlayerMapping{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
RaceName: raceName,
|
||||
EnginePlayerUUID: enginePlayerUUID,
|
||||
CreatedAt: createdAt.UTC(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure Store satisfies the ports.PlayerMappingStore interface at
|
||||
// compile time.
|
||||
var _ ports.PlayerMappingStore = (*Store)(nil)
|
||||
@@ -0,0 +1,264 @@
|
||||
package playermappingstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/postgres/internal/pgtest"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/playermappingstore"
|
||||
"galaxy/gamemaster/internal/domain/playermapping"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) { pgtest.RunMain(m) }
|
||||
|
||||
func newStore(t *testing.T) *playermappingstore.Store {
|
||||
t.Helper()
|
||||
pgtest.TruncateAll(t)
|
||||
store, err := playermappingstore.New(playermappingstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
|
||||
func mapping(gameID, userID, raceName, uuid string, createdAt time.Time) playermapping.PlayerMapping {
|
||||
return playermapping.PlayerMapping{
|
||||
GameID: gameID,
|
||||
UserID: userID,
|
||||
RaceName: raceName,
|
||||
EnginePlayerUUID: uuid,
|
||||
CreatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidConfig(t *testing.T) {
|
||||
_, err := playermappingstore.New(playermappingstore.Config{})
|
||||
require.Error(t, err)
|
||||
|
||||
store, err := playermappingstore.New(playermappingstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: 0,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, store)
|
||||
}
|
||||
|
||||
func TestBulkInsertHappy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
records := []playermapping.PlayerMapping{
|
||||
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
|
||||
mapping("game-001", "user-2", "Drazi", "uuid-2", now),
|
||||
mapping("game-001", "user-3", "Voltori", "uuid-3", now),
|
||||
}
|
||||
require.NoError(t, store.BulkInsert(ctx, records))
|
||||
|
||||
for _, want := range records {
|
||||
got, err := store.Get(ctx, want.GameID, want.UserID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want.RaceName, got.RaceName)
|
||||
assert.Equal(t, want.EnginePlayerUUID, got.EnginePlayerUUID)
|
||||
assert.True(t, got.CreatedAt.Equal(now))
|
||||
assert.Equal(t, time.UTC, got.CreatedAt.Location())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulkInsertEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
require.NoError(t, store.BulkInsert(ctx, nil))
|
||||
require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{}))
|
||||
|
||||
got, err := store.ListByGame(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestBulkInsertAtomicConflictRaceName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
// user-2 reuses Aelinari (already taken by user-1) inside the same
|
||||
// game — the unique (game_id, race_name) index must reject the
|
||||
// whole batch.
|
||||
records := []playermapping.PlayerMapping{
|
||||
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
|
||||
mapping("game-001", "user-2", "Drazi", "uuid-2", now),
|
||||
mapping("game-001", "user-3", "Aelinari", "uuid-3", now),
|
||||
}
|
||||
err := store.BulkInsert(ctx, records)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, playermapping.ErrConflict), "want ErrConflict, got %v", err)
|
||||
|
||||
got, err := store.ListByGame(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got, "atomic batch must roll back every row when any row fails")
|
||||
}
|
||||
|
||||
func TestBulkInsertAtomicConflictUserID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
records := []playermapping.PlayerMapping{
|
||||
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
|
||||
mapping("game-001", "user-1", "Drazi", "uuid-2", now), // user-1 twice
|
||||
}
|
||||
err := store.BulkInsert(ctx, records)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, playermapping.ErrConflict))
|
||||
}
|
||||
|
||||
func TestBulkInsertConflictAcrossCalls(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{
|
||||
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
|
||||
}))
|
||||
|
||||
err := store.BulkInsert(ctx, []playermapping.PlayerMapping{
|
||||
mapping("game-001", "user-1", "DifferentRace", "uuid-2", now),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, playermapping.ErrConflict))
|
||||
}
|
||||
|
||||
func TestBulkInsertRejectsInvalid(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
bad := []playermapping.PlayerMapping{
|
||||
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
|
||||
{GameID: "game-001", UserID: "", RaceName: "Drazi", EnginePlayerUUID: "uuid-2", CreatedAt: now},
|
||||
}
|
||||
err := store.BulkInsert(ctx, bad)
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, playermapping.ErrConflict))
|
||||
|
||||
got, err := store.ListByGame(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got, "validation rejection must not insert any row")
|
||||
}
|
||||
|
||||
func TestGetMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.Get(ctx, "game-001", "user-1")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, playermapping.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestGetByRace(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{
|
||||
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
|
||||
mapping("game-001", "user-2", "Drazi", "uuid-2", now),
|
||||
}))
|
||||
|
||||
got, err := store.GetByRace(ctx, "game-001", "Aelinari")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user-1", got.UserID)
|
||||
|
||||
_, err = store.GetByRace(ctx, "game-001", "Voltori")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, playermapping.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestListByGameSortedByUserID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{
|
||||
mapping("game-001", "user-c", "Aelinari", "uuid-1", now),
|
||||
mapping("game-001", "user-a", "Drazi", "uuid-2", now),
|
||||
mapping("game-001", "user-b", "Voltori", "uuid-3", now),
|
||||
// other game's mappings must not leak
|
||||
mapping("game-002", "user-z", "Outsider", "uuid-4", now),
|
||||
}))
|
||||
|
||||
got, err := store.ListByGame(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 3)
|
||||
assert.Equal(t, "user-a", got[0].UserID)
|
||||
assert.Equal(t, "user-b", got[1].UserID)
|
||||
assert.Equal(t, "user-c", got[2].UserID)
|
||||
}
|
||||
|
||||
func TestListByGameUnknown(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
got, err := store.ListByGame(ctx, "unknown-game")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
}
|
||||
|
||||
func TestDeleteByGameIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.BulkInsert(ctx, []playermapping.PlayerMapping{
|
||||
mapping("game-001", "user-1", "Aelinari", "uuid-1", now),
|
||||
mapping("game-001", "user-2", "Drazi", "uuid-2", now),
|
||||
}))
|
||||
|
||||
require.NoError(t, store.DeleteByGame(ctx, "game-001"))
|
||||
got, err := store.ListByGame(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
|
||||
// Second call must be a no-op.
|
||||
require.NoError(t, store.DeleteByGame(ctx, "game-001"))
|
||||
}
|
||||
|
||||
func TestGetRejectsEmptyArgs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.Get(ctx, "", "user-1")
|
||||
require.Error(t, err)
|
||||
_, err = store.Get(ctx, "game-001", "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetByRaceRejectsEmptyArgs(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.GetByRace(ctx, "", "Aelinari")
|
||||
require.Error(t, err)
|
||||
_, err = store.GetByRace(ctx, "game-001", "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestListByGameRejectsEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
_, err := store.ListByGame(ctx, "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDeleteByGameRejectsEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
err := store.DeleteByGame(ctx, "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
// Package runtimerecordstore implements the PostgreSQL-backed adapter
|
||||
// for `ports.RuntimeRecordStore`.
|
||||
//
|
||||
// The package owns the on-disk shape of the `runtime_records` table
|
||||
// defined in
|
||||
// `galaxy/gamemaster/internal/adapters/postgres/migrations/00001_init.sql`
|
||||
// and translates the schema-agnostic `ports.RuntimeRecordStore`
|
||||
// interface declared in `internal/ports/runtimerecordstore.go` into
|
||||
// concrete go-jet/v2 statements driven by the pgx driver.
|
||||
//
|
||||
// Lifecycle transitions (UpdateStatus) use compare-and-swap on
|
||||
// `(game_id, status)` rather than holding a SELECT ... FOR UPDATE lock
|
||||
// across the caller's logic, mirroring the pattern used by
|
||||
// `rtmanager/internal/adapters/postgres/runtimerecordstore`.
|
||||
package runtimerecordstore
|
||||
|
||||
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/runtime"
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
|
||||
pg "github.com/go-jet/jet/v2/postgres"
|
||||
)
|
||||
|
||||
// Config configures one PostgreSQL-backed runtime-record store. The
|
||||
// store does not own the underlying *sql.DB lifecycle; the caller
|
||||
// (typically the service runtime) opens, instruments, migrates, and
|
||||
// closes the pool.
|
||||
type Config struct {
|
||||
// DB stores the connection pool the store uses for every query.
|
||||
DB *sql.DB
|
||||
|
||||
// OperationTimeout bounds one round trip. The store creates a
|
||||
// derived context for each operation so callers cannot starve the
|
||||
// pool with an unbounded ctx.
|
||||
OperationTimeout time.Duration
|
||||
}
|
||||
|
||||
// Store persists Game Master runtime records in PostgreSQL.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
operationTimeout time.Duration
|
||||
}
|
||||
|
||||
// New constructs one PostgreSQL-backed runtime-record store from cfg.
|
||||
func New(cfg Config) (*Store, error) {
|
||||
if cfg.DB == nil {
|
||||
return nil, errors.New("new postgres runtime record store: db must not be nil")
|
||||
}
|
||||
if cfg.OperationTimeout <= 0 {
|
||||
return nil, errors.New("new postgres runtime record store: operation timeout must be positive")
|
||||
}
|
||||
return &Store{
|
||||
db: cfg.DB,
|
||||
operationTimeout: cfg.OperationTimeout,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// runtimeSelectColumns is the canonical SELECT list for the
|
||||
// runtime_records table, matching scanRecord's column order.
|
||||
var runtimeSelectColumns = pg.ColumnList{
|
||||
pgtable.RuntimeRecords.GameID,
|
||||
pgtable.RuntimeRecords.Status,
|
||||
pgtable.RuntimeRecords.EngineEndpoint,
|
||||
pgtable.RuntimeRecords.CurrentImageRef,
|
||||
pgtable.RuntimeRecords.CurrentEngineVersion,
|
||||
pgtable.RuntimeRecords.TurnSchedule,
|
||||
pgtable.RuntimeRecords.CurrentTurn,
|
||||
pgtable.RuntimeRecords.NextGenerationAt,
|
||||
pgtable.RuntimeRecords.SkipNextTick,
|
||||
pgtable.RuntimeRecords.EngineHealth,
|
||||
pgtable.RuntimeRecords.CreatedAt,
|
||||
pgtable.RuntimeRecords.UpdatedAt,
|
||||
pgtable.RuntimeRecords.StartedAt,
|
||||
pgtable.RuntimeRecords.StoppedAt,
|
||||
pgtable.RuntimeRecords.FinishedAt,
|
||||
}
|
||||
|
||||
// Get returns the record identified by gameID. It returns
|
||||
// runtime.ErrNotFound when no record exists.
|
||||
func (store *Store) Get(ctx context.Context, gameID string) (runtime.RuntimeRecord, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return runtime.RuntimeRecord{}, errors.New("get runtime record: nil store")
|
||||
}
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return runtime.RuntimeRecord{}, fmt.Errorf("get runtime record: game id must not be empty")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "get runtime record", store.operationTimeout)
|
||||
if err != nil {
|
||||
return runtime.RuntimeRecord{}, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(runtimeSelectColumns).
|
||||
FROM(pgtable.RuntimeRecords).
|
||||
WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(gameID)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
row := store.db.QueryRowContext(operationCtx, query, args...)
|
||||
record, err := scanRecord(row)
|
||||
if sqlx.IsNoRows(err) {
|
||||
return runtime.RuntimeRecord{}, runtime.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return runtime.RuntimeRecord{}, fmt.Errorf("get runtime record: %w", err)
|
||||
}
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// Insert installs record into the store. Returns runtime.ErrConflict
|
||||
// when a row already exists for record.GameID.
|
||||
func (store *Store) Insert(ctx context.Context, record runtime.RuntimeRecord) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("insert runtime record: nil store")
|
||||
}
|
||||
if err := record.Validate(); err != nil {
|
||||
return fmt.Errorf("insert runtime record: %w", err)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "insert runtime record", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.RuntimeRecords.INSERT(
|
||||
pgtable.RuntimeRecords.GameID,
|
||||
pgtable.RuntimeRecords.Status,
|
||||
pgtable.RuntimeRecords.EngineEndpoint,
|
||||
pgtable.RuntimeRecords.CurrentImageRef,
|
||||
pgtable.RuntimeRecords.CurrentEngineVersion,
|
||||
pgtable.RuntimeRecords.TurnSchedule,
|
||||
pgtable.RuntimeRecords.CurrentTurn,
|
||||
pgtable.RuntimeRecords.NextGenerationAt,
|
||||
pgtable.RuntimeRecords.SkipNextTick,
|
||||
pgtable.RuntimeRecords.EngineHealth,
|
||||
pgtable.RuntimeRecords.CreatedAt,
|
||||
pgtable.RuntimeRecords.UpdatedAt,
|
||||
pgtable.RuntimeRecords.StartedAt,
|
||||
pgtable.RuntimeRecords.StoppedAt,
|
||||
pgtable.RuntimeRecords.FinishedAt,
|
||||
).VALUES(
|
||||
record.GameID,
|
||||
string(record.Status),
|
||||
record.EngineEndpoint,
|
||||
record.CurrentImageRef,
|
||||
record.CurrentEngineVersion,
|
||||
record.TurnSchedule,
|
||||
int32(record.CurrentTurn),
|
||||
sqlx.NullableTimePtr(record.NextGenerationAt),
|
||||
record.SkipNextTick,
|
||||
record.EngineHealth,
|
||||
record.CreatedAt.UTC(),
|
||||
record.UpdatedAt.UTC(),
|
||||
sqlx.NullableTimePtr(record.StartedAt),
|
||||
sqlx.NullableTimePtr(record.StoppedAt),
|
||||
sqlx.NullableTimePtr(record.FinishedAt),
|
||||
)
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
|
||||
if sqlx.IsUniqueViolation(err) {
|
||||
return fmt.Errorf("insert runtime record: %w", runtime.ErrConflict)
|
||||
}
|
||||
return fmt.Errorf("insert runtime record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateStatus applies one status transition with a compare-and-swap
|
||||
// guard on (game_id, status). The destination's lifecycle timestamps
|
||||
// (started_at, stopped_at, finished_at) and the optional fields
|
||||
// (engine_health, current_image_ref, current_engine_version) are
|
||||
// written only when applicable.
|
||||
func (store *Store) UpdateStatus(ctx context.Context, input ports.UpdateStatusInput) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("update runtime status: nil store")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime status", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
assignments := buildUpdateStatusAssignments(input, input.Now.UTC())
|
||||
|
||||
// The first positional argument to UPDATE is required by jet's
|
||||
// API but ignored when SET receives ColumnAssigment values
|
||||
// (jet then serialises SetClauseNew instead of clauseSet).
|
||||
stmt := pgtable.RuntimeRecords.UPDATE(pgtable.RuntimeRecords.Status).
|
||||
SET(assignments[0], assignments[1:]...).
|
||||
WHERE(pg.AND(
|
||||
pgtable.RuntimeRecords.GameID.EQ(pg.String(input.GameID)),
|
||||
pgtable.RuntimeRecords.Status.EQ(pg.String(string(input.ExpectedFrom))),
|
||||
))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime status: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime status: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return store.classifyMissingUpdate(operationCtx, input.GameID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildUpdateStatusAssignments returns the slice of column assignments
|
||||
// produced by one UpdateStatus call. Mandatory assignments (status,
|
||||
// updated_at) are always present; lifecycle timestamps and optional
|
||||
// fields appear only when relevant to the destination status or when
|
||||
// the corresponding pointer is non-nil.
|
||||
//
|
||||
// The slice element type is `any` so the result can be spread into
|
||||
// `UpdateStatement.SET(value any, values ...any)` without manual
|
||||
// boxing at the call site.
|
||||
func buildUpdateStatusAssignments(input ports.UpdateStatusInput, now time.Time) []any {
|
||||
nowExpr := pg.TimestampzT(now)
|
||||
assignments := []any{
|
||||
pgtable.RuntimeRecords.Status.SET(pg.String(string(input.To))),
|
||||
pgtable.RuntimeRecords.UpdatedAt.SET(nowExpr),
|
||||
}
|
||||
|
||||
if input.To == runtime.StatusRunning && input.ExpectedFrom == runtime.StatusStarting {
|
||||
assignments = append(assignments, pgtable.RuntimeRecords.StartedAt.SET(nowExpr))
|
||||
}
|
||||
if input.To == runtime.StatusStopped {
|
||||
assignments = append(assignments, pgtable.RuntimeRecords.StoppedAt.SET(nowExpr))
|
||||
}
|
||||
if input.To == runtime.StatusFinished {
|
||||
assignments = append(assignments, pgtable.RuntimeRecords.FinishedAt.SET(nowExpr))
|
||||
}
|
||||
if input.EngineHealthSummary != nil {
|
||||
assignments = append(assignments, pgtable.RuntimeRecords.EngineHealth.SET(pg.String(*input.EngineHealthSummary)))
|
||||
}
|
||||
if input.CurrentImageRef != nil {
|
||||
assignments = append(assignments, pgtable.RuntimeRecords.CurrentImageRef.SET(pg.String(*input.CurrentImageRef)))
|
||||
}
|
||||
if input.CurrentEngineVersion != nil {
|
||||
assignments = append(assignments, pgtable.RuntimeRecords.CurrentEngineVersion.SET(pg.String(*input.CurrentEngineVersion)))
|
||||
}
|
||||
|
||||
return assignments
|
||||
}
|
||||
|
||||
// classifyMissingUpdate distinguishes ErrNotFound from ErrConflict
|
||||
// after an UPDATE that affected zero rows. A row that is absent yields
|
||||
// ErrNotFound; a row whose status does not match the CAS predicate
|
||||
// yields ErrConflict.
|
||||
func (store *Store) classifyMissingUpdate(ctx context.Context, gameID string) error {
|
||||
probe := pg.SELECT(pgtable.RuntimeRecords.Status).
|
||||
FROM(pgtable.RuntimeRecords).
|
||||
WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(gameID)))
|
||||
probeQuery, probeArgs := probe.Sql()
|
||||
|
||||
var current string
|
||||
row := store.db.QueryRowContext(ctx, probeQuery, probeArgs...)
|
||||
if err := row.Scan(¤t); err != nil {
|
||||
if sqlx.IsNoRows(err) {
|
||||
return runtime.ErrNotFound
|
||||
}
|
||||
return fmt.Errorf("update runtime status: probe: %w", err)
|
||||
}
|
||||
return runtime.ErrConflict
|
||||
}
|
||||
|
||||
// UpdateImage rotates the `current_image_ref` and
|
||||
// `current_engine_version` columns of one runtime row under a
|
||||
// compare-and-swap guard on `(game_id, status)`. The destination
|
||||
// status is preserved; only `updated_at` and the two image columns
|
||||
// change. Returns runtime.ErrNotFound when no row matches and
|
||||
// runtime.ErrConflict when the stored status differs from
|
||||
// input.ExpectedStatus. Used by the admin patch flow (Stage 17) where
|
||||
// Runtime Manager recreates the engine container with a new image
|
||||
// while the runtime stays `running`.
|
||||
func (store *Store) UpdateImage(ctx context.Context, input ports.UpdateImageInput) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("update runtime image: nil store")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime image", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
now := input.Now.UTC()
|
||||
stmt := pgtable.RuntimeRecords.UPDATE(
|
||||
pgtable.RuntimeRecords.CurrentImageRef,
|
||||
pgtable.RuntimeRecords.CurrentEngineVersion,
|
||||
pgtable.RuntimeRecords.UpdatedAt,
|
||||
).SET(
|
||||
pg.String(input.CurrentImageRef),
|
||||
pg.String(input.CurrentEngineVersion),
|
||||
pg.TimestampzT(now),
|
||||
).WHERE(pg.AND(
|
||||
pgtable.RuntimeRecords.GameID.EQ(pg.String(input.GameID)),
|
||||
pgtable.RuntimeRecords.Status.EQ(pg.String(string(input.ExpectedStatus))),
|
||||
))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime image: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime image: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return store.classifyMissingUpdate(operationCtx, input.GameID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateEngineHealth rotates the `engine_health` column of one runtime
|
||||
// row plus `updated_at`. The destination status is preserved and no
|
||||
// CAS guard is applied so late-arriving runtime:health_events still
|
||||
// refresh the summary regardless of the current runtime status. Used
|
||||
// by the Stage 18 health-events consumer. Returns runtime.ErrNotFound
|
||||
// when no row exists for input.GameID.
|
||||
func (store *Store) UpdateEngineHealth(ctx context.Context, input ports.UpdateEngineHealthInput) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("update runtime engine health: nil store")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime engine health", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.RuntimeRecords.UPDATE(
|
||||
pgtable.RuntimeRecords.EngineHealth,
|
||||
pgtable.RuntimeRecords.UpdatedAt,
|
||||
).SET(
|
||||
pg.String(input.EngineHealthSummary),
|
||||
pg.TimestampzT(input.Now.UTC()),
|
||||
).WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(input.GameID)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime engine health: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime engine health: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return runtime.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateScheduling mutates the scheduling columns of one runtime row
|
||||
// (`next_generation_at`, `skip_next_tick`, `current_turn`) plus
|
||||
// `updated_at`. Returns runtime.ErrNotFound when no row exists.
|
||||
func (store *Store) UpdateScheduling(ctx context.Context, input ports.UpdateSchedulingInput) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("update runtime scheduling: nil store")
|
||||
}
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "update runtime scheduling", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
var nextGenExpr pg.Expression
|
||||
if input.NextGenerationAt != nil {
|
||||
nextGenExpr = pg.TimestampzT(input.NextGenerationAt.UTC())
|
||||
} else {
|
||||
nextGenExpr = pg.NULL
|
||||
}
|
||||
|
||||
stmt := pgtable.RuntimeRecords.UPDATE(
|
||||
pgtable.RuntimeRecords.NextGenerationAt,
|
||||
pgtable.RuntimeRecords.SkipNextTick,
|
||||
pgtable.RuntimeRecords.CurrentTurn,
|
||||
pgtable.RuntimeRecords.UpdatedAt,
|
||||
).SET(
|
||||
nextGenExpr,
|
||||
pg.Bool(input.SkipNextTick),
|
||||
pg.Int32(int32(input.CurrentTurn)),
|
||||
pg.TimestampzT(input.Now.UTC()),
|
||||
).WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(input.GameID)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
result, err := store.db.ExecContext(operationCtx, query, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime scheduling: %w", err)
|
||||
}
|
||||
affected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("update runtime scheduling: rows affected: %w", err)
|
||||
}
|
||||
if affected == 0 {
|
||||
return runtime.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes the record identified by gameID. The call is
|
||||
// idempotent: it returns nil even when no row matches (mirrors
|
||||
// PlayerMappingStore.DeleteByGame). Used by the register-runtime
|
||||
// rollback path (Stage 13) when engine /admin/init or any later setup
|
||||
// step fails after the row has been installed with status=starting.
|
||||
func (store *Store) Delete(ctx context.Context, gameID string) error {
|
||||
if store == nil || store.db == nil {
|
||||
return errors.New("delete runtime record: nil store")
|
||||
}
|
||||
if strings.TrimSpace(gameID) == "" {
|
||||
return fmt.Errorf("delete runtime record: game id must not be empty")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "delete runtime record", store.operationTimeout)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pgtable.RuntimeRecords.DELETE().
|
||||
WHERE(pgtable.RuntimeRecords.GameID.EQ(pg.String(gameID)))
|
||||
|
||||
query, args := stmt.Sql()
|
||||
if _, err := store.db.ExecContext(operationCtx, query, args...); err != nil {
|
||||
return fmt.Errorf("delete runtime record: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDueRunning returns every record whose status is `running` and
|
||||
// whose `next_generation_at <= now`. The order is
|
||||
// (next_generation_at ASC, game_id ASC), matching the
|
||||
// `runtime_records_status_next_gen_idx` direction.
|
||||
func (store *Store) ListDueRunning(ctx context.Context, now time.Time) ([]runtime.RuntimeRecord, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("list due runtime records: nil store")
|
||||
}
|
||||
if now.IsZero() {
|
||||
return nil, fmt.Errorf("list due runtime records: now must not be zero")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list due runtime records", store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
cutoff := pg.TimestampzT(now.UTC())
|
||||
stmt := pg.SELECT(runtimeSelectColumns).
|
||||
FROM(pgtable.RuntimeRecords).
|
||||
WHERE(pg.AND(
|
||||
pgtable.RuntimeRecords.Status.EQ(pg.String(string(runtime.StatusRunning))),
|
||||
pgtable.RuntimeRecords.NextGenerationAt.LT_EQ(cutoff),
|
||||
)).
|
||||
ORDER_BY(
|
||||
pgtable.RuntimeRecords.NextGenerationAt.ASC(),
|
||||
pgtable.RuntimeRecords.GameID.ASC(),
|
||||
)
|
||||
|
||||
return store.queryRecords(operationCtx, stmt, "list due runtime records")
|
||||
}
|
||||
|
||||
// List returns every record in the store, ordered by `created_at`
|
||||
// descending and by `game_id` ascending as a tie-breaker. Used by the
|
||||
// `internalListRuntimes` REST handler when no status filter is
|
||||
// supplied.
|
||||
func (store *Store) List(ctx context.Context) ([]runtime.RuntimeRecord, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("list runtime records: nil store")
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list runtime records", store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(runtimeSelectColumns).
|
||||
FROM(pgtable.RuntimeRecords).
|
||||
ORDER_BY(
|
||||
pgtable.RuntimeRecords.CreatedAt.DESC(),
|
||||
pgtable.RuntimeRecords.GameID.ASC(),
|
||||
)
|
||||
|
||||
return store.queryRecords(operationCtx, stmt, "list runtime records")
|
||||
}
|
||||
|
||||
// ListByStatus returns every record currently indexed under status,
|
||||
// ordered by game_id ASC.
|
||||
func (store *Store) ListByStatus(ctx context.Context, status runtime.Status) ([]runtime.RuntimeRecord, error) {
|
||||
if store == nil || store.db == nil {
|
||||
return nil, errors.New("list runtime records by status: nil store")
|
||||
}
|
||||
if !status.IsKnown() {
|
||||
return nil, fmt.Errorf("list runtime records by status: status %q is unsupported", status)
|
||||
}
|
||||
|
||||
operationCtx, cancel, err := sqlx.WithTimeout(ctx, "list runtime records by status", store.operationTimeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
stmt := pg.SELECT(runtimeSelectColumns).
|
||||
FROM(pgtable.RuntimeRecords).
|
||||
WHERE(pgtable.RuntimeRecords.Status.EQ(pg.String(string(status)))).
|
||||
ORDER_BY(pgtable.RuntimeRecords.GameID.ASC())
|
||||
|
||||
return store.queryRecords(operationCtx, stmt, "list runtime records by status")
|
||||
}
|
||||
|
||||
// queryRecords runs a SELECT statement and scans every returned row
|
||||
// into a runtime.RuntimeRecord slice. opName is used only to prefix
|
||||
// error messages.
|
||||
func (store *Store) queryRecords(ctx context.Context, stmt pg.SelectStatement, opName string) ([]runtime.RuntimeRecord, error) {
|
||||
query, args := stmt.Sql()
|
||||
rows, err := store.db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", opName, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
records := make([]runtime.RuntimeRecord, 0)
|
||||
for rows.Next() {
|
||||
record, err := scanRecord(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: scan: %w", opName, err)
|
||||
}
|
||||
records = append(records, record)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", opName, err)
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// rowScanner abstracts *sql.Row and *sql.Rows so scanRecord can be
|
||||
// shared across both single-row and iterated reads.
|
||||
type rowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
// scanRecord scans one runtime_records row from rs. Returns
|
||||
// sql.ErrNoRows verbatim so callers can distinguish "no row" from a
|
||||
// hard error.
|
||||
func scanRecord(rs rowScanner) (runtime.RuntimeRecord, error) {
|
||||
var (
|
||||
gameID string
|
||||
status string
|
||||
engineEndpoint string
|
||||
currentImageRef string
|
||||
currentEngineVersion string
|
||||
turnSchedule string
|
||||
currentTurn int32
|
||||
nextGenerationAt sql.NullTime
|
||||
skipNextTick bool
|
||||
engineHealth string
|
||||
createdAt time.Time
|
||||
updatedAt time.Time
|
||||
startedAt sql.NullTime
|
||||
stoppedAt sql.NullTime
|
||||
finishedAt sql.NullTime
|
||||
)
|
||||
if err := rs.Scan(
|
||||
&gameID,
|
||||
&status,
|
||||
&engineEndpoint,
|
||||
¤tImageRef,
|
||||
¤tEngineVersion,
|
||||
&turnSchedule,
|
||||
¤tTurn,
|
||||
&nextGenerationAt,
|
||||
&skipNextTick,
|
||||
&engineHealth,
|
||||
&createdAt,
|
||||
&updatedAt,
|
||||
&startedAt,
|
||||
&stoppedAt,
|
||||
&finishedAt,
|
||||
); err != nil {
|
||||
return runtime.RuntimeRecord{}, err
|
||||
}
|
||||
return runtime.RuntimeRecord{
|
||||
GameID: gameID,
|
||||
Status: runtime.Status(status),
|
||||
EngineEndpoint: engineEndpoint,
|
||||
CurrentImageRef: currentImageRef,
|
||||
CurrentEngineVersion: currentEngineVersion,
|
||||
TurnSchedule: turnSchedule,
|
||||
CurrentTurn: int(currentTurn),
|
||||
NextGenerationAt: sqlx.TimePtrFromNullable(nextGenerationAt),
|
||||
SkipNextTick: skipNextTick,
|
||||
EngineHealth: engineHealth,
|
||||
CreatedAt: createdAt.UTC(),
|
||||
UpdatedAt: updatedAt.UTC(),
|
||||
StartedAt: sqlx.TimePtrFromNullable(startedAt),
|
||||
StoppedAt: sqlx.TimePtrFromNullable(stoppedAt),
|
||||
FinishedAt: sqlx.TimePtrFromNullable(finishedAt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Ensure Store satisfies the ports.RuntimeRecordStore interface at
|
||||
// compile time.
|
||||
var _ ports.RuntimeRecordStore = (*Store)(nil)
|
||||
@@ -0,0 +1,718 @@
|
||||
package runtimerecordstore_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gamemaster/internal/adapters/postgres/internal/pgtest"
|
||||
"galaxy/gamemaster/internal/adapters/postgres/runtimerecordstore"
|
||||
"galaxy/gamemaster/internal/domain/runtime"
|
||||
"galaxy/gamemaster/internal/ports"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) { pgtest.RunMain(m) }
|
||||
|
||||
func newStore(t *testing.T) *runtimerecordstore.Store {
|
||||
t.Helper()
|
||||
pgtest.TruncateAll(t)
|
||||
store, err := runtimerecordstore.New(runtimerecordstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: pgtest.OperationTimeout,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return store
|
||||
}
|
||||
|
||||
func startingRecord(gameID string, createdAt time.Time) runtime.RuntimeRecord {
|
||||
return runtime.RuntimeRecord{
|
||||
GameID: gameID,
|
||||
Status: runtime.StatusStarting,
|
||||
EngineEndpoint: "http://galaxy-game-" + gameID + ":8080",
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
CurrentEngineVersion: "v1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CurrentTurn: 0,
|
||||
EngineHealth: "",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: createdAt,
|
||||
}
|
||||
}
|
||||
|
||||
func runningRecord(gameID string, createdAt time.Time, nextGen time.Time) runtime.RuntimeRecord {
|
||||
startedAt := createdAt.Add(time.Second)
|
||||
return runtime.RuntimeRecord{
|
||||
GameID: gameID,
|
||||
Status: runtime.StatusRunning,
|
||||
EngineEndpoint: "http://galaxy-game-" + gameID + ":8080",
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.3",
|
||||
CurrentEngineVersion: "v1.2.3",
|
||||
TurnSchedule: "0 18 * * *",
|
||||
CurrentTurn: 1,
|
||||
NextGenerationAt: &nextGen,
|
||||
EngineHealth: "healthy",
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: startedAt,
|
||||
StartedAt: &startedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidConfig(t *testing.T) {
|
||||
_, err := runtimerecordstore.New(runtimerecordstore.Config{})
|
||||
require.Error(t, err)
|
||||
|
||||
store, err := runtimerecordstore.New(runtimerecordstore.Config{
|
||||
DB: pgtest.Ensure(t).Pool(),
|
||||
OperationTimeout: 0,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, store)
|
||||
}
|
||||
|
||||
func TestInsertGetRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
record := startingRecord("game-001", now)
|
||||
|
||||
require.NoError(t, store.Insert(ctx, record))
|
||||
|
||||
got, err := store.Get(ctx, record.GameID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, record.GameID, got.GameID)
|
||||
assert.Equal(t, runtime.StatusStarting, got.Status)
|
||||
assert.Equal(t, record.EngineEndpoint, got.EngineEndpoint)
|
||||
assert.Equal(t, record.CurrentImageRef, got.CurrentImageRef)
|
||||
assert.Equal(t, record.CurrentEngineVersion, got.CurrentEngineVersion)
|
||||
assert.Equal(t, record.TurnSchedule, got.TurnSchedule)
|
||||
assert.Equal(t, 0, got.CurrentTurn)
|
||||
assert.Nil(t, got.NextGenerationAt)
|
||||
assert.False(t, got.SkipNextTick)
|
||||
assert.Equal(t, "", got.EngineHealth)
|
||||
assert.True(t, got.CreatedAt.Equal(now), "created_at: want %v, got %v", now, got.CreatedAt)
|
||||
assert.Equal(t, time.UTC, got.CreatedAt.Location())
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
assert.Equal(t, time.UTC, got.UpdatedAt.Location())
|
||||
assert.Nil(t, got.StartedAt)
|
||||
assert.Nil(t, got.StoppedAt)
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
}
|
||||
|
||||
func TestInsertRejectsDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
record := startingRecord("game-001", now)
|
||||
require.NoError(t, store.Insert(ctx, record))
|
||||
|
||||
err := store.Insert(ctx, record)
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrConflict), "want ErrConflict, got %v", err)
|
||||
}
|
||||
|
||||
func TestInsertRejectsInvalidRecord(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
bad := runtime.RuntimeRecord{} // empty
|
||||
err := store.Insert(ctx, bad)
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, runtime.ErrConflict))
|
||||
}
|
||||
|
||||
func TestGetReturnsErrNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.Get(ctx, "missing")
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestUpdateStatusStartingToRunningSetsStartedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
|
||||
|
||||
now := created.Add(2 * time.Second)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusStarting,
|
||||
To: runtime.StatusRunning,
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusRunning, got.Status)
|
||||
require.NotNil(t, got.StartedAt)
|
||||
assert.True(t, got.StartedAt.Equal(now))
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
assert.Nil(t, got.StoppedAt)
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
}
|
||||
|
||||
func TestUpdateStatusToFinishedSetsFinishedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusGenerationInProgress,
|
||||
Now: created.Add(2 * time.Second),
|
||||
}))
|
||||
|
||||
finishAt := created.Add(time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusGenerationInProgress,
|
||||
To: runtime.StatusFinished,
|
||||
Now: finishAt,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusFinished, got.Status)
|
||||
require.NotNil(t, got.FinishedAt)
|
||||
assert.True(t, got.FinishedAt.Equal(finishAt))
|
||||
assert.True(t, got.UpdatedAt.Equal(finishAt))
|
||||
}
|
||||
|
||||
func TestUpdateStatusToStoppedSetsStoppedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
stopAt := created.Add(2 * time.Hour)
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusStopped,
|
||||
Now: stopAt,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusStopped, got.Status)
|
||||
require.NotNil(t, got.StoppedAt)
|
||||
assert.True(t, got.StoppedAt.Equal(stopAt))
|
||||
require.NotNil(t, got.StartedAt, "started_at must remain set after stop")
|
||||
assert.Nil(t, got.FinishedAt)
|
||||
}
|
||||
|
||||
func TestUpdateStatusEngineUnreachableRecoveryKeepsStartedAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
original := runningRecord("game-001", created, nextGen)
|
||||
require.NoError(t, store.Insert(ctx, original))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusEngineUnreachable,
|
||||
Now: created.Add(time.Minute),
|
||||
}))
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusEngineUnreachable,
|
||||
To: runtime.StatusRunning,
|
||||
Now: created.Add(2 * time.Minute),
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusRunning, got.Status)
|
||||
require.NotNil(t, got.StartedAt)
|
||||
assert.True(t, got.StartedAt.Equal(*original.StartedAt),
|
||||
"recovery transition must not overwrite started_at")
|
||||
}
|
||||
|
||||
func TestUpdateStatusOptionalFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
healthy := "engine_unreachable_summary"
|
||||
imageRef := "ghcr.io/galaxy/game:v1.2.4"
|
||||
engineVersion := "v1.2.4"
|
||||
now := created.Add(time.Minute)
|
||||
|
||||
require.NoError(t, store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusGenerationInProgress,
|
||||
Now: now,
|
||||
EngineHealthSummary: &healthy,
|
||||
CurrentImageRef: &imageRef,
|
||||
CurrentEngineVersion: &engineVersion,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusGenerationInProgress, got.Status)
|
||||
assert.Equal(t, healthy, got.EngineHealth)
|
||||
assert.Equal(t, imageRef, got.CurrentImageRef)
|
||||
assert.Equal(t, engineVersion, got.CurrentEngineVersion)
|
||||
}
|
||||
|
||||
func TestUpdateStatusOnMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "ghost",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusStopped,
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound), "want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateStatusStaleCASReturnsConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
|
||||
|
||||
err := store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusStopped,
|
||||
Now: created.Add(time.Second),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrConflict), "want ErrConflict, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateStatusConcurrentCAS(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
const concurrency = 8
|
||||
results := make([]error, concurrency)
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(concurrency)
|
||||
for index := range concurrency {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
results[index] = store.UpdateStatus(ctx, ports.UpdateStatusInput{
|
||||
GameID: "game-001",
|
||||
ExpectedFrom: runtime.StatusRunning,
|
||||
To: runtime.StatusStopped,
|
||||
Now: created.Add(time.Duration(index+1) * time.Second),
|
||||
})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
wins, conflicts := 0, 0
|
||||
for _, err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
wins++
|
||||
case errors.Is(err, runtime.ErrConflict):
|
||||
conflicts++
|
||||
default:
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
assert.Equal(t, 1, wins, "exactly one caller must win the CAS race")
|
||||
assert.Equal(t, concurrency-1, conflicts, "the rest must observe runtime.ErrConflict")
|
||||
}
|
||||
|
||||
func TestUpdateImageHappy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
now := nextGen.Add(time.Second)
|
||||
require.NoError(t, store.UpdateImage(ctx, ports.UpdateImageInput{
|
||||
GameID: "game-001",
|
||||
ExpectedStatus: runtime.StatusRunning,
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
|
||||
CurrentEngineVersion: "v1.2.4",
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusRunning, got.Status, "patch must not change status")
|
||||
assert.Equal(t, "ghcr.io/galaxy/game:v1.2.4", got.CurrentImageRef)
|
||||
assert.Equal(t, "v1.2.4", got.CurrentEngineVersion)
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
require.NotNil(t, got.NextGenerationAt, "next_generation_at must remain untouched")
|
||||
assert.True(t, got.NextGenerationAt.Equal(nextGen))
|
||||
assert.Equal(t, 1, got.CurrentTurn, "current_turn must remain untouched")
|
||||
}
|
||||
|
||||
func TestUpdateImageStaleStatusReturnsConflict(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
|
||||
|
||||
err := store.UpdateImage(ctx, ports.UpdateImageInput{
|
||||
GameID: "game-001",
|
||||
ExpectedStatus: runtime.StatusRunning,
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
|
||||
CurrentEngineVersion: "v1.2.4",
|
||||
Now: created.Add(time.Second),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrConflict), "want ErrConflict, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateImageOnMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateImage(ctx, ports.UpdateImageInput{
|
||||
GameID: "ghost",
|
||||
ExpectedStatus: runtime.StatusRunning,
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
|
||||
CurrentEngineVersion: "v1.2.4",
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound), "want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateImageRejectsInvalidInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateImage(ctx, ports.UpdateImageInput{
|
||||
GameID: "",
|
||||
ExpectedStatus: runtime.StatusRunning,
|
||||
CurrentImageRef: "ghcr.io/galaxy/game:v1.2.4",
|
||||
CurrentEngineVersion: "v1.2.4",
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, runtime.ErrConflict))
|
||||
require.False(t, errors.Is(err, runtime.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthHappy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
now := nextGen.Add(2 * time.Second)
|
||||
require.NoError(t, store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "game-001",
|
||||
EngineHealthSummary: "probe_failed",
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusRunning, got.Status, "engine health update must not change status")
|
||||
assert.Equal(t, "probe_failed", got.EngineHealth)
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
require.NotNil(t, got.NextGenerationAt, "next_generation_at must remain untouched")
|
||||
assert.True(t, got.NextGenerationAt.Equal(nextGen))
|
||||
assert.Equal(t, 1, got.CurrentTurn, "current_turn must remain untouched")
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthAcceptsEmptySummary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
now := nextGen.Add(time.Second)
|
||||
require.NoError(t, store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "game-001",
|
||||
EngineHealthSummary: "",
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "", got.EngineHealth)
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthOnMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "ghost",
|
||||
EngineHealthSummary: "exited",
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound), "want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthRejectsInvalidInput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "",
|
||||
EngineHealthSummary: "healthy",
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.False(t, errors.Is(err, runtime.ErrConflict))
|
||||
require.False(t, errors.Is(err, runtime.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestUpdateEngineHealthAppliesFromAnyStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", created)))
|
||||
|
||||
now := created.Add(time.Second)
|
||||
require.NoError(t, store.UpdateEngineHealth(ctx, ports.UpdateEngineHealthInput{
|
||||
GameID: "game-001",
|
||||
EngineHealthSummary: "exited",
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, runtime.StatusStarting, got.Status, "no status mutation expected")
|
||||
assert.Equal(t, "exited", got.EngineHealth)
|
||||
}
|
||||
|
||||
func TestUpdateSchedulingHappy(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
updated := nextGen.Add(time.Hour)
|
||||
now := nextGen.Add(time.Second)
|
||||
require.NoError(t, store.UpdateScheduling(ctx, ports.UpdateSchedulingInput{
|
||||
GameID: "game-001",
|
||||
NextGenerationAt: &updated,
|
||||
SkipNextTick: true,
|
||||
CurrentTurn: 5,
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.NextGenerationAt)
|
||||
assert.True(t, got.NextGenerationAt.Equal(updated))
|
||||
assert.True(t, got.SkipNextTick)
|
||||
assert.Equal(t, 5, got.CurrentTurn)
|
||||
assert.True(t, got.UpdatedAt.Equal(now))
|
||||
}
|
||||
|
||||
func TestUpdateSchedulingClearsNextGen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
nextGen := created.Add(time.Hour)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-001", created, nextGen)))
|
||||
|
||||
now := nextGen.Add(time.Second)
|
||||
require.NoError(t, store.UpdateScheduling(ctx, ports.UpdateSchedulingInput{
|
||||
GameID: "game-001",
|
||||
NextGenerationAt: nil,
|
||||
SkipNextTick: false,
|
||||
CurrentTurn: 0,
|
||||
Now: now,
|
||||
}))
|
||||
|
||||
got, err := store.Get(ctx, "game-001")
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, got.NextGenerationAt)
|
||||
assert.False(t, got.SkipNextTick)
|
||||
}
|
||||
|
||||
func TestUpdateSchedulingOnMissingReturnsNotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
err := store.UpdateScheduling(ctx, ports.UpdateSchedulingInput{
|
||||
GameID: "ghost",
|
||||
CurrentTurn: 0,
|
||||
Now: time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.True(t, errors.Is(err, runtime.ErrNotFound))
|
||||
}
|
||||
|
||||
func TestListDueRunning(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
createdEarlier := time.Date(2026, time.April, 27, 10, 0, 0, 0, time.UTC)
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
due := created.Add(-time.Minute) // due before now
|
||||
future := created.Add(time.Hour) // not due yet
|
||||
|
||||
dueRecord := runningRecord("game-due", created, due)
|
||||
require.NoError(t, store.Insert(ctx, dueRecord))
|
||||
|
||||
futureRecord := runningRecord("game-future", created, future)
|
||||
require.NoError(t, store.Insert(ctx, futureRecord))
|
||||
|
||||
// A stopped record whose next_generation_at is in the past must
|
||||
// still be excluded by the running-status filter.
|
||||
stoppedRecord := startingRecord("game-stopped", createdEarlier)
|
||||
stoppedRecord.Status = runtime.StatusStopped
|
||||
startedAt := createdEarlier.Add(time.Second)
|
||||
stoppedAt := createdEarlier.Add(time.Minute)
|
||||
stoppedRecord.StartedAt = &startedAt
|
||||
stoppedRecord.StoppedAt = &stoppedAt
|
||||
stoppedRecord.UpdatedAt = stoppedAt
|
||||
stalePast := created.Add(-30 * time.Minute)
|
||||
stoppedRecord.NextGenerationAt = &stalePast
|
||||
require.NoError(t, store.Insert(ctx, stoppedRecord))
|
||||
|
||||
results, err := store.ListDueRunning(ctx, created)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, results, 1)
|
||||
assert.Equal(t, "game-due", results[0].GameID)
|
||||
}
|
||||
|
||||
func TestListByStatus(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
created := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-r1", created, created.Add(time.Hour))))
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-r2", created, created.Add(time.Hour))))
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-s1", created)))
|
||||
|
||||
running, err := store.ListByStatus(ctx, runtime.StatusRunning)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, running, 2)
|
||||
assert.Equal(t, "game-r1", running[0].GameID)
|
||||
assert.Equal(t, "game-r2", running[1].GameID)
|
||||
|
||||
starting, err := store.ListByStatus(ctx, runtime.StatusStarting)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, starting, 1)
|
||||
assert.Equal(t, "game-s1", starting[0].GameID)
|
||||
|
||||
finished, err := store.ListByStatus(ctx, runtime.StatusFinished)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, finished)
|
||||
}
|
||||
|
||||
func TestListReturnsEveryRecordOrderedByCreatedAtDesc(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
earliest := time.Date(2026, time.April, 27, 10, 0, 0, 0, time.UTC)
|
||||
middle := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
latest := time.Date(2026, time.April, 27, 14, 0, 0, 0, time.UTC)
|
||||
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-earliest", earliest)))
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-middle", middle, middle.Add(time.Hour))))
|
||||
require.NoError(t, store.Insert(ctx, runningRecord("game-latest", latest, latest.Add(time.Hour))))
|
||||
|
||||
records, err := store.List(ctx)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 3)
|
||||
assert.Equal(t, "game-latest", records[0].GameID)
|
||||
assert.Equal(t, "game-middle", records[1].GameID)
|
||||
assert.Equal(t, "game-earliest", records[2].GameID)
|
||||
}
|
||||
|
||||
func TestListReturnsEmptySliceWhenStoreIsEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
records, err := store.List(ctx)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, records)
|
||||
}
|
||||
|
||||
func TestListByStatusUnknownRejected(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.ListByStatus(ctx, runtime.Status("exotic"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestListDueRunningRejectsZeroNow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.ListDueRunning(ctx, time.Time{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetRejectsEmptyGameID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
_, err := store.Get(ctx, "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDeleteIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
now := time.Date(2026, time.April, 27, 12, 0, 0, 0, time.UTC)
|
||||
require.NoError(t, store.Insert(ctx, startingRecord("game-001", now)))
|
||||
|
||||
require.NoError(t, store.Delete(ctx, "game-001"))
|
||||
|
||||
_, err := store.Get(ctx, "game-001")
|
||||
require.ErrorIs(t, err, runtime.ErrNotFound)
|
||||
|
||||
// Second call must be a no-op.
|
||||
require.NoError(t, store.Delete(ctx, "game-001"))
|
||||
}
|
||||
|
||||
func TestDeleteRejectsEmptyGameID(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
store := newStore(t)
|
||||
|
||||
require.Error(t, store.Delete(ctx, ""))
|
||||
}
|
||||
Reference in New Issue
Block a user