package runtime import ( "context" "database/sql" "errors" "fmt" "time" "galaxy/backend/internal/postgres/jet/backend/model" "galaxy/backend/internal/postgres/jet/backend/table" "github.com/go-jet/jet/v2/postgres" "github.com/go-jet/jet/v2/qrm" "github.com/google/uuid" ) // engineVersionsPK is the constraint name surfaced when a duplicate // `version` is inserted. Postgres synthesises `_pkey` for the // primary-key constraint, matching the migration in // `backend/internal/postgres/migrations/00001_init.sql:407`. const engineVersionsPK = "engine_versions_pkey" // runtimeRecordsPK is the constraint name surfaced when a duplicate // `runtime_records.game_id` insert hits the primary key. const runtimeRecordsPK = "runtime_records_pkey" // playerMappingsRaceUnique mirrors // `player_mappings_game_race_uidx`, the partial UNIQUE that enforces // the one-race-per-game invariant. const playerMappingsRaceUnique = "player_mappings_game_race_uidx" // Store is the Postgres-backed query surface for the runtime package. // All queries are built through go-jet against the generated table // bindings under `backend/internal/postgres/jet/backend/table`. type Store struct { db *sql.DB } // NewStore constructs a Store wrapping db. func NewStore(db *sql.DB) *Store { return &Store{db: db} } // engineVersionColumns is the canonical projection used by every // engine-version read path. func engineVersionColumns() postgres.ColumnList { v := table.EngineVersions return postgres.ColumnList{v.Version, v.ImageRef, v.Enabled, v.CreatedAt, v.UpdatedAt} } // runtimeRecordColumns is the canonical projection used by every // runtime-record read path. func runtimeRecordColumns() postgres.ColumnList { r := table.RuntimeRecords return postgres.ColumnList{ r.GameID, r.Status, r.CurrentContainerID, r.CurrentImageRef, r.CurrentEngineVersion, r.EngineEndpoint, r.StatePath, r.DockerNetwork, r.TurnSchedule, r.CurrentTurn, r.NextGenerationAt, r.SkipNextTick, r.Paused, r.PausedAt, r.EngineHealth, r.CreatedAt, r.UpdatedAt, r.StartedAt, r.StoppedAt, r.FinishedAt, r.RemovedAt, r.LastObservedAt, } } // operationLogColumns is the canonical projection used by every read // of `backend.runtime_operation_log`. func operationLogColumns() postgres.ColumnList { o := table.RuntimeOperationLog return postgres.ColumnList{ o.OperationID, o.GameID, o.Op, o.Source, o.Status, o.ImageRef, o.ContainerID, o.ErrorCode, o.ErrorMessage, o.StartedAt, o.FinishedAt, } } // ===================================================================== // Engine version registry // ===================================================================== // ListEngineVersions returns every engine_versions row ordered by // created_at DESC. func (s *Store) ListEngineVersions(ctx context.Context) ([]EngineVersion, error) { v := table.EngineVersions stmt := postgres.SELECT(engineVersionColumns()). FROM(v). ORDER_BY(v.CreatedAt.DESC(), v.Version.DESC()) var rows []model.EngineVersions if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("runtime store: list engine versions: %w", err) } out := make([]EngineVersion, 0, len(rows)) for _, row := range rows { out = append(out, modelToEngineVersion(row)) } return out, nil } // GetEngineVersion returns the row for version. Returns ErrNotFound // when no row matches. func (s *Store) GetEngineVersion(ctx context.Context, version string) (EngineVersion, error) { v := table.EngineVersions stmt := postgres.SELECT(engineVersionColumns()). FROM(v). WHERE(v.Version.EQ(postgres.String(version))). LIMIT(1) var row model.EngineVersions if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return EngineVersion{}, ErrNotFound } return EngineVersion{}, fmt.Errorf("runtime store: load engine version %q: %w", version, err) } return modelToEngineVersion(row), nil } // InsertEngineVersion persists a fresh engine version row. Returns // ErrEngineVersionTaken when the primary key collides. func (s *Store) InsertEngineVersion(ctx context.Context, version, imageRef string, enabled bool, now time.Time) (EngineVersion, error) { v := table.EngineVersions stmt := v.INSERT(v.Version, v.ImageRef, v.Enabled, v.CreatedAt, v.UpdatedAt). VALUES(version, imageRef, enabled, now, now). RETURNING(engineVersionColumns()) var row model.EngineVersions if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if isUniqueViolation(err, engineVersionsPK) { return EngineVersion{}, ErrEngineVersionTaken } return EngineVersion{}, fmt.Errorf("runtime store: insert engine version %q: %w", version, err) } return modelToEngineVersion(row), nil } // engineVersionUpdate carries the parameters for UpdateEngineVersion. // Nil pointers leave the corresponding column alone. type engineVersionUpdate struct { ImageRef *string Enabled *bool } // UpdateEngineVersion patches the supplied columns and bumps // updated_at. Returns ErrNotFound when no row matches. func (s *Store) UpdateEngineVersion(ctx context.Context, version string, patch engineVersionUpdate, now time.Time) (EngineVersion, error) { v := table.EngineVersions rest := []any{} if patch.ImageRef != nil { rest = append(rest, v.ImageRef.SET(postgres.String(*patch.ImageRef))) } if patch.Enabled != nil { rest = append(rest, v.Enabled.SET(postgres.Bool(*patch.Enabled))) } stmt := v.UPDATE(). SET(v.UpdatedAt.SET(postgres.TimestampzT(now)), rest...). WHERE(v.Version.EQ(postgres.String(version))). RETURNING(engineVersionColumns()) var row model.EngineVersions if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return EngineVersion{}, ErrNotFound } return EngineVersion{}, fmt.Errorf("runtime store: update engine version %q: %w", version, err) } return modelToEngineVersion(row), nil } // ===================================================================== // Runtime records // ===================================================================== // runtimeRecordInsert carries the parameters for InsertRuntimeRecord. type runtimeRecordInsert struct { GameID uuid.UUID Status string CurrentContainerID string CurrentImageRef string CurrentEngineVersion string EngineEndpoint string StatePath string DockerNetwork string TurnSchedule string StartedAt *time.Time } // InsertRuntimeRecord creates a fresh row. func (s *Store) InsertRuntimeRecord(ctx context.Context, in runtimeRecordInsert) (RuntimeRecord, error) { r := table.RuntimeRecords stmt := r.INSERT( r.GameID, r.Status, r.CurrentContainerID, r.CurrentImageRef, r.CurrentEngineVersion, r.EngineEndpoint, r.StatePath, r.DockerNetwork, r.TurnSchedule, r.StartedAt, ).VALUES( in.GameID, in.Status, nullableString(in.CurrentContainerID), nullableString(in.CurrentImageRef), nullableString(in.CurrentEngineVersion), in.EngineEndpoint, nullableString(in.StatePath), nullableString(in.DockerNetwork), in.TurnSchedule, nullableTime(in.StartedAt), ).RETURNING(runtimeRecordColumns()) var row model.RuntimeRecords if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if isUniqueViolation(err, runtimeRecordsPK) { return RuntimeRecord{}, ErrConflict } return RuntimeRecord{}, fmt.Errorf("runtime store: insert runtime_record %s: %w", in.GameID, err) } return modelToRuntimeRecord(row), nil } // LoadRuntimeRecord returns the row for gameID. Returns ErrNotFound // when no row matches. func (s *Store) LoadRuntimeRecord(ctx context.Context, gameID uuid.UUID) (RuntimeRecord, error) { r := table.RuntimeRecords stmt := postgres.SELECT(runtimeRecordColumns()). FROM(r). WHERE(r.GameID.EQ(postgres.UUID(gameID))). LIMIT(1) var row model.RuntimeRecords if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return RuntimeRecord{}, ErrNotFound } return RuntimeRecord{}, fmt.Errorf("runtime store: load runtime_record %s: %w", gameID, err) } return modelToRuntimeRecord(row), nil } // ListAllRuntimeRecords returns every row, used by Cache.Warm. func (s *Store) ListAllRuntimeRecords(ctx context.Context) ([]RuntimeRecord, error) { stmt := postgres.SELECT(runtimeRecordColumns()).FROM(table.RuntimeRecords) var rows []model.RuntimeRecords if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("runtime store: list runtime_records: %w", err) } out := make([]RuntimeRecord, 0, len(rows)) for _, row := range rows { out = append(out, modelToRuntimeRecord(row)) } return out, nil } // runtimeRecordUpdate carries the parameters for UpdateRuntimeRecord. // Pointer fields default to "leave alone" when nil. type runtimeRecordUpdate struct { Status *string CurrentContainerID *string CurrentImageRef *string CurrentEngineVersion *string EngineEndpoint *string StatePath *string DockerNetwork *string TurnSchedule *string CurrentTurn *int32 NextGenerationAt **time.Time SkipNextTick *bool Paused *bool PausedAt **time.Time EngineHealth *string StartedAt **time.Time StoppedAt **time.Time FinishedAt **time.Time RemovedAt **time.Time LastObservedAt **time.Time } // UpdateRuntimeRecord patches the supplied columns. Pointer fields are // translated into a dynamic SET list — only the fields the caller // supplies are emitted in the UPDATE. Nullable timestamps use a // `**time.Time` so callers can distinguish "leave alone" (outer nil) // from "clear to NULL" (inner nil). func (s *Store) UpdateRuntimeRecord(ctx context.Context, gameID uuid.UUID, patch runtimeRecordUpdate, now time.Time) (RuntimeRecord, error) { r := table.RuntimeRecords rest := []any{} if patch.Status != nil { rest = append(rest, r.Status.SET(postgres.String(*patch.Status))) } if patch.CurrentContainerID != nil { rest = append(rest, r.CurrentContainerID.SET(nullableStringSetExpr(*patch.CurrentContainerID))) } if patch.CurrentImageRef != nil { rest = append(rest, r.CurrentImageRef.SET(nullableStringSetExpr(*patch.CurrentImageRef))) } if patch.CurrentEngineVersion != nil { rest = append(rest, r.CurrentEngineVersion.SET(nullableStringSetExpr(*patch.CurrentEngineVersion))) } if patch.EngineEndpoint != nil { rest = append(rest, r.EngineEndpoint.SET(postgres.String(*patch.EngineEndpoint))) } if patch.StatePath != nil { rest = append(rest, r.StatePath.SET(nullableStringSetExpr(*patch.StatePath))) } if patch.DockerNetwork != nil { rest = append(rest, r.DockerNetwork.SET(nullableStringSetExpr(*patch.DockerNetwork))) } if patch.TurnSchedule != nil { rest = append(rest, r.TurnSchedule.SET(postgres.String(*patch.TurnSchedule))) } if patch.CurrentTurn != nil { rest = append(rest, r.CurrentTurn.SET(postgres.Int(int64(*patch.CurrentTurn)))) } if patch.NextGenerationAt != nil { rest = append(rest, r.NextGenerationAt.SET(timePtrSetExpr(*patch.NextGenerationAt))) } if patch.SkipNextTick != nil { rest = append(rest, r.SkipNextTick.SET(postgres.Bool(*patch.SkipNextTick))) } if patch.Paused != nil { rest = append(rest, r.Paused.SET(postgres.Bool(*patch.Paused))) } if patch.PausedAt != nil { rest = append(rest, r.PausedAt.SET(timePtrSetExpr(*patch.PausedAt))) } if patch.EngineHealth != nil { rest = append(rest, r.EngineHealth.SET(postgres.String(*patch.EngineHealth))) } if patch.StartedAt != nil { rest = append(rest, r.StartedAt.SET(timePtrSetExpr(*patch.StartedAt))) } if patch.StoppedAt != nil { rest = append(rest, r.StoppedAt.SET(timePtrSetExpr(*patch.StoppedAt))) } if patch.FinishedAt != nil { rest = append(rest, r.FinishedAt.SET(timePtrSetExpr(*patch.FinishedAt))) } if patch.RemovedAt != nil { rest = append(rest, r.RemovedAt.SET(timePtrSetExpr(*patch.RemovedAt))) } if patch.LastObservedAt != nil { rest = append(rest, r.LastObservedAt.SET(timePtrSetExpr(*patch.LastObservedAt))) } stmt := r.UPDATE(). SET(r.UpdatedAt.SET(postgres.TimestampzT(now)), rest...). WHERE(r.GameID.EQ(postgres.UUID(gameID))). RETURNING(runtimeRecordColumns()) var row model.RuntimeRecords if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return RuntimeRecord{}, ErrNotFound } return RuntimeRecord{}, fmt.Errorf("runtime store: update runtime_record %s: %w", gameID, err) } return modelToRuntimeRecord(row), nil } // DeleteRuntimeRecord removes the row at gameID. Idempotent: nil when // no row matched. func (s *Store) DeleteRuntimeRecord(ctx context.Context, gameID uuid.UUID) error { stmt := table.RuntimeRecords.DELETE(). WHERE(table.RuntimeRecords.GameID.EQ(postgres.UUID(gameID))) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("runtime store: delete runtime_record %s: %w", gameID, err) } return nil } // ===================================================================== // Player mappings // ===================================================================== // InsertPlayerMappings persists a slice of mappings in a single // transaction. Existing rows for the (game_id, user_id) pair are // replaced (ON CONFLICT) so re-runs of StartGame after a transient // failure stay idempotent. func (s *Store) InsertPlayerMappings(ctx context.Context, mappings []PlayerMapping) error { if len(mappings) == 0 { return nil } tx, err := s.db.BeginTx(ctx, nil) if err != nil { return fmt.Errorf("runtime store: begin player_mappings tx: %w", err) } defer func() { _ = tx.Rollback() }() pm := table.PlayerMappings for _, m := range mappings { stmt := pm.INSERT(pm.GameID, pm.UserID, pm.RaceName, pm.EnginePlayerUUID). VALUES(m.GameID, m.UserID, m.RaceName, m.EnginePlayerUUID). ON_CONFLICT(pm.GameID, pm.UserID). DO_UPDATE(postgres.SET( pm.RaceName.SET(pm.EXCLUDED.RaceName), pm.EnginePlayerUUID.SET(pm.EXCLUDED.EnginePlayerUUID), )) if _, err := stmt.ExecContext(ctx, tx); err != nil { if isUniqueViolation(err, playerMappingsRaceUnique) { return fmt.Errorf("%w: race name %q duplicated within game", ErrConflict, m.RaceName) } return fmt.Errorf("runtime store: insert player_mapping %s/%s: %w", m.GameID, m.UserID, err) } } if err := tx.Commit(); err != nil { return fmt.Errorf("runtime store: commit player_mappings: %w", err) } return nil } // LoadPlayerMapping returns the mapping for (gameID, userID). Returns // ErrNotFound when no row matches. func (s *Store) LoadPlayerMapping(ctx context.Context, gameID, userID uuid.UUID) (PlayerMapping, error) { pm := table.PlayerMappings stmt := postgres.SELECT(pm.GameID, pm.UserID, pm.RaceName, pm.EnginePlayerUUID, pm.CreatedAt). FROM(pm). WHERE( pm.GameID.EQ(postgres.UUID(gameID)). AND(pm.UserID.EQ(postgres.UUID(userID))), ). LIMIT(1) var row model.PlayerMappings if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return PlayerMapping{}, ErrNotFound } return PlayerMapping{}, fmt.Errorf("runtime store: load player_mapping: %w", err) } return modelToPlayerMapping(row), nil } // ListPlayerMappingsForGame returns every mapping for gameID. func (s *Store) ListPlayerMappingsForGame(ctx context.Context, gameID uuid.UUID) ([]PlayerMapping, error) { pm := table.PlayerMappings stmt := postgres.SELECT(pm.GameID, pm.UserID, pm.RaceName, pm.EnginePlayerUUID, pm.CreatedAt). FROM(pm). WHERE(pm.GameID.EQ(postgres.UUID(gameID))). ORDER_BY(pm.RaceName.ASC()) var rows []model.PlayerMappings if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("runtime store: list player_mappings: %w", err) } out := make([]PlayerMapping, 0, len(rows)) for _, row := range rows { out = append(out, modelToPlayerMapping(row)) } return out, nil } // DeletePlayerMappingsForGame removes every mapping for gameID. Used // on stop / cancel / reconciler-removal so a future StartGame can // repopulate the projection without violating the per-game UNIQUE. func (s *Store) DeletePlayerMappingsForGame(ctx context.Context, gameID uuid.UUID) error { stmt := table.PlayerMappings.DELETE(). WHERE(table.PlayerMappings.GameID.EQ(postgres.UUID(gameID))) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("runtime store: delete player_mappings %s: %w", gameID, err) } return nil } // ===================================================================== // Operation log // ===================================================================== // operationLogInsert carries the parameters for InsertOperationLog. type operationLogInsert struct { OperationID uuid.UUID GameID uuid.UUID Op string Source string Status string ImageRef string ContainerID string StartedAt time.Time } // InsertOperationLog persists a queued / running operation row. func (s *Store) InsertOperationLog(ctx context.Context, in operationLogInsert) (OperationLog, error) { o := table.RuntimeOperationLog stmt := o.INSERT( o.OperationID, o.GameID, o.Op, o.Source, o.Status, o.ImageRef, o.ContainerID, o.StartedAt, ).VALUES( in.OperationID, in.GameID, in.Op, in.Source, in.Status, in.ImageRef, in.ContainerID, in.StartedAt, ).RETURNING(operationLogColumns()) var row model.RuntimeOperationLog if err := stmt.QueryContext(ctx, s.db, &row); err != nil { return OperationLog{}, err } return modelToOperationLog(row), nil } // CompleteOperationLog updates the status / error fields on // operationID. Returns the refreshed row. func (s *Store) CompleteOperationLog(ctx context.Context, operationID uuid.UUID, status, errCode, errMsg string, finishedAt time.Time) (OperationLog, error) { o := table.RuntimeOperationLog stmt := o.UPDATE(). SET( o.Status.SET(postgres.String(status)), o.ErrorCode.SET(postgres.String(errCode)), o.ErrorMessage.SET(postgres.String(errMsg)), o.FinishedAt.SET(postgres.TimestampzT(finishedAt)), ). WHERE(o.OperationID.EQ(postgres.UUID(operationID))). RETURNING(operationLogColumns()) var row model.RuntimeOperationLog if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return OperationLog{}, ErrNotFound } return OperationLog{}, fmt.Errorf("runtime store: complete operation_log %s: %w", operationID, err) } return modelToOperationLog(row), nil } // ===================================================================== // Health snapshots // ===================================================================== // InsertHealthSnapshot persists a JSON-encoded engine status snapshot. func (s *Store) InsertHealthSnapshot(ctx context.Context, snapshotID, gameID uuid.UUID, observedAt time.Time, payload []byte) error { hs := table.RuntimeHealthSnapshots stmt := hs.INSERT(hs.SnapshotID, hs.GameID, hs.ObservedAt, hs.Payload). VALUES(snapshotID, gameID, observedAt, string(payload)) if _, err := stmt.ExecContext(ctx, s.db); err != nil { return fmt.Errorf("runtime store: insert health_snapshot %s: %w", gameID, err) } return nil } // ===================================================================== // Read-only lobby projection (per The implementation D2) // ===================================================================== // LoadGameProjection reads `backend.games` for runtime's start/stop // flow. Lobby remains the only writer of the table; runtime is a // read-only consumer. Returns ErrNotFound on miss. func (s *Store) LoadGameProjection(ctx context.Context, gameID uuid.UUID) (Game, error) { g := table.Games stmt := postgres.SELECT( g.GameID, g.OwnerUserID, g.Visibility, g.Status, g.GameName, g.TurnSchedule, g.TargetEngineVersion, g.MinPlayers, g.MaxPlayers, g.StartGapHours, g.StartGapPlayers, ). FROM(g). WHERE(g.GameID.EQ(postgres.UUID(gameID))). LIMIT(1) var row model.Games if err := stmt.QueryContext(ctx, s.db, &row); err != nil { if errors.Is(err, qrm.ErrNoRows) { return Game{}, ErrNotFound } return Game{}, fmt.Errorf("runtime store: load game %s: %w", gameID, err) } out := Game{ GameID: row.GameID, Visibility: row.Visibility, Status: row.Status, GameName: row.GameName, TurnSchedule: row.TurnSchedule, TargetEngineVersion: row.TargetEngineVersion, MinPlayers: row.MinPlayers, MaxPlayers: row.MaxPlayers, StartGapHours: row.StartGapHours, StartGapPlayers: row.StartGapPlayers, } if row.OwnerUserID != nil { owner := *row.OwnerUserID out.OwnerUserID = &owner } return out, nil } // ListActiveMemberships reads active rows from `backend.memberships` // for gameID. func (s *Store) ListActiveMemberships(ctx context.Context, gameID uuid.UUID) ([]MembershipRow, error) { m := table.Memberships stmt := postgres.SELECT(m.MembershipID, m.GameID, m.UserID, m.RaceName). FROM(m). WHERE( m.GameID.EQ(postgres.UUID(gameID)). AND(m.Status.EQ(postgres.String("active"))), ). ORDER_BY(m.JoinedAt.ASC()) var rows []model.Memberships if err := stmt.QueryContext(ctx, s.db, &rows); err != nil { return nil, fmt.Errorf("runtime store: list memberships %s: %w", gameID, err) } out := make([]MembershipRow, 0, len(rows)) for _, row := range rows { out = append(out, MembershipRow{ MembershipID: row.MembershipID, GameID: row.GameID, UserID: row.UserID, RaceName: row.RaceName, }) } return out, nil } // ===================================================================== // Model → domain converters // ===================================================================== func modelToEngineVersion(row model.EngineVersions) EngineVersion { return EngineVersion{ Version: row.Version, ImageRef: row.ImageRef, Enabled: row.Enabled, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, } } func modelToRuntimeRecord(row model.RuntimeRecords) RuntimeRecord { rec := RuntimeRecord{ GameID: row.GameID, Status: row.Status, EngineEndpoint: row.EngineEndpoint, TurnSchedule: row.TurnSchedule, CurrentTurn: row.CurrentTurn, SkipNextTick: row.SkipNextTick, Paused: row.Paused, EngineHealth: row.EngineHealth, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, CurrentContainerID: derefString(row.CurrentContainerID), CurrentImageRef: derefString(row.CurrentImageRef), CurrentEngineVersion: derefString(row.CurrentEngineVersion), StatePath: derefString(row.StatePath), DockerNetwork: derefString(row.DockerNetwork), } rec.NextGenerationAt = copyTimePtr(row.NextGenerationAt) rec.PausedAt = copyTimePtr(row.PausedAt) rec.StartedAt = copyTimePtr(row.StartedAt) rec.StoppedAt = copyTimePtr(row.StoppedAt) rec.FinishedAt = copyTimePtr(row.FinishedAt) rec.RemovedAt = copyTimePtr(row.RemovedAt) rec.LastObservedAt = copyTimePtr(row.LastObservedAt) return rec } func modelToOperationLog(row model.RuntimeOperationLog) OperationLog { op := OperationLog{ OperationID: row.OperationID, GameID: row.GameID, Op: row.Op, Source: row.Source, Status: row.Status, ImageRef: row.ImageRef, ContainerID: row.ContainerID, ErrorCode: row.ErrorCode, ErrorMessage: row.ErrorMessage, StartedAt: row.StartedAt, } op.FinishedAt = copyTimePtr(row.FinishedAt) return op } func modelToPlayerMapping(row model.PlayerMappings) PlayerMapping { return PlayerMapping{ GameID: row.GameID, UserID: row.UserID, RaceName: row.RaceName, EnginePlayerUUID: row.EnginePlayerUUID, CreatedAt: row.CreatedAt, } } // ===================================================================== // Scalar helpers // ===================================================================== // nullableString converts a Go string to the `any` form expected by // jet INSERT VALUES bindings: an empty string becomes nil so the // column receives NULL. func nullableString(s string) any { if s == "" { return nil } return s } // nullableTime mirrors nullableString for *time.Time. func nullableTime(t *time.Time) any { if t == nil { return nil } return *t } // nullableStringSetExpr returns a typed jet expression suitable for // UPDATE SET on a nullable text column. The empty string is mapped to // SQL NULL, mirroring the INSERT-side semantics so a "" patch clears // the column. func nullableStringSetExpr(v string) postgres.StringExpression { if v == "" { return postgres.StringExp(postgres.NULL) } return postgres.String(v) } // timePtrSetExpr mirrors nullableStringSetExpr for *time.Time. nil // clears the column; non-nil sets it. func timePtrSetExpr(t *time.Time) postgres.TimestampzExpression { if t == nil { return postgres.TimestampzExp(postgres.NULL) } return postgres.TimestampzT(*t) } func derefString(p *string) string { if p == nil { return "" } return *p } func copyTimePtr(p *time.Time) *time.Time { if p == nil { return nil } t := *p return &t }