Files
browser/platform/repo/mysql_scum.go
T

927 lines
33 KiB
Go

package repo
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"sync"
"time"
"browser.local/platform/domain"
"github.com/go-sql-driver/mysql"
)
// The SCUM tables are platform-owned storage for the typed SCUM facts Run
// pushes for one server instance. Columns mirror platform/model/scum.go and
// carry the indexes the platform queries below rely on.
const (
scumMaxListLimit = 1000
scumTrajectoryRetention = 30 * 24 * time.Hour
scumTrajectoryPruneEvery = time.Hour
)
var scumTableDDL = []struct {
table string
statement string
}{
{"scum_user", `CREATE TABLE IF NOT EXISTS scum_user (
id VARCHAR(191) PRIMARY KEY,
server_instance_id VARCHAR(191) NOT NULL,
steam_id VARCHAR(64) NOT NULL,
display_name VARCHAR(191) NOT NULL DEFAULT '',
squad_id BIGINT NOT NULL DEFAULT 0,
registered_at DATETIME(6) NULL,
last_login_at DATETIME(6) NULL,
last_activity_at DATETIME(6) NULL,
last_login_ip VARCHAR(64) NOT NULL DEFAULT '',
online BOOLEAN NOT NULL DEFAULT FALSE,
x DOUBLE NULL,
y DOUBLE NULL,
z DOUBLE NULL,
bank_balance BIGINT NULL,
gold_bars BIGINT NULL,
ridden_vehicle_id VARCHAR(191) NOT NULL DEFAULT '',
game_vehicle_id VARCHAR(191) NOT NULL DEFAULT '',
welcome_queued_at DATETIME(6) NULL,
welcome_last_error VARCHAR(512) NOT NULL DEFAULT '',
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY uq_scum_user_instance_steam (server_instance_id, steam_id),
KEY ix_scum_user_presence (server_instance_id, online, last_activity_at),
KEY ix_scum_user_updated (server_instance_id, updated_at)
)`},
{"scum_user_trajectory", `CREATE TABLE IF NOT EXISTS scum_user_trajectory (
id VARCHAR(191) PRIMARY KEY,
server_instance_id VARCHAR(191) NOT NULL,
scum_user_id VARCHAR(191) NOT NULL,
steam_id VARCHAR(64) NOT NULL,
display_name VARCHAR(191) NOT NULL DEFAULT '',
x DOUBLE NOT NULL,
y DOUBLE NOT NULL,
z DOUBLE NOT NULL,
ridden_vehicle_id VARCHAR(191) NOT NULL DEFAULT '',
game_vehicle_id VARCHAR(191) NOT NULL DEFAULT '',
observed_at DATETIME(6) NOT NULL,
created_at DATETIME(6) NOT NULL,
KEY ix_scum_user_track_subject (server_instance_id, scum_user_id, observed_at),
KEY ix_scum_user_track_steam (server_instance_id, steam_id, observed_at),
KEY ix_scum_user_track_retention (observed_at)
)`},
{"scum_vehicle", `CREATE TABLE IF NOT EXISTS scum_vehicle (
id VARCHAR(191) PRIMARY KEY,
server_instance_id VARCHAR(191) NOT NULL,
game_vehicle_id VARCHAR(191) NOT NULL,
vehicle_class VARCHAR(64) NOT NULL DEFAULT '',
display_name VARCHAR(191) NOT NULL DEFAULT '',
exists_in_game BOOLEAN NOT NULL DEFAULT FALSE,
locked BOOLEAN NOT NULL DEFAULT FALSE,
functional BOOLEAN NULL,
x DOUBLE NULL,
y DOUBLE NULL,
z DOUBLE NULL,
last_observed_at DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL,
updated_at DATETIME(6) NOT NULL,
UNIQUE KEY uq_scum_vehicle_instance_game (server_instance_id, game_vehicle_id),
KEY ix_scum_vehicle_presence (server_instance_id, exists_in_game, last_observed_at),
KEY ix_scum_vehicle_updated (server_instance_id, updated_at)
)`},
{"scum_vehicle_trajectory", `CREATE TABLE IF NOT EXISTS scum_vehicle_trajectory (
id VARCHAR(191) PRIMARY KEY,
server_instance_id VARCHAR(191) NOT NULL,
scum_vehicle_id VARCHAR(191) NOT NULL,
game_vehicle_id VARCHAR(191) NOT NULL,
vehicle_class VARCHAR(64) NOT NULL DEFAULT '',
x DOUBLE NOT NULL,
y DOUBLE NOT NULL,
z DOUBLE NOT NULL,
observed_at DATETIME(6) NOT NULL,
created_at DATETIME(6) NOT NULL,
KEY ix_scum_vehicle_track_vehicle (server_instance_id, scum_vehicle_id, observed_at),
KEY ix_scum_vehicle_track_game (server_instance_id, game_vehicle_id, observed_at),
KEY ix_scum_vehicle_track_retention (observed_at)
)`},
{"scum_vehicle_lock", `CREATE TABLE IF NOT EXISTS scum_vehicle_lock (
id VARCHAR(191) PRIMARY KEY,
server_instance_id VARCHAR(191) NOT NULL,
scum_vehicle_id VARCHAR(191) NOT NULL,
game_vehicle_id VARCHAR(191) NOT NULL,
scum_user_id VARCHAR(191) NOT NULL,
steam_id VARCHAR(64) NOT NULL,
locked_at DATETIME(6) NOT NULL,
created_at DATETIME(6) NOT NULL,
KEY ix_scum_vehicle_lock_vehicle (server_instance_id, scum_vehicle_id, locked_at),
KEY ix_scum_vehicle_lock_user (server_instance_id, scum_user_id, locked_at)
)`},
}
// initializeSCUMTables creates the typed SCUM tables. An earlier unreleased
// build created a throwaway snapshot-shaped table with a payload JSON column
// that no code path ever read; that shape is replaced here instead of being
// kept around with missing typed columns.
func (store *MySQLStore) initializeSCUMTables(ctx context.Context) error {
for _, entry := range scumTableDDL {
legacy, err := store.scumTableUsesLegacyPayload(ctx, entry.table)
if err != nil {
return err
}
if legacy {
if _, err := store.db.ExecContext(ctx, "DROP TABLE IF EXISTS "+entry.table); err != nil {
return fmt.Errorf("drop legacy SCUM table %s: %w", entry.table, err)
}
}
if _, err := store.db.ExecContext(ctx, entry.statement); err != nil {
return fmt.Errorf("create SCUM table %s: %w", entry.table, err)
}
}
if err := store.ensureSCUMVehicleFunctionalColumn(ctx); err != nil {
return err
}
return nil
}
func (store *MySQLStore) ensureSCUMVehicleFunctionalColumn(ctx context.Context) error {
var found int
if err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'scum_vehicle' AND COLUMN_NAME = 'functional'`).Scan(&found); err != nil {
return fmt.Errorf("inspect SCUM vehicle functional column: %w", err)
}
if found > 0 {
return nil
}
if _, err := store.db.ExecContext(ctx, `ALTER TABLE scum_vehicle ADD COLUMN functional BOOLEAN NULL AFTER locked`); err != nil {
return fmt.Errorf("add SCUM vehicle functional column: %w", err)
}
return nil
}
func (store *MySQLStore) scumTableUsesLegacyPayload(ctx context.Context, table string) (bool, error) {
var found int
err := store.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = 'payload'
AND EXISTS (SELECT 1 FROM information_schema.COLUMNS c
WHERE c.TABLE_SCHEMA = DATABASE() AND c.TABLE_NAME = ? AND c.COLUMN_NAME = 'server_instance_id')`, table, table).Scan(&found)
if err != nil {
return false, fmt.Errorf("inspect SCUM table %s: %w", table, err)
}
return found > 0, nil
}
func (store *MySQLStore) SCUMUsers() SCUMUserRepository {
return &mysqlSCUMUserRepository{db: store.db}
}
func (store *MySQLStore) SCUMUserTrajectories() SCUMUserTrajectoryRepository {
return &mysqlSCUMUserTrajectoryRepository{db: store.db, pruner: &store.scumUserTrackPruner}
}
func (store *MySQLStore) SCUMVehicles() SCUMVehicleRepository {
return &mysqlSCUMVehicleRepository{db: store.db}
}
func (store *MySQLStore) SCUMVehicleTrajectories() SCUMVehicleTrajectoryRepository {
return &mysqlSCUMVehicleTrajectoryRepository{db: store.db, pruner: &store.scumVehicleTrackPruner}
}
func (store *MySQLStore) SCUMVehicleLocks() SCUMVehicleLockRepository {
return &mysqlSCUMVehicleLockRepository{db: store.db}
}
type mysqlSCUMUserRepository struct{ db *sql.DB }
const scumUserColumns = `id, server_instance_id, steam_id, display_name, squad_id, registered_at, last_login_at, last_activity_at, last_login_ip, online, x, y, z, bank_balance, gold_bars, ridden_vehicle_id, game_vehicle_id, welcome_queued_at, welcome_last_error, created_at, updated_at`
func (repository *mysqlSCUMUserRepository) Create(value domain.SCUMUser) error {
ctx, cancel := scumQueryContext()
defer cancel()
_, err := repository.db.ExecContext(ctx, `INSERT INTO scum_user (`+scumUserColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, scumUserArgs(value)...)
if err != nil {
if isMySQLDuplicateKey(err) {
return ErrDuplicate
}
return fmt.Errorf("create scum_user: %w", err)
}
return nil
}
func (repository *mysqlSCUMUserRepository) Get(id string) (domain.SCUMUser, error) {
ctx, cancel := scumQueryContext()
defer cancel()
value, err := scanSCUMUser(repository.db.QueryRowContext(ctx, `SELECT `+scumUserColumns+` FROM scum_user WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return domain.SCUMUser{}, ErrNotFound
}
if err != nil {
return domain.SCUMUser{}, fmt.Errorf("read scum_user: %w", err)
}
return value, nil
}
func (repository *mysqlSCUMUserRepository) List(filter domain.SCUMUserFilter) ([]domain.SCUMUser, error) {
query := `SELECT ` + scumUserColumns + ` FROM scum_user WHERE server_instance_id = ?`
args := []any{filter.ServerInstanceID}
if filter.SteamID != "" {
query += " AND steam_id = ?"
args = append(args, filter.SteamID)
}
if filter.Online != nil {
query += " AND online = ?"
args = append(args, *filter.Online)
}
if !filter.ChangedAfter.IsZero() {
query += " AND updated_at > ?"
args = append(args, filter.ChangedAfter.UTC())
}
if !filter.StaleBefore.IsZero() {
query += " AND COALESCE(last_activity_at, updated_at) < ?"
args = append(args, filter.StaleBefore.UTC())
}
query += " ORDER BY last_activity_at DESC, steam_id ASC LIMIT ?"
args = append(args, scumListLimit(filter.Limit))
ctx, cancel := scumQueryContext()
defer cancel()
rows, err := repository.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list scum_user: %w", err)
}
defer rows.Close()
values := make([]domain.SCUMUser, 0, 16)
for rows.Next() {
value, err := scanSCUMUser(rows)
if err != nil {
return nil, fmt.Errorf("scan scum_user: %w", err)
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list scum_user: %w", err)
}
return values, nil
}
func (repository *mysqlSCUMUserRepository) Update(value domain.SCUMUser) error {
ctx, cancel := scumQueryContext()
defer cancel()
result, err := repository.db.ExecContext(ctx, `UPDATE scum_user SET server_instance_id=?, steam_id=?, display_name=?, squad_id=?, registered_at=?, last_login_at=?, last_activity_at=?, last_login_ip=?, online=?, x=?, y=?, z=?, bank_balance=?, gold_bars=?, ridden_vehicle_id=?, game_vehicle_id=?, welcome_queued_at=?, welcome_last_error=?, created_at=?, updated_at=? WHERE id=?`, scumUserUpdateArgs(value)...)
if err != nil {
return fmt.Errorf("update scum_user: %w", err)
}
return scumUpdateResult(result, func() error {
_, err := repository.Get(value.ID)
return err
})
}
func (repository *mysqlSCUMUserRepository) Delete(id string) error {
ctx, cancel := scumQueryContext()
defer cancel()
result, err := repository.db.ExecContext(ctx, `DELETE FROM scum_user WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete scum_user: %w", err)
}
return scumDeleteResult(result)
}
type mysqlSCUMVehicleRepository struct{ db *sql.DB }
const scumVehicleColumns = `id, server_instance_id, game_vehicle_id, vehicle_class, display_name, exists_in_game, locked, functional, x, y, z, last_observed_at, created_at, updated_at`
func (repository *mysqlSCUMVehicleRepository) Create(value domain.SCUMVehicle) error {
ctx, cancel := scumQueryContext()
defer cancel()
_, err := repository.db.ExecContext(ctx, `INSERT INTO scum_vehicle (`+scumVehicleColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, scumVehicleArgs(value)...)
if err != nil {
if isMySQLDuplicateKey(err) {
return ErrDuplicate
}
return fmt.Errorf("create scum_vehicle: %w", err)
}
return nil
}
func (repository *mysqlSCUMVehicleRepository) Get(id string) (domain.SCUMVehicle, error) {
ctx, cancel := scumQueryContext()
defer cancel()
value, err := scanSCUMVehicle(repository.db.QueryRowContext(ctx, `SELECT `+scumVehicleColumns+` FROM scum_vehicle WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return domain.SCUMVehicle{}, ErrNotFound
}
if err != nil {
return domain.SCUMVehicle{}, fmt.Errorf("read scum_vehicle: %w", err)
}
return value, nil
}
func (repository *mysqlSCUMVehicleRepository) List(filter domain.SCUMVehicleFilter) ([]domain.SCUMVehicle, error) {
query := `SELECT ` + scumVehicleColumns + ` FROM scum_vehicle WHERE server_instance_id = ?`
args := []any{filter.ServerInstanceID}
if filter.GameVehicleID != "" {
query += " AND game_vehicle_id = ?"
args = append(args, filter.GameVehicleID)
}
if filter.Exists != nil {
query += " AND exists_in_game = ?"
args = append(args, *filter.Exists)
}
if !filter.ChangedAfter.IsZero() {
query += " AND updated_at > ?"
args = append(args, filter.ChangedAfter.UTC())
}
query += " ORDER BY last_observed_at DESC, game_vehicle_id ASC LIMIT ?"
args = append(args, scumListLimit(filter.Limit))
ctx, cancel := scumQueryContext()
defer cancel()
rows, err := repository.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list scum_vehicle: %w", err)
}
defer rows.Close()
values := make([]domain.SCUMVehicle, 0, 16)
for rows.Next() {
value, err := scanSCUMVehicle(rows)
if err != nil {
return nil, fmt.Errorf("scan scum_vehicle: %w", err)
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list scum_vehicle: %w", err)
}
return values, nil
}
func (repository *mysqlSCUMVehicleRepository) Update(value domain.SCUMVehicle) error {
ctx, cancel := scumQueryContext()
defer cancel()
result, err := repository.db.ExecContext(ctx, `UPDATE scum_vehicle SET server_instance_id=?, game_vehicle_id=?, vehicle_class=?, display_name=?, exists_in_game=?, locked=?, functional=?, x=?, y=?, z=?, last_observed_at=?, created_at=?, updated_at=? WHERE id=?`, scumVehicleUpdateArgs(value)...)
if err != nil {
return fmt.Errorf("update scum_vehicle: %w", err)
}
return scumUpdateResult(result, func() error {
_, err := repository.Get(value.ID)
return err
})
}
func (repository *mysqlSCUMVehicleRepository) Delete(id string) error {
ctx, cancel := scumQueryContext()
defer cancel()
result, err := repository.db.ExecContext(ctx, `DELETE FROM scum_vehicle WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete scum_vehicle: %w", err)
}
return scumDeleteResult(result)
}
type mysqlSCUMUserTrajectoryRepository struct {
db *sql.DB
pruner *scumTrajectoryPruner
}
const scumUserTrajectoryColumns = `id, server_instance_id, scum_user_id, steam_id, display_name, x, y, z, ridden_vehicle_id, game_vehicle_id, observed_at, created_at`
func (repository *mysqlSCUMUserTrajectoryRepository) Create(value domain.SCUMUserTrajectory) error {
if err := repository.pruner.prune(repository.db, "scum_user_trajectory", "observed_at"); err != nil {
return err
}
ctx, cancel := scumQueryContext()
defer cancel()
_, err := repository.db.ExecContext(ctx, `INSERT INTO scum_user_trajectory (`+scumUserTrajectoryColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, value.ID, value.ServerInstanceID, value.SCUMUserID, value.SteamID, value.DisplayName, value.X, value.Y, value.Z, value.RiddenVehicleID, value.GameVehicleID, value.ObservedAt.UTC(), value.CreatedAt.UTC())
if err != nil {
if isMySQLDuplicateKey(err) {
return ErrDuplicate
}
return fmt.Errorf("create scum_user_trajectory: %w", err)
}
return nil
}
func (repository *mysqlSCUMUserTrajectoryRepository) Get(id string) (domain.SCUMUserTrajectory, error) {
ctx, cancel := scumQueryContext()
defer cancel()
value, err := scanSCUMUserTrajectory(repository.db.QueryRowContext(ctx, `SELECT `+scumUserTrajectoryColumns+` FROM scum_user_trajectory WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return domain.SCUMUserTrajectory{}, ErrNotFound
}
if err != nil {
return domain.SCUMUserTrajectory{}, fmt.Errorf("read scum_user_trajectory: %w", err)
}
return value, nil
}
func (repository *mysqlSCUMUserTrajectoryRepository) List(filter domain.SCUMUserTrajectoryFilter) ([]domain.SCUMUserTrajectory, error) {
query := `SELECT ` + scumUserTrajectoryColumns + ` FROM scum_user_trajectory WHERE server_instance_id = ?`
args := []any{filter.ServerInstanceID}
if filter.SCUMUserID != "" {
query += " AND scum_user_id = ?"
args = append(args, filter.SCUMUserID)
}
if filter.SteamID != "" {
query += " AND steam_id = ?"
args = append(args, filter.SteamID)
}
if !filter.After.IsZero() {
query += " AND observed_at > ?"
args = append(args, filter.After.UTC())
}
query += " ORDER BY observed_at DESC, id DESC LIMIT ?"
args = append(args, scumListLimit(filter.Limit))
return querySCUMUserTrajectories(repository.db, query, args)
}
func (repository *mysqlSCUMUserTrajectoryRepository) Update(value domain.SCUMUserTrajectory) error {
ctx, cancel := scumQueryContext()
defer cancel()
result, err := repository.db.ExecContext(ctx, `UPDATE scum_user_trajectory SET server_instance_id=?, scum_user_id=?, steam_id=?, display_name=?, x=?, y=?, z=?, ridden_vehicle_id=?, game_vehicle_id=?, observed_at=?, created_at=? WHERE id=?`, value.ServerInstanceID, value.SCUMUserID, value.SteamID, value.DisplayName, value.X, value.Y, value.Z, value.RiddenVehicleID, value.GameVehicleID, value.ObservedAt.UTC(), value.CreatedAt.UTC(), value.ID)
if err != nil {
return fmt.Errorf("update scum_user_trajectory: %w", err)
}
return scumUpdateResult(result, func() error {
_, err := repository.Get(value.ID)
return err
})
}
func (repository *mysqlSCUMUserTrajectoryRepository) Delete(id string) error {
ctx, cancel := scumQueryContext()
defer cancel()
result, err := repository.db.ExecContext(ctx, `DELETE FROM scum_user_trajectory WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete scum_user_trajectory: %w", err)
}
return scumDeleteResult(result)
}
type mysqlSCUMVehicleTrajectoryRepository struct {
db *sql.DB
pruner *scumTrajectoryPruner
}
const scumVehicleTrajectoryColumns = `id, server_instance_id, scum_vehicle_id, game_vehicle_id, vehicle_class, x, y, z, observed_at, created_at`
func (repository *mysqlSCUMVehicleTrajectoryRepository) Create(value domain.SCUMVehicleTrajectory) error {
if err := repository.pruner.prune(repository.db, "scum_vehicle_trajectory", "observed_at"); err != nil {
return err
}
ctx, cancel := scumQueryContext()
defer cancel()
_, err := repository.db.ExecContext(ctx, `INSERT INTO scum_vehicle_trajectory (`+scumVehicleTrajectoryColumns+`) VALUES (?,?,?,?,?,?,?,?,?,?)`, value.ID, value.ServerInstanceID, value.SCUMVehicleID, value.GameVehicleID, value.VehicleClass, value.X, value.Y, value.Z, value.ObservedAt.UTC(), value.CreatedAt.UTC())
if err != nil {
if isMySQLDuplicateKey(err) {
return ErrDuplicate
}
return fmt.Errorf("create scum_vehicle_trajectory: %w", err)
}
return nil
}
func (repository *mysqlSCUMVehicleTrajectoryRepository) Get(id string) (domain.SCUMVehicleTrajectory, error) {
ctx, cancel := scumQueryContext()
defer cancel()
value, err := scanSCUMVehicleTrajectory(repository.db.QueryRowContext(ctx, `SELECT `+scumVehicleTrajectoryColumns+` FROM scum_vehicle_trajectory WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return domain.SCUMVehicleTrajectory{}, ErrNotFound
}
if err != nil {
return domain.SCUMVehicleTrajectory{}, fmt.Errorf("read scum_vehicle_trajectory: %w", err)
}
return value, nil
}
func (repository *mysqlSCUMVehicleTrajectoryRepository) List(filter domain.SCUMVehicleTrajectoryFilter) ([]domain.SCUMVehicleTrajectory, error) {
query := `SELECT ` + scumVehicleTrajectoryColumns + ` FROM scum_vehicle_trajectory WHERE server_instance_id = ?`
args := []any{filter.ServerInstanceID}
if filter.SCUMVehicleID != "" {
query += " AND scum_vehicle_id = ?"
args = append(args, filter.SCUMVehicleID)
}
if filter.GameVehicleID != "" {
query += " AND game_vehicle_id = ?"
args = append(args, filter.GameVehicleID)
}
if !filter.After.IsZero() {
query += " AND observed_at > ?"
args = append(args, filter.After.UTC())
}
query += " ORDER BY observed_at DESC, id DESC LIMIT ?"
args = append(args, scumListLimit(filter.Limit))
return querySCUMVehicleTrajectories(repository.db, query, args)
}
func (repository *mysqlSCUMVehicleTrajectoryRepository) Update(value domain.SCUMVehicleTrajectory) error {
ctx, cancel := scumQueryContext()
defer cancel()
result, err := repository.db.ExecContext(ctx, `UPDATE scum_vehicle_trajectory SET server_instance_id=?, scum_vehicle_id=?, game_vehicle_id=?, vehicle_class=?, x=?, y=?, z=?, observed_at=?, created_at=? WHERE id=?`, value.ServerInstanceID, value.SCUMVehicleID, value.GameVehicleID, value.VehicleClass, value.X, value.Y, value.Z, value.ObservedAt.UTC(), value.CreatedAt.UTC(), value.ID)
if err != nil {
return fmt.Errorf("update scum_vehicle_trajectory: %w", err)
}
return scumUpdateResult(result, func() error {
_, err := repository.Get(value.ID)
return err
})
}
func (repository *mysqlSCUMVehicleTrajectoryRepository) Delete(id string) error {
ctx, cancel := scumQueryContext()
defer cancel()
result, err := repository.db.ExecContext(ctx, `DELETE FROM scum_vehicle_trajectory WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete scum_vehicle_trajectory: %w", err)
}
return scumDeleteResult(result)
}
type mysqlSCUMVehicleLockRepository struct{ db *sql.DB }
const scumVehicleLockColumns = `id, server_instance_id, scum_vehicle_id, game_vehicle_id, scum_user_id, steam_id, locked_at, created_at`
func (repository *mysqlSCUMVehicleLockRepository) Create(value domain.SCUMVehicleLock) error {
ctx, cancel := scumQueryContext()
defer cancel()
_, err := repository.db.ExecContext(ctx, `INSERT INTO scum_vehicle_lock (`+scumVehicleLockColumns+`) VALUES (?,?,?,?,?,?,?,?)`, value.ID, value.ServerInstanceID, value.SCUMVehicleID, value.GameVehicleID, value.SCUMUserID, value.SteamID, value.LockedAt.UTC(), value.CreatedAt.UTC())
if err != nil {
if isMySQLDuplicateKey(err) {
return ErrDuplicate
}
return fmt.Errorf("create scum_vehicle_lock: %w", err)
}
return nil
}
func (repository *mysqlSCUMVehicleLockRepository) Get(id string) (domain.SCUMVehicleLock, error) {
ctx, cancel := scumQueryContext()
defer cancel()
value, err := scanSCUMVehicleLock(repository.db.QueryRowContext(ctx, `SELECT `+scumVehicleLockColumns+` FROM scum_vehicle_lock WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return domain.SCUMVehicleLock{}, ErrNotFound
}
if err != nil {
return domain.SCUMVehicleLock{}, fmt.Errorf("read scum_vehicle_lock: %w", err)
}
return value, nil
}
func (repository *mysqlSCUMVehicleLockRepository) List(filter domain.SCUMVehicleLockFilter) ([]domain.SCUMVehicleLock, error) {
query := `SELECT ` + scumVehicleLockColumns + ` FROM scum_vehicle_lock WHERE server_instance_id = ?`
args := []any{filter.ServerInstanceID}
if filter.SCUMVehicleID != "" {
query += " AND scum_vehicle_id = ?"
args = append(args, filter.SCUMVehicleID)
}
if filter.GameVehicleID != "" {
query += " AND game_vehicle_id = ?"
args = append(args, filter.GameVehicleID)
}
if filter.SCUMUserID != "" {
query += " AND scum_user_id = ?"
args = append(args, filter.SCUMUserID)
}
if filter.SteamID != "" {
query += " AND steam_id = ?"
args = append(args, filter.SteamID)
}
if !filter.After.IsZero() {
query += " AND locked_at > ?"
args = append(args, filter.After.UTC())
}
query += " ORDER BY locked_at DESC, id DESC LIMIT ?"
args = append(args, scumListLimit(filter.Limit))
ctx, cancel := scumQueryContext()
defer cancel()
rows, err := repository.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list scum_vehicle_lock: %w", err)
}
defer rows.Close()
values := make([]domain.SCUMVehicleLock, 0, 16)
for rows.Next() {
value, err := scanSCUMVehicleLock(rows)
if err != nil {
return nil, fmt.Errorf("scan scum_vehicle_lock: %w", err)
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list scum_vehicle_lock: %w", err)
}
return values, nil
}
func (repository *mysqlSCUMVehicleLockRepository) Update(value domain.SCUMVehicleLock) error {
ctx, cancel := scumQueryContext()
defer cancel()
result, err := repository.db.ExecContext(ctx, `UPDATE scum_vehicle_lock SET server_instance_id=?, scum_vehicle_id=?, game_vehicle_id=?, scum_user_id=?, steam_id=?, locked_at=?, created_at=? WHERE id=?`, value.ServerInstanceID, value.SCUMVehicleID, value.GameVehicleID, value.SCUMUserID, value.SteamID, value.LockedAt.UTC(), value.CreatedAt.UTC(), value.ID)
if err != nil {
return fmt.Errorf("update scum_vehicle_lock: %w", err)
}
return scumUpdateResult(result, func() error {
_, err := repository.Get(value.ID)
return err
})
}
func (repository *mysqlSCUMVehicleLockRepository) Delete(id string) error {
ctx, cancel := scumQueryContext()
defer cancel()
result, err := repository.db.ExecContext(ctx, `DELETE FROM scum_vehicle_lock WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete scum_vehicle_lock: %w", err)
}
return scumDeleteResult(result)
}
type scumTrajectoryPruner struct {
mu sync.Mutex
lastPrune time.Time
}
// prune drops trajectory samples past the retention window. It runs at most
// once per hour from the trajectory write path so the delete cannot dominate
// ingest work.
func (pruner *scumTrajectoryPruner) prune(db *sql.DB, table string, timeColumn string) error {
pruner.mu.Lock()
defer pruner.mu.Unlock()
now := time.Now().UTC()
if !pruner.lastPrune.IsZero() && now.Sub(pruner.lastPrune) < scumTrajectoryPruneEvery {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if _, err := db.ExecContext(ctx, "DELETE FROM "+table+" WHERE "+timeColumn+" < ?", now.Add(-scumTrajectoryRetention)); err != nil {
return fmt.Errorf("prune %s: %w", table, err)
}
pruner.lastPrune = now
return nil
}
// mysqlDSNWithParseTime keeps DATETIME columns readable as time.Time even when
// the operator-provided DSN omits the driver flag.
func mysqlDSNWithParseTime(dsn string) string {
if strings.Contains(dsn, "parseTime=") {
return dsn
}
separator := "?"
if strings.Contains(dsn, "?") {
separator = "&"
}
return dsn + separator + "parseTime=true"
}
func scumQueryContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), 10*time.Second)
}
func scumListLimit(requested int) int {
if requested <= 0 {
return domain.SCUMDefaultListLimit
}
if requested > scumMaxListLimit {
return scumMaxListLimit
}
return requested
}
func isMySQLDuplicateKey(err error) bool {
var mysqlError *mysql.MySQLError
return errors.As(err, &mysqlError) && mysqlError.Number == 1062
}
func scumUpdateResult(result sql.Result, confirm func() error) error {
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return confirm()
}
return nil
}
func scumDeleteResult(result sql.Result) error {
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return ErrNotFound
}
return nil
}
func scumTimeValue(value time.Time) any {
if value.IsZero() {
return nil
}
return value.UTC()
}
func scumTimeFromNull(value sql.NullTime) time.Time {
if !value.Valid {
return time.Time{}
}
return value.Time.UTC()
}
func scumFloatValue(value *float64) any {
if value == nil {
return nil
}
return *value
}
func scumFloatFromNull(value sql.NullFloat64) *float64 {
if !value.Valid {
return nil
}
copied := value.Float64
return &copied
}
func scumIntValue(value *int64) any {
if value == nil {
return nil
}
return *value
}
func scumIntFromNull(value sql.NullInt64) *int64 {
if !value.Valid {
return nil
}
copied := value.Int64
return &copied
}
func scumUserArgs(value domain.SCUMUser) []any {
return []any{
value.ID, value.ServerInstanceID, value.SteamID, value.DisplayName, value.SquadID,
scumTimeValue(value.RegisteredAt), scumTimeValue(value.LastLoginAt), scumTimeValue(value.LastActivityAt),
value.LastLoginIP, value.Online, scumFloatValue(value.X), scumFloatValue(value.Y), scumFloatValue(value.Z),
scumIntValue(value.BankBalance), scumIntValue(value.GoldBars), value.RiddenVehicleID, value.GameVehicleID,
scumTimeValue(value.WelcomeQueuedAt), value.WelcomeLastError, value.CreatedAt.UTC(), value.UpdatedAt.UTC(),
}
}
func scumUserUpdateArgs(value domain.SCUMUser) []any {
args := scumUserArgs(value)
return append(args[1:], args[0])
}
func scumVehicleArgs(value domain.SCUMVehicle) []any {
return []any{
value.ID, value.ServerInstanceID, value.GameVehicleID, value.VehicleClass, value.DisplayName,
value.Exists, value.Locked, value.Functional, scumFloatValue(value.X), scumFloatValue(value.Y), scumFloatValue(value.Z),
scumTimeValue(value.LastObservedAt), value.CreatedAt.UTC(), value.UpdatedAt.UTC(),
}
}
func scumVehicleUpdateArgs(value domain.SCUMVehicle) []any {
args := scumVehicleArgs(value)
return append(args[1:], args[0])
}
type scumRowScanner interface {
Scan(dest ...any) error
}
func scanSCUMUser(scanner scumRowScanner) (domain.SCUMUser, error) {
var (
value domain.SCUMUser
registeredAt, lastLoginAt, lastActivityAt sql.NullTime
welcomeQueuedAt sql.NullTime
x, y, z sql.NullFloat64
bankBalance, goldBars sql.NullInt64
)
err := scanner.Scan(
&value.ID, &value.ServerInstanceID, &value.SteamID, &value.DisplayName, &value.SquadID,
&registeredAt, &lastLoginAt, &lastActivityAt, &value.LastLoginIP, &value.Online,
&x, &y, &z, &bankBalance, &goldBars, &value.RiddenVehicleID, &value.GameVehicleID,
&welcomeQueuedAt, &value.WelcomeLastError, &value.CreatedAt, &value.UpdatedAt,
)
if err != nil {
return domain.SCUMUser{}, err
}
value.RegisteredAt = scumTimeFromNull(registeredAt)
value.LastLoginAt = scumTimeFromNull(lastLoginAt)
value.LastActivityAt = scumTimeFromNull(lastActivityAt)
value.WelcomeQueuedAt = scumTimeFromNull(welcomeQueuedAt)
value.X = scumFloatFromNull(x)
value.Y = scumFloatFromNull(y)
value.Z = scumFloatFromNull(z)
value.BankBalance = scumIntFromNull(bankBalance)
value.GoldBars = scumIntFromNull(goldBars)
value.CreatedAt = value.CreatedAt.UTC()
value.UpdatedAt = value.UpdatedAt.UTC()
return value, nil
}
func scanSCUMVehicle(scanner scumRowScanner) (domain.SCUMVehicle, error) {
var (
value domain.SCUMVehicle
x, y, z sql.NullFloat64
lastObservedAt sql.NullTime
)
err := scanner.Scan(
&value.ID, &value.ServerInstanceID, &value.GameVehicleID, &value.VehicleClass, &value.DisplayName,
&value.Exists, &value.Locked, &value.Functional, &x, &y, &z, &lastObservedAt, &value.CreatedAt, &value.UpdatedAt,
)
if err != nil {
return domain.SCUMVehicle{}, err
}
value.X = scumFloatFromNull(x)
value.Y = scumFloatFromNull(y)
value.Z = scumFloatFromNull(z)
value.LastObservedAt = scumTimeFromNull(lastObservedAt)
value.CreatedAt = value.CreatedAt.UTC()
value.UpdatedAt = value.UpdatedAt.UTC()
return value, nil
}
func scanSCUMUserTrajectory(scanner scumRowScanner) (domain.SCUMUserTrajectory, error) {
var value domain.SCUMUserTrajectory
err := scanner.Scan(
&value.ID, &value.ServerInstanceID, &value.SCUMUserID, &value.SteamID, &value.DisplayName,
&value.X, &value.Y, &value.Z, &value.RiddenVehicleID, &value.GameVehicleID, &value.ObservedAt, &value.CreatedAt,
)
if err != nil {
return domain.SCUMUserTrajectory{}, err
}
value.ObservedAt = value.ObservedAt.UTC()
value.CreatedAt = value.CreatedAt.UTC()
return value, nil
}
func scanSCUMVehicleTrajectory(scanner scumRowScanner) (domain.SCUMVehicleTrajectory, error) {
var value domain.SCUMVehicleTrajectory
err := scanner.Scan(
&value.ID, &value.ServerInstanceID, &value.SCUMVehicleID, &value.GameVehicleID, &value.VehicleClass,
&value.X, &value.Y, &value.Z, &value.ObservedAt, &value.CreatedAt,
)
if err != nil {
return domain.SCUMVehicleTrajectory{}, err
}
value.ObservedAt = value.ObservedAt.UTC()
value.CreatedAt = value.CreatedAt.UTC()
return value, nil
}
func scanSCUMVehicleLock(scanner scumRowScanner) (domain.SCUMVehicleLock, error) {
var value domain.SCUMVehicleLock
err := scanner.Scan(
&value.ID, &value.ServerInstanceID, &value.SCUMVehicleID, &value.GameVehicleID, &value.SCUMUserID,
&value.SteamID, &value.LockedAt, &value.CreatedAt,
)
if err != nil {
return domain.SCUMVehicleLock{}, err
}
value.LockedAt = value.LockedAt.UTC()
value.CreatedAt = value.CreatedAt.UTC()
return value, nil
}
func querySCUMUserTrajectories(db *sql.DB, query string, args []any) ([]domain.SCUMUserTrajectory, error) {
ctx, cancel := scumQueryContext()
defer cancel()
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list scum_user_trajectory: %w", err)
}
defer rows.Close()
values := make([]domain.SCUMUserTrajectory, 0, 16)
for rows.Next() {
value, err := scanSCUMUserTrajectory(rows)
if err != nil {
return nil, fmt.Errorf("scan scum_user_trajectory: %w", err)
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list scum_user_trajectory: %w", err)
}
return values, nil
}
func querySCUMVehicleTrajectories(db *sql.DB, query string, args []any) ([]domain.SCUMVehicleTrajectory, error) {
ctx, cancel := scumQueryContext()
defer cancel()
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list scum_vehicle_trajectory: %w", err)
}
defer rows.Close()
values := make([]domain.SCUMVehicleTrajectory, 0, 16)
for rows.Next() {
value, err := scanSCUMVehicleTrajectory(rows)
if err != nil {
return nil, fmt.Errorf("scan scum_vehicle_trajectory: %w", err)
}
values = append(values, value)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list scum_vehicle_trajectory: %w", err)
}
return values, nil
}