feat: gamemaster

This commit is contained in:
Ilia Denisov
2026-05-03 07:59:03 +02:00
committed by GitHub
parent a7cee15115
commit 3e2622757e
229 changed files with 41521 additions and 1098 deletions
@@ -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(&current); 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,
&currentImageRef,
&currentEngineVersion,
&turnSchedule,
&currentTurn,
&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, ""))
}